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
This commit is contained in:
Marc-André Bombeck committed 2026-10-02 21:22:56 +02:00
1 parent 2ea9dc3936
commit 5e6fd327a5
4 files changed
+388 -1

No files matched your search

+60
View File
@@ -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:
+109 -1
View File
@@ -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 });
},
);
@@ -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();
});
});
+30
View File
@@ -312,6 +312,36 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...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: {