Files
HealthLog/src/lib/workouts/delete-manual-workout.ts
T
Marc-André Bombeck 817a922df5 refactor(workouts): move the workout detail read and the manual delete out of their route
GET keeps authentication, the module gate and the AI capability, and hands
the rest to readWorkoutDetail (lib/workouts/detail.ts): the stored row, the
canonical winner of its cluster, the enrichment reads and the body, as
named stages. DELETE keeps authentication and the HTTP answers; the
ownership check, the synced-source refusal and the transactional removal
with its records, audit, cache and detection tail move to
lib/workouts/delete-manual-workout.ts.

The parity walk recorded before the move is unchanged; the guards that name
the canonical-identity reads and the track readers point at the service.
2026-10-23 14:00:28 +02:00

116 lines
3.9 KiB
TypeScript

/**
* `DELETE /api/workouts/{id}`: remove a workout that was entered by hand.
*
* - Ownership: another user's row reads as missing, like the detail read.
* - `MANUAL` only. Every other source is a sync, and the next sync would
* write the row back, so it is refused instead of pretending.
*
* The workout table carries no tombstone, so the row is removed outright;
* route, samples and insight rows cascade with it. The insight's generation
* claim stays behind with a null workout id: it is the day's cap ledger, and
* a delete must not hand the slot back. 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.
*/
import { prisma } from "@/lib/db";
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 {
lockPersonalRecordsForUser,
workoutPrSlotsForSport,
} from "@/lib/personal-records/pr-detection-worker";
export type DeleteManualWorkoutOutcome =
{ kind: "not_found" } | { kind: "synced_source" } | { kind: "deleted" };
export async function deleteManualWorkout(args: {
userId: string;
id: string;
ipAddress: string | null;
}): Promise<DeleteManualWorkoutOutcome> {
const { userId, id } = args;
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 !== userId) {
return { kind: "not_found" };
}
if (row.source !== "MANUAL") {
annotate({
action: {
name: "workout.delete",
entity_type: "workout",
entity_id: id,
},
meta: { outcome: "synced_source", source: row.source },
});
return { kind: "synced_source" };
}
const removedRecords = await prisma.$transaction(async (tx) => {
// The detector reads the best workout and writes its record under this
// same lock, so it either wrote before this delete (and the record goes
// below) or reads after it (and never sees the workout).
await lockPersonalRecordsForUser(tx, userId);
const records = await tx.personalRecord.deleteMany({
where: {
userId: userId,
source: "MANUAL",
...(row.externalId !== null
? { metricSlot: { not: null }, externalId: row.externalId }
: {
// A legacy record carries no external id, so the start time
// is all that ties it to this workout. Scope it to the slots
// of this workout's sport so a record another sport set at
// the same instant stays.
metricSlot: { in: workoutPrSlotsForSport(row.sportType) },
externalId: null,
achievedAt: row.startedAt,
}),
},
});
await tx.workout.delete({ where: { id: row.id } });
return records.count;
});
await auditLog("workout.delete", {
userId: userId,
ipAddress: args.ipAddress,
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(userId, { evict: true });
if (removedRecords > 0) {
try {
await enqueuePrDetection(userId, { silent: true });
} catch (err) {
annotate({
action: { name: "personal_records.detection_enqueue_failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
}
return { kind: "deleted" };
}