mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
1 parent
28054e69c2
commit
0dfce73570
3 files changed
+48
-7
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user