3a5b929284
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.
205 lines
7.6 KiB
TypeScript
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 });
|
|
}
|
|
}
|