feat(api): expose workouts list + detail endpoints

Swap the v1.4.27 cluster picker on `GET /api/workouts` for the
source-priority-aware `pickCanonicalWorkoutRows()` (v1.4.30) so the
per-user ladder governs the canonical pick, fix the latent
Prisma-field-name bug (`distanceMeters` / `energyKcal` → the actual
`totalDistanceM` / `totalEnergyKcal` columns), and align the wire
shape with the iOS handoff contract (`distanceM`,
`activeEnergyKcal`, `avgHr`, `maxHr`).

Add `GET /api/workouts/{id}` for the workout-detail surface — owns
the optional `WorkoutRoute` GeoJSON geometry, exposes `canonicalId`
so deep-link redirects can land on the cluster winner, and seals
the existence channel with a cross-user 404 guard.

Register both routes in the OpenAPI registry so the pre-commit
drift hook catches the new shape going forward.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 15:29:33 +02:00
1 parent da15885dd4
commit 8d514ed127
6 files changed
+919 -80

No files matched your search

+327
View File
@@ -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
+136
View File
@@ -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,
});
},
);
@@ -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"]);
});
});
@@ -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",
]);
});
});
+56 -53
View File
@@ -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,
},
});
});
+116
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],