mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
1 parent
2ea9dc3936
commit
5e6fd327a5
4 files changed
+388
-1
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user