mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'work/ecg-ingest' into release/v1.35.3
This commit is contained in:
commit
3b37d73f31
13 files changed
+1728
-108
No files matched your search
@@ -37,6 +37,8 @@ import {
|
||||
insightsPregenerateResponse,
|
||||
dashboardSnapshotResponse,
|
||||
ecgListResponse,
|
||||
ecgIngestRequest,
|
||||
ecgIngestResponse,
|
||||
ecgDetailQuery,
|
||||
ecgDetailResponse,
|
||||
rhythmEventsResponse,
|
||||
@@ -457,6 +459,45 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
summary: "Ingest one ECG recording",
|
||||
description:
|
||||
"The live ECG ingest: one Apple Watch recording per request, for a client draining its HealthKit ECG observer. Before this existed, a watch ECG could only reach HealthLog inside a full `export.zip`. One recording per request because a 30 s / 512 Hz strip is ~15 360 samples. Samples are INTEGER MICRO-VOLTS (convert from HealthKit's Volts), stored AES-256-GCM encrypted; `sampleCount` and `durationSeconds` are derived server-side. The `classification` is the device's own verdict stored verbatim — HealthLog never reads the waveform to produce or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field. Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports. Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: ecgIngestRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The recording was already stored: `updated` (same id, overwritten in place) or `duplicate` (already present under a different id, nothing written).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
ecgIngestResponse,
|
||||
"EcgIngestResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"201": {
|
||||
description: "A new recording was stored (`status: inserted`).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
ecgIngestResponse,
|
||||
"EcgIngestCreatedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...moduleDisabledResponse,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/ecg/{id}": {
|
||||
get: {
|
||||
|
||||
@@ -1439,6 +1439,96 @@ export const ecgDetailResponse = z
|
||||
"One ECG recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where — a foreign / unknown id 404s. The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. no-store.",
|
||||
});
|
||||
|
||||
// The live ECG ingest. One recording per request: a 30 s Apple Watch strip at
|
||||
// 512 Hz is ~15 360 samples, so a batch of them is not a shape anyone wants on
|
||||
// the wire. `userId` is never a body field — it comes from the session.
|
||||
export const ecgIngestRequest = z
|
||||
.object({
|
||||
externalRecordingId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.describe(
|
||||
"The recording's stable id AS THE CLIENT KNOWS IT — for an Apple Health client, the `HKSample.uuid`. It is the source's own identity, not a content hash: re-posting the same id overwrites that recording in place. It does not have to agree with the id the `export.zip` importer derives for the same strip; the server reconciles the two doors itself (see `status: duplicate`).",
|
||||
),
|
||||
recordedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe(
|
||||
"When the strip was recorded on-device, with offset. Part of the recording's identity — see `status: duplicate`.",
|
||||
),
|
||||
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`.",
|
||||
),
|
||||
samples: z
|
||||
.array(z.number().int().min(-1_000_000).max(1_000_000))
|
||||
.min(1)
|
||||
.max(32_768)
|
||||
.describe(
|
||||
"The waveform as INTEGER MICRO-VOLTS, in recording order — the same unit the `export.zip` CSV parser produces, so convert from HealthKit's Volts before sending. Capped at 32 768 samples (a clean factor of two above a 30 s / 512 Hz strip). Stored AES-256-GCM encrypted; never persisted as plaintext.",
|
||||
),
|
||||
lead: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.nullable()
|
||||
.optional()
|
||||
.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."),
|
||||
classification: ecgClassification
|
||||
.optional()
|
||||
.describe(
|
||||
"The RECORDING DEVICE's own verdict, stored verbatim. Only the three ECG values are accepted here. `RhythmClassification` in the database has six members because walking-steadiness and neutral event verdicts share it, and those appear on GET /api/insights/rhythm-events — a decoder generated from this schema must NOT be reused there. HealthLog never reads the waveform to produce or revise a verdict.",
|
||||
),
|
||||
source: z
|
||||
.enum(["APPLE_HEALTH"])
|
||||
.describe(
|
||||
"The client's own source. `APPLE_HEALTH` only: `WITHINGS` rows are minted by the OAuth sync and `COMPUTED` ones by the server, so neither is client-assertable — the same posture POST /api/measurements/batch takes.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "EcgIngestRequest",
|
||||
description:
|
||||
"One Apple Watch ECG recording. Unknown keys are REJECTED with a 422 naming them rather than silently dropped, because every field here is load-bearing metadata for a stored recording. `sampleCount` and `durationSeconds` are derived server-side and must not be sent.",
|
||||
});
|
||||
|
||||
export const ecgIngestResponse = z
|
||||
.object({
|
||||
id: z.string().describe("The recording row's id (cuid)."),
|
||||
status: z
|
||||
.enum(["inserted", "updated", "duplicate"])
|
||||
.describe(
|
||||
"What the write did. `inserted` — a new recording landed (HTTP 201). `updated` — this `externalRecordingId` was already stored and the row was overwritten in place (HTTP 200); a retry is safe and creates nothing. `duplicate` — this exact recording is already stored under a DIFFERENT id, i.e. it arrived earlier through the `export.zip` importer (HTTP 200); nothing was written and `id` names the row that already holds it. A recording is the same recording when `(source, recordedAt, samplingFrequency)` match, which is enforced by a unique index rather than by convention. All three statuses mean the recording is stored and the client may advance its cursor.",
|
||||
),
|
||||
recordedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe("The `recordedAt` that was submitted, echoed back."),
|
||||
sampleCount: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Server-derived: the number of samples received."),
|
||||
durationSeconds: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("Server-derived: `sampleCount / samplingFrequency`."),
|
||||
})
|
||||
.meta({
|
||||
id: "EcgIngestResponse",
|
||||
description:
|
||||
"The outcome of one ECG ingest. Reported honestly rather than optimistically — a re-post never claims to have inserted.",
|
||||
});
|
||||
|
||||
// v1.10.0 — device-flagged event timeline (rhythm-events, WX-B). Deliberately
|
||||
// a SEPARATE enum from `ecgClassification` above: an EVENT row's
|
||||
// `rhythmClassification` carries the FULL RhythmClassification set (the
|
||||
|
||||
Reference in new issue
Block a user