327 lines
9.0 KiB
TypeScript
327 lines
9.0 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { auth } from "@clerk/nextjs/server";
|
|
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();
|
|
if (!clerkUserId) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
log.debug("Approve recommendation request body", { body });
|
|
|
|
const { recommendationId, status } = body;
|
|
|
|
if (!recommendationId || !status) {
|
|
log.error("Missing required fields", {
|
|
recommendationId,
|
|
status,
|
|
receivedBody: body,
|
|
});
|
|
return NextResponse.json(
|
|
{ error: "Recommendation ID and status are required" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const db = await getDatabase();
|
|
const currentUser = await ensureUserSynced(clerkUserId, db);
|
|
|
|
if (!currentUser) {
|
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
}
|
|
|
|
const canApproveRecommendations =
|
|
currentUser.role === "superAdmin" ||
|
|
currentUser.role === "admin" ||
|
|
currentUser.role === "trainer";
|
|
|
|
if (!canApproveRecommendations) {
|
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
}
|
|
|
|
const existingRecommendation = (await db.getAllRecommendations()).find(
|
|
(recommendation) => recommendation.id === recommendationId,
|
|
);
|
|
|
|
if (!existingRecommendation) {
|
|
return NextResponse.json(
|
|
{ error: "Recommendation not found" },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
if (currentUser.role !== "superAdmin") {
|
|
const targetUser = await db.getUserById(existingRecommendation.userId);
|
|
|
|
if (
|
|
!currentUser.gymId ||
|
|
!targetUser ||
|
|
targetUser.gymId !== currentUser.gymId
|
|
) {
|
|
return NextResponse.json(
|
|
{ error: "Forbidden - Cannot access users from other gyms" },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Update recommendation status
|
|
const updates: any = {
|
|
status,
|
|
approvedAt: status === "approved" ? new Date() : undefined,
|
|
approvedBy: status === "approved" ? clerkUserId : undefined,
|
|
};
|
|
|
|
// Remove undefined keys
|
|
Object.keys(updates).forEach(
|
|
(key) => updates[key] === undefined && delete updates[key],
|
|
);
|
|
|
|
const updatedRecommendation = await db.updateRecommendation(
|
|
recommendationId,
|
|
updates,
|
|
);
|
|
|
|
if (!updatedRecommendation) {
|
|
return NextResponse.json(
|
|
{ error: "Recommendation not found" },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
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(),
|
|
userId: updatedRecommendation.userId,
|
|
title: "Recommendation Approved! 🎉",
|
|
message:
|
|
"Your AI-powered fitness recommendation has been approved by your trainer. Check it out now!",
|
|
type: "system",
|
|
read: false,
|
|
});
|
|
|
|
log.info("Notification created for approved recommendation", {
|
|
recommendationId,
|
|
userId: updatedRecommendation.userId,
|
|
});
|
|
} catch (notificationError) {
|
|
// Log error but don't fail the approval
|
|
log.error("Failed to create notification", notificationError);
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: updatedRecommendation,
|
|
meta: {
|
|
timestamp: new Date().toISOString(),
|
|
pausedGoals: pausedGoalsCount,
|
|
createdGoals: createdGoalsCount,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
log.error("Error approving recommendation", error);
|
|
return NextResponse.json(
|
|
{ error: "Internal server error" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|