f487204b73
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.
208 lines
5.9 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|