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
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 12:50:08 +02:00
1 parent 28054e69c2
commit 0dfce73570
3 files changed
+48 -7

No files matched your search

+27 -3
View File
@@ -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);
}
+4 -4
View File
@@ -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(
@@ -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({