diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f66443ff6..d066ed6f3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4706,13 +4706,14 @@ paths: 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 | skipped`) lets the iOS sync cursor checkpoint accurately. - An entry matching a stored workout is first-write-wins for the workout row; 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`. + 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`. **Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the @@ -43190,11 +43191,14 @@ components: - inserted - duplicate - enriched + - 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`. - `skipped`: the server refused the entry, see `reason`." + `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/batch/route.ts b/src/app/api/workouts/batch/route.ts index 8c42af76a..5b885aa9e 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -77,6 +77,7 @@ import { import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; +import { lockPersonalRecordsForUser } from "@/lib/personal-records/pr-detection-worker"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { MAX_WORKOUTS_PER_BATCH } from "@/lib/validations/workout"; @@ -129,8 +130,63 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024; * to a 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. */ -type EntryStatus = "inserted" | "duplicate" | "enriched" | "skipped"; +type EntryStatus = + "inserted" | "duplicate" | "enriched" | "updated" | "skipped"; + +/** The workout columns a hand-entered workout's resubmit may overwrite. */ +const MANUAL_OVERWRITE_FIELDS = [ + "sportType", + "startedAt", + "endedAt", + "durationSec", + "totalEnergyKcal", + "totalDistanceM", + "avgHeartRate", + "maxHeartRate", + "minHeartRate", + "stepCount", + "elevationM", + "pauseDurationSec", +] as const; +type ManualOverwriteField = (typeof MANUAL_OVERWRITE_FIELDS)[number]; +type ManualOverwriteValues = { + [K in ManualOverwriteField]: string | number | Date | null; +}; + +/** + * A workout entered by hand carries one id per form opening, and the form + * resends it with the edited values when the person corrects a submit. Such a + * resubmit is an edit of the stored row, the way a `stats:` measurement + * re-post is. Every other id stays first-write-wins. + */ +function isManualOverwrite(source: unknown, externalId: string | null) { + return ( + source === "MANUAL" && + externalId !== null && + externalId.startsWith("manual:") + ); +} + +/** The changed columns of a manual resubmit, or null when nothing differs. */ +function manualOverwriteChanges( + stored: ManualOverwriteValues, + sent: Prisma.WorkoutCreateManyInput, +): Partial | null { + const changes: Partial = {}; + for (const field of MANUAL_OVERWRITE_FIELDS) { + const next = (sent[field] ?? null) as string | number | Date | null; + const prev = stored[field]; + const same = + next instanceof Date && prev instanceof Date + ? next.getTime() === prev.getTime() + : next === prev; + if (!same) changes[field] = next; + } + return Object.keys(changes).length > 0 ? changes : null; +} interface EntryResult { index: number; status: EntryStatus; @@ -483,6 +539,7 @@ async function postBatch(request: NextRequest): Promise { let duplicateCount = droppedByWriteDedup.length; let insertedCount = 0; let enrichedCount = 0; + let updatedCount = 0; const insertedIdByIndex = new Map(); if (survivors.length > 0) { @@ -510,16 +567,45 @@ async function postBatch(request: NextRequest): Promise { source: true, externalId: true, samples: { select: { workoutId: true } }, + sportType: true, + startedAt: true, + endedAt: true, + durationSec: true, + totalEnergyKcal: true, + totalDistanceM: true, + avgHeartRate: true, + maxHeartRate: true, + minHeartRate: true, + stepCount: true, + elevationM: true, + pauseDurationSec: true, }, }) : []; - const existingByKey = new Map(); + const existingByKey = new Map< + string, + { id: string; hasSeries: boolean; values: ManualOverwriteValues } + >(); for (const row of existing) { if (row.externalId === null) continue; existingByKey.set(`${row.source}::${row.externalId}`, { id: row.id, hasSeries: row.samples !== null, + values: { + sportType: row.sportType, + startedAt: row.startedAt, + endedAt: row.endedAt, + durationSec: row.durationSec, + totalEnergyKcal: row.totalEnergyKcal, + totalDistanceM: row.totalDistanceM, + avgHeartRate: row.avgHeartRate, + maxHeartRate: row.maxHeartRate, + minHeartRate: row.minHeartRate, + stepCount: row.stepCount, + elevationM: row.elevationM, + pauseDurationSec: row.pauseDurationSec, + }, }); } @@ -530,6 +616,13 @@ async function postBatch(request: NextRequest): Promise { workoutId: string; samples: NonNullable; }> = []; + /** Hand-entered workouts this batch edits in place. */ + const toOverwrite: Array<{ + index: number; + workoutId: string; + externalId: string; + changes: Partial; + }> = []; /** Workouts already claimed by an earlier entry of THIS batch. */ const enrichClaimed = new Set(); for (const p of survivors) { @@ -537,7 +630,27 @@ async function postBatch(request: NextRequest): Promise { ? `${p.dedupKey.source}::${p.dedupKey.externalId}` : null; const known = keyTuple !== null ? existingByKey.get(keyTuple) : undefined; - if (known) { + if ( + known && + p.dedupKey && + isManualOverwrite(p.dedupKey.source, p.dedupKey.externalId) + ) { + const changes = enrichClaimed.has(known.id) + ? null + : manualOverwriteChanges(known.values, p.row); + if (changes) { + enrichClaimed.add(known.id); + toOverwrite.push({ + index: p.index, + workoutId: known.id, + externalId: p.dedupKey.externalId, + changes, + }); + } else { + results[p.index] = { index: p.index, status: "duplicate" }; + duplicateCount += 1; + } + } else if (known) { if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) { enrichClaimed.add(known.id); // Enrichment. The status is assigned after the write, because @@ -560,12 +673,45 @@ async function postBatch(request: NextRequest): Promise { } } - if (toInsert.length > 0 || toEnrich.length > 0) { + if (toInsert.length > 0 || toEnrich.length > 0 || toOverwrite.length > 0) { const CHUNK = 100; /** Workout ids whose series row this request actually wrote. */ const seriesLanded = new Set(); + /** Batch indices whose manual edit this request actually wrote. */ + const overwritten = new Set(); await prisma.$transaction(async (tx) => { + // A hand-entered workout sent again with other values. Columns are + // copied one by one from the validated entry; the records the old + // values set go with them and the detection pass below re-derives + // the honest best, as the DELETE does. + if (toOverwrite.length > 0) { + await lockPersonalRecordsForUser(tx, user.id); + } + for (const o of toOverwrite) { + const data: Prisma.WorkoutUpdateManyMutationInput = {}; + for (const field of MANUAL_OVERWRITE_FIELDS) { + if (field in o.changes) { + (data as Record)[field] = o.changes[field]; + } + } + const written = await tx.workout.updateMany({ + where: { id: o.workoutId, userId: user.id, source: "MANUAL" }, + data, + }); + if (written.count === 1) { + overwritten.add(o.index); + await tx.personalRecord.deleteMany({ + where: { + userId: user.id, + source: "MANUAL", + metricSlot: { not: null }, + externalId: o.externalId, + }, + }); + } + } + const withExternalId = toInsert.filter((p) => p.dedupKey !== null); const withoutExternalId = toInsert.filter((p) => p.dedupKey === null); @@ -686,6 +832,18 @@ async function postBatch(request: NextRequest): Promise { if (seriesLanded.has(e.workoutId)) enrichedCount += 1; duplicateCount += 1; } + + // Like an enrichment, an edit inserts no row, so it counts towards + // `duplicates`; the per-entry status says the stored row changed. + for (const o of toOverwrite) { + const landed = overwritten.has(o.index); + results[o.index] = { + index: o.index, + status: landed ? "updated" : "duplicate", + }; + if (landed) updatedCount += 1; + duplicateCount += 1; + } } } @@ -701,6 +859,7 @@ async function postBatch(request: NextRequest): Promise { inserted: insertedCount, duplicates: duplicateCount, enriched: enrichedCount, + updated: updatedCount, skipped: skipped.length, }, }); @@ -739,6 +898,7 @@ async function postBatch(request: NextRequest): Promise { inserted: insertedCount, duplicates: duplicateCount, enriched: enrichedCount, + updated: updatedCount, skipped: skipped.length, }, }); @@ -750,7 +910,7 @@ async function postBatch(request: NextRequest): Promise { // An enrichment changes what the workout reads as — the detail seam // gains its curve, the list gains its glyph — so it busts the cache // too, even though no workout row was written. - if (insertedCount > 0 || enrichedCount > 0) { + if (insertedCount > 0 || enrichedCount > 0 || updatedCount > 0) { invalidateUserMeasurements(user.id); } diff --git a/src/components/workouts/__tests__/manual-workout-form.test.tsx b/src/components/workouts/__tests__/manual-workout-form.test.tsx index 64734040b..15f0d3818 100644 --- a/src/components/workouts/__tests__/manual-workout-form.test.tsx +++ b/src/components/workouts/__tests__/manual-workout-form.test.tsx @@ -138,6 +138,18 @@ describe("saveManualWorkout — the wire", () => { ).resolves.toBe("duplicate"); }); + it("treats an edited resubmit (`updated`) as saved and refreshes", async () => { + fetchSpy.mockResolvedValue( + envelope({ entries: [{ index: 0, status: "updated" }] }), + ); + const client = fakeClient(); + + await expect( + saveManualWorkout(ENTRY, client as unknown as QueryClient), + ).resolves.toBe("updated"); + expect(client.invalidateQueries).toHaveBeenCalled(); + }); + it("throws when the route skipped the entry, and refreshes nothing", async () => { fetchSpy.mockResolvedValue( envelope({ entries: [{ index: 0, status: "skipped" }] }), diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index 9902cac66..a254605e4 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -59,7 +59,7 @@ interface BatchResult { entries?: Array<{ status: string }>; } -export type ManualWorkoutSaveOutcome = "inserted" | "duplicate"; +export type ManualWorkoutSaveOutcome = "inserted" | "duplicate" | "updated"; /** * Post the entry and refresh what shows workouts. A `duplicate` is a success: @@ -74,7 +74,7 @@ export async function saveManualWorkout( workouts: [entry], }); const status = result?.entries?.[0]?.status; - if (status !== "inserted" && status !== "duplicate") { + if (status !== "inserted" && status !== "duplicate" && status !== "updated") { throw new Error(`workout entry not stored: ${status ?? "unknown"}`); } // The list, the dashboard tile and every detail read sit under this @@ -112,7 +112,8 @@ export function ManualWorkoutForm({ const timezone = user?.timezone || DEFAULT_TIMEZONE; // Minted once per opened form and sent with every submit, so a second tap - // or a retried request lands as a duplicate rather than a second row. + // or a retried request lands as a duplicate rather than a second row, and + // a submit after an edit updates the row the first submit stored. const [externalId] = useState(newManualWorkoutExternalId); const [draft, setDraft] = useState(() => emptyManualWorkoutDraft(new Date(), timezone), diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index e8bda4fe9..037b49bb5 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -38,9 +38,9 @@ const workoutBatchEntryResult = z .object({ index: z.number().int().nonnegative(), status: z - .enum(["inserted", "duplicate", "enriched", "skipped"]) + .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`. `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 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`.", ), reason: z .string() @@ -349,7 +349,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 | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row; 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). 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/tests/integration/workout-manual-entry.test.ts b/tests/integration/workout-manual-entry.test.ts index b02b03f71..e6ee0bd20 100644 --- a/tests/integration/workout-manual-entry.test.ts +++ b/tests/integration/workout-manual-entry.test.ts @@ -9,6 +9,8 @@ * - the entry lands as `MANUAL`, with the start, duration and metres the * form computed, and the list serves it; * - a second submit of the same form is a `duplicate`, not a second row; + * - a submit after an edit is `updated` and the one row carries the edit, + * while a synced id with changed values stays first-write-wins; * - delete removes a hand-entered workout and refuses a synced one. */ import { NextRequest } from "next/server"; @@ -163,6 +165,79 @@ describe("a workout entered by hand", () => { ).toBe(1); }); + it("updates the stored row when the same form is sent again with an edit", async () => { + const prisma = getPrismaClient(); + const entry = formEntry(newManualWorkoutExternalId()); + await post({ workouts: [entry] }); + const stored = await prisma.workout.findFirstOrThrow({ + where: { userId: TEST_USER_ID }, + }); + // A record the first values set; the edit must not leave it behind. + await prisma.personalRecord.create({ + data: { + userId: TEST_USER_ID, + metricType: "WALKING_RUNNING_DISTANCE", + metricSlot: "longest_distance_run", + direction: "MAX", + value: 8046.7, + unit: "m", + achievedAt: stored.startedAt, + source: "MANUAL", + externalId: entry.externalId, + }, + }); + + const edited = { ...entry, totalDistanceM: 6000, totalEnergyKcal: 380 }; + const res = await post({ workouts: [edited] }); + + expect((await res.json()).data.entries).toEqual([ + { index: 0, status: "updated" }, + ]); + const rows = await prisma.workout.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: stored.id, + totalDistanceM: 6000, + totalEnergyKcal: 380, + durationSec: 45 * 60, + }); + expect( + await prisma.personalRecord.count({ + where: { userId: TEST_USER_ID, externalId: entry.externalId }, + }), + ).toBe(0); + + // The same edit once more changes nothing and says so. + const again = await post({ workouts: [edited] }); + expect((await again.json()).data.entries).toEqual([ + { index: 0, status: "duplicate" }, + ]); + }); + + it("keeps a synced workout first-write-wins under the same resend", async () => { + const prisma = getPrismaClient(); + const entry = { + ...formEntry("hk-uuid-1"), + source: "APPLE_HEALTH" as const, + externalId: "8F14E45F-CEEA-467A-9575-0F5D6C5A1B2C", + }; + await post({ workouts: [entry] }); + + const res = await post({ + workouts: [{ ...entry, totalDistanceM: 1234 }], + }); + + expect((await res.json()).data.entries).toEqual([ + { index: 0, status: "duplicate" }, + ]); + const row = await prisma.workout.findFirstOrThrow({ + where: { userId: TEST_USER_ID }, + }); + expect(row.totalDistanceM).not.toBe(1234); + }); + it("can be deleted, and is gone from the list", async () => { await post({ workouts: [formEntry(newManualWorkoutExternalId())] }); const stored = await getPrismaClient().workout.findFirstOrThrow({