From 0dfce735702d1ff27b0d15b9a2efaf59250fae65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 12:50:08 +0200 Subject: [PATCH 1/2] fix(ecg): accept the fractional heart rate and frequency HealthKit reports The ingest route required integers for the average heart rate and the sampling frequency. HealthKit reports both as doubles and a watch's average heart rate is often fractional, so most new recordings were refused with 422 while older ones, whose value happened to be whole, went through. Both are now accepted as numbers and rounded once before they are stored; the columns stay integers. A refused body now logs which fields were refused and why, never their values. Refs #1060 --- src/app/api/insights/ecg/route.ts | 30 +++++++++++++++++-- src/lib/openapi/routes/insights/schemas.ts | 8 ++--- .../integration/ecg-ingest-roundtrip.test.ts | 17 +++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index 63e497784..1a0bc0f9d 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -92,13 +92,29 @@ const ecgIngestSchema = z .object({ externalRecordingId: z.string().min(1).max(120), recordedAt: z.iso.datetime({ offset: true }), - samplingFrequency: z.number().int().min(1).max(10_000), + // HealthKit hands both of these over as doubles. A watch reports its + // average heart rate with a fraction (64.7) often enough that requiring + // an integer refused most new recordings while the old ones, synced when + // the value happened to be whole, went through (#1060). The columns are + // integers and the person reads whole beats per minute, so the server + // rounds once here rather than every client doing it. + samplingFrequency: z + .number() + .min(1) + .max(10_000) + .transform((v) => Math.round(v)), samples: z .array(z.number().int().min(-1_000_000).max(1_000_000)) .min(1) .max(MAX_SAMPLES), lead: z.string().min(1).max(40).nullable().optional(), - averageHeartRate: z.number().int().min(1).max(300).nullable().optional(), + averageHeartRate: z + .number() + .min(1) + .max(300) + .transform((v) => Math.round(v)) + .nullable() + .optional(), classification: ecgClassificationEnum.nullable().optional(), source: ingestSourceEnum, }) @@ -198,9 +214,17 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = ecgIngestSchema.safeParse(rawBody); if (!parsed.success) { + // Which field was refused and why, never the value: without it a refused + // recording is a bare "invalid" in the log and the cause has to be guessed. annotate({ action: { name: "insights.ecg.ingest" }, - meta: { outcome: "invalid" }, + meta: { + outcome: "invalid", + invalidFields: parsed.error.issues + .map((issue) => `${issue.path.join(".") || "(body)"}:${issue.code}`) + .slice(0, 8) + .join(","), + }, }); return returnAllZodIssues(parsed.error); } diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 2f7b934e6..cc49e7eca 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -1678,11 +1678,10 @@ export const ecgIngestRequest = z ), samplingFrequency: z .number() - .int() .min(1) .max(10_000) .describe( - "Signal sampling rate in Hz (an Apple Watch ECG is 512). Part of the recording's identity, and the divisor for the server-derived `durationSeconds`.", + "Signal sampling rate in Hz (an Apple Watch ECG is 512). A fractional value is accepted and rounded to the nearest whole hertz before it is stored. Part of the recording's identity, and the divisor for the server-derived `durationSeconds`.", ), samples: z .array(z.number().int().min(-1_000_000).max(1_000_000)) @@ -1700,12 +1699,13 @@ export const ecgIngestRequest = z .describe("Recording lead label when the device reports one (e.g. `I`)."), averageHeartRate: z .number() - .int() .min(1) .max(300) .nullable() .optional() - .describe("The device's average heart rate (BPM) for the strip."), + .describe( + "The device's average heart rate (BPM) for the strip. HealthKit reports it with a fraction; it is accepted as sent and rounded to whole beats per minute before it is stored.", + ), classification: ecgClassification .optional() .describe( diff --git a/tests/integration/ecg-ingest-roundtrip.test.ts b/tests/integration/ecg-ingest-roundtrip.test.ts index 94a104947..2f321980e 100644 --- a/tests/integration/ecg-ingest-roundtrip.test.ts +++ b/tests/integration/ecg-ingest-roundtrip.test.ts @@ -188,6 +188,23 @@ describe("POST /api/insights/ecg — round trip through the real routes", () => expect(detail.body.classification).toBe("NOT_DETECTED"); }); + it("accepts the fractional heart rate and frequency HealthKit reports and rounds them (#1060)", async () => { + // HealthKit hands both over as doubles; a watch's average heart rate is + // often fractional. Requiring integers refused those recordings outright. + const posted = await ingest( + ingestPayload({ averageHeartRate: 64.7, samplingFrequency: 511.96 }), + ); + expect(posted.status).toBe(201); + expect(posted.data.status).toBe("inserted"); + + const list = await readList(); + expect(list.recordings[0]).toMatchObject({ + averageHeartRate: 65, + samplingFrequency: 512, + }); + expect(posted.data.durationSeconds).toBeCloseTo(SAMPLES.length / 512, 10); + }); + it("never writes the waveform as plaintext", async () => { const posted = await ingest(ingestPayload()); const row = await getPrismaClient().ecgRecording.findUniqueOrThrow({ From 729401e03218401c84f2b652a64166e2b06dd629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 12:53:25 +0200 Subject: [PATCH 2/2] chore(openapi): describe the rounded ECG heart rate and frequency --- docs/api/openapi.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fee198522..a608c438c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26425,11 +26425,12 @@ components: description: "When the strip was recorded on-device, with offset. Part of the recording's identity — see `status: duplicate`." samplingFrequency: - type: integer + type: number minimum: 1 maximum: 10000 - description: Signal sampling rate in Hz (an Apple Watch ECG is 512). Part of the recording's identity, and the divisor - for the server-derived `durationSeconds`. + description: Signal sampling rate in Hz (an Apple Watch ECG is 512). A fractional value is accepted and rounded to the + nearest whole hertz before it is stored. Part of the recording's identity, and the divisor for the + server-derived `durationSeconds`. samples: minItems: 1 maxItems: 32768 @@ -26449,9 +26450,10 @@ components: maxLength: 40 - type: "null" averageHeartRate: - description: The device's average heart rate (BPM) for the strip. + description: The device's average heart rate (BPM) for the strip. HealthKit reports it with a fraction; it is accepted + as sent and rounded to whole beats per minute before it is stored. anyOf: - - type: integer + - type: number minimum: 1 maximum: 300 - type: "null"