Files
HealthLog/src/app/api/activity/goals/route.ts
T
Marc-André Bombeck 5eb7caebae feat(activity): add an activity calendar with personal baseline and goals
A week or month of steps, active minutes, workouts by sport and training
load, each day placed against the person's own spread over the 84 days
before the period and each period against the median of the twelve weeks
or six months before it. Optional weekly and monthly goals live on the
account (activity_goals_json), travel with the backup and are cleared by
a data wipe. The calendar sits on the workouts page; the steps page links
to it. Computed server-side without AI.
2026-10-21 21:38:32 +02:00

91 lines
2.6 KiB
TypeScript

/**
* GET / PATCH the caller's activity goals (D1036).
*
* Every goal is optional and set by the person; there is no default goal.
* PATCH replaces the goals the body names (`null` clears one) and keeps the
* rest; an unknown key or an out-of-bounds goal is refused with the
* multi-issue 422. The goals live on their own `User` column
* (`activity_goals_json`) and the activity calendar reads them.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { Prisma } from "@/generated/prisma/client";
import { prisma, toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
ACTIVITY_GOAL_KEYS,
activityGoalsSchema,
hasNoActivityGoals,
mergeActivityGoals,
parseActivityGoals,
type ActivityGoals,
} from "@/lib/validations/activity-goals";
export const dynamic = "force-dynamic";
async function readGoals(userId: string): Promise<ActivityGoals> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { activityGoalsJson: true },
});
return parseActivityGoals(row?.activityGoalsJson ?? null);
}
function setKeys(goals: ActivityGoals): string[] {
return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null);
}
export const GET = apiHandler(async () => {
// Owner-only, like the write: a delegate sees the goals where they matter,
// inside the calendar, which carries them.
const { user } = await requireAuth();
const goals = await readGoals(user.id);
annotate({
action: { name: "activity.goals.read" },
meta: { set: setKeys(goals) },
});
return apiSuccess(goals);
});
export const PATCH = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = activityGoalsSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "activity.goals.validation-failed" } });
return returnAllZodIssues(parsed.error, 422);
}
const next = mergeActivityGoals(await readGoals(user.id), parsed.data);
await prisma.user.update({
where: { id: user.id },
data: {
activityGoalsJson: hasNoActivityGoals(next)
? Prisma.DbNull
: toJson(next),
},
});
await auditLog("activity.goals.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { set: setKeys(next) },
});
annotate({
action: { name: "activity.goals.update" },
meta: { set: setKeys(next) },
});
return apiSuccess(next);
});