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);
}