From 9330f4fd05629ad7fce67508d4beb6a9b8d722ac Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 20:02:28 +0200 Subject: [PATCH 1/3] 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) { From f9a588fcd6d6d4c1ed3db453082a871d59165e32 Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 20:03:52 +0200 Subject: [PATCH 2/3] db --- apps/admin/data/fitai.db | Bin 266240 -> 266240 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index bde5f2114e09ba034c1e3193982b4cc5f31ff77f..921f4a4f5ce0c6208acf854503ac1e04c65072f7 100644 GIT binary patch delta 107 zcmV-x0F?iLpb&td5Re-I_mLb!0r!DmwO|1nSpo?Jm%VNQB)6bh0cj=={15jJ@(=9~ z=nvx$-VfQc5pcl|0|WyJm)ws5ED#Ud548`E4`~lW4;>Eo4$-qAFu4wwkDmc0hxVQU NhxVQVxAvX`3UOR!D2xCA delta 112 zcmV-$0FVEGpb&td5Re-I_K_Sz0rr7lwO|1nSpo_0}65aTPRon From 4dd2ed5839557a3f92141ab8dc5f10a08fa746df Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 20:04:05 +0200 Subject: [PATCH 3/3] dbg --- apps/admin/data/fitai.db | Bin 266240 -> 270336 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index 921f4a4f5ce0c6208acf854503ac1e04c65072f7..90c6a7bb0dbf4838d50b114b58b4f7fe31e1367b 100644 GIT binary patch delta 1134 zcma))Piz}S6vplDIwYp{CZr)EZIb6oAlvdTUdIlk2f%4X#i3GKh$@hfI=egGU3qtA zvonivE=1yARU}l@!@vz8BrZi2id4jfa^O+~IdegRTzVi)1&7uVLSoi#B)D;y!@M`| zy>EW++xm2J>$}OT$8&>BCbRkxJf9tWb?NgP$Huf>8OW?^e`)u%KeRpVj<&m9n!G)> z{oBp#L{6sKN3Y$vHNQ|O>}85G#jDzT+Ku9__PX|S(Jky1ztJ8oKBqOeOYlRXQdZWp zR;SslFWXpOX|-1L=F*C#BddXQR5#6M&4%fi$SJ%%v-AGUPF9{HU(f6)zqj)RIVn4S zJu`k?PXBCt`NQ*t$@O!K@0wT82N#d4nT)FbkIrJ_wQNa5_mydl60j*@KI541pf673 z)TaddQ<&JcEU|6OnDGi7b5MVG>l5+I17%^J@fZ^~ z)lD40#7SaAY;3?q6oeg_D~4t6R=zL9o(iP(WpFsvoa)7Qm`1EtkW zxF7hNSTeORvAwh{<4NrFBzoZ+H@n6qoXT`C7WoqtK>L#+hY_{rd z<fBtl3hgM8^2#QJ*zuF_;i!ht?kSHiq$}|Wk)X=YoT}Gh?Of%QtXV}} Y?7+*SeNXvfI+A9SJMe!zdin7D1G~X_G5`Po delta 104 zcmZoTAkeTtV1hL3cLoLq$B7E|jNdmVELkta%KscFz~IRLoBtdCNB%eb&o?UyoMzkp z(2S9dbMo{3vLZZuq6~bZd=vQF_}BA2;*aIO!I!>S>cA!5$p!-2&7$YpMb9w;G1GR@ IbIh+D0altMYXATM