Files
proof-of-work/proof-of-work/app/api/programs/[id]/route.ts
T
Keysat 3a5b929284 v1.1.0:1 — Programs UI (manual create / save / follow)
Schema
- Workout.programDayId added (nullable FK to ProgramDay) so a
  Workout logged from a program day can be tied back to the planned
  session for adherence analytics. Compat ALTER in entrypoint adds
  the column + index to existing /data; ON DELETE SET NULL so
  deleting a program doesn't remove historical workouts logged
  against it.
- Back-relation `workouts: Workout[]` added to ProgramDay.

API (proof-of-work/app/api/programs/...)
- GET    /api/programs                       — list user's programs
- POST   /api/programs                       — create with full nested
                                                 weeks/days/exercises
                                                 tree in one transaction
- GET    /api/programs/[id]                  — full tree
- PATCH  /api/programs/[id]                  — update metadata AND/OR
                                                 replace entire weeks
                                                 tree (same shape as
                                                 POST). UI editor + AI
                                                 apply flow share this.
- DELETE /api/programs/[id]                  — cascading
- POST   /api/programs/[id]/days/[dayId]/start
                                              — creates a Workout
                                                 pre-populated with
                                                 empty SetLogs (one per
                                                 planned set), tagged
                                                 with programDayId.

UI (proof-of-work/app/main/programs/...)
- /main/programs               — list with cards, today's-session
                                  callout, "active" badge
- /main/programs/new           — create form using ProgramEditor
- /main/programs/[id]          — detail + edit using same editor;
                                  today's-session card + Start button
                                  if program is active
- ProgramEditor component (components/programs/ProgramEditor.tsx) —
  expandable tree editor for weeks -> days -> exercises with
  per-row sets/reps/RPE/rest/notes fields + library exercise picker
- ProgramActions: delete button
- StartSessionButton: POSTs to start endpoint, redirects to new
  workout

Navigation
- "Programs" link added to bottom nav + sidebar (between Workouts
  and Exercises).
- /main/programs page itself shows the today's-session card; the
  same component pattern can be lifted into the dashboard later
  if we want.

lib/db/programs.ts
- getPrograms, getProgramById, getActivePrograms,
  computeTodaysSessionForProgram, getTodaysSession helpers.
- Today's session math: floor((todayUTC - startDateUTC) / 1day),
  weekNumber = floor(.../7) + 1, dayOfWeek = today.getUTCDay().
  Returns null if not started, past durationWeeks, or no day
  matching today's slot (= rest day).

Tests (tests/routes-programs.test.ts)
- 11 new tests covering: 401 unauthenticated, full-tree create
  with nested weeks+days+exercises, cross-user exerciseId
  rejection, list scoped to actor, GET detail returns 404 for
  another user's program, PATCH replace-tree atomicity,
  cascading DELETE, start-day Workout creation with the right
  number of empty SetLogs + programDayId stamped, start-day
  refused for cross-user program day.
- Total: 96 tests across 11 files.

This is the foundation for v1.1.0:2's AI-generated programs —
the AI will produce the same JSON shape POST /api/programs
already accepts, so the apply path is `editor.tsx + POST
/api/programs` with no new API surface.
2026-05-10 07:15:31 -05:00

205 lines
7.6 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getProgramById } from "@/lib/db/programs";
/**
* GET /api/programs/[id] — full program tree (weeks + days + exercises).
* PATCH /api/programs/[id] — update metadata (name/description/type/
* durationWeeks/startDate/isActive) AND/OR
* replace the entire weeks tree if provided.
* Replace-tree is the simpler mental model
* for the UI editor + the AI apply flow:
* same payload shape as POST /api/programs.
* DELETE /api/programs/[id] — cascading delete (weeks/days/exercises go
* via Prisma onDelete: Cascade).
*/
const exerciseInput = z.object({
exerciseId: z.string().min(1),
order: z.number().int().nonnegative(),
sets: z.number().int().positive().optional().nullable(),
repsMin: z.number().int().positive().optional().nullable(),
repsMax: z.number().int().positive().optional().nullable(),
rpe: z.number().int().min(1).max(10).optional().nullable(),
restSeconds: z.number().int().nonnegative().optional().nullable(),
notes: z.string().optional().nullable(),
});
const dayInput = z.object({
dayOfWeek: z.number().int().min(0).max(6),
name: z.string().optional().nullable(),
description: z.string().optional().nullable(),
exercises: z.array(exerciseInput),
});
const weekInput = z.object({
weekNumber: z.number().int().positive(),
phase: z.string().optional().nullable(),
description: z.string().optional().nullable(),
days: z.array(dayInput),
});
const patchSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
type: z.string().min(1).optional(),
durationWeeks: z.number().int().positive().optional(),
startDate: z.string().optional(),
isActive: z.boolean().optional(),
/** When provided, REPLACES the entire weeks tree. */
weeks: z.array(weekInput).optional(),
});
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } },
) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const program = await getProgramById(user.id, params.id);
if (!program) {
return NextResponse.json({ error: "Program not found" }, { status: 404 });
}
return NextResponse.json(program);
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } },
) {
try {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const existing = await prisma.program.findFirst({
where: { id: params.id, userId: user.id },
select: { id: true },
});
if (!existing) {
return NextResponse.json({ error: "Program not found" }, { status: 404 });
}
const body = await request.json();
const validated = patchSchema.parse(body);
// If replacing the tree, verify exercise ownership.
if (validated.weeks) {
const allExerciseIds = new Set<string>();
for (const w of validated.weeks)
for (const d of w.days)
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
if (allExerciseIds.size > 0) {
const owned = await prisma.exercise.findMany({
where: { userId: user.id, id: { in: Array.from(allExerciseIds) } },
select: { id: true },
});
const ownedIds = new Set(owned.map((e) => e.id));
const bad = Array.from(allExerciseIds).filter((id) => !ownedIds.has(id));
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 },
);
}
}
}
const programData: Prisma.ProgramUpdateInput = {};
if (validated.name !== undefined) programData.name = validated.name;
if (validated.description !== undefined)
programData.description = validated.description;
if (validated.type !== undefined) programData.type = validated.type;
if (validated.durationWeeks !== undefined)
programData.durationWeeks = validated.durationWeeks;
if (validated.startDate !== undefined)
programData.startDate = new Date(validated.startDate);
if (validated.isActive !== undefined) programData.isActive = validated.isActive;
await prisma.$transaction(async (tx) => {
if (Object.keys(programData).length > 0) {
await tx.program.update({ where: { id: params.id }, data: programData });
}
if (validated.weeks) {
// Wipe and rebuild the entire tree. Cascading delete on
// ProgramWeek removes ProgramDay + ProgramExercise; Workouts
// referencing those days have their programDayId set to NULL
// by the FK ON DELETE SET NULL clause we declared in the
// schema, so adherence references aren't catastrophic.
await tx.programWeek.deleteMany({ where: { programId: params.id } });
for (const w of validated.weeks) {
const week = await tx.programWeek.create({
data: {
programId: params.id,
weekNumber: w.weekNumber,
phase: w.phase ?? null,
description: w.description ?? null,
},
});
for (const d of w.days) {
const day = await tx.programDay.create({
data: {
weekId: week.id,
dayOfWeek: d.dayOfWeek,
name: d.name ?? null,
description: d.description ?? null,
},
});
if (d.exercises.length > 0) {
await tx.programExercise.createMany({
data: d.exercises.map((ex) => ({
dayId: day.id,
exerciseId: ex.exerciseId,
order: ex.order,
sets: ex.sets ?? null,
repsMin: ex.repsMin ?? null,
repsMax: ex.repsMax ?? null,
rpe: ex.rpe ?? null,
restSeconds: ex.restSeconds ?? null,
notes: ex.notes ?? null,
})) as Prisma.ProgramExerciseCreateManyInput[],
});
}
}
}
}
});
const updated = await getProgramById(user.id, params.id);
return NextResponse.json(updated);
} catch (err) {
if (err instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid patch payload", details: err.errors },
{ status: 400 },
);
}
console.error("PATCH /api/programs/[id] error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } },
) {
try {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const existing = await prisma.program.findFirst({
where: { id: params.id, userId: user.id },
select: { id: true },
});
if (!existing) {
return NextResponse.json({ error: "Program not found" }, { status: 404 });
}
await prisma.program.delete({ where: { id: params.id } });
return NextResponse.json({ success: true });
} catch (err) {
console.error("DELETE /api/programs/[id] error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}