From 817a922df553c8ad56ee7aa8911f8430403f2eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 14:00:28 +0200 Subject: [PATCH] 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. --- .../ai-egress-capability-guard.test.ts | 2 +- .../workout-canonical-identity-guard.test.ts | 3 +- src/app/api/workouts/[id]/route.ts | 422 +----------------- src/lib/workouts/delete-manual-workout.ts | 115 +++++ src/lib/workouts/detail.ts | 407 +++++++++++++++++ 5 files changed, 540 insertions(+), 409 deletions(-) create mode 100644 src/lib/workouts/delete-manual-workout.ts create mode 100644 src/lib/workouts/detail.ts diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index 2b84a83b4..403e64687 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -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 = { - "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.", diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 88d05da85..03b706acf 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -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 = { - "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", diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 274abaefd..2cfe031fa 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.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( - read: WorkoutBoundedRead, - latestStartsBefore: (to: Date, take: number) => Promise, - before: Date, - priorityJson: unknown, -): Promise { - 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 ? : 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 }); }, ); diff --git a/src/lib/workouts/delete-manual-workout.ts b/src/lib/workouts/delete-manual-workout.ts new file mode 100644 index 000000000..2a0a6846c --- /dev/null +++ b/src/lib/workouts/delete-manual-workout.ts @@ -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 { + 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" }; +} diff --git a/src/lib/workouts/detail.ts b/src/lib/workouts/detail.ts new file mode 100644 index 000000000..ed1d6e026 --- /dev/null +++ b/src/lib/workouts/detail.ts @@ -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>; + +/** 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( + read: WorkoutBoundedRead, + latestStartsBefore: (to: Date, take: number) => Promise, + before: Date, + priorityJson: unknown, +): Promise { + 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>>; + +/** + * 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>, +) { + 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>, + enrichment: Awaited>, + 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 ? : 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, + }; +}