From 9330f4fd05629ad7fce67508d4beb6a9b8d722ac Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 20:02:28 +0200 Subject: [PATCH] regenerate linked active goals when admin approves ai recommendation --- .../app/api/recommendations/approve/route.ts | 194 +++++++++++++++++- 1 file changed, 193 insertions(+), 1 deletion(-) diff --git a/apps/admin/src/app/api/recommendations/approve/route.ts b/apps/admin/src/app/api/recommendations/approve/route.ts index 730fb4a..b1b1b7d 100644 --- a/apps/admin/src/app/api/recommendations/approve/route.ts +++ b/apps/admin/src/app/api/recommendations/approve/route.ts @@ -4,6 +4,101 @@ import { getDatabase } from "@/lib/database"; import log from "@/lib/logger"; import { ensureUserSynced } from "@/lib/sync-user"; +const AI_LINK_PREFIX = "[AI_LINKED]"; + +type GoalType = + | "weight_target" + | "strength_milestone" + | "endurance_target" + | "flexibility_goal" + | "habit_building" + | "custom"; + +interface ParsedPlanItem { + id: string; + title: string; + description: string; + goalType: GoalType; +} + +function inferGoalType(text: string): GoalType { + const normalized = text.toLowerCase(); + + if ( + normalized.includes("strength") || + normalized.includes("bench") || + normalized.includes("squat") || + normalized.includes("deadlift") || + normalized.includes("weights") + ) { + return "strength_milestone"; + } + + if ( + normalized.includes("run") || + normalized.includes("cardio") || + normalized.includes("endurance") || + normalized.includes("cycle") + ) { + return "endurance_target"; + } + + if ( + normalized.includes("stretch") || + normalized.includes("mobility") || + normalized.includes("yoga") || + normalized.includes("flexibility") + ) { + return "flexibility_goal"; + } + + if ( + normalized.includes("daily") || + normalized.includes("routine") || + normalized.includes("habit") + ) { + return "habit_building"; + } + + return "custom"; +} + +function parseActivityPlanToItems(activityPlan: string): ParsedPlanItem[] { + const lines = activityPlan + .replace(/\r\n/g, "\n") + .split(/\n+/) + .flatMap((line) => line.split(/(?<=[.!?])\s+(?=[A-Z0-9])/g)) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.replace(/^[-*•\d.)\s]+/, "").trim()) + .filter((line) => line.length > 10) + .slice(0, 8); + + const uniqueLines = Array.from(new Set(lines)); + + return uniqueLines.map((line) => ({ + id: crypto.randomUUID(), + title: line.length > 72 ? `${line.slice(0, 69)}...` : line, + description: line, + goalType: inferGoalType(line), + })); +} + +function getDefaultPlanItems(): ParsedPlanItem[] { + const defaults = [ + "Complete 3 strength sessions this week with progressive overload.", + "Add 2 cardio sessions of 25-30 minutes for endurance.", + "Do a 10-minute mobility routine daily after training.", + ]; + + return defaults.map((line) => ({ + id: crypto.randomUUID(), + title: line.length > 72 ? `${line.slice(0, 69)}...` : line, + description: line, + goalType: inferGoalType(line), + })); +} + export async function POST(req: Request) { try { const { userId: clerkUserId } = await auth(); @@ -94,8 +189,103 @@ export async function POST(req: Request) { ); } - // If approved, create a notification for the user + let pausedGoalsCount = 0; + let createdGoalsCount = 0; + + // If approved, regenerate linked AI goals and create a notification for the user if (status === "approved") { + try { + const existingActiveGoals = await db.getFitnessGoalsByUserId( + updatedRecommendation.userId, + "active", + ); + + const linkedGoals = existingActiveGoals.filter((goal) => + goal.notes?.startsWith(AI_LINK_PREFIX), + ); + + pausedGoalsCount = linkedGoals.length; + + await Promise.all( + linkedGoals.map((goal) => + db.updateFitnessGoal(goal.id, { + status: "paused", + notes: `${goal.notes || ""}\nPaused due to recommendation approval on ${new Date().toISOString()}`, + }), + ), + ); + + let planItems = parseActivityPlanToItems( + updatedRecommendation.activityPlan || "", + ); + + if ( + planItems.length === 0 && + updatedRecommendation.recommendationText + ) { + planItems = parseActivityPlanToItems( + updatedRecommendation.recommendationText, + ); + } + + if (planItems.length === 0) { + planItems = getDefaultPlanItems(); + } + + const fitnessProfileId = + updatedRecommendation.fitnessProfileId || + (await db.getFitnessProfileByUserId(updatedRecommendation.userId)) + ?.id; + + if (!fitnessProfileId) { + log.warn("No fitness profile available for AI goal creation", { + recommendationId, + userId: updatedRecommendation.userId, + }); + } else { + const createdGoals = await Promise.all( + planItems.map((item) => + db.createFitnessGoal({ + id: crypto.randomUUID(), + userId: updatedRecommendation.userId, + fitnessProfileId, + goalType: item.goalType, + title: item.title, + description: item.description, + targetValue: undefined, + currentValue: 0, + unit: undefined, + startDate: new Date(), + targetDate: undefined, + completedDate: undefined, + status: "active", + progress: 0, + priority: "medium", + notes: `${AI_LINK_PREFIX} recommendationId=${updatedRecommendation.id}; itemId=${item.id}`, + }), + ), + ); + + createdGoalsCount = createdGoals.length; + } + + log.info("Regenerated linked AI goals from approved recommendation", { + recommendationId: updatedRecommendation.id, + userId: updatedRecommendation.userId, + pausedGoals: pausedGoalsCount, + createdGoals: createdGoalsCount, + }); + } catch (goalConversionError) { + log.error( + "Failed to regenerate linked goals for approved recommendation", + goalConversionError, + { + recommendationId, + userId: updatedRecommendation.userId, + }, + ); + } + try { await db.createNotification({ id: crypto.randomUUID(), @@ -122,6 +312,8 @@ export async function POST(req: Request) { data: updatedRecommendation, meta: { timestamp: new Date().toISOString(), + pausedGoals: pausedGoalsCount, + createdGoals: createdGoalsCount, }, }); } catch (error) {