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 { readJsonBody } from "@/lib/http"; import { findUnownedExerciseIds } from "@/lib/exerciseOwnership"; // Schema now supports creating empty workouts (just date) or with sets const createWorkoutSchema = z.object({ name: z.string().optional(), notes: z.string().optional(), durationMinutes: z.number().int().positive().optional(), difficulty: z.number().int().min(1).max(10).optional(), caloriesBurned: z.number().int().positive().optional(), date: z .string() .refine((s) => !Number.isNaN(Date.parse(s)), { message: "Invalid date" }) .optional(), // ISO date string or date-only string sets: z .array( z.object({ exerciseId: z.string(), setNumber: z.number().int().positive(), reps: z.number().int().positive().optional(), weight: z.number().positive().optional(), weightUnit: z.string().default("lbs"), rpe: z.number().int().min(1).max(10).optional(), gear: z.number().int().min(1).max(5).optional(), durationSeconds: z.number().int().positive().optional(), distance: z.number().positive().optional(), distanceUnit: z.string().optional(), calories: z.number().int().positive().optional(), watts: z.number().int().positive().optional(), customMetrics: z.record(z.string()).optional(), notes: z.string().optional(), }) ) .optional() .default([]), }); // GET: List workouts with search/date filters export async function GET(request: NextRequest) { try { const user = await getCurrentUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const searchParams = request.nextUrl.searchParams; const query = searchParams.get("q"); const dateFrom = searchParams.get("dateFrom"); const dateTo = searchParams.get("dateTo"); // Validate pagination up front: a negative offset or non-numeric value // would otherwise reach Prisma's `skip`/`take` and throw a generic 500. const pagination = z .object({ limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0), }) .safeParse({ limit: searchParams.get("limit") || undefined, offset: searchParams.get("offset") || undefined, }); if (!pagination.success) { return NextResponse.json( { error: "Invalid pagination parameters", details: pagination.error.errors }, { status: 400 } ); } const { limit, offset } = pagination.data; const where: Prisma.WorkoutWhereInput = { userId: user.id, deletedAt: null, }; if (query) { where.name = { contains: query }; } if (dateFrom || dateTo) { const dateFilter: Prisma.DateTimeFilter = {}; if (dateFrom) dateFilter.gte = new Date(dateFrom); if (dateTo) { const toDate = new Date(dateTo); toDate.setHours(23, 59, 59, 999); dateFilter.lte = toDate; } where.date = dateFilter; } const [workouts, total] = await Promise.all([ prisma.workout.findMany({ where, include: { setLogs: { include: { exercise: true, }, orderBy: { setNumber: "asc", }, }, }, orderBy: { date: "desc", }, take: limit, skip: offset, }), prisma.workout.count({ where }), ]); return NextResponse.json({ data: workouts, meta: { total, limit, offset, hasMore: offset + limit < total, }, }); } catch (error) { console.error("Failed to fetch workouts:", error); return NextResponse.json( { error: "Failed to fetch workouts" }, { status: 500 } ); } } // POST: Create workout (can be empty or with sets) export async function POST(request: NextRequest) { try { const user = await getCurrentUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const body = await readJsonBody(request); const validated = createWorkoutSchema.parse(body); // Every referenced exercise must belong to this user (see // lib/exerciseOwnership). const bad = await findUnownedExerciseIds( user.id, validated.sets.map((s) => s.exerciseId), ); if (bad.length > 0) { return NextResponse.json( { error: "Some exerciseIds don't exist in your library", details: bad }, { status: 400 } ); } const workoutDate = validated.date ? new Date(validated.date) : new Date(); const createData: Prisma.WorkoutCreateInput = { user: { connect: { id: user.id } }, name: validated.name || null, notes: validated.notes, durationMinutes: validated.durationMinutes, difficulty: validated.difficulty, caloriesBurned: validated.caloriesBurned, date: workoutDate, setLogs: validated.sets.length > 0 ? { create: validated.sets.map((set) => ({ exercise: { connect: { id: set.exerciseId } }, setNumber: set.setNumber, reps: set.reps, weight: set.weight, weightUnit: set.weightUnit, rpe: set.rpe, gear: set.gear, durationSeconds: set.durationSeconds, distance: set.distance, distanceUnit: set.distanceUnit, calories: set.calories, watts: set.watts, customMetrics: set.customMetrics && Object.keys(set.customMetrics).length > 0 ? JSON.stringify(set.customMetrics) : undefined, notes: set.notes, })), } : undefined, }; const includeOpts = { setLogs: { include: { exercise: true }, orderBy: { setNumber: "asc" as const }, }, }; const workout = await prisma.workout.create({ data: createData, include: includeOpts }); return NextResponse.json(workout, { status: 201 }); } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( { error: "Invalid request data", details: error.errors }, { status: 400 } ); } console.error("Failed to create workout:", error); return NextResponse.json( { error: "Failed to create workout" }, { status: 500 } ); } }