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" 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({