mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
38baafcdd7
commit
817a922df5
5 files changed
+540
-409
No files matched your search
@@ -312,7 +312,7 @@ describe("workout route geometry never reaches an AI path", () => {
|
||||
/\breadRouteGeometry\s*\(|\bencryptRouteGeometry\s*\(|\bgeometryEncrypted\b|\bgeometry\s*:\s*true\b/;
|
||||
|
||||
const ROUTE_READERS: Record<string, string> = {
|
||||
"app/api/workouts/[id]/route.ts":
|
||||
"lib/workouts/detail.ts":
|
||||
"The workout detail read: serves the track to the person's own map.",
|
||||
"lib/workouts/batch-ingest/write.ts":
|
||||
"The workout batch ingest's write: stores a kept track (opt-in), sealed.",
|
||||
|
||||
@@ -48,7 +48,8 @@ const PICKER_CALLERS = ["lib/workouts/canonical-read.ts"];
|
||||
* acknowledgement. The value is the file that owns the read's `select`.
|
||||
*/
|
||||
const IDENTITY_OWNER: Record<string, string> = {
|
||||
"app/api/workouts/[id]/route.ts": "app/api/workouts/[id]/route.ts",
|
||||
// The workout detail read behind `GET /api/workouts/{id}`.
|
||||
"lib/workouts/detail.ts": "lib/workouts/detail.ts",
|
||||
"lib/jobs/workout-insight-generate.ts":
|
||||
"lib/jobs/workout-insight-generate.ts",
|
||||
"lib/workouts/list-read.ts": "lib/workouts/list-read.ts",
|
||||
|
||||
@@ -29,76 +29,16 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
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 {
|
||||
lockPersonalRecordsForUser,
|
||||
workoutPrSlotsForSport,
|
||||
} from "@/lib/personal-records/pr-detection-worker";
|
||||
import {
|
||||
canonicalClusterOf,
|
||||
canonicalWorkoutsInRange,
|
||||
readWorkoutsAroundRange,
|
||||
type CanonicalReadRow,
|
||||
type WorkoutBoundedRead,
|
||||
type WorkoutStartBounds,
|
||||
} from "@/lib/workouts/canonical-read";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
|
||||
import {
|
||||
computeZones,
|
||||
hrMaxFromAge,
|
||||
parseWhoopZoneDurations,
|
||||
} from "@/lib/workouts/zones";
|
||||
import { computeSplits } from "@/lib/workouts/splits";
|
||||
import { buildSportContext } from "@/lib/workouts/sport-context";
|
||||
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
|
||||
import { readRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { readWorkoutDetail } from "@/lib/workouts/detail";
|
||||
import { deleteManualWorkout } from "@/lib/workouts/delete-manual-workout";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** How many earlier rows one step of the previous-session search reads. */
|
||||
const PREVIOUS_SESSION_BATCH = 20;
|
||||
/** Steps before the search gives up; each reaches past the last one. */
|
||||
const PREVIOUS_SESSION_MAX_STEPS = 5;
|
||||
|
||||
/**
|
||||
* The newest canonical workout of the sport that starts before `before` (the
|
||||
* first start of the current session's cluster). A batch of earlier rows names
|
||||
* the span to resolve; the span is read through the windowed reader so its
|
||||
* twins are resolved the way the list resolves them. A span whose rows are all
|
||||
* hidden by a twin further back moves the search back one batch.
|
||||
*/
|
||||
async function previousCanonicalWorkoutId<T extends CanonicalReadRow>(
|
||||
read: WorkoutBoundedRead<T>,
|
||||
latestStartsBefore: (to: Date, take: number) => Promise<Date[]>,
|
||||
before: Date,
|
||||
priorityJson: unknown,
|
||||
): Promise<string | null> {
|
||||
let to = before;
|
||||
for (let step = 0; step < PREVIOUS_SESSION_MAX_STEPS; step += 1) {
|
||||
const earlier = await latestStartsBefore(to, PREVIOUS_SESSION_BATCH);
|
||||
if (earlier.length === 0) return null;
|
||||
const from = earlier[earlier.length - 1]!;
|
||||
const windowed = await readWorkoutsAroundRange({ from, to: before }, read);
|
||||
const canonical = canonicalWorkoutsInRange(windowed, priorityJson);
|
||||
const newest = canonical[canonical.length - 1];
|
||||
if (newest) return newest.id;
|
||||
to = from;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -119,283 +59,19 @@ export const GET = apiHandler(
|
||||
// not even loaded, `aiInsight` is null, and `ai` says why.
|
||||
const ai = await aiCapabilityToServe(user.id, "workoutInsights");
|
||||
|
||||
const row = await prisma.workout.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
route: {
|
||||
select: {
|
||||
id: true,
|
||||
geometry: true,
|
||||
geometryEncrypted: true,
|
||||
sampleTimestamps: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
samples: {
|
||||
select: {
|
||||
samples: true,
|
||||
sampleCount: true,
|
||||
},
|
||||
},
|
||||
insight: ai.available
|
||||
? {
|
||||
select: { paragraphEncrypted: true, generatedAt: true },
|
||||
}
|
||||
: false,
|
||||
},
|
||||
const body = await readWorkoutDetail({
|
||||
userId: user.id,
|
||||
id,
|
||||
compact,
|
||||
ai,
|
||||
});
|
||||
|
||||
// Cross-user 404 guard. The user can only fetch rows they own; any
|
||||
// other id surfaces as "not found" so the existence channel stays
|
||||
// sealed.
|
||||
if (!row || row.userId !== user.id) {
|
||||
if (!body) {
|
||||
return apiError("Workout not found", 404);
|
||||
}
|
||||
|
||||
// Resolve the canonical winner for the row's cluster. The read goes
|
||||
// through the same windowed reader as the list, so the cluster drawn here
|
||||
// is the one the list draws: a twin that straddles a clock slot boundary,
|
||||
// or one whose cluster begins with an earlier workout, resolves to the row
|
||||
// the list shows.
|
||||
const readSameSport = (bounds: WorkoutStartBounds) =>
|
||||
prisma.workout.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: bounds,
|
||||
},
|
||||
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
startedAt: true,
|
||||
sportType: true,
|
||||
// Part of the canonical session identity consulted by the picker —
|
||||
// without it a HealthKit re-send of this very workout stays in the
|
||||
// cluster and the deep link can resolve to the duplicate instead of
|
||||
// the row the list shows.
|
||||
durationSec: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
const around = await readWorkoutsAroundRange(
|
||||
{ from: row.startedAt, to: row.startedAt, inclusiveEnd: true },
|
||||
readSameSport,
|
||||
);
|
||||
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true, dateOfBirth: true },
|
||||
});
|
||||
// The local calendar day this session belongs to. Resolved here, in the
|
||||
// user's timezone, so the day linkage stays server-authoritative — a
|
||||
// client that derived it from `startedAt` would fall back to the browser
|
||||
// zone and put a late-evening session on the wrong day whenever the two
|
||||
// disagree.
|
||||
const timezone = await resolveUserTimezone(user.id);
|
||||
const dayKey = userDayKey(row.startedAt, timezone);
|
||||
|
||||
const cluster = canonicalClusterOf(
|
||||
around,
|
||||
row.id,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
// The cluster's canonical rows, the requested row first when it is one of
|
||||
// them and otherwise the nearest in time, so a deep link to a hidden twin
|
||||
// lands on the most similar row.
|
||||
const canonical = cluster?.canonical[0] ?? null;
|
||||
const effectiveAvgHeartRate = canonical?.avgHeartRate ?? row.avgHeartRate;
|
||||
const effectiveMaxHeartRate = canonical?.maxHeartRate ?? row.maxHeartRate;
|
||||
|
||||
// ── Enrichment reads (all over existing tables — no migration) ────
|
||||
|
||||
// Heart-rate curve: stored series first, pulse-window fallback
|
||||
// second, hide third. One server path, one DTO with provenance.
|
||||
const hrSeries = await buildWorkoutHrSeries({
|
||||
userId: user.id,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
durationSec: row.durationSec,
|
||||
storedSamples: row.samples?.samples ?? null,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "workouts.detail.hr_series" },
|
||||
meta: {
|
||||
source: hrSeries?.source ?? "none",
|
||||
points: hrSeries?.points.length ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Effort zones: WHOOP device durations win; else %HRmax fold from
|
||||
// the series when profile age exists; else null.
|
||||
const ageYears = getAgeFromDateOfBirth(userRow?.dateOfBirth ?? null);
|
||||
const zones = computeZones({
|
||||
hrMax: hrMaxFromAge(ageYears),
|
||||
series: hrSeries?.points ?? [],
|
||||
bucketSec: hrSeries?.bucketSec ?? 0,
|
||||
whoopZoneDurations: parseWhoopZoneDurations(
|
||||
canonical?.metadata ?? row.metadata,
|
||||
),
|
||||
});
|
||||
|
||||
// Per-km splits computed server-side from the geometry + timestamps
|
||||
// so the web client keeps dropping the raw timestamp blob under
|
||||
// `compact=1` while still rendering splits (server-authoritative
|
||||
// parity — iOS gets the same resolved figures).
|
||||
// v1.39.4 — the track is stored sealed; the legacy readable column is
|
||||
// read only for a row the backfill has not reached yet.
|
||||
const routeGeometry = row.route ? readRouteGeometry(row.route) : null;
|
||||
const geometryCoords =
|
||||
routeGeometry &&
|
||||
typeof routeGeometry === "object" &&
|
||||
Array.isArray((routeGeometry as { coordinates?: unknown }).coordinates)
|
||||
? (routeGeometry as { coordinates: RouteCoordinate[] }).coordinates
|
||||
: null;
|
||||
const splits =
|
||||
geometryCoords && Array.isArray(row.route?.sampleTimestamps)
|
||||
? computeSplits(geometryCoords, row.route.sampleTimestamps as string[])
|
||||
: null;
|
||||
|
||||
// Sport context: the user's own last-180-days average for this
|
||||
// sport, cross-source-collapsed so twins don't double-count.
|
||||
const sportContext = await buildSportContext(
|
||||
user.id,
|
||||
row.sportType,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
row.id,
|
||||
);
|
||||
|
||||
// The session before this one in the same sport. "How does this compare
|
||||
// to last time?" is the most likely next question after reading a
|
||||
// workout, and today it costs a trip back to the list plus a scan.
|
||||
// Scoped to rows that start before this session's cluster, so a twin of
|
||||
// THIS session can never be offered as the previous one, and resolved
|
||||
// through the same windowed read so the link lands on the row the list
|
||||
// shows.
|
||||
const clusterStart = cluster?.members[0]?.startedAt ?? row.startedAt;
|
||||
const previousWorkoutId = await previousCanonicalWorkoutId(
|
||||
readSameSport,
|
||||
async (to, take) =>
|
||||
(
|
||||
await prisma.workout.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: { lt: to },
|
||||
},
|
||||
orderBy: [{ startedAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
select: { startedAt: true },
|
||||
})
|
||||
).map((earlier) => earlier.startedAt),
|
||||
clusterStart,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
|
||||
// A route whose track cannot be read (a sealed value that no longer
|
||||
// opens, or an old row stored as a JSON null) is reported as no route at
|
||||
// all: clients decode `route.geometry` as required, so a route object
|
||||
// with a null geometry would fail the whole workout detail for them.
|
||||
const route =
|
||||
row.route && routeGeometry
|
||||
? {
|
||||
geometry: routeGeometry,
|
||||
// `compact=1` drops the (up to 20k-entry) timestamp array; the
|
||||
// SVG needs geometry, not the per-sample timestamps, and the
|
||||
// splits above are already derived from them server-side.
|
||||
sampleTimestamps: compact
|
||||
? null
|
||||
: (row.route.sampleTimestamps ?? null),
|
||||
}
|
||||
: null;
|
||||
|
||||
// v1.10.0 — route-independent per-workout HR series. Present for
|
||||
// both indoor (no route) and outdoor workouts that shipped a
|
||||
// `samples` array on ingest; null otherwise. `compact=1` keeps the
|
||||
// denormalised count but drops the raw sample blob (the web curve
|
||||
// reads `hrSeries` instead).
|
||||
const samples = row.samples
|
||||
? {
|
||||
sampleCount: row.samples.sampleCount,
|
||||
samples: compact ? null : row.samples.samples,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Decrypt the stored paragraph. Fail-SOFT here and only here: `decrypt` is
|
||||
// fail-closed by design, so a rotated-away key would otherwise turn the
|
||||
// whole workout-detail page into a 500 over a garnish field. An
|
||||
// undecryptable paragraph degrades to no card, exactly like a workout that
|
||||
// never had one.
|
||||
let aiInsight: { paragraph: string; generatedAt: string } | null = null;
|
||||
if (ai.available && row.insight) {
|
||||
try {
|
||||
aiInsight = {
|
||||
paragraph: decryptFromBytes(row.insight.paragraphEncrypted),
|
||||
generatedAt: row.insight.generatedAt.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
annotate({
|
||||
action: { name: "workouts.detail.insight_undecryptable" },
|
||||
meta: { workoutId: row.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
id: row.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
// `YYYY-MM-DD` in the user's timezone. Additive; it addresses the
|
||||
// day-scoped reads (intraday pulse, that night's sleep, the day's
|
||||
// mood) the detail surface renders around the session.
|
||||
dayKey,
|
||||
durationSec: row.durationSec,
|
||||
distanceM: row.totalDistanceM,
|
||||
activeEnergyKcal: row.totalEnergyKcal,
|
||||
avgHr: effectiveAvgHeartRate,
|
||||
maxHr: effectiveMaxHeartRate,
|
||||
minHr: row.minHeartRate,
|
||||
// The row's own heart rate. `avgHr` / `maxHr` above may be filled in
|
||||
// from a twin by the canonical picker, which is right for display and
|
||||
// wrong for an edit: re-posting them would store the twin's values on
|
||||
// the hand-entered row (#1162).
|
||||
storedAvgHr: row.avgHeartRate,
|
||||
storedMaxHr: row.maxHeartRate,
|
||||
stepCount: row.stepCount,
|
||||
elevationM: row.elevationM,
|
||||
pauseDurationSec: row.pauseDurationSec,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
metadata: row.metadata,
|
||||
route,
|
||||
samples,
|
||||
// #67 enrichment — all additive, all over existing tables.
|
||||
hrSeries,
|
||||
zones,
|
||||
splits,
|
||||
sportContext,
|
||||
// The per-workout Activity Insight. A pure READ of a row the
|
||||
// `workout-insight-generate` worker wrote when this workout landed —
|
||||
// this route never generates, never enqueues, and never falls back to
|
||||
// a provider. A workout with no row (every historical one, every
|
||||
// re-synced one, every one on a provider-less install) serves null and
|
||||
// the page's `{aiInsight ? <card/> : null}` renders nothing.
|
||||
aiInsight,
|
||||
// Whether the Activity Insight can be shown, and why not.
|
||||
ai,
|
||||
// v1.4.32 — when the requested id is a non-canonical twin the
|
||||
// caller can redirect to `canonicalId` to land on the cluster
|
||||
// winner. `canonicalId === id` when the requested row already
|
||||
// is the winner.
|
||||
canonicalId: canonical?.id ?? row.id,
|
||||
// The canonical previous session in this sport, or null when this is
|
||||
// the first one on record. Additive; a client that does not read it is
|
||||
// unaffected.
|
||||
previousWorkoutId,
|
||||
});
|
||||
return apiSuccess(body);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -425,89 +101,21 @@ export const DELETE = apiHandler(
|
||||
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,
|
||||
},
|
||||
const outcome = await deleteManualWorkout({
|
||||
userId: user.id,
|
||||
id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
if (!row || row.userId !== user.id) {
|
||||
if (outcome.kind === "not_found") {
|
||||
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 },
|
||||
});
|
||||
if (outcome.kind === "synced_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) => {
|
||||
// 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, user.id);
|
||||
const records = await tx.personalRecord.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
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: 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,115 @@
|
||||
/**
|
||||
* `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" };
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* The single-workout detail behind `GET /api/workouts/{id}`: the stored row,
|
||||
* the canonical winner of its duplicate cluster, the enrichment reads (heart
|
||||
* rate curve, effort zones, splits, sport context, the previous session) and
|
||||
* the stored Activity Insight while it may be served. The route owns
|
||||
* authentication, the module gate and the AI capability.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
canonicalClusterOf,
|
||||
canonicalWorkoutsInRange,
|
||||
readWorkoutsAroundRange,
|
||||
type CanonicalReadRow,
|
||||
type WorkoutBoundedRead,
|
||||
type WorkoutStartBounds,
|
||||
} from "@/lib/workouts/canonical-read";
|
||||
import type { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
|
||||
import {
|
||||
computeZones,
|
||||
hrMaxFromAge,
|
||||
parseWhoopZoneDurations,
|
||||
} from "@/lib/workouts/zones";
|
||||
import { computeSplits } from "@/lib/workouts/splits";
|
||||
import { buildSportContext } from "@/lib/workouts/sport-context";
|
||||
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
|
||||
import { readRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
type AiState = Awaited<ReturnType<typeof aiCapabilityToServe>>;
|
||||
|
||||
/** How many earlier rows one step of the previous-session search reads. */
|
||||
const PREVIOUS_SESSION_BATCH = 20;
|
||||
/** Steps before the search gives up; each reaches past the last one. */
|
||||
const PREVIOUS_SESSION_MAX_STEPS = 5;
|
||||
|
||||
/**
|
||||
* The newest canonical workout of the sport that starts before `before` (the
|
||||
* first start of the current session's cluster). A batch of earlier rows names
|
||||
* the span to resolve; the span is read through the windowed reader so its
|
||||
* twins are resolved the way the list resolves them. A span whose rows are all
|
||||
* hidden by a twin further back moves the search back one batch.
|
||||
*/
|
||||
async function previousCanonicalWorkoutId<T extends CanonicalReadRow>(
|
||||
read: WorkoutBoundedRead<T>,
|
||||
latestStartsBefore: (to: Date, take: number) => Promise<Date[]>,
|
||||
before: Date,
|
||||
priorityJson: unknown,
|
||||
): Promise<string | null> {
|
||||
let to = before;
|
||||
for (let step = 0; step < PREVIOUS_SESSION_MAX_STEPS; step += 1) {
|
||||
const earlier = await latestStartsBefore(to, PREVIOUS_SESSION_BATCH);
|
||||
if (earlier.length === 0) return null;
|
||||
const from = earlier[earlier.length - 1]!;
|
||||
const windowed = await readWorkoutsAroundRange({ from, to: before }, read);
|
||||
const canonical = canonicalWorkoutsInRange(windowed, priorityJson);
|
||||
const newest = canonical[canonical.length - 1];
|
||||
if (newest) return newest.id;
|
||||
to = from;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The stored workout with its track, series and (when served) insight. */
|
||||
async function loadWorkout(id: string, ai: AiState) {
|
||||
return prisma.workout.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
route: {
|
||||
select: {
|
||||
id: true,
|
||||
geometry: true,
|
||||
geometryEncrypted: true,
|
||||
sampleTimestamps: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
samples: {
|
||||
select: {
|
||||
samples: true,
|
||||
sampleCount: true,
|
||||
},
|
||||
},
|
||||
insight: ai.available
|
||||
? {
|
||||
select: { paragraphEncrypted: true, generatedAt: true },
|
||||
}
|
||||
: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type StoredWorkout = NonNullable<Awaited<ReturnType<typeof loadWorkout>>>;
|
||||
|
||||
/**
|
||||
* The detail body for a workout the caller owns, or null when the id names
|
||||
* no workout of theirs (another account's row reads as missing, so the
|
||||
* existence channel stays sealed).
|
||||
*/
|
||||
export async function readWorkoutDetail(args: {
|
||||
userId: string;
|
||||
id: string;
|
||||
compact: boolean;
|
||||
ai: AiState;
|
||||
}) {
|
||||
const { userId, id, compact, ai } = args;
|
||||
const row = await loadWorkout(id, ai);
|
||||
if (!row || row.userId !== userId) return null;
|
||||
const identity = await resolveIdentity(userId, row);
|
||||
const enrichment = await readEnrichment(userId, row, identity);
|
||||
return shapeDetail(row, identity, enrichment, { compact, ai });
|
||||
}
|
||||
|
||||
/** The canonical winner of the row's cluster, and the local day. */
|
||||
async function resolveIdentity(userId: string, row: StoredWorkout) {
|
||||
// Resolve the canonical winner for the row's cluster. The read goes
|
||||
// through the same windowed reader as the list, so the cluster drawn here
|
||||
// is the one the list draws: a twin that straddles a clock slot boundary,
|
||||
// or one whose cluster begins with an earlier workout, resolves to the row
|
||||
// the list shows.
|
||||
const readSameSport = (bounds: WorkoutStartBounds) =>
|
||||
prisma.workout.findMany({
|
||||
where: {
|
||||
userId,
|
||||
sportType: row.sportType,
|
||||
startedAt: bounds,
|
||||
},
|
||||
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
startedAt: true,
|
||||
sportType: true,
|
||||
// Part of the canonical session identity consulted by the picker —
|
||||
// without it a HealthKit re-send of this very workout stays in the
|
||||
// cluster and the deep link can resolve to the duplicate instead of
|
||||
// the row the list shows.
|
||||
durationSec: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
const around = await readWorkoutsAroundRange(
|
||||
{ from: row.startedAt, to: row.startedAt, inclusiveEnd: true },
|
||||
readSameSport,
|
||||
);
|
||||
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { sourcePriorityJson: true, dateOfBirth: true },
|
||||
});
|
||||
// The local calendar day this session belongs to. Resolved here, in the
|
||||
// user's timezone, so the day linkage stays server-authoritative — a
|
||||
// client that derived it from `startedAt` would fall back to the browser
|
||||
// zone and put a late-evening session on the wrong day whenever the two
|
||||
// disagree.
|
||||
const timezone = await resolveUserTimezone(userId);
|
||||
const dayKey = userDayKey(row.startedAt, timezone);
|
||||
|
||||
const cluster = canonicalClusterOf(
|
||||
around,
|
||||
row.id,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
// The cluster's canonical rows, the requested row first when it is one of
|
||||
// them and otherwise the nearest in time, so a deep link to a hidden twin
|
||||
// lands on the most similar row.
|
||||
const canonical = cluster?.canonical[0] ?? null;
|
||||
const effectiveAvgHeartRate = canonical?.avgHeartRate ?? row.avgHeartRate;
|
||||
const effectiveMaxHeartRate = canonical?.maxHeartRate ?? row.maxHeartRate;
|
||||
|
||||
return {
|
||||
readSameSport,
|
||||
userRow,
|
||||
dayKey,
|
||||
cluster,
|
||||
canonical,
|
||||
effectiveAvgHeartRate,
|
||||
effectiveMaxHeartRate,
|
||||
};
|
||||
}
|
||||
|
||||
async function readEnrichment(
|
||||
userId: string,
|
||||
row: StoredWorkout,
|
||||
identity: Awaited<ReturnType<typeof resolveIdentity>>,
|
||||
) {
|
||||
const { readSameSport, userRow, cluster, canonical } = identity;
|
||||
// ── Enrichment reads (all over existing tables — no migration) ────
|
||||
|
||||
// Heart-rate curve: stored series first, pulse-window fallback
|
||||
// second, hide third. One server path, one DTO with provenance.
|
||||
const hrSeries = await buildWorkoutHrSeries({
|
||||
userId,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
durationSec: row.durationSec,
|
||||
storedSamples: row.samples?.samples ?? null,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "workouts.detail.hr_series" },
|
||||
meta: {
|
||||
source: hrSeries?.source ?? "none",
|
||||
points: hrSeries?.points.length ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Effort zones: WHOOP device durations win; else %HRmax fold from
|
||||
// the series when profile age exists; else null.
|
||||
const ageYears = getAgeFromDateOfBirth(userRow?.dateOfBirth ?? null);
|
||||
const zones = computeZones({
|
||||
hrMax: hrMaxFromAge(ageYears),
|
||||
series: hrSeries?.points ?? [],
|
||||
bucketSec: hrSeries?.bucketSec ?? 0,
|
||||
whoopZoneDurations: parseWhoopZoneDurations(
|
||||
canonical?.metadata ?? row.metadata,
|
||||
),
|
||||
});
|
||||
|
||||
// Per-km splits computed server-side from the geometry + timestamps
|
||||
// so the web client keeps dropping the raw timestamp blob under
|
||||
// `compact=1` while still rendering splits (server-authoritative
|
||||
// parity — iOS gets the same resolved figures).
|
||||
// The track is stored sealed; the legacy readable column is
|
||||
// read only for a row the backfill has not reached yet.
|
||||
const routeGeometry = row.route ? readRouteGeometry(row.route) : null;
|
||||
const geometryCoords =
|
||||
routeGeometry &&
|
||||
typeof routeGeometry === "object" &&
|
||||
Array.isArray((routeGeometry as { coordinates?: unknown }).coordinates)
|
||||
? (routeGeometry as { coordinates: RouteCoordinate[] }).coordinates
|
||||
: null;
|
||||
const splits =
|
||||
geometryCoords && Array.isArray(row.route?.sampleTimestamps)
|
||||
? computeSplits(geometryCoords, row.route.sampleTimestamps as string[])
|
||||
: null;
|
||||
|
||||
// Sport context: the user's own last-180-days average for this
|
||||
// sport, cross-source-collapsed so twins don't double-count.
|
||||
const sportContext = await buildSportContext(
|
||||
userId,
|
||||
row.sportType,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
row.id,
|
||||
);
|
||||
|
||||
// The session before this one in the same sport. "How does this compare
|
||||
// to last time?" is the most likely next question after reading a
|
||||
// workout, and today it costs a trip back to the list plus a scan.
|
||||
// Scoped to rows that start before this session's cluster, so a twin of
|
||||
// THIS session can never be offered as the previous one, and resolved
|
||||
// through the same windowed read so the link lands on the row the list
|
||||
// shows.
|
||||
const clusterStart = cluster?.members[0]?.startedAt ?? row.startedAt;
|
||||
const previousWorkoutId = await previousCanonicalWorkoutId(
|
||||
readSameSport,
|
||||
async (to, take) =>
|
||||
(
|
||||
await prisma.workout.findMany({
|
||||
where: {
|
||||
userId,
|
||||
sportType: row.sportType,
|
||||
startedAt: { lt: to },
|
||||
},
|
||||
orderBy: [{ startedAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
select: { startedAt: true },
|
||||
})
|
||||
).map((earlier) => earlier.startedAt),
|
||||
clusterStart,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
return {
|
||||
hrSeries,
|
||||
zones,
|
||||
routeGeometry,
|
||||
splits,
|
||||
sportContext,
|
||||
previousWorkoutId,
|
||||
};
|
||||
}
|
||||
|
||||
function shapeDetail(
|
||||
row: StoredWorkout,
|
||||
identity: Awaited<ReturnType<typeof resolveIdentity>>,
|
||||
enrichment: Awaited<ReturnType<typeof readEnrichment>>,
|
||||
options: { compact: boolean; ai: AiState },
|
||||
) {
|
||||
const { compact, ai } = options;
|
||||
const { dayKey, canonical, effectiveAvgHeartRate, effectiveMaxHeartRate } =
|
||||
identity;
|
||||
const {
|
||||
hrSeries,
|
||||
zones,
|
||||
routeGeometry,
|
||||
splits,
|
||||
sportContext,
|
||||
previousWorkoutId,
|
||||
} = enrichment;
|
||||
// A route whose track cannot be read (a sealed value that no longer
|
||||
// opens, or an old row stored as a JSON null) is reported as no route at
|
||||
// all: clients decode `route.geometry` as required, so a route object
|
||||
// with a null geometry would fail the whole workout detail for them.
|
||||
const route =
|
||||
row.route && routeGeometry
|
||||
? {
|
||||
geometry: routeGeometry,
|
||||
// `compact=1` drops the (up to 20k-entry) timestamp array; the
|
||||
// SVG needs geometry, not the per-sample timestamps, and the
|
||||
// splits above are already derived from them server-side.
|
||||
sampleTimestamps: compact
|
||||
? null
|
||||
: (row.route.sampleTimestamps ?? null),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Route-independent per-workout HR series. Present for
|
||||
// both indoor (no route) and outdoor workouts that shipped a
|
||||
// `samples` array on ingest; null otherwise. `compact=1` keeps the
|
||||
// denormalised count but drops the raw sample blob (the web curve
|
||||
// reads `hrSeries` instead).
|
||||
const samples = row.samples
|
||||
? {
|
||||
sampleCount: row.samples.sampleCount,
|
||||
samples: compact ? null : row.samples.samples,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Decrypt the stored paragraph. Fail-SOFT here and only here: `decrypt` is
|
||||
// fail-closed by design, so a rotated-away key would otherwise turn the
|
||||
// whole workout-detail page into a 500 over a garnish field. An
|
||||
// undecryptable paragraph degrades to no card, exactly like a workout that
|
||||
// never had one.
|
||||
let aiInsight: { paragraph: string; generatedAt: string } | null = null;
|
||||
if (ai.available && row.insight) {
|
||||
try {
|
||||
aiInsight = {
|
||||
paragraph: decryptFromBytes(row.insight.paragraphEncrypted),
|
||||
generatedAt: row.insight.generatedAt.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
annotate({
|
||||
action: { name: "workouts.detail.insight_undecryptable" },
|
||||
meta: { workoutId: row.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
// `YYYY-MM-DD` in the user's timezone. Additive; it addresses the
|
||||
// day-scoped reads (intraday pulse, that night's sleep, the day's
|
||||
// mood) the detail surface renders around the session.
|
||||
dayKey,
|
||||
durationSec: row.durationSec,
|
||||
distanceM: row.totalDistanceM,
|
||||
activeEnergyKcal: row.totalEnergyKcal,
|
||||
avgHr: effectiveAvgHeartRate,
|
||||
maxHr: effectiveMaxHeartRate,
|
||||
minHr: row.minHeartRate,
|
||||
// The row's own heart rate. `avgHr` / `maxHr` above may be filled in
|
||||
// from a twin by the canonical picker, which is right for display and
|
||||
// wrong for an edit: re-posting them would store the twin's values on
|
||||
// the hand-entered row (#1162).
|
||||
storedAvgHr: row.avgHeartRate,
|
||||
storedMaxHr: row.maxHeartRate,
|
||||
stepCount: row.stepCount,
|
||||
elevationM: row.elevationM,
|
||||
pauseDurationSec: row.pauseDurationSec,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
metadata: row.metadata,
|
||||
route,
|
||||
samples,
|
||||
// #67 enrichment — all additive, all over existing tables.
|
||||
hrSeries,
|
||||
zones,
|
||||
splits,
|
||||
sportContext,
|
||||
// The per-workout Activity Insight. A pure READ of a row the
|
||||
// `workout-insight-generate` worker wrote when this workout landed —
|
||||
// this route never generates, never enqueues, and never falls back to
|
||||
// a provider. A workout with no row (every historical one, every
|
||||
// re-synced one, every one on a provider-less install) serves null and
|
||||
// the page's `{aiInsight ? <card/> : null}` renders nothing.
|
||||
aiInsight,
|
||||
// Whether the Activity Insight can be shown, and why not.
|
||||
ai,
|
||||
// When the requested id is a non-canonical twin the
|
||||
// caller can redirect to `canonicalId` to land on the cluster
|
||||
// winner. `canonicalId === id` when the requested row already
|
||||
// is the winner.
|
||||
canonicalId: canonical?.id ?? row.id,
|
||||
// The canonical previous session in this sport, or null when this is
|
||||
// the first one on record. Additive; a client that does not read it is
|
||||
// unaffected.
|
||||
previousWorkoutId,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user