mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
da15885dd4
commit
8d514ed127
6 files changed
+919
-80
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user