Files
proof-of-work/proof-of-work/app/api/exercises/[id]/route.ts
T
Keysat f487204b73
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
v1.2.0:1 — upgrade to Next.js 15 / React 19
Closes the remaining P1: move off Next 14 onto the CVE-patched Next 15
line (15.5.x), eliminating the framework's RSC DoS/source-exposure
advisories and the middleware-auth-bypass class that applied to the 14.x
auth gate. App Router on Next 15 requires React 19, so react/react-dom
move to 19.x in lockstep; lucide-react and next-themes bump to their
React-19-compatible releases.

The code surface was the Next 15 async-request-API change: params and
searchParams are now Promises. All [id] route handlers (10 files) and the
four server pages that read them now await the resolved value, using a
uniform re-derive idiom that leaves handler bodies untouched. cookies()/
headers() were already awaited, so no other request-API changes were
needed; all routes stay dynamic, so the uncached-by-default change is a
no-op. next.config.js (static CSP) and the middleware matcher are
unchanged. No schema, no API contract change, no data migration.

Verified: tsc + lint clean, 209 tests pass, next build succeeds with the
standalone bundle tracing the Prisma engine.
2026-06-13 00:29:47 -05:00

208 lines
5.9 KiB
TypeScript

import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
/**
* GET /api/exercises/[id]
*
* Get the exercise + a paginated slice of its workout history.
*
* Query params:
* - offset: number (default 0) — how many workouts to skip
* - limit: number (default 25, max 100) — page size
*
* Response shape:
* { exercise, history: [{workout:{id,date,name}, sets:[...]}], hasMore: bool }
*
* Pagination uses the take: limit + 1 trick — fetch one extra row, slice
* it off, and use its presence to set hasMore. Avoids a second COUNT()
* query.
*/
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const sp = request.nextUrl.searchParams;
const limit = Math.min(parseInt(sp.get("limit") || "25"), 100);
const offset = Math.max(parseInt(sp.get("offset") || "0"), 0);
const exercise = await prisma.exercise.findFirst({
where: {
id: params.id,
userId: user.id,
},
});
if (!exercise) {
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
}
// Pull workouts that contain this exercise, with only the matching
// set logs included. Cleaner + faster than fetching all set logs
// and grouping in JS — Prisma generates a single SQL with the
// (userId, deletedAt, date) composite index doing the heavy lift.
const rows = await prisma.workout.findMany({
where: {
userId: user.id,
deletedAt: null,
setLogs: { some: { exerciseId: params.id } },
},
select: {
id: true,
date: true,
name: true,
setLogs: {
where: { exerciseId: params.id },
orderBy: { setNumber: "asc" },
},
},
orderBy: { date: "desc" },
take: limit + 1,
skip: offset,
});
const hasMore = rows.length > limit;
const history = rows.slice(0, limit).map((w) => ({
workout: { id: w.id, date: w.date, name: w.name },
sets: w.setLogs,
}));
return NextResponse.json({ exercise, history, hasMore });
} catch (error) {
console.error("GET /api/exercises/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
/**
* PATCH /api/exercises/[id]
* Edit exercise details
*/
const updateExerciseSchema = z.object({
name: z.string().min(1).optional(),
type: z.string().min(1).optional(),
muscleGroups: z.array(z.string()).optional(),
description: z.string().optional(),
inputFields: z.array(z.string().min(1)).optional(),
defaultWeightUnit: z.string().nullable().optional(),
});
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const exercise = await prisma.exercise.findFirst({
where: {
id: params.id,
userId: user.id,
},
});
if (!exercise) {
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
}
const body = await readJsonBody(request);
const validated = updateExerciseSchema.parse(body);
const data: any = {};
if (validated.name !== undefined) data.name = validated.name;
if (validated.type !== undefined) data.type = validated.type;
if (validated.description !== undefined) data.description = validated.description;
if (validated.muscleGroups !== undefined)
data.muscleGroups = JSON.stringify(validated.muscleGroups);
if (validated.inputFields !== undefined)
data.inputFields = JSON.stringify(validated.inputFields);
if (validated.defaultWeightUnit !== undefined)
data.defaultWeightUnit = validated.defaultWeightUnit;
// Flip isCustom -> true on any user edit. The boot-time
// ensureExerciseLibrary reconciliation only updates rows where
// isCustom = 0, so this preserves the user's intent: once they've
// edited a library exercise, the maintainer can no longer
// overwrite their changes via a curated-library refresh.
data.isCustom = true;
const updated = await prisma.exercise.update({
where: { id: params.id },
data,
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid data", details: error.errors },
{ status: 400 }
);
}
console.error("PATCH /api/exercises/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
/**
* DELETE /api/exercises/[id]
*/
export async function DELETE(
_request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const exercise = await prisma.exercise.findFirst({
where: {
id: params.id,
userId: user.id,
},
});
if (!exercise) {
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
}
await prisma.setLog.deleteMany({
where: { exerciseId: params.id },
});
await prisma.exercise.delete({
where: { id: params.id },
});
return NextResponse.json({ message: "Exercise deleted successfully" });
} catch (error) {
console.error("DELETE /api/exercises/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}