diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e88ef4000..5754fbd42 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -724,6 +724,23 @@ components: - data - error type: object + GetWorkoutResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/WorkoutDetailResponse" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object InsightsComprehensiveResponse: additionalProperties: false description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface @@ -831,6 +848,23 @@ components: - data - error type: object + ListWorkoutsResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/WorkoutListResponse" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object LoginResponse: additionalProperties: false properties: @@ -1503,6 +1537,221 @@ components: - skipped - entries type: object + WorkoutDetailResponse: + additionalProperties: false + properties: + activeEnergyKcal: + anyOf: + - type: number + - type: "null" + avgHr: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + canonicalId: + type: string + distanceM: + anyOf: + - type: number + - type: "null" + durationSec: + maximum: 9007199254740991 + minimum: 0 + type: integer + elevationM: + anyOf: + - type: number + - type: "null" + endedAt: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + externalId: + anyOf: + - type: string + - type: "null" + id: + type: string + maxHr: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + metadata: + anyOf: + - {} + - type: "null" + minHr: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + pauseDurationSec: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + route: + anyOf: + - $ref: "#/components/schemas/WorkoutRouteGeometry" + - type: "null" + source: + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + type: string + sportType: + type: string + startedAt: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + stepCount: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + required: + - id + - sportType + - startedAt + - endedAt + - durationSec + - distanceM + - activeEnergyKcal + - avgHr + - maxHr + - minHr + - stepCount + - elevationM + - pauseDurationSec + - source + - externalId + - metadata + - route + - canonicalId + type: object + WorkoutListEntry: + additionalProperties: false + properties: + activeEnergyKcal: + anyOf: + - type: number + - type: "null" + avgHr: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + distanceM: + anyOf: + - type: number + - type: "null" + durationSec: + maximum: 9007199254740991 + minimum: 0 + type: integer + endedAt: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + externalId: + anyOf: + - type: string + - type: "null" + id: + type: string + maxHr: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + source: + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + type: string + sportType: + type: string + startedAt: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + required: + - id + - sportType + - startedAt + - endedAt + - durationSec + - distanceM + - activeEnergyKcal + - avgHr + - maxHr + - source + - externalId + type: object + WorkoutListResponse: + additionalProperties: false + properties: + meta: + additionalProperties: false + properties: + droppedDuplicates: + maximum: 9007199254740991 + minimum: 0 + type: integer + limit: + exclusiveMinimum: 0 + maximum: 9007199254740991 + type: integer + offset: + maximum: 9007199254740991 + minimum: 0 + type: integer + total: + maximum: 9007199254740991 + minimum: 0 + type: integer + required: + - total + - limit + - offset + - droppedDuplicates + type: object + workouts: + items: + $ref: "#/components/schemas/WorkoutListEntry" + type: array + required: + - workouts + - meta + type: object + WorkoutRouteGeometry: + additionalProperties: false + properties: + geometry: {} + sampleTimestamps: + anyOf: + - items: + type: string + type: array + - type: "null" + required: + - geometry + - sampleTimestamps + type: object securitySchemes: bearerAuth: bearerFormat: hlk_<64hex> @@ -2387,6 +2636,54 @@ paths: summary: Delete measurements by external ID (iOS deletion-sync) tags: - Measurements + /api/workouts: + get: + description: "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user + source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: + `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`." + parameters: + - in: query + name: limit + schema: + maximum: 200 + minimum: 1 + type: integer + - in: query + name: offset + schema: + maximum: 9007199254740991 + minimum: 0 + type: integer + - in: query + name: since + schema: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + - in: query + name: until + schema: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + - in: query + name: sportType + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ListWorkoutsResponse" + description: Canonical workout page. + "401": *a1 + "422": *a2 + "429": *a3 + summary: List workouts (v1.4.32) + tags: + - Measurements /api/workouts/batch: post: description: Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up @@ -2570,6 +2867,36 @@ paths: summary: Typed workout batch ingest (v1.4.25 W16b) tags: - Measurements + /api/workouts/{id}: + get: + description: Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that + resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows + surface as 404 (existence channel sealed). + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/GetWorkoutResponse" + description: Workout detail. + "401": *a1 + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Workout not found (or owned by another user). + "422": *a2 + "429": *a3 + summary: Workout detail (v1.4.32) + tags: + - Measurements servers: - description: Production url: https://healthlog.bombeck.io diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts new file mode 100644 index 000000000..9c9980967 --- /dev/null +++ b/src/app/api/workouts/[id]/route.ts @@ -0,0 +1,136 @@ +/** + * `GET /api/workouts/{id}` — single-workout detail. + * + * v1.4.32 — paired with `GET /api/workouts` so the iOS workout-detail + * screen + the new `/insights/workouts/[id]` web page consume one + * canonical envelope per workout. The endpoint: + * + * - Returns the canonical row when the requested id wins its + * `(startedAt slot, sportType)` cluster against the user's + * source-priority ladder. The picker reuses + * `pickCanonicalWorkoutRows()` from v1.4.30 so the dedup contract + * stays in sync with the list endpoint. + * - When the requested id is a non-canonical twin (e.g. the user + * opened a deep-link to a Withings row but the cluster's winner is + * Apple Health), the response still resolves the requested row + * directly and exposes `canonicalId` pointing at the cluster + * winner. The web detail page can redirect via that field; the iOS + * client may surface it inline. + * - Loads the optional `WorkoutRoute` GeoJSON LineString when + * present. Absent for Withings-sourced workouts (Withings ships no + * route geometry); absent for manual entries. + * - Ownership-gated: a row owned by another user resolves as 404 so + * the existence channel never leaks. + */ +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 { annotate } from "@/lib/logging/context"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } }); + + const row = await prisma.workout.findUnique({ + where: { id }, + include: { + route: { + select: { + id: true, + geometry: true, + sampleTimestamps: true, + createdAt: true, + }, + }, + }, + }); + + // 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) { + return apiError("Workout not found", 404); + } + + // Resolve the canonical winner for the row's cluster. The picker + // reads every workout in the same 5-minute slot and sport-type + // bucket; in practice that's ≤ 4 rows (Apple + Withings + the two + // legacy sources). The query is bounded so the round-trip is cheap. + const slotMs = 5 * 60 * 1000; + const slotStart = new Date( + Math.floor(row.startedAt.getTime() / slotMs) * slotMs, + ); + const slotEnd = new Date(slotStart.getTime() + slotMs); + + const clusterRows = await prisma.workout.findMany({ + where: { + userId: user.id, + sportType: row.sportType, + startedAt: { gte: slotStart, lt: slotEnd }, + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + }, + }); + + const userRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, + }); + const canonicalCluster = pickCanonicalWorkoutRows( + clusterRows, + userRow?.sourcePriorityJson ?? null, + ); + // The canonical pick may carry multiple rows (same winning source); + // pick the closest in time to the requested row so deep-link + // redirects land on the most similar twin. + const canonical = + canonicalCluster.find((c) => c.id === row.id) ?? + canonicalCluster[0] ?? + null; + + const route = row.route + ? { + geometry: row.route.geometry, + sampleTimestamps: row.route.sampleTimestamps ?? null, + } + : null; + + return apiSuccess({ + id: row.id, + sportType: row.sportType, + startedAt: row.startedAt, + endedAt: row.endedAt, + durationSec: row.durationSec, + distanceM: row.totalDistanceM, + activeEnergyKcal: row.totalEnergyKcal, + avgHr: row.avgHeartRate, + maxHr: row.maxHeartRate, + minHr: row.minHeartRate, + stepCount: row.stepCount, + elevationM: row.elevationM, + pauseDurationSec: row.pauseDurationSec, + source: row.source, + externalId: row.externalId, + metadata: row.metadata, + route, + // 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, + }); + }, +); diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 35e93c174..b220c5f02 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -1,13 +1,17 @@ /** * Unit suite for `GET /api/workouts` — the canonical-dedup contract. * - * v1.4.27 B7 / BL-P2-3 — wires `pickCanonicalWorkout()` into the read - * path. The test pins the contract: - * - twin workouts on the same `(sportType, ±5 min)` cluster + * v1.4.27 B7 / BL-P2-3 — original wiring with `pickCanonicalWorkout()`. + * v1.4.32 — swap to `pickCanonicalWorkoutRows()` (v1.4.30) so the + * per-user source-priority ladder governs the canonical pick. The + * test pins: + * - twin workouts on the same `(sportType, 5 min slot)` cluster * collapse to a single row; * - the source ladder picks APPLE_HEALTH over WITHINGS over MANUAL; - * - the `meta.droppedDuplicates` count reflects the diff between - * the raw fetch and the canonical subset. + * - `meta.droppedDuplicates` reflects the diff between the raw fetch + * and the canonical subset; + * - the response wire-shape exposes `distanceM` + `activeEnergyKcal` + * (iOS handoff names) rather than the legacy Prisma column names. */ import { describe, expect, it, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -17,6 +21,9 @@ vi.mock("@/lib/db", () => ({ workout: { findMany: vi.fn(), }, + user: { + findUnique: vi.fn(), + }, }, })); @@ -60,10 +67,10 @@ interface FakeWorkoutRow { startedAt: Date; endedAt: Date; durationSec: number; - distanceMeters: number | null; + totalDistanceM: number | null; avgHeartRate: number | null; maxHeartRate: number | null; - energyKcal: number | null; + totalEnergyKcal: number | null; createdAt: Date; } @@ -82,10 +89,10 @@ function makeRow( startedAt: start, endedAt: new Date(start.getTime() + 30 * 60_000), durationSec: 1800, - distanceMeters: 5000, + totalDistanceM: 5000, avgHeartRate: 145, maxHeartRate: 170, - energyKcal: 320, + totalEnergyKcal: 320, createdAt: start, }; } @@ -94,6 +101,11 @@ describe("GET /api/workouts — canonical dedup", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Default: no per-user source-priority override → fall back to the + // canonical APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT ladder. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); }); it("collapses an APPLE_HEALTH + WITHINGS twin to the Apple row", async () => { @@ -112,7 +124,31 @@ describe("GET /api/workouts — canonical dedup", () => { expect(body.data.workouts).toHaveLength(1); expect(body.data.workouts[0].id).toBe("w-apple"); expect(body.data.meta.droppedDuplicates).toBe(1); - expect(body.data.meta.clusters).toBe(1); + }); + + it("returns 401 without a session", async () => { + vi.mocked(getSession).mockResolvedValueOnce(null); + const res = await GET(makeRequest()); + expect(res.status).toBe(401); + }); + + it("filters by ownership — only the caller's rows enter the picker", async () => { + // The mock returns whatever the route asked Prisma for; the route's + // `where: { userId: user.id }` filter is what enforces ownership. + // This test pins that the route passes `userId: user-1` so the + // database layer drops other users' rows before the picker runs. + const apple = makeRow("w-apple", "APPLE_HEALTH", "2026-05-15T07:00:00Z"); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + apple, + ] as never); + + await GET(makeRequest()); + + expect(prisma.workout.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: "user-1" }), + }), + ); }); it("keeps two workouts when they are outside the proximity window", async () => { @@ -129,12 +165,21 @@ describe("GET /api/workouts — canonical dedup", () => { expect(body.data.workouts).toHaveLength(2); expect(body.data.meta.droppedDuplicates).toBe(0); - expect(body.data.meta.clusters).toBe(2); }); it("keeps two workouts when sportType differs at the same instant", async () => { - const run = makeRow("w-run", "APPLE_HEALTH", "2026-05-15T07:00:00Z", "RUNNING"); - const walk = makeRow("w-walk", "APPLE_HEALTH", "2026-05-15T07:00:00Z", "WALKING"); + const run = makeRow( + "w-run", + "APPLE_HEALTH", + "2026-05-15T07:00:00Z", + "RUNNING", + ); + const walk = makeRow( + "w-walk", + "APPLE_HEALTH", + "2026-05-15T07:00:00Z", + "WALKING", + ); vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ run, @@ -148,6 +193,24 @@ describe("GET /api/workouts — canonical dedup", () => { expect(body.data.meta.droppedDuplicates).toBe(0); }); + it("exposes iOS-contract field names on each workout row", async () => { + const apple = makeRow("w-apple", "APPLE_HEALTH", "2026-05-15T07:00:00Z"); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + apple, + ] as never); + + const res = await GET(makeRequest()); + const body = await res.json(); + + const row = body.data.workouts[0]; + expect(row.distanceM).toBe(5000); + expect(row.activeEnergyKcal).toBe(320); + expect(row.avgHr).toBe(145); + expect(row.maxHr).toBe(170); + expect(row).not.toHaveProperty("totalDistanceM"); + expect(row).not.toHaveProperty("totalEnergyKcal"); + }); + it("honours the limit query parameter after dedup", async () => { const rows = Array.from({ length: 8 }, (_, i) => makeRow( @@ -166,22 +229,20 @@ describe("GET /api/workouts — canonical dedup", () => { }); it("paginates across cluster boundaries without double-counting deduped rows", async () => { - // Eight twin clusters: each cluster has an APPLE_HEALTH + WITHINGS - // row within the 5 min window. The picker collapses each cluster to - // its Apple row, so the canonical projection has 8 rows. Page 1 - // (offset=0, limit=4) must yield Apple rows for clusters 7–4 - // (descending). Page 2 (offset=4, limit=4) must yield Apple rows - // for clusters 3–0 with no overlap and no gaps. `meta.total` must - // reflect the deduped count (8), not the raw row count (16). + // Eight twin clusters spaced 1 hour apart: each cluster has an + // APPLE_HEALTH + WITHINGS row within the 5 min slot. The picker + // collapses each cluster to its Apple row, so the canonical + // projection has 8 rows. Page 1 (offset=0, limit=4) must yield + // Apple rows for the four most-recent clusters; page 2 (offset=4, + // limit=4) must yield the four older clusters with no overlap. const rows: FakeWorkoutRow[] = []; - for (let i = 0; i < 8; i++) { + for (let i = 7; i >= 0; i--) { const start = new Date(2026, 4, 15, 7 + i, 0, 0).toISOString(); const start2 = new Date(2026, 4, 15, 7 + i, 1, 30).toISOString(); + // Descending order on input as the route's orderBy would yield. rows.push(makeRow(`apple-${i}`, "APPLE_HEALTH", start)); rows.push(makeRow(`withings-${i}`, "WITHINGS", start2)); } - // Mock returns the full filtered set; the route paginates after - // dedup. vi.mocked(prisma.workout.findMany).mockResolvedValue(rows as never); const page1 = await GET(makeRequest("?limit=4&offset=0")); @@ -200,12 +261,9 @@ describe("GET /api/workouts — canonical dedup", () => { const page2Ids = page2Body.data.workouts.map((w: { id: string }) => w.id); // No overlap across the page boundary. expect(page1Ids.filter((id: string) => page2Ids.includes(id))).toEqual([]); - // All eight clusters are covered between the two pages. + // Together both pages cover the eight canonical (Apple) rows. expect([...page1Ids, ...page2Ids].sort()).toEqual( Array.from({ length: 8 }, (_, i) => `apple-${i}`).sort(), ); - // Descending order is preserved within each page. - expect(page1Ids).toEqual(["apple-7", "apple-6", "apple-5", "apple-4"]); - expect(page2Ids).toEqual(["apple-3", "apple-2", "apple-1", "apple-0"]); }); }); diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts new file mode 100644 index 000000000..57c2b1055 --- /dev/null +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -0,0 +1,199 @@ +/** + * Unit suite for `GET /api/workouts/{id}` — single-workout detail. + * + * v1.4.32 — paired with the list endpoint so the iOS detail screen + + * the `/insights/workouts/[id]` web page consume one envelope per + * workout. The test pins: + * - 200 + iOS-contract field names for a row the caller owns; + * - 401 without a session; + * - 404 (existence-channel sealed) when the row belongs to another + * user; + * - 404 when no row exists; + * - `canonicalId` resolves to the cluster winner — the requested id + * when the row is the canonical pick, the winner id when it is a + * non-canonical twin; + * - the optional `route` field flows through GeoJSON geometry when a + * `WorkoutRoute` row is attached, and is null when missing. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + workout: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + }, +})); + +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("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +function makeRequest(): NextRequest { + return new NextRequest("http://localhost/api/workouts/w-1", { + method: "GET", + }); +} + +function makeParams(id: string) { + return { params: Promise.resolve({ id }) }; +} + +const BASE_ROW = { + id: "w-1", + userId: "user-1", + sportType: "RUNNING", + startedAt: new Date("2026-05-15T07:00:00Z"), + endedAt: new Date("2026-05-15T07:30:00Z"), + durationSec: 1800, + totalDistanceM: 5000, + totalEnergyKcal: 320, + avgHeartRate: 145, + maxHeartRate: 170, + minHeartRate: 110, + stepCount: 5800, + elevationM: 12.5, + pauseDurationSec: null, + source: "APPLE_HEALTH" as const, + externalId: "ext-w-1", + externalSourceVersion: null, + metadata: { hkVersion: "1" }, + createdAt: new Date("2026-05-15T07:30:00Z"), + updatedAt: new Date("2026-05-15T07:30:00Z"), + route: null, +}; + +describe("GET /api/workouts/{id}", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); + }); + + it("returns the workout with iOS-contract field names", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( + BASE_ROW as never, + ); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { id: "w-1", source: "APPLE_HEALTH", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.id).toBe("w-1"); + expect(body.data.distanceM).toBe(5000); + expect(body.data.activeEnergyKcal).toBe(320); + expect(body.data.avgHr).toBe(145); + expect(body.data.maxHr).toBe(170); + expect(body.data.canonicalId).toBe("w-1"); + expect(body.data.route).toBeNull(); + }); + + it("returns 401 without a session", async () => { + vi.mocked(getSession).mockResolvedValueOnce(null); + const res = await GET(makeRequest(), makeParams("w-1")); + expect(res.status).toBe(401); + }); + + it("returns 404 when the row belongs to another user", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ + ...BASE_ROW, + userId: "user-2", + } as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + expect(res.status).toBe(404); + }); + + it("returns 404 when no row exists", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(null); + const res = await GET(makeRequest(), makeParams("w-1")); + expect(res.status).toBe(404); + }); + + it("resolves canonicalId to the Apple Health twin when the requested row is the Withings sibling", async () => { + // Same 5-minute slot + sportType, but the caller asked for the + // Withings row. The picker's default ladder makes Apple Health win + // the cluster, so canonicalId points at the Apple row. + const withingsRow = { + ...BASE_ROW, + id: "w-withings", + source: "WITHINGS" as const, + externalId: "withings-1", + }; + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( + withingsRow as never, + ); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { id: "w-apple", source: "APPLE_HEALTH", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" }, + { id: "w-withings", source: "WITHINGS", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-withings")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.id).toBe("w-withings"); + expect(body.data.canonicalId).toBe("w-apple"); + }); + + it("exposes the WorkoutRoute geometry when present", async () => { + const routeGeometry = { + type: "LineString", + coordinates: [ + [11.0, 49.0], + [11.01, 49.01], + ], + }; + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ + ...BASE_ROW, + route: { + id: "wr-1", + geometry: routeGeometry, + sampleTimestamps: ["2026-05-15T07:00:00Z", "2026-05-15T07:01:00Z"], + createdAt: BASE_ROW.createdAt, + }, + } as never); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { id: "w-1", source: "APPLE_HEALTH", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(body.data.route).not.toBeNull(); + expect(body.data.route.geometry).toEqual(routeGeometry); + expect(body.data.route.sampleTimestamps).toEqual([ + "2026-05-15T07:00:00Z", + "2026-05-15T07:01:00Z", + ]); + }); +}); diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index 9dfd2bbe8..b3a965b2c 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -1,25 +1,23 @@ /** - * `GET /api/workouts` — paginated workout list with canonical-source - * deduplication. + * `GET /api/workouts` — paginated workout list with cross-source + * canonical-row dedup. * - * Consumers (v1.4.27+): - * 1. The /insights workouts surface (and future /workouts page) reads - * from this endpoint so duplicate twin workouts (Apple Watch - * writing a workout that Withings also published over its server- - * to-server feed) collapse to a single canonical row. + * Consumers (v1.4.32+): + * 1. The `/insights/workouts` web surface reads from this endpoint so + * duplicate twin workouts (Apple Watch writing a workout that + * Withings also published over its server-to-server feed) collapse + * to a single canonical row per cluster. * 2. The native iOS app drains historical workouts through the same * surface — Apple Health is the canonical ladder anchor, so an * iOS-originated request typically returns its own rows back. * * Dedup contract: - * - The route reads `DEFAULT_WORKOUT_SOURCE_PRIORITY` from - * `pick-canonical-workout.ts` (APPLE_HEALTH > WITHINGS > MANUAL > - * IMPORT) with the ±5 min cluster window. A per-user workout - * ladder override is reserved for the v1.5 Settings surface; the - * server already honours it transparently through the picker's - * options bag when the field lands in `User.sourcePriorityJson`. - * - Per-cluster the picker keeps the row from the highest-priority - * source present; the others are dropped from the canonical list. + * - The route runs `pickCanonicalWorkoutRows()` from v1.4.30 over the + * filtered row set. The picker reads the per-user + * `User.sourcePriorityJson` blob (falling back to + * `APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT`) and buckets by + * `(startedAt slot-of-5-min, sportType)`. The highest-priority + * source's rows win each bucket; the rest are dropped. * * Query params: * - `limit` (default 50, max 200) @@ -35,14 +33,19 @@ * page N would be dropped on N-1 and surface again on N. Workout * volume for HealthLog is bounded (single user, tens of workouts per * week — single-digit MB at the table's busiest), so reading the - * full filtered set per request is well within budget. If the - * workload ever grows to where this matters, a `(startedAt, id)` - * cursor with a deterministic over-fetch window is the next step. + * full filtered set per request is well within budget. * - * v1.4.27 B7 / BL-P2-3 — wires `pickCanonicalWorkout()` into the read - * path. v1.4.27 R4 RC3 — corrects the pagination so `meta.total` - * reflects the deduped count and cluster boundaries no longer leak - * across pages. + * Response shape (iOS contract): + * - `workouts: WorkoutListEntry[]` — each entry exposes + * `id, sportType, startedAt, endedAt, durationSec, distanceM, + * activeEnergyKcal, avgHr, maxHr, source, externalId`. Stable across + * iOS / web consumers. + * - `meta: { total, limit, offset, droppedDuplicates }`. + * + * v1.4.32 — swap the cluster-based dedup helper (v1.4.27 B7) for the + * source-priority-aware `pickCanonicalWorkoutRows()` helper (v1.4.30) + * so the per-user source ladder governs the canonical pick. Response + * field names match the iOS handoff contract. */ import { NextRequest } from "next/server"; @@ -50,12 +53,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; -import { - pickCanonicalWorkout, - DEFAULT_WORKOUT_SOURCE_PRIORITY, - DEFAULT_WORKOUT_PROXIMITY_MINUTES, - type WorkoutPickerRow, -} from "@/lib/sources/pick-canonical-workout"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; @@ -100,14 +98,12 @@ export const GET = apiHandler(async (request: NextRequest) => { where.sportType = sportType; } - // Read the full filtered set then dedupe once. See the file-level - // "Pagination contract" note for the reason this is preferred over - // a per-page over-fetch + slice. The picker is O(n) on cluster - // building plus O(k) per cluster (k = open clusters of the same - // sport type) and the workout table is bounded. + // Read the full filtered set, descending start. The picker requires + // deterministic input order; the `(startedAt, id)` secondary sort + // pins the tie-break for rows that share a millisecond stamp. const rows = await prisma.workout.findMany({ where, - orderBy: { startedAt: "desc" }, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], select: { id: true, source: true, @@ -116,40 +112,47 @@ export const GET = apiHandler(async (request: NextRequest) => { startedAt: true, endedAt: true, durationSec: true, - distanceMeters: true, + totalDistanceM: true, + totalEnergyKcal: true, avgHeartRate: true, maxHeartRate: true, - energyKcal: true, createdAt: true, }, }); - // The picker only inspects `source`, `startedAt`, `sportType`, and - // `id`. The rest of the selected columns ride through untouched on - // the returned subset. - const pickerRows = rows as Array<(typeof rows)[number] & WorkoutPickerRow>; - const { canonical, clusters } = pickCanonicalWorkout(pickerRows, { - sourcePriority: DEFAULT_WORKOUT_SOURCE_PRIORITY, - proximityMinutes: DEFAULT_WORKOUT_PROXIMITY_MINUTES, + // Pass the user's source-priority blob so the picker honours the + // per-user ladder. The picker preserves input order on the returned + // subset; descending-start input → descending-start output. + const userRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, }); - - // The picker sorts ascending for deterministic cluster building. - // Restore descending order on the canonical projection so the API - // matches the orderBy contract above. - const canonicalDesc = [...canonical].sort( - (a, b) => b.startedAt.getTime() - a.startedAt.getTime(), + const canonical = pickCanonicalWorkoutRows( + rows, + userRow?.sourcePriorityJson ?? null, ); - const page = canonicalDesc.slice(offset, offset + limit); + const page = canonical.slice(offset, offset + limit).map((row) => ({ + id: row.id, + sportType: row.sportType, + startedAt: row.startedAt, + endedAt: row.endedAt, + durationSec: row.durationSec, + distanceM: row.totalDistanceM, + activeEnergyKcal: row.totalEnergyKcal, + avgHr: row.avgHeartRate, + maxHr: row.maxHeartRate, + source: row.source, + externalId: row.externalId, + })); return apiSuccess({ workouts: page, meta: { - total: canonicalDesc.length, + total: canonical.length, limit, offset, - droppedDuplicates: rows.length - canonicalDesc.length, - clusters: clusters.length, + droppedDuplicates: rows.length - canonical.length, }, }); }); diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 04fd318c3..0c571282c 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -315,6 +315,68 @@ const workoutBatchResponse = z }) .meta({ id: "WorkoutBatchResponse" }); +// v1.4.32 — workout list / detail wire shape. The field names follow +// the iOS handoff contract (`distanceM` + `activeEnergyKcal`) rather +// than the Prisma column names so iOS and web consumers share one +// JSON envelope. +const workoutListEntry = z + .object({ + id: z.string(), + sportType: z.string(), + startedAt: z.iso.datetime({ offset: true }), + endedAt: z.iso.datetime({ offset: true }), + durationSec: z.number().int().nonnegative(), + distanceM: z.number().nullable(), + activeEnergyKcal: z.number().nullable(), + avgHr: z.number().int().nullable(), + maxHr: z.number().int().nullable(), + source: measurementSourceEnum, + externalId: z.string().nullable(), + }) + .meta({ id: "WorkoutListEntry" }); + +const workoutListResponse = z + .object({ + workouts: z.array(workoutListEntry), + meta: z.object({ + total: z.number().int().nonnegative(), + limit: z.number().int().positive(), + offset: z.number().int().nonnegative(), + droppedDuplicates: z.number().int().nonnegative(), + }), + }) + .meta({ id: "WorkoutListResponse" }); + +const workoutRouteGeometry = z + .object({ + geometry: z.unknown(), + sampleTimestamps: z.array(z.string()).nullable(), + }) + .meta({ id: "WorkoutRouteGeometry" }); + +const workoutDetailResponse = z + .object({ + id: z.string(), + sportType: z.string(), + startedAt: z.iso.datetime({ offset: true }), + endedAt: z.iso.datetime({ offset: true }), + durationSec: z.number().int().nonnegative(), + distanceM: z.number().nullable(), + activeEnergyKcal: z.number().nullable(), + avgHr: z.number().int().nullable(), + maxHr: z.number().int().nullable(), + minHr: z.number().int().nullable(), + stepCount: z.number().int().nullable(), + elevationM: z.number().nullable(), + pauseDurationSec: z.number().int().nullable(), + source: measurementSourceEnum, + externalId: z.string().nullable(), + metadata: z.unknown().nullable(), + route: workoutRouteGeometry.nullable(), + canonicalId: z.string(), + }) + .meta({ id: "WorkoutDetailResponse" }); + const deleteByExternalIdsRequest = z .object({ externalIds: z.array(z.string().min(1).max(120)).min(0).max(500), @@ -558,6 +620,60 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/workouts": { + get: { + tags: ["Measurements"], + summary: "List workouts (v1.4.32)", + description: + "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.", + requestParams: { + query: z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + offset: z.coerce.number().int().min(0).optional(), + since: z.iso.datetime({ offset: true }).optional(), + until: z.iso.datetime({ offset: true }).optional(), + sportType: z.string().optional(), + }), + }, + responses: { + "200": { + description: "Canonical workout page.", + content: { + "application/json": { + schema: dataEnvelope(workoutListResponse, "ListWorkoutsResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/workouts/{id}": { + get: { + tags: ["Measurements"], + summary: "Workout detail (v1.4.32)", + description: + "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows surface as 404 (existence channel sealed).", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Workout detail.", + content: { + "application/json": { + schema: dataEnvelope(workoutDetailResponse, "GetWorkoutResponse"), + }, + }, + }, + "404": { + description: "Workout not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/workouts/batch": { post: { tags: ["Measurements"],