456 lines
13 KiB
TypeScript
456 lines
13 KiB
TypeScript
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<GeneratedPlanContent> {
|
|
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<GeneratedPlanContent> {
|
|
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<GeneratedPlanContent> {
|
|
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 },
|
|
);
|
|
}
|
|
}
|