From 7b4c67b3721e1f0e2515001302a9ca66dc8d232a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 5 Nov 2026 16:04:08 +0100 Subject: [PATCH] feat(workouts): attach a route to a stored workout that has none The workout batch kept `route` only for entries it inserted. An entry matching a stored workout reported `duplicate` and its track was dropped, so turning on `storeWorkoutRoutes` only helped workouts recorded from then on. A matching entry that carries a route now attaches it when the stored workout has no track and the account keeps tracks, the same way the heart-rate series enrichment works: the planner probes for a stored track, the write seals it like an insert does, and the entry reports `enriched` (once, also when it attaches series and track together). The climb derived from the track fills an empty `elevationM`, as it would on insert. A workout that already has a track keeps it, and with routes off nothing is written. Track rows now go through `createManyAndReturn` on the unique `workoutId`, so a track that lands between the probe and the write wins and this entry settles as a duplicate instead of failing or adding a second row. --- CHANGELOG.md | 6 + docs/api/openapi.yaml | 28 +- .../workouts/__tests__/batch-create.test.ts | 32 +- .../__tests__/unstable-external-id.test.ts | 4 +- src/app/api/workouts/batch/route.ts | 19 +- src/lib/openapi/routes/workouts.ts | 12 +- .../batch-ingest/__tests__/plan.test.ts | 166 ++++++++ src/lib/workouts/batch-ingest/entries.ts | 6 +- src/lib/workouts/batch-ingest/plan.ts | 38 +- src/lib/workouts/batch-ingest/write.ts | 98 +++-- .../workout-batch-route-enrich.test.ts | 377 ++++++++++++++++++ 11 files changed, 705 insertions(+), 81 deletions(-) create mode 100644 src/lib/workouts/batch-ingest/__tests__/plan.test.ts create mode 100644 tests/integration/workout-batch-route-enrich.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e53a9ef..7c07f6f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ window. The native client can then tell "nothing in this window" from "further back than the Coach may read". The answer still names the limit, and the web trail keeps its wording. +- Turning on workout routes no longer helps only workouts recorded from + then on. When the app sends a route again for a workout that is already + stored without one, the server now keeps it, sealed like any other + track, and fills the workout's climb if it had none; the entry reports + `enriched`, as it does for a heart-rate series. A workout that already + has a route keeps it, and with routes switched off nothing is stored. ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c8a0b86f8..955c5652e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5289,10 +5289,13 @@ paths: accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports `updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is - attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry - whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the - batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling - receive a 413 with `workout.batch.payload_too_large`. + attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). The same + holds for a `route` while the account keeps tracks (`storeWorkoutRoutes`): a stored workout without a track gets + the one the entry carries, sealed like any other, and the entry reports `enriched`; a workout that already has a + track keeps it. An entry that attaches both reports one `enriched`. An entry whose `samples` array fails + validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The + request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with + `workout.batch.payload_too_large`. **Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the @@ -52685,8 +52688,8 @@ components: minimum: 0 maximum: 9007199254740991 description: Entries that wrote no workout row because the workout was already stored. The top-level counters count - workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series was - attached. + workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series or + GPS track was attached. skipped: type: array items: @@ -52729,11 +52732,14 @@ components: - updated - skipped description: "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. - `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; - the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. - `updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was - sent again with different values and the stored row now carries them; no row was inserted, so it also counts - towards `duplicates`. `skipped`: the server refused the entry, see `reason`." + `enriched`: the workout was already stored and the heart-rate series or GPS track this entry carried (or + both) was attached because the stored workout had none; no workout row was inserted, and the entry also + counts towards the top-level `duplicates`. A track is attached only while the account keeps tracks + (`storeWorkoutRoutes`), and when the stored workout had no elevation, the climb derived from the attached + track fills `elevationM`; no other column of the workout row is written. `updated` (since v1.40.0): a + workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was sent again with different + values and the stored row now carries them; no row was inserted, so it also counts towards `duplicates`. + `skipped`: the server refused the entry, see `reason`." reason: description: "Why a `skipped` entry was refused: `unstable_external_id` for an id that does not survive a client restart, `invalid_samples` for a heart-rate series that failed validation." diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index 34af4f9cd..ec652af43 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -20,7 +20,7 @@ vi.mock("@/lib/db", () => ({ createManyAndReturn: vi.fn(), }, workoutRoute: { - createMany: vi.fn(), + createManyAndReturn: vi.fn(), }, workoutSamples: { createManyAndReturn: vi.fn(), @@ -201,13 +201,16 @@ beforeEach(() => { }) as never, ); vi.mocked(prisma.workout.findFirst).mockResolvedValue(null); - vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 }); - // The series write reports which rows landed, so an enrichment can tell - // an attach from a row a concurrent writer got in first. The fake - // echoes every row back as landed. + // The series and track writes report which rows landed, so an + // enrichment can tell an attach from a row a concurrent writer got in + // first. The fakes echo every row back as landed. + const echoLanded = (async (args: { data: Array<{ workoutId: string }> }) => + args.data.map((row) => ({ workoutId: row.workoutId }))) as never; + vi.mocked(prisma.workoutRoute.createManyAndReturn).mockImplementation( + echoLanded, + ); vi.mocked(prisma.workoutSamples.createManyAndReturn).mockImplementation( - (async (args: { data: Array<{ workoutId: string }> }) => - args.data.map((row) => ({ workoutId: row.workoutId }))) as never, + echoLanded, ); // v1.4.43 — default to no per-user source-priority override so // the write-time picker walks the canonical default ladder. Tests @@ -550,7 +553,7 @@ describe("POST /api/workouts/batch — workout-route opt-in", () => { }; // Same per-entry status as a stored track: the client needs no branch. expect(body.data.entries[0]?.status).toBe("inserted"); - expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled(); }); it("drops the track when the account row carries no opt-in at all", async () => { @@ -564,7 +567,7 @@ describe("POST /api/workouts/batch — workout-route opt-in", () => { const res = await POST(makeRequest({ workouts: [withRoute()] })); expect(res.status).toBe(200); - expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled(); }); }); @@ -599,8 +602,9 @@ describe("POST /api/workouts/batch — nested route attachment", () => { }), ); expect(res.status).toBe(200); - expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0]; + expect(prisma.workoutRoute.createManyAndReturn).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.workoutRoute.createManyAndReturn).mock + .calls[0]?.[0]; const rows = ( call as { data: Array<{ @@ -654,7 +658,7 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => { ); expect(res.status).toBe(200); // The route table is untouched — an indoor workout has no GPS route. - expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled(); // The HR series lands in the dedicated child table by FK. expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1); const call = vi.mocked(prisma.workoutSamples.createManyAndReturn).mock @@ -703,10 +707,10 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => { }), ); expect(res.status).toBe(200); - expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1); + expect(prisma.workoutRoute.createManyAndReturn).toHaveBeenCalledTimes(1); expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1); const routeRows = ( - vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as { + vi.mocked(prisma.workoutRoute.createManyAndReturn).mock.calls[0]?.[0] as { data: Array<{ workoutId: string }>; } ).data; diff --git a/src/app/api/workouts/batch/__tests__/unstable-external-id.test.ts b/src/app/api/workouts/batch/__tests__/unstable-external-id.test.ts index 64dc293c3..1c83cca25 100644 --- a/src/app/api/workouts/batch/__tests__/unstable-external-id.test.ts +++ b/src/app/api/workouts/batch/__tests__/unstable-external-id.test.ts @@ -27,7 +27,7 @@ vi.mock("@/lib/db", () => ({ const txClient = { workout: { createManyAndReturn: vi.fn() }, - workoutRoute: { createMany: vi.fn() }, + workoutRoute: { createManyAndReturn: vi.fn() }, workoutSamples: { createMany: vi.fn() }, }; @@ -133,7 +133,7 @@ beforeEach(() => { })); }, ); - txClient.workoutRoute.createMany.mockResolvedValue({ count: 0 }); + txClient.workoutRoute.createManyAndReturn.mockResolvedValue([]); txClient.workoutSamples.createMany.mockResolvedValue({ count: 0 }); }); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index ba76b39a7..2b753b77f 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -36,8 +36,8 @@ * - `Content-Length` ceiling at 5 MB before parsing — anything * larger returns 413 so the iOS client falls back to one workout * per call. - * - Per-entry status (`inserted | duplicate | enriched | skipped`) so - * the iOS sync cursor can checkpoint accurately. + * - Per-entry status (`inserted | duplicate | enriched | updated | + * skipped`) so the iOS sync cursor can checkpoint accurately. * * Idempotency contract: * - HTTP-level via `Idempotency-Key` (replays cached envelope). @@ -45,12 +45,15 @@ * composite index. Re-posting the same batch surfaces duplicates * rather than failing the call. * - * Heart-rate series enrichment: a re-post that matches a stored - * workout and carries a `samples` array attaches that series when the - * workout has none. The workout row itself stays first-write-wins — - * nothing on it is written, not one column — and a workout that already - * holds a series is a no-op, so the client's history sweep is repeatable - * as often as it likes. See `src/lib/workouts/batch-ingest/plan.ts`. + * Enrichment: a re-post that matches a stored workout and carries a + * `samples` array attaches that series when the workout has none, and one + * that carries a `route` attaches that track when the workout has none and + * the account keeps tracks. The workout row itself stays first-write-wins: + * a series writes nothing on it, an attached track fills only an empty + * `elevationM` with its climb (as an insert would). A workout that already + * holds a series or a track keeps it, so the client's history sweep is + * repeatable as often as it likes. See + * `src/lib/workouts/batch-ingest/plan.ts`. * * Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through * Prisma's `createManyAndReturn`. The returned composite keys identify the diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 9483839fc..7be56014c 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -28,9 +28,9 @@ import { isoDateTime } from "@/lib/validations/datetime"; // refused: an unusable external id, or a `samples` array that failed // validation (`reason: "invalid_samples"`). // -// `enriched` says the entry matched a stored workout that had no -// heart-rate series and the series it carried was attached. The -// workout row itself was not written, so such an entry also counts +// `enriched` says the entry matched a stored workout that lacked the +// heart-rate series or the GPS track it carried, and that part was +// attached. No workout row was inserted, so such an entry also counts // towards `duplicates`. That rule is part of the published contract, // not just a note here: it rides the field descriptions below, because // a client summing the counters has to know which bucket an @@ -41,7 +41,7 @@ const workoutBatchEntryResult = z status: z .enum(["inserted", "duplicate", "enriched", "updated", "skipped"]) .describe( - "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. `updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was sent again with different values and the stored row now carries them; no row was inserted, so it also counts towards `duplicates`. `skipped`: the server refused the entry, see `reason`.", + "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series or GPS track this entry carried (or both) was attached because the stored workout had none; no workout row was inserted, and the entry also counts towards the top-level `duplicates`. A track is attached only while the account keeps tracks (`storeWorkoutRoutes`), and when the stored workout had no elevation, the climb derived from the attached track fills `elevationM`; no other column of the workout row is written. `updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was sent again with different values and the stored row now carries them; no row was inserted, so it also counts towards `duplicates`. `skipped`: the server refused the entry, see `reason`.", ), reason: z .string() @@ -71,7 +71,7 @@ const workoutBatchResponse = z .int() .nonnegative() .describe( - "Entries that wrote no workout row because the workout was already stored. The top-level counters count workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series was attached.", + "Entries that wrote no workout row because the workout was already stored. The top-level counters count workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series or GPS track was attached.", ), skipped: z.array( z.object({ @@ -440,7 +440,7 @@ export const workoutPaths: NonNullable = { tags: ["Measurements"], summary: "Typed workout batch ingest (v1.4.25 W16b)", description: - 'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | updated | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports `updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.\n\n' + + 'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | updated | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports `updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). The same holds for a `route` while the account keeps tracks (`storeWorkoutRoutes`): a stored workout without a track gets the one the entry carries, sealed like any other, and the entry reports `enriched`; a workout that already has a track keeps it. An entry that attaches both reports one `enriched`. An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.\n\n' + "**Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the supported way to push workouts from a bridge that is not the phone. This route is the scope's only one. Such a credential writes only on its owner's own record; its workouts are attributed `EXTERNAL`, resolved from the credential, and it may not name a source itself, so a batch in which any entry carries one, `MANUAL` and `APPLE_HEALTH` alike, is refused 422 (`workout.batch.source_not_permitted`) and nothing is written. `EXTERNAL` gives those writes their own `(userId, source, externalId)` namespace, so a bridge's ids can never collide with the phone's. Every other narrow token, `measurements:write` included, is refused 403.", requestBody: { required: true, diff --git a/src/lib/workouts/batch-ingest/__tests__/plan.test.ts b/src/lib/workouts/batch-ingest/__tests__/plan.test.ts new file mode 100644 index 000000000..7194ed3e4 --- /dev/null +++ b/src/lib/workouts/batch-ingest/__tests__/plan.test.ts @@ -0,0 +1,166 @@ +/** + * The planner's enrichment arm: a stored workout gets the series or the + * track it lacks from an entry that carries one, once per workout, and + * nothing it already holds. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { findMany } = vi.hoisted(() => ({ + findMany: vi.fn<(args: unknown) => Promise>(), +})); +vi.mock("@/lib/db", () => ({ + prisma: { workout: { findMany } }, +})); + +import type { EntryResult, PreparedWorkout } from "../entries"; +import { planWorkoutWrites } from "../plan"; + +const USER = "user-plan"; +const ROUTE = { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452, 300], + [11.078, 49.453, 312], + ], + }, + sampleTimestamps: null, +}; +const SAMPLES = { + samples: [{ t: "2026-05-14T06:30:00.000Z", hr: 121 }], + sampleCount: 1, +}; + +function entry( + index: number, + externalId: string, + parts: Partial> = {}, +): PreparedWorkout { + return { + index, + row: { + userId: USER, + sportType: "running", + startedAt: new Date("2026-05-14T06:30:00.000Z"), + endedAt: new Date("2026-05-14T07:15:00.000Z"), + durationSec: 2700, + source: "APPLE_HEALTH", + externalId, + }, + route: parts.route ?? null, + samples: parts.samples ?? null, + dedupKey: { source: "APPLE_HEALTH", externalId }, + }; +} + +function stored( + externalId: string, + has: { route?: boolean; samples?: boolean } = {}, +) { + return { + id: `w-${externalId}`, + source: "APPLE_HEALTH", + externalId, + route: has.route ? { workoutId: `w-${externalId}` } : null, + samples: has.samples ? { workoutId: `w-${externalId}` } : null, + sportType: "running", + startedAt: new Date("2026-05-14T06:30:00.000Z"), + endedAt: new Date("2026-05-14T07:15:00.000Z"), + durationSec: 2700, + totalEnergyKcal: null, + totalDistanceM: null, + avgHeartRate: null, + maxHeartRate: null, + minHeartRate: null, + stepCount: null, + elevationM: null, + pauseDurationSec: null, + }; +} + +async function plan(rows: unknown[], survivors: PreparedWorkout[]) { + findMany.mockResolvedValue(rows); + const results: EntryResult[] = new Array(survivors.length); + const written = await planWorkoutWrites(USER, survivors, results); + return { plan: written, results }; +} + +beforeEach(() => { + findMany.mockReset(); +}); + +describe("planWorkoutWrites: track enrichment", () => { + it("hands a stored workout without a track the one the entry carries", async () => { + const { plan: p, results } = await plan( + [stored("a")], + [entry(0, "a", { route: ROUTE })], + ); + expect(p.toEnrich).toEqual([ + { index: 0, workoutId: "w-a", samples: null, route: ROUTE }, + ]); + expect(p.toInsert).toEqual([]); + expect(p.duplicates).toBe(0); + // Settled after the write, from what landed. + expect(results[0]).toBeUndefined(); + }); + + it("leaves a stored track alone and settles the entry as a duplicate", async () => { + const { plan: p, results } = await plan( + [stored("a", { route: true })], + [entry(0, "a", { route: ROUTE })], + ); + expect(p.toEnrich).toEqual([]); + expect(p.duplicates).toBe(1); + expect(results[0]).toEqual({ index: 0, status: "duplicate" }); + }); + + it("plans one enrichment carrying both parts when both are missing", async () => { + const { plan: p } = await plan( + [stored("a")], + [entry(0, "a", { route: ROUTE, samples: SAMPLES })], + ); + expect(p.toEnrich).toEqual([ + { index: 0, workoutId: "w-a", samples: SAMPLES, route: ROUTE }, + ]); + }); + + it("drops only the part the stored workout already holds", async () => { + const { plan: p } = await plan( + [stored("a", { samples: true }), stored("b", { route: true })], + [ + entry(0, "a", { route: ROUTE, samples: SAMPLES }), + entry(1, "b", { route: ROUTE, samples: SAMPLES }), + ], + ); + expect(p.toEnrich).toEqual([ + { index: 0, workoutId: "w-a", samples: null, route: ROUTE }, + { index: 1, workoutId: "w-b", samples: SAMPLES, route: null }, + ]); + }); + + it("plans nothing for an entry whose track the opt-in already dropped", async () => { + // `prepareWorkouts` hands a null route while the account keeps none. + const { plan: p, results } = await plan([stored("a")], [entry(0, "a")]); + expect(p.toEnrich).toEqual([]); + expect(results[0]).toEqual({ index: 0, status: "duplicate" }); + }); + + it("lets only the first entry of a batch enrich a given workout", async () => { + const { plan: p, results } = await plan( + [stored("a")], + [entry(0, "a", { samples: SAMPLES }), entry(1, "a", { route: ROUTE })], + ); + expect(p.toEnrich).toEqual([ + { index: 0, workoutId: "w-a", samples: SAMPLES, route: null }, + ]); + expect(results[1]).toEqual({ index: 1, status: "duplicate" }); + }); + + it("asks the probe whether a track is stored", async () => { + await plan([], [entry(0, "a", { route: ROUTE })]); + const args = findMany.mock.calls[0]?.[0] as { + select: Record; + }; + expect(args.select.route).toEqual({ select: { workoutId: true } }); + }); +}); diff --git a/src/lib/workouts/batch-ingest/entries.ts b/src/lib/workouts/batch-ingest/entries.ts index de392b9c0..d1668b437 100644 --- a/src/lib/workouts/batch-ingest/entries.ts +++ b/src/lib/workouts/batch-ingest/entries.ts @@ -20,9 +20,9 @@ export type WorkoutEntry = CreateBatchWorkoutInput["workouts"][number]; * Per-entry outcome the iOS client uses to advance its sync cursor. * `inserted`, `duplicate` and `enriched` all indicate the row landed (or * was already present); the client can checkpoint past them identically. - * `enriched` additionally says the entry's heart-rate series was attached - * to a workout that had none, which is what lets a history sweep count - * its own progress. `skipped` covers entries the server refused: an + * `enriched` additionally says the entry's heart-rate series or GPS track + * (or both) was attached to a stored workout that had none, which is what + * lets a history sweep count its own progress. `skipped` covers entries the server refused: an * unusable external id, or a `samples` array that failed validation. * `updated` says a hand-entered workout (`manual:` id, source MANUAL) was * sent again with different values and the stored row now carries them. diff --git a/src/lib/workouts/batch-ingest/plan.ts b/src/lib/workouts/batch-ingest/plan.ts index 0092c2ae8..597603b2e 100644 --- a/src/lib/workouts/batch-ingest/plan.ts +++ b/src/lib/workouts/batch-ingest/plan.ts @@ -1,7 +1,7 @@ /** * What each surviving entry of a workout batch does to the store: insert a - * new workout, hand a stored one its heart-rate series, correct a - * hand-entered one in place, or nothing (a duplicate). + * new workout, hand a stored one the heart-rate series or GPS track it lacks, + * correct a hand-entered one in place, or nothing (a duplicate). */ import { prisma } from "@/lib/db"; import type { Prisma } from "@/generated/prisma/client"; @@ -62,11 +62,16 @@ function manualOverwriteChanges( export interface WritePlan { toInsert: PreparedWorkout[]; - /** Stored workouts this batch hands a heart-rate series to. */ + /** + * Stored workouts this batch hands what they lack: a heart-rate series, + * a GPS track, or both. Each part is null when the entry carried none or + * the stored workout already has one. + */ toEnrich: Array<{ index: number; workoutId: string; - samples: NonNullable; + samples: PreparedWorkout["samples"]; + route: PreparedWorkout["route"]; }>; /** Hand-entered workouts this batch edits in place. */ toOverwrite: Array<{ @@ -82,6 +87,7 @@ export interface WritePlan { type StoredWorkout = { id: string; hasSeries: boolean; + hasRoute: boolean; values: ManualOverwriteValues; }; @@ -89,8 +95,8 @@ type StoredWorkout = { * Only the write-time dedup survivors are probed against existing rows; the * dropped twins already carry a `duplicate` status and never reach the DB. * - * The same probe answers both questions: does the row exist, and does it - * already carry a series. `id` comes along so an enrichment can address the + * The same probe answers every question: does the row exist, and does it + * already carry a series and a track. `id` comes along so an enrichment can address the * stored workout without a second round trip. */ async function readStoredWorkouts( @@ -115,6 +121,7 @@ async function readStoredWorkouts( source: true, externalId: true, samples: { select: { workoutId: true } }, + route: { select: { workoutId: true } }, sportType: true, startedAt: true, endedAt: true, @@ -137,6 +144,7 @@ async function readStoredWorkouts( existingByKey.set(`${row.source}::${row.externalId}`, { id: row.id, hasSeries: row.samples !== null, + hasRoute: row.route !== null, values: { sportType: row.sportType, startedAt: row.startedAt, @@ -197,17 +205,23 @@ export async function planWorkoutWrites( plan.duplicates += 1; } } else if (known) { - if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) { + // `p.route` is already null while the account keeps no tracks, so + // the opt-in needs no second check here. + const samples = p.samples && !known.hasSeries ? p.samples : null; + const route = p.route && !known.hasRoute ? p.route : null; + if ((samples || route) && !enrichClaimed.has(known.id)) { enrichClaimed.add(known.id); // Enrichment. The status is assigned after the write, because - // a concurrent writer may get the series in first — in which - // case this entry is the plain duplicate it always was. A - // second entry of this batch naming the same workout takes the - // duplicate arm above: one workout, one series, one `enriched`. + // a concurrent writer may get the series or the track in first — + // in which case this entry is the plain duplicate it always was. + // A second entry of this batch naming the same workout takes the + // duplicate arm: one workout, one series, one track, one + // `enriched`. plan.toEnrich.push({ index: p.index, workoutId: known.id, - samples: p.samples, + samples, + route, }); } else { results[p.index] = { index: p.index, status: "duplicate" }; diff --git a/src/lib/workouts/batch-ingest/write.ts b/src/lib/workouts/batch-ingest/write.ts index 4f9526d41..3d979b64e 100644 --- a/src/lib/workouts/batch-ingest/write.ts +++ b/src/lib/workouts/batch-ingest/write.ts @@ -7,6 +7,7 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { lockPersonalRecordsForUser } from "@/lib/personal-records/pr-detection-worker"; import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; +import { routeClimbM } from "@/lib/workouts/route-climb"; import type { EntryResult, PreparedWorkout } from "./entries"; import { MANUAL_OVERWRITE_FIELDS, type WritePlan } from "./plan"; @@ -47,13 +48,19 @@ export async function writeWorkoutPlan( const { insertedIdByIndex } = outcome; /** Workout ids whose series row this request actually wrote. */ const seriesLanded = new Set(); + /** Workout ids whose track row this request actually wrote. */ + const routesLanded = new Set(); /** Batch indices whose manual edit this request actually wrote. */ const overwritten = new Set(); await prisma.$transaction(async (tx) => { await overwriteManualWorkouts(tx, userId, toOverwrite, overwritten); await insertWorkouts(tx, toInsert, insertedIdByIndex); - await insertChildren(tx, plan, insertedIdByIndex, seriesLanded); + await insertChildren(tx, plan, insertedIdByIndex, { + seriesLanded, + routesLanded, + }); + await fillClimbFromAttachedRoutes(tx, userId, toEnrich, routesLanded); }); outcome.insertedCount = insertedIdByIndex.size; @@ -68,13 +75,16 @@ export async function writeWorkoutPlan( // An enriched entry is still a duplicate of the workout row: the // top-level counters count rows, and no row was inserted for it. - // The per-entry status carries what happened to the series. + // The per-entry status carries what happened to the series and the + // track: one `enriched` when either (or both) landed. for (const e of toEnrich) { + const landed = + seriesLanded.has(e.workoutId) || routesLanded.has(e.workoutId); results[e.index] = { index: e.index, - status: seriesLanded.has(e.workoutId) ? "enriched" : "duplicate", + status: landed ? "enriched" : "duplicate", }; - if (seriesLanded.has(e.workoutId)) outcome.enrichedCount += 1; + if (landed) outcome.enrichedCount += 1; outcome.duplicates += 1; } @@ -194,29 +204,36 @@ async function insertWorkouts( } } +/** One track row, the same for a new workout and a stored one. */ +function routeRow( + workoutId: string, + route: NonNullable, +): Prisma.WorkoutRouteCreateManyInput { + return { + workoutId, + // The GPS track is stored sealed only; the readable `geometry` + // column stays NULL. + geometryEncrypted: encryptRouteGeometry(route.geometry), + sampleTimestamps: + route.sampleTimestamps === null + ? Prisma.JsonNull + : (route.sampleTimestamps as Prisma.InputJsonValue), + }; +} + async function insertChildren( tx: Tx, plan: WritePlan, insertedIdByIndex: Map, - seriesLanded: Set, + landed: { seriesLanded: Set; routesLanded: Set }, ): Promise { + const { seriesLanded, routesLanded } = landed; const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = []; const samplesToInsert: Prisma.WorkoutSamplesCreateManyInput[] = []; for (const p of plan.toInsert) { const id = insertedIdByIndex.get(p.index); if (!id) continue; - if (p.route) { - routesToInsert.push({ - workoutId: id, - // The GPS track is stored sealed only; the readable `geometry` - // column stays NULL. - geometryEncrypted: encryptRouteGeometry(p.route.geometry), - sampleTimestamps: - p.route.sampleTimestamps === null - ? Prisma.JsonNull - : (p.route.sampleTimestamps as Prisma.InputJsonValue), - }); - } + if (p.route) routesToInsert.push(routeRow(id, p.route)); if (p.samples) { samplesToInsert.push({ workoutId: id, @@ -226,24 +243,32 @@ async function insertChildren( } } - // Enrichment rides the SAME child write as a fresh workout's - // series — one mechanism, one place where a series is stored. + // Enrichment rides the SAME child writes as a fresh workout's + // series and track — one mechanism, one place where each is stored. // The only difference is where the `workoutId` came from: the // pre-flight probe instead of this request's INSERT ... RETURNING. // Nothing here writes to the workout row. for (const e of plan.toEnrich) { - samplesToInsert.push({ - workoutId: e.workoutId, - samples: e.samples.samples as Prisma.InputJsonValue, - sampleCount: e.samples.sampleCount, - }); + if (e.samples) { + samplesToInsert.push({ + workoutId: e.workoutId, + samples: e.samples.samples as Prisma.InputJsonValue, + sampleCount: e.samples.sampleCount, + }); + } + if (e.route) routesToInsert.push(routeRow(e.workoutId, e.route)); } for (let i = 0; i < routesToInsert.length; i += CHUNK) { - await tx.workoutRoute.createMany({ + // `workoutId` is unique here too: a workout that gained a track + // between the probe and this write (a concurrent batch, the Apple + // Health import) keeps it, and RETURNING leaves this one out. + const landedRoutes = await tx.workoutRoute.createManyAndReturn({ data: routesToInsert.slice(i, i + CHUNK), skipDuplicates: true, + select: { workoutId: true }, }); + for (const row of landedRoutes) routesLanded.add(row.workoutId); } for (let i = 0; i < samplesToInsert.length; i += CHUNK) { // RETURNING names the rows that actually landed. `workoutId` is @@ -258,3 +283,26 @@ async function insertChildren( for (const row of landed) seriesLanded.add(row.workoutId); } } + +/** + * A track attached to a stored workout fills its climb the way a new + * workout's does (`preparedFor`), because the readers take the climb from + * the workout row and never open the track for it. Only where the column + * is still empty: an elevation the source sent stays. + */ +async function fillClimbFromAttachedRoutes( + tx: Tx, + userId: string, + toEnrich: WritePlan["toEnrich"], + routesLanded: ReadonlySet, +): Promise { + for (const e of toEnrich) { + if (!e.route || !routesLanded.has(e.workoutId)) continue; + const climb = routeClimbM(e.route.geometry); + if (climb === null) continue; + await tx.workout.updateMany({ + where: { id: e.workoutId, userId, elevationM: null }, + data: { elevationM: climb }, + }); + } +} diff --git a/tests/integration/workout-batch-route-enrich.test.ts b/tests/integration/workout-batch-route-enrich.test.ts new file mode 100644 index 000000000..8a10ea169 --- /dev/null +++ b/tests/integration/workout-batch-route-enrich.test.ts @@ -0,0 +1,377 @@ +/** + * Integration suite for the track enrichment path of `POST /api/workouts/batch`, + * against a real Postgres in a testcontainer. + * + * The case: an account turned `storeWorkoutRoutes` on after its workouts were + * stored without tracks. A later re-post of the SAME `(source, externalId)` + * carrying a `route` attaches the track to the stored workout, sealed the way + * an insert seals it, and reports the entry as `enriched`. + * + * What the suite pins: + * - The attach happens once, sealed, and reports `enriched`; the climb + * fills the empty `elevationM` and no other column of the workout moves + * - The readers (detail, list) and the backup see the attached track by + * construction + * - A workout that already holds a track keeps it and reports `duplicate` + * - With the opt-in off, nothing is written + * - An entry attaching both series and track reports one `enriched` + * - Two concurrent batches for the same workout leave one track row and + * both answer 200 + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-workout-route-enrich-integration-32-bytes-min-12345"; +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const TEST_USER_ID = "user-workout-route-enrich-test"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "workout-route-enrich", + email: "workout-route-enrich@example.test", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +interface BatchResponse { + processed: number; + inserted: number; + duplicates: number; + entries: Array<{ index: number; status: string; reason?: string }>; +} + +type Geometry = { type: "LineString"; coordinates: number[][] }; + +const TRACK: Geometry = { + type: "LineString", + coordinates: [ + [11.077, 49.452, 300], + [11.078, 49.453, 312], + [11.079, 49.454, 325], + ], +}; +const OTHER_TRACK: Geometry = { + type: "LineString", + coordinates: [ + [7.21, 51.48, 90], + [7.22, 51.49, 95], + ], +}; +const SERIES = [ + { t: "2026-05-14T06:30:00.000Z", hr: 121 }, + { t: "2026-05-14T06:30:05.000Z", hr: 128 }, +]; + +function workout( + externalId: string, + extra: { route?: { geometry: Geometry }; samples?: unknown } = {}, +) { + return { + sportType: "running", + startedAt: "2026-05-14T06:30:00.000Z", + endedAt: "2026-05-14T07:15:00.000Z", + source: "APPLE_HEALTH", + externalId, + ...extra, + }; +} + +async function post(workouts: unknown[]): Promise<{ + status: number; + data: BatchResponse; +}> { + const { POST } = await import("@/app/api/workouts/batch/route"); + const res = await POST( + new NextRequest("http://localhost/api/workouts/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ workouts }), + }), + ); + const json = (await res.json()) as { data: BatchResponse }; + return { status: res.status, data: json.data }; +} + +async function keepRoutes(on: boolean): Promise { + await getPrismaClient().user.update({ + where: { id: TEST_USER_ID }, + data: { storeWorkoutRoutes: on }, + }); +} + +/** A workout stored before the account kept tracks. */ +async function storeWithoutTrack(externalId: string): Promise { + const first = await post([workout(externalId)]); + expect(first.data.entries[0]?.status).toBe("inserted"); + const row = await getPrismaClient().workout.findFirstOrThrow({ + where: { userId: TEST_USER_ID, externalId }, + include: { route: true }, + }); + expect(row.route).toBeNull(); + return row.id; +} + +async function decrypt(workoutId: string): Promise { + const { decryptRouteGeometry } = + await import("@/lib/workouts/route-geometry-cipher"); + const route = await getPrismaClient().workoutRoute.findUniqueOrThrow({ + where: { workoutId }, + }); + return decryptRouteGeometry(route.geometryEncrypted!); +} + +describe("POST /api/workouts/batch — track enrichment (real Postgres)", () => { + it("attaches a sealed track to a stored workout without one and reports enriched", async () => { + const id = await storeWithoutTrack("hk-route-enrich-001"); + const before = await getPrismaClient().workout.findUniqueOrThrow({ + where: { id }, + }); + await keepRoutes(true); + + const second = await post([ + workout("hk-route-enrich-001", { route: { geometry: TRACK } }), + ]); + expect(second.status).toBe(200); + expect(second.data.entries).toEqual([{ index: 0, status: "enriched" }]); + expect(second.data.inserted).toBe(0); + expect(second.data.duplicates).toBe(1); + + // Sealed only, exactly like an insert: the readable column is SQL NULL, + // the ciphertext holds no coordinate, and it opens to what was sent. + const onDisk = await getPrismaClient().$queryRaw< + { readable_is_null: boolean; leaks: boolean }[] + >` + SELECT geometry IS NULL AS readable_is_null, + position(convert_to('49.452', 'UTF8') IN geometry_encrypted) > 0 + AS leaks + FROM workout_routes WHERE workout_id = ${id}`; + expect(onDisk).toEqual([{ readable_is_null: true, leaks: false }]); + expect(await decrypt(id)).toEqual(TRACK); + + // The climb fills the empty column (300 → 312 → 325 m); nothing else + // on the workout row moves. + const after = await getPrismaClient().workout.findUniqueOrThrow({ + where: { id }, + }); + expect(after.elevationM).toBe(25); + const { elevationM: _b, updatedAt: _bu, ...restBefore } = before; + const { elevationM: _a, updatedAt: _au, ...restAfter } = after; + expect(restAfter).toEqual(restBefore); + + // A third post is a no-op: the track is there now. + const third = await post([ + workout("hk-route-enrich-001", { route: { geometry: OTHER_TRACK } }), + ]); + expect(third.data.entries).toEqual([{ index: 0, status: "duplicate" }]); + expect(await decrypt(id)).toEqual(TRACK); + }); + + it("serves the attached track to the readers and the climb to the backup", async () => { + const id = await storeWithoutTrack("hk-route-enrich-readers"); + await keepRoutes(true); + await post([ + workout("hk-route-enrich-readers", { route: { geometry: TRACK } }), + ]); + + const { GET: detail } = await import("@/app/api/workouts/[id]/route"); + const detailRes = await detail( + new NextRequest(`http://localhost/api/workouts/${id}?compact=1`), + { params: Promise.resolve({ id }) }, + ); + expect(detailRes.status).toBe(200); + const detailBody = (await detailRes.json()) as { + data: { route: { geometry: unknown } | null }; + }; + expect(detailBody.data.route?.geometry).toEqual(TRACK); + + const { GET: list } = await import("@/app/api/workouts/route"); + const listRes = await list( + new NextRequest("http://localhost/api/workouts"), + ); + expect(listRes.status).toBe(200); + const listBody = (await listRes.json()) as { + data: { workouts: Array<{ id: string; hasRoute: boolean }> }; + }; + expect(listBody.data.workouts.find((w) => w.id === id)?.hasRoute).toBe( + true, + ); + + // Tracks do not travel in the account backup (its manifest says so); + // the climb they filled does, on the workout row. + const { buildWorkoutsBackupSlice } = + await import("@/lib/export/workouts-backup"); + const slice = await buildWorkoutsBackupSlice( + getPrismaClient(), + TEST_USER_ID, + true, + ); + expect(slice.workouts.find((w) => w.id === id)?.elevationM).toBe(25); + }); + + it("keeps a stored track and reports duplicate", async () => { + await keepRoutes(true); + const first = await post([ + workout("hk-route-enrich-kept", { route: { geometry: TRACK } }), + ]); + expect(first.data.entries[0]?.status).toBe("inserted"); + const row = await getPrismaClient().workout.findFirstOrThrow({ + where: { externalId: "hk-route-enrich-kept" }, + include: { route: true }, + }); + + const second = await post([ + workout("hk-route-enrich-kept", { route: { geometry: OTHER_TRACK } }), + ]); + expect(second.data.entries).toEqual([{ index: 0, status: "duplicate" }]); + const route = await getPrismaClient().workoutRoute.findUniqueOrThrow({ + where: { workoutId: row.id }, + }); + expect(route.id).toBe(row.route!.id); + expect(await decrypt(row.id)).toEqual(TRACK); + }); + + it("writes nothing while the account keeps no tracks", async () => { + const id = await storeWithoutTrack("hk-route-enrich-off"); + const before = await getPrismaClient().workout.findUniqueOrThrow({ + where: { id }, + }); + + const second = await post([ + workout("hk-route-enrich-off", { route: { geometry: TRACK } }), + ]); + expect(second.data.entries).toEqual([{ index: 0, status: "duplicate" }]); + expect( + await getPrismaClient().workoutRoute.count({ where: { workoutId: id } }), + ).toBe(0); + expect( + await getPrismaClient().workout.findUniqueOrThrow({ where: { id } }), + ).toEqual(before); + }); + + it("reports one enriched for an entry that attaches series and track", async () => { + const id = await storeWithoutTrack("hk-route-enrich-both"); + await keepRoutes(true); + + const second = await post([ + workout("hk-route-enrich-both", { + route: { geometry: TRACK }, + samples: SERIES, + }), + ]); + expect(second.data.entries).toEqual([{ index: 0, status: "enriched" }]); + const row = await getPrismaClient().workout.findUniqueOrThrow({ + where: { id }, + include: { route: true, samples: true }, + }); + expect(row.route).not.toBeNull(); + expect(row.samples?.sampleCount).toBe(SERIES.length); + }); + + it("leaves one track row when two batches race for the same workout", async () => { + const id = await storeWithoutTrack("hk-route-enrich-race"); + await keepRoutes(true); + + const [a, b] = await Promise.all([ + post([workout("hk-route-enrich-race", { route: { geometry: TRACK } })]), + post([ + workout("hk-route-enrich-race", { route: { geometry: OTHER_TRACK } }), + ]), + ]); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + const statuses = [a, b].map((r) => r.data.entries[0]?.status).sort(); + expect(statuses).toEqual(["duplicate", "enriched"]); + + expect( + await getPrismaClient().workoutRoute.count({ where: { workoutId: id } }), + ).toBe(1); + const winner = + a.data.entries[0]?.status === "enriched" ? TRACK : OTHER_TRACK; + expect(await decrypt(id)).toEqual(winner); + }); + it("settles as duplicate when a track lands between the probe and the write", async () => { + const id = await storeWithoutTrack("hk-route-enrich-gap"); + const { prepareWorkouts } = + await import("@/lib/workouts/batch-ingest/entries"); + const { planWorkoutWrites } = + await import("@/lib/workouts/batch-ingest/plan"); + const { writeWorkoutPlan } = + await import("@/lib/workouts/batch-ingest/write"); + const { encryptRouteGeometry } = + await import("@/lib/workouts/route-geometry-cipher"); + const { createBatchWorkoutSchema } = + await import("@/lib/validations/workout"); + + const { prepared, results } = prepareWorkouts({ + userId: TEST_USER_ID, + scoped: false, + keepRoutes: true, + // Through the route's own schema, so the entry is the validated shape. + workouts: createBatchWorkoutSchema.parse({ + workouts: [ + workout("hk-route-enrich-gap", { route: { geometry: TRACK } }), + ], + }).workouts, + refusedSampleIndices: new Set(), + }); + const plan = await planWorkoutWrites(TEST_USER_ID, prepared, results); + expect(plan.toEnrich).toHaveLength(1); + + // Another writer gets its track in after the probe. + await getPrismaClient().workoutRoute.create({ + data: { + workoutId: id, + geometryEncrypted: encryptRouteGeometry(OTHER_TRACK), + }, + }); + + const outcome = await writeWorkoutPlan(TEST_USER_ID, plan, results); + expect(outcome.enrichedCount).toBe(0); + expect(results[0]).toEqual({ index: 0, status: "duplicate" }); + expect(await decrypt(id)).toEqual(OTHER_TRACK); + const row = await getPrismaClient().workout.findUniqueOrThrow({ + where: { id }, + }); + // The loser fills no climb either. + expect(row.elevationM).toBeNull(); + }); +});