From 5d2ae7732e4c9262932d1f75e1abfe47338cc8b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 22:53:12 +0200 Subject: [PATCH] feat(api): accept an optional source on the measurements batch endpoint Add an optional per-entry `source` to POST /api/measurements/batch, restricted to the {APPLE_HEALTH, MANUAL} subset of MeasurementSource and defaulting to APPLE_HEALTH when absent so existing web and iOS callers are unchanged. The standalone iOS client can now tag adopt-on-pair backfill rows that the user entered by hand as MANUAL instead of having them mis-attributed to HealthKit. source is threaded into the (userId, type, source, externalId) dedup key, the existence probe, and the stats:* overwrite scope, so a MANUAL row and an APPLE_HEALTH row sharing an externalId are distinct rows and the stats:* re-post overwrite only touches the matching source. WITHINGS and IMPORT stay server-owned and are rejected here with 422. Regenerate the OpenAPI spec and add integration coverage for the absent-source default, explicit MANUAL, cross-source distinctness, and the rejected-source 422 path. --- docs/api/openapi.yaml | 9 ++ src/app/api/measurements/batch/route.ts | 42 +++++- src/lib/openapi/routes.ts | 11 ++ tests/integration/measurements-batch.test.ts | 137 +++++++++++++++++++ 4 files changed, 193 insertions(+), 6 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2788fb057..1a42a3b8b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1992,6 +1992,15 @@ components: type: string minLength: 1 maxLength: 120 + source: + description: Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user + entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source, + externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` / + `IMPORT` are not accepted here. + type: string + enum: + - APPLE_HEALTH + - MANUAL deviceType: description: Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index c9e98ecef..f2c2713ba 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -72,6 +72,17 @@ const MAX_BATCH_ENTRIES = 500; const BATCH_RATE_LIMIT_MAX = 60; const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; +// v1.8.6 W6 — the set of `source` values this client-facing ingest +// route accepts. Deliberately narrower than the full `MeasurementSource` +// enum: `WITHINGS` and `IMPORT` are server-owned (the Withings webhook +// and the CSV importer mint those rows respectively), so letting an iOS +// client forge rows attributed to them would let an authenticated client +// pollute the per-source canonical picker with rows the server never saw +// from those integrations. The standalone iOS client only needs to tag +// its own adopt-on-pair backfill as `MANUAL` vs the default +// `APPLE_HEALTH` passthrough, so we accept exactly that pair. +const batchSourceEnum = z.enum(["APPLE_HEALTH", "MANUAL"]); + const batchEntrySchema = z.object({ hkIdentifier: z.string().min(1).max(120), value: z.number().finite(), @@ -81,6 +92,14 @@ const batchEntrySchema = z.object({ sleepStage: z.number().int().min(0).max(20).optional(), externalId: z.string().min(1).max(120), externalSourceVersion: z.string().min(1).max(120).optional(), + // v1.8.6 W6 — optional per-entry source tag. Defaults to + // `APPLE_HEALTH` so every pre-W6 caller (web + current iOS) stays + // byte-for-byte unchanged. The iOS standalone adopt-on-pair backfill + // sends `MANUAL` for rows the user entered by hand on-device so they + // are not mis-attributed to HealthKit. `source` is part of the + // `(userId, type, source, externalId)` dedup key, so a `MANUAL` row + // and an `APPLE_HEALTH` row sharing an externalId are distinct rows. + source: batchSourceEnum.optional(), // v1.4.25 W8c — optional device-type tag. The iOS client maps the // `HKDevice.model` of each sample to one of the canonical device // classes (watch | band | ring | phone | scale | other | unknown). @@ -228,7 +247,9 @@ async function postBatch(request: NextRequest): Promise { type: mapped.type, value: mapped.value, unit: mapped.unit, - source: "APPLE_HEALTH", + // v1.8.6 W6 — honour the per-entry source tag, defaulting to + // `APPLE_HEALTH` when absent so legacy callers are unchanged. + source: entry.source ?? "APPLE_HEALTH", measuredAt: mapped.takenAt, externalId: entry.externalId, externalSourceVersion: entry.externalSourceVersion ?? null, @@ -251,31 +272,37 @@ async function postBatch(request: NextRequest): Promise { // (createMany with skipDuplicates does the dedup but doesn't tell // us *which* rows it skipped). We look up existing rows under the // composite unique key first, then createMany the survivors. + // v1.8.6 W6 — `source` is now part of the dedup key, so the lookup + // matches on (type, source, externalId) per incoming row rather than + // a single hardcoded `APPLE_HEALTH`. A `MANUAL` row and an + // `APPLE_HEALTH` row sharing an externalId are distinct, so each must + // carry its source through the existence probe and the composite key. const incomingKeys = prepared.map((p) => ({ type: p.row.type as Prisma.MeasurementCreateManyInput["type"], + source: p.row.source as Prisma.MeasurementCreateManyInput["source"], externalId: p.row.externalId as string, })); const existing = await prisma.measurement.findMany({ where: { userId: user.id, - source: "APPLE_HEALTH", OR: incomingKeys.map((k) => ({ type: k.type, + source: k.source, externalId: k.externalId, })), }, - select: { type: true, externalId: true }, + select: { type: true, source: true, externalId: true }, }); const existingSet = new Set( - existing.map((row) => `${row.type}::${row.externalId}`), + existing.map((row) => `${row.type}::${row.source}::${row.externalId}`), ); const toInsert: Prisma.MeasurementCreateManyInput[] = []; const toOverwrite: Prepared[] = []; for (const p of prepared) { - const key = `${p.row.type}::${p.row.externalId}`; + const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`; if (existingSet.has(key)) { // v1.5.0 issue #213 — per-day cumulative `stats:*` rows are // intentionally overwritten on a re-post so today's tile reflects @@ -300,7 +327,10 @@ async function postBatch(request: NextRequest): Promise { await tx.measurement.updateMany({ where: { userId: user.id, - source: "APPLE_HEALTH", + // v1.8.6 W6 — scope the overwrite to the row's own source + // so a `stats:*` re-post only touches the matching + // (type, source, externalId) row. + source: p.row.source as Prisma.MeasurementCreateManyInput["source"], type: p.row.type as MeasurementType, externalId: p.row.externalId as string, }, diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index aa2a52e74..346fda5de 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -283,6 +283,17 @@ const batchEntrySchema = z .max(120) .describe("HKSample.uuid string — the dedup key."), externalSourceVersion: z.string().min(1).max(120).optional(), + // v1.8.6 W6 — optional per-entry source tag. Defaults to + // `APPLE_HEALTH` server-side when omitted, so legacy clients are + // unchanged. Restricted to the `{APPLE_HEALTH, MANUAL}` subset of + // `MeasurementSource`: `WITHINGS` / `IMPORT` are server-owned and + // rejected on this client-facing route. + source: z + .enum(["APPLE_HEALTH", "MANUAL"]) + .optional() + .describe( + "Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source, externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` / `IMPORT` are not accepted here.", + ), // v1.4.25 W8c — optional device-type tag fed into the canonical // source picker's second axis. NULL is treated as `unknown`; // legacy iOS builds that don't ship the field continue to work. diff --git a/tests/integration/measurements-batch.test.ts b/tests/integration/measurements-batch.test.ts index 8b120c3b8..81fabf5a8 100644 --- a/tests/integration/measurements-batch.test.ts +++ b/tests/integration/measurements-batch.test.ts @@ -69,6 +69,7 @@ interface BatchEntryFixture { externalId: string; externalSourceVersion?: string; sleepStage?: number; + source?: string; } function makeRequest( @@ -698,4 +699,140 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(stepsRow?.value).toBeCloseTo(8400, 5); }); }); + + // v1.8.6 W6 — optional per-entry `source`. The standalone iOS client + // tags adopt-on-pair backfill rows it knows were entered by hand as + // `MANUAL`; every other caller (web + current iOS) omits the field and + // must keep storing rows as `APPLE_HEALTH`. `source` is part of the + // `(userId, type, source, externalId)` dedup key. + describe("optional source field (W6)", () => { + it("persists a row as APPLE_HEALTH when source is absent (backward-compat)", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const response = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 80.2, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: "uuid-source-absent-001", + }, + ], + }), + ); + expect(response.status).toBe(200); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, externalId: "uuid-source-absent-001" }, + }); + expect(stored).toHaveLength(1); + expect(stored[0]!.source).toBe("APPLE_HEALTH"); + }); + + it("persists a row as MANUAL when source is explicitly MANUAL", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const response = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 79.8, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: "uuid-source-manual-001", + source: "MANUAL", + }, + ], + }), + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { data: { inserted: number } }; + expect(json.data.inserted).toBe(1); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, externalId: "uuid-source-manual-001" }, + }); + expect(stored).toHaveLength(1); + expect(stored[0]!.source).toBe("MANUAL"); + }); + + it("treats the same externalId under different sources as two distinct rows", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const sharedExternalId = "uuid-source-collision-001"; + const response = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 81.0, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: sharedExternalId, + source: "APPLE_HEALTH", + }, + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 81.0, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: sharedExternalId, + source: "MANUAL", + }, + ], + }), + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { + data: { inserted: number; duplicates: number }; + }; + // Distinct dedup keys — both insert, neither is a duplicate. + expect(json.data.inserted).toBe(2); + expect(json.data.duplicates).toBe(0); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, externalId: sharedExternalId }, + }); + expect(stored).toHaveLength(2); + expect(new Set(stored.map((r) => r.source))).toEqual( + new Set(["APPLE_HEALTH", "MANUAL"]), + ); + }); + + it("rejects an out-of-range source value with 422", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const response = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 80.5, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: "uuid-source-bad-001", + // WITHINGS is server-owned and not accepted on this + // client-facing route — must surface as a 422, not silently + // mint a forged Withings-attributed row. + source: "WITHINGS", + }, + ], + }), + ); + expect(response.status).toBe(422); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, externalId: "uuid-source-bad-001" }, + }); + expect(stored).toHaveLength(0); + }); + }); });