From 5e6fd327a55ce52df257c67609202859957145c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:22:56 +0200 Subject: [PATCH] feat(workouts): delete a workout entered by hand DELETE /api/workouts/{id} removes a MANUAL workout outright, with its child rows and the personal records keyed on it, then queues a silent detection pass so the previous best comes back. Any other source answers 409: the next sync would write it back. Own record only, 404 for another user's row. Refs #1085 --- docs/api/openapi.yaml | 60 ++++++ src/app/api/workouts/[id]/route.ts | 110 +++++++++- src/app/api/workouts/__tests__/delete.test.ts | 189 ++++++++++++++++++ src/lib/openapi/routes/workouts.ts | 30 +++ 4 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 src/app/api/workouts/__tests__/delete.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4a1bd8893..f0055fcf0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4661,6 +4661,42 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + delete: + tags: + - Measurements + summary: Delete a workout entered by hand + description: "Removes a `MANUAL` workout outright, with its child rows, and any personal record that workout set; a + silent detection pass then re-derives the previous best. A workout from any other source answers 409 + (`workout.delete.synced_source`), because the next sync would write it back. Own record only: a cookie session + or a wildcard token. Another user's row answers 404." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteWorkoutResponse" + "401": *a1 + "404": + description: Workout not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The workout was synced from a device or service, not entered by hand. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/workouts/batch: post: tags: @@ -43051,6 +43087,30 @@ components: description: "One AI capability, resolved for one record from every layer that can say no. Render from it; never recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it says." + DeleteWorkoutResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false BatchWorkoutsResponse: type: object properties: diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index f7a7350b9..21f04d8a5 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -21,13 +21,21 @@ * route geometry); absent for manual entries. * - Ownership-gated: a row owned by another user resolves as 404 so * the existence channel never leaks. + * + * `DELETE /api/workouts/{id}` removes a workout entered by hand. Only a + * `MANUAL` row: a synced workout would come back with the next sync, so + * deleting it here would be a promise the record cannot keep. See the + * handler below. */ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; @@ -357,3 +365,103 @@ export const GET = apiHandler( }); }, ); + +/** + * `DELETE /api/workouts/{id}` — remove a workout that was entered by hand. + * + * - Own record only: `requireAuth()` with no scope admits a cookie session or + * a wildcard token and refuses an acting-account carrier, the same arm the + * detail read above uses. A narrow token is refused. + * - Ownership: another user's row answers 404, like the read. + * - `MANUAL` only. Every other source is a sync, and the next sync would + * write the row back, so it answers 409 instead of pretending. + * - Not module-gated: deleting is cleanup of the person's own rows, which + * the data layer allows whatever the module says + * (`module-route-gate-inventory.test.ts`). + * + * The workout table carries no tombstone, so the row is removed outright; + * route, samples, insight and claim rows cascade with it. A personal record + * the workout set would otherwise outlive it (the detector only ever raises + * a stored best), so the records keyed on this workout go in the same + * transaction and a silent detection pass re-derives the honest best. + */ +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const row = await prisma.workout.findUnique({ + where: { id }, + select: { + id: true, + userId: true, + source: true, + externalId: true, + startedAt: true, + sportType: true, + }, + }); + if (!row || row.userId !== user.id) { + return apiError("Workout not found", 404); + } + if (row.source !== "MANUAL") { + annotate({ + action: { + name: "workout.delete", + entity_type: "workout", + entity_id: id, + }, + meta: { outcome: "synced_source", source: row.source }, + }); + return apiError( + "Only a workout entered by hand can be deleted here", + 409, + { errorCode: "workout.delete.synced_source" }, + ); + } + + const removedRecords = await prisma.$transaction(async (tx) => { + const records = await tx.personalRecord.deleteMany({ + where: { + userId: user.id, + metricSlot: { not: null }, + source: "MANUAL", + ...(row.externalId !== null + ? { externalId: row.externalId } + : { externalId: null, achievedAt: row.startedAt }), + }, + }); + await tx.workout.delete({ where: { id: row.id } }); + return records.count; + }); + + await auditLog("workout.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + workoutId: id, + sportType: row.sportType, + personalRecordsRemoved: removedRecords, + }, + }); + annotate({ + action: { name: "workout.delete", entity_type: "workout", entity_id: id }, + meta: { outcome: "deleted", personalRecordsRemoved: removedRecords }, + }); + + invalidateUserMeasurements(user.id, { evict: true }); + + if (removedRecords > 0) { + try { + await enqueuePrDetection(user.id, { silent: true }); + } catch (err) { + annotate({ + action: { name: "personal_records.detection_enqueue_failed" }, + meta: { error: err instanceof Error ? err.message : String(err) }, + }); + } + } + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/workouts/__tests__/delete.test.ts b/src/app/api/workouts/__tests__/delete.test.ts new file mode 100644 index 000000000..340fd2cee --- /dev/null +++ b/src/app/api/workouts/__tests__/delete.test.ts @@ -0,0 +1,189 @@ +/** + * Unit suite for `DELETE /api/workouts/{id}`. + * + * Pins: + * - a workout entered by hand is removed, with the personal records keyed + * on it, and a silent detection pass re-derives the previous best; + * - a synced workout answers 409 and nothing is touched, because the next + * sync would write it back; + * - another user's row and a missing row answer 404; + * - no session answers 401. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const tx = { + personalRecord: { deleteMany: vi.fn() }, + workout: { delete: vi.fn() }, +}; + +vi.mock("@/lib/db", () => ({ + prisma: { + workout: { findUnique: vi.fn() }, + $transaction: vi.fn(async (fn: (client: typeof tx) => unknown) => fn(tx)), + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/jobs/pr-detection", () => ({ + enqueuePrDetection: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { DELETE } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const MANUAL_ROW = { + id: "w-1", + userId: "user-1", + source: "MANUAL" as const, + externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666", + startedAt: new Date("2026-09-01T07:00:00Z"), + sportType: "running", +}; + +function del(id = "w-1") { + return DELETE( + new NextRequest(`http://localhost/api/workouts/${id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id }) }, + ); +} + +describe("DELETE /api/workouts/{id}", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + tx.personalRecord.deleteMany.mockResolvedValue({ count: 0 }); + tx.workout.delete.mockResolvedValue({ id: "w-1" }); + }); + + it("removes a workout entered by hand", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never); + + const res = await del(); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ data: { deleted: true }, error: null }); + expect(tx.workout.delete).toHaveBeenCalledWith({ where: { id: "w-1" } }); + expect(auditLog).toHaveBeenCalledWith( + "workout.delete", + expect.objectContaining({ userId: "user-1" }), + ); + expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("takes the personal records the workout set with it and re-derives the best", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never); + tx.personalRecord.deleteMany.mockResolvedValue({ count: 2 }); + + const res = await del(); + + expect(res.status).toBe(200); + expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({ + where: { + userId: "user-1", + metricSlot: { not: null }, + source: "MANUAL", + externalId: MANUAL_ROW.externalId, + }, + }); + expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", { + silent: true, + }); + }); + + it("keys a record without an external id on the session's start", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue({ + ...MANUAL_ROW, + externalId: null, + } as never); + + await del(); + + expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({ + where: { + userId: "user-1", + metricSlot: { not: null }, + source: "MANUAL", + externalId: null, + achievedAt: MANUAL_ROW.startedAt, + }, + }); + // Nothing was removed, so nothing needs re-deriving. + expect(enqueuePrDetection).not.toHaveBeenCalled(); + }); + + it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"])( + "refuses a %s workout with 409 and touches nothing", + async (source) => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue({ + ...MANUAL_ROW, + source, + } as never); + + const res = await del(); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.data).toBeNull(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(tx.workout.delete).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalled(); + }, + ); + + it("answers 404 for another user's workout", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue({ + ...MANUAL_ROW, + userId: "someone-else", + } as never); + + const res = await del(); + + expect(res.status).toBe(404); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("answers 404 when there is no such workout", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue(null); + + const res = await del("missing"); + + expect(res.status).toBe(404); + }); + + it("answers 401 without a session", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await del(); + + expect(res.status).toBe(401); + expect(prisma.workout.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 2ef30d3bf..e8bda4fe9 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -312,6 +312,36 @@ export const workoutPaths: NonNullable = { ...stdResponses, }, }, + delete: { + tags: ["Measurements"], + summary: "Delete a workout entered by hand", + description: + "Removes a `MANUAL` workout outright, with its child rows, and any personal record that workout set; a silent detection pass then re-derives the previous best. A workout from any other source answers 409 (`workout.delete.synced_source`), because the next sync would write it back. Own record only: a cookie session or a wildcard token. Another user's row answers 404.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.literal(true) }), + "DeleteWorkoutResponse", + ), + }, + }, + }, + "404": { + description: "Workout not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The workout was synced from a device or service, not entered by hand.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, }, "/api/workouts/batch": { post: {