import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { getDatabase } from "@/lib/database"; import { buildAIContext } from "@/lib/ai/ai-context"; import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder"; import log from "@/lib/logger"; import { ensureUserSynced } from "@/lib/sync-user"; import { getUserMembershipContext } from "@/lib/membership/access"; const AI_LINK_PREFIX = "[AI_LINKED]"; interface ParsedPlanItem { id: string; title: string; description: string; goalType: | "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom"; } interface GeneratedPlanContent { recommendationText?: string; activityPlan?: string; dietPlan?: string; } function buildFallbackPlan(profile: { activityLevel?: string; fitnessGoals?: string[] | string; medicalConditions?: string; }): GeneratedPlanContent { const goals = Array.isArray(profile.fitnessGoals) ? profile.fitnessGoals : typeof profile.fitnessGoals === "string" && profile.fitnessGoals ? [profile.fitnessGoals] : ["general fitness"]; const primaryGoal = goals[0] || "general fitness"; const activityLevel = profile.activityLevel || "moderate"; const hasMedicalNotes = Boolean(profile.medicalConditions?.trim()); return { recommendationText: `Personalized starter plan focused on ${primaryGoal} with ${activityLevel} activity pacing.` + (hasMedicalNotes ? " Medical notes detected, so keep intensity conservative and progress gradually." : ""), activityPlan: "- 3 strength sessions per week (full-body, 35-45 min)\n" + "- 2 cardio sessions per week (20-30 min brisk walk/run/cycle)\n" + "- 10 minutes daily mobility/stretching after workouts\n" + "- 1 full recovery day each week", dietPlan: "- Build meals around lean protein, vegetables, whole grains, and hydration\n" + "- Keep portions consistent and avoid skipping meals\n" + "- Track intake daily and adjust calories based on weekly progress", }; } function inferGoalType(text: string): ParsedPlanItem["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), })); } function parseJsonPayload(content: string): GeneratedPlanContent { let cleanResponse = content.trim(); if (cleanResponse.startsWith("```json")) { cleanResponse = cleanResponse .replace(/^```json\s*/, "") .replace(/\s*```$/, ""); } else if (cleanResponse.startsWith("```")) { cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, ""); } const firstBrace = cleanResponse.indexOf("{"); const lastBrace = cleanResponse.lastIndexOf("}"); if (firstBrace !== -1 && lastBrace !== -1) { cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1); } return JSON.parse(cleanResponse) as GeneratedPlanContent; } async function generateWithOpenAI( openaiApiKey: string, prompt: string, ): Promise { const response = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${openaiApiKey}`, }, body: JSON.stringify({ model: "gpt-4o-mini", messages: [ { role: "system", content: 'You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks. The response must have this exact structure: {"recommendationText": "string", "activityPlan": "string", "dietPlan": "string"}', }, { role: "user", content: prompt, }, ], temperature: 0.7, max_tokens: 1500, response_format: { type: "json_object" }, }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`OpenAI failed: ${response.status} ${errorText}`); } const data = await response.json(); return parseJsonPayload(data.choices[0].message.content as string); } async function generateWithDeepSeek( deepseekApiKey: string, prompt: string, ): Promise { const response = await fetch("https://api.deepseek.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${deepseekApiKey}`, }, body: JSON.stringify({ model: "deepseek-chat", messages: [ { role: "system", content: "You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks.", }, { role: "user", content: prompt, }, ], temperature: 0.7, max_tokens: 1200, }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`DeepSeek failed: ${response.status} ${errorText}`); } const data = await response.json(); return parseJsonPayload(data.choices[0].message.content as string); } async function generateWithOllama( prompt: string, ): Promise { const response = await fetch("http://localhost:11434/api/generate", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ model: "gemma3:latest", prompt, stream: false, format: "json", }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Ollama failed: ${response.status} ${errorText}`); } const data = await response.json(); return parseJsonPayload(data.response as string); } export async function POST() { try { const { userId } = await auth(); if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const db = await getDatabase(); const currentUser = await ensureUserSynced(userId, db); if (!currentUser) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const { membershipType, features } = await getUserMembershipContext(userId); if (membershipType === "basic") { return NextResponse.json( { error: "AI plan generation is available on Premium and VIP memberships", membershipType, }, { status: 403 }, ); } if (features.recommendationsPerMonth > 0) { const currentMonth = new Date(); const monthStart = new Date( currentMonth.getFullYear(), currentMonth.getMonth(), 1, ); const monthEnd = new Date( currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1, ); const existingRecommendations = await db.getRecommendationsByUserId(userId); const recommendationsThisMonth = existingRecommendations.filter( (recommendation) => recommendation.generatedAt >= monthStart && recommendation.generatedAt < monthEnd, ).length; if (recommendationsThisMonth >= features.recommendationsPerMonth) { return NextResponse.json( { error: `Your ${membershipType} plan includes ${features.recommendationsPerMonth} AI recommendation(s) per month`, membershipType, }, { status: 403 }, ); } } const profile = await db.getFitnessProfileByUserId(userId); if (!profile) { return NextResponse.json( { error: "Complete your fitness profile before generating a plan" }, { status: 404 }, ); } let prompt: string; try { const context = await buildAIContext(userId); prompt = buildEnhancedPrompt(context); } catch (error) { log.warn("Failed to build AI context for self-generate", { userId, error: error instanceof Error ? error.message : String(error), }); prompt = buildBasicPrompt(profile); } const openaiApiKey = process.env.OPENAI_API_KEY; const deepseekApiKey = process.env.DEEPSEEK_API_KEY; let parsedResponse: GeneratedPlanContent; let usedFallbackPlan = false; try { if (openaiApiKey) { parsedResponse = await generateWithOpenAI(openaiApiKey, prompt); } else if (deepseekApiKey) { parsedResponse = await generateWithDeepSeek(deepseekApiKey, prompt); } else { parsedResponse = await generateWithOllama(prompt); } } catch (providerError) { log.error("Self-generate provider failed", providerError, { userId, hasOpenAI: Boolean(openaiApiKey), hasDeepSeek: Boolean(deepseekApiKey), }); parsedResponse = buildFallbackPlan({ activityLevel: profile.activityLevel, fitnessGoals: profile.fitnessGoals, medicalConditions: profile.medicalConditions, }); usedFallbackPlan = true; } const recommendation = await db.createRecommendation({ id: crypto.randomUUID(), userId, fitnessProfileId: profile.id, recommendationText: parsedResponse.recommendationText || "", activityPlan: parsedResponse.activityPlan || "", dietPlan: parsedResponse.dietPlan || "", status: "approved", generatedAt: new Date(), updatedAt: new Date(), }); const existingActiveGoals = await db.getFitnessGoalsByUserId( userId, "active", ); const linkedGoals = existingActiveGoals.filter((goal) => goal.notes?.startsWith(AI_LINK_PREFIX), ); await Promise.all( linkedGoals.map((goal) => db.updateFitnessGoal(goal.id, { status: "paused", notes: `${goal.notes || ""}\nPaused due to new AI plan generation on ${new Date().toISOString()}`, }), ), ); let planItems = parseActivityPlanToItems(parsedResponse.activityPlan || ""); if (planItems.length === 0 && parsedResponse.recommendationText) { planItems = parseActivityPlanToItems(parsedResponse.recommendationText); } if (planItems.length === 0) { planItems = getDefaultPlanItems(); } log.debug("AI plan parsed into goal items", { recommendationId: recommendation.id, userId, parsedItems: planItems.length, }); const createdGoals = await Promise.all( planItems.map((item) => db.createFitnessGoal({ id: crypto.randomUUID(), userId, fitnessProfileId: profile.id, 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=${recommendation.id}; itemId=${item.id}`, }), ), ); return NextResponse.json({ success: true, data: recommendation, meta: { timestamp: new Date().toISOString(), createdGoals: createdGoals.length, pausedGoals: linkedGoals.length, usedFallbackPlan, }, }); } catch (error) { log.error("Failed to self-generate recommendation", error); return NextResponse.json( { error: "Internal server error" }, { status: 500 }, ); } }