mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge w11b/a3ecg into release/v1.19.0
This commit is contained in:
commit
d62a33a5e7
6 files changed
+617
-11
No files matched your search
@@ -0,0 +1,70 @@
|
||||
-- v1.19.0 — full Withings ECG waveform capture.
|
||||
--
|
||||
-- v1.18.11 captured only the device's atrial-fibrillation *verdict* per
|
||||
-- on-device ECG (one IRREGULAR_RHYTHM_NOTIFICATION measurement EVENT row).
|
||||
-- This adds the raw ECG **waveform** so a real trace can later be rendered.
|
||||
--
|
||||
-- One additive table, `ecg_recordings`:
|
||||
--
|
||||
-- * `waveform_encrypted` (BYTEA) holds the AES-256-GCM ciphertext of the
|
||||
-- JSON-encoded micro-volt sample array (Withings Heart `get` `body.signal`)
|
||||
-- — the `*Encrypted` Bytes convention (the coach_messages.encrypted_content
|
||||
-- precedent). The waveform NEVER lands as plaintext (fail-closed crypto).
|
||||
-- * `sampling_frequency` (Hz), `sample_count`, `duration_seconds`, `lead`,
|
||||
-- and `average_heart_rate` stay plaintext descriptors so a renderer can
|
||||
-- read them without a per-row decrypt.
|
||||
-- * `rhythm_classification` snapshots the paired EVENT row's AFib verdict;
|
||||
-- `measurement_id` FKs back to that row (ON DELETE SET NULL — the trace
|
||||
-- survives a re-keyed/removed EVENT row).
|
||||
--
|
||||
-- Idempotency: a re-sync of the same recording upserts in place via the
|
||||
-- unique `(user_id, source, external_recording_id)` (Withings `signalid`,
|
||||
-- or a `ts-<unix>` fallback).
|
||||
--
|
||||
-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` /
|
||||
-- `DO $$`) so reruns are safe on prod.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "ecg_recordings";
|
||||
-- The waveform store is self-contained — no other domain depends on it.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "ecg_recordings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"source" "measurement_source" NOT NULL DEFAULT 'WITHINGS',
|
||||
"external_recording_id" TEXT NOT NULL,
|
||||
"recorded_at" TIMESTAMP(3) NOT NULL,
|
||||
"waveform_encrypted" BYTEA NOT NULL,
|
||||
"sampling_frequency" INTEGER NOT NULL,
|
||||
"sample_count" INTEGER NOT NULL,
|
||||
"duration_seconds" DOUBLE PRECISION,
|
||||
"lead" TEXT,
|
||||
"average_heart_rate" INTEGER,
|
||||
"rhythm_classification" "rhythm_classification",
|
||||
"measurement_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ecg_recordings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ecg_recordings_user_id_source_external_recording_id_key" ON "ecg_recordings"("user_id", "source", "external_recording_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "ecg_recordings_user_id_recorded_at_idx" ON "ecg_recordings"("user_id", "recorded_at" DESC);
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ecg_recordings" ADD CONSTRAINT "ecg_recordings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ecg_recordings" ADD CONSTRAINT "ecg_recordings_measurement_id_fkey" FOREIGN KEY ("measurement_id") REFERENCES "measurements"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -657,6 +657,10 @@ model User {
|
||||
// per-day timeline mirroring the CycleDayLog shape.
|
||||
illnessEpisodes IllnessEpisode[]
|
||||
illnessDayLogs IllnessDayLog[]
|
||||
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
|
||||
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
|
||||
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
|
||||
ecgRecordings EcgRecording[]
|
||||
|
||||
// v1.7.0 profile — optional patient-identity fields surfaced on the
|
||||
// health-record export cover (PDF) and the FHIR `Patient` resource.
|
||||
@@ -1070,6 +1074,10 @@ model Measurement {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
// v1.19.0 — optional back-relation to the full ECG waveform recording(s)
|
||||
// paired with this EVENT row. SET NULL on the FK side keeps the trace if
|
||||
// the EVENT row is re-keyed / removed.
|
||||
ecgRecordings EcgRecording[]
|
||||
|
||||
// Original measuredAt-based dedup — kept for legacy Withings ingest
|
||||
// and the manual UI's "no duplicate at the same wall-clock" guard.
|
||||
@@ -1130,6 +1138,75 @@ model Measurement {
|
||||
@@map("measurements")
|
||||
}
|
||||
|
||||
// ─── ECG waveform recordings (v1.19.0) ─────────────────────
|
||||
//
|
||||
// v1.18.11 captured only the device's AFib *verdict* for each on-device
|
||||
// ECG (one IRREGULAR_RHYTHM_NOTIFICATION Measurement EVENT row). This model
|
||||
// stores the full raw ECG **waveform** so a real trace can later be rendered.
|
||||
//
|
||||
// The waveform is sensitive health data, so the sample array is held
|
||||
// AES-256-GCM at rest in `waveformEncrypted` (Bytes) — the same Bytes-codec
|
||||
// precedent as `CoachMessage.encryptedContent`. The plaintext is the JSON
|
||||
// encoding of the integer micro-volt sample array returned by the Withings
|
||||
// Heart `get` endpoint (`body.signal`); decrypt → `JSON.parse` → number[].
|
||||
// It NEVER lands as plaintext (fail-closed crypto in `src/lib/crypto.ts`).
|
||||
//
|
||||
// The non-sensitive descriptors stay plaintext so a future renderer can read
|
||||
// `samplingFrequency` / `sampleCount` / `durationSeconds` without a per-row
|
||||
// decrypt: `samplingFrequency` (Hz) pairs with the sample index to recover
|
||||
// each sample's time offset; `sampleCount` is the array length; `durationSeconds`
|
||||
// is `sampleCount / samplingFrequency`; `lead` is the recording lead/pathway
|
||||
// when the source reports it; `averageHeartRate` mirrors the source's reported
|
||||
// average BPM for the strip when present.
|
||||
//
|
||||
// `rhythmClassification` snapshots the same AFib verdict captured on the paired
|
||||
// Measurement EVENT row so the waveform self-describes without a join, and
|
||||
// `measurementId` links back to that row (SET NULL — the trace survives a
|
||||
// re-keyed/removed EVENT row).
|
||||
//
|
||||
// Idempotency: `externalRecordingId` is the source's stable per-recording id
|
||||
// (Withings `signalid`, or a `ts-<unix>` fallback when absent). The unique
|
||||
// `(userId, source, externalRecordingId)` lets a re-sync overwrite the same
|
||||
// recording in place rather than inserting a duplicate.
|
||||
//
|
||||
// RENDERING UI IS OUT OF SCOPE FOR v1.19.0 — this is ingest-only. A trace
|
||||
// view (and any analytics over the waveform) is a follow-up.
|
||||
model EcgRecording {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
source MeasurementSource @default(WITHINGS)
|
||||
/// Source's stable per-recording id (Withings `signalid`, or `ts-<unix>`).
|
||||
externalRecordingId String @map("external_recording_id")
|
||||
/// When the strip was recorded on-device (source timestamp).
|
||||
recordedAt DateTime @map("recorded_at")
|
||||
/// AES-256-GCM ciphertext of the JSON-encoded micro-volt sample array.
|
||||
waveformEncrypted Bytes @map("waveform_encrypted")
|
||||
/// Sampling rate of the signal in Hz (sample index → time offset).
|
||||
samplingFrequency Int @map("sampling_frequency")
|
||||
/// Number of samples in the (decrypted) waveform array.
|
||||
sampleCount Int @map("sample_count")
|
||||
/// `sampleCount / samplingFrequency` in seconds; NULL when frequency is 0.
|
||||
durationSeconds Float? @map("duration_seconds")
|
||||
/// Recording lead / pathway label when the source reports it (else NULL).
|
||||
lead String?
|
||||
/// Source-reported average heart rate (BPM) for the strip, when present.
|
||||
averageHeartRate Int? @map("average_heart_rate")
|
||||
/// Snapshot of the device AFib verdict for the paired EVENT row.
|
||||
rhythmClassification RhythmClassification? @map("rhythm_classification")
|
||||
/// FK to the IRREGULAR_RHYTHM_NOTIFICATION Measurement EVENT row, when known.
|
||||
measurementId String? @map("measurement_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
measurement Measurement? @relation(fields: [measurementId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Re-sync overwrites in place rather than inserting a duplicate.
|
||||
@@unique([userId, source, externalRecordingId])
|
||||
@@index([userId, recordedAt(sort: Desc)])
|
||||
@@map("ecg_recordings")
|
||||
}
|
||||
|
||||
// ─── Measurement rollups (v1.5.0) ──────────────────────────
|
||||
//
|
||||
// Persistent rollup table — second cache tier behind the 60-s LRU.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* v1.19.0 — ECG waveform Bytes-codec round-trip tests.
|
||||
*
|
||||
* The raw micro-volt sample array is stored AES-256-GCM encrypted in the
|
||||
* `ecg_recordings.waveform_encrypted` Bytes column. These tests assert the
|
||||
* encrypt → Bytes → decrypt round-trip preserves the array exactly, that the
|
||||
* stored bytes are NOT plaintext (no cleartext sample shows through), and that
|
||||
* a tampered / non-array payload fails closed.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
decryptWaveformFromBytes,
|
||||
encryptWaveformToBytes,
|
||||
} from "../ecg-waveform-codec";
|
||||
|
||||
const KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", KEY);
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
describe("ecg-waveform-codec", () => {
|
||||
it("round-trips a sample array through encrypt → Bytes → decrypt", () => {
|
||||
const samples = [0, 12, -47, 1024, -2048, 999999, -1000000, 1];
|
||||
const bytes = encryptWaveformToBytes(samples);
|
||||
expect(decryptWaveformFromBytes(bytes)).toEqual(samples);
|
||||
});
|
||||
|
||||
it("round-trips an empty array", () => {
|
||||
const bytes = encryptWaveformToBytes([]);
|
||||
expect(decryptWaveformFromBytes(bytes)).toEqual([]);
|
||||
});
|
||||
|
||||
it("stores the waveform encrypted, not as plaintext", () => {
|
||||
const samples = [424242, -313131];
|
||||
const bytes = encryptWaveformToBytes(samples);
|
||||
const asText = Buffer.from(bytes).toString("utf8");
|
||||
// The ciphertext is `<keyId>.<base64>`; no raw sample value leaks.
|
||||
expect(asText).not.toContain("424242");
|
||||
expect(asText).not.toContain("-313131");
|
||||
expect(asText.startsWith("v1.")).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed on a payload that does not decode to a number array", () => {
|
||||
// Hand-encrypt a non-array JSON value through the same active key.
|
||||
const tampered = Buffer.from(encrypt(JSON.stringify({ not: "an array" })));
|
||||
expect(() => decryptWaveformFromBytes(tampered)).toThrow(/number array/);
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,18 @@ vi.mock("@/lib/db", () => ({
|
||||
measurement: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
ecgRecording: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../ecg-waveform-codec", () => ({
|
||||
encryptWaveformToBytes: vi.fn((samples: number[]) =>
|
||||
Uint8Array.from([samples.length & 0xff]),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
isReauthRequired: vi.fn().mockResolvedValue(false),
|
||||
recordSyncFailure: vi.fn(),
|
||||
@@ -36,6 +45,7 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
addExternalCall: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
@@ -61,16 +71,40 @@ import {
|
||||
|
||||
import {
|
||||
fetchWithingsHeartList,
|
||||
fetchWithingsHeartSignal,
|
||||
mapWithingsAfib,
|
||||
syncUserEcg,
|
||||
type WithingsHeartEntry,
|
||||
} from "../sync-ecg";
|
||||
|
||||
function installFetchMock(entries: WithingsHeartEntry[]) {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ status: 0, body: { series: entries } }),
|
||||
}));
|
||||
/** A default signal `get` body returned for any `action=get` request. */
|
||||
const DEFAULT_SIGNAL = {
|
||||
signal: [1, 2, 3, 4],
|
||||
sampling_frequency: 300,
|
||||
heart_rate: 72,
|
||||
};
|
||||
|
||||
/**
|
||||
* Install a fetch mock that routes by request body: `action=list` returns the
|
||||
* heart-list series, `action=get` returns a signal body. The signal body can
|
||||
* be overridden (or set to null-signal) per test.
|
||||
*/
|
||||
function installFetchMock(
|
||||
entries: WithingsHeartEntry[],
|
||||
signal: Record<string, unknown> | null = DEFAULT_SIGNAL,
|
||||
) {
|
||||
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
|
||||
if (init.body.includes("action=get")) {
|
||||
return {
|
||||
status: 200,
|
||||
json: async () => ({ status: 0, body: signal ?? {} }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
json: async () => ({ status: 0, body: { series: entries } }),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
@@ -78,6 +112,10 @@ function installFetchMock(entries: WithingsHeartEntry[]) {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(isReauthRequired).mockResolvedValue(false);
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
|
||||
id: "m-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.ecgRecording.upsert).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -122,13 +160,36 @@ describe("fetchWithingsHeartList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchWithingsHeartSignal", () => {
|
||||
it("POSTs action=get with the signalid and returns the signal body", async () => {
|
||||
const fetchMock = installFetchMock([]);
|
||||
const sig = await fetchWithingsHeartSignal("token", 999);
|
||||
|
||||
const init = fetchMock.mock.calls[0][1] as { body: string };
|
||||
expect(init.body).toContain("action=get");
|
||||
expect(init.body).toContain("signalid=999");
|
||||
expect(sig).toEqual({
|
||||
signal: [1, 2, 3, 4],
|
||||
sampling_frequency: 300,
|
||||
heart_rate: 72,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the response carries no usable signal array", async () => {
|
||||
installFetchMock([], { signal: [], sampling_frequency: 300 });
|
||||
expect(await fetchWithingsHeartSignal("token", 999)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncUserEcg", () => {
|
||||
it("writes one IRREGULAR_RHYTHM_NOTIFICATION EVENT row per ECG recording", async () => {
|
||||
installFetchMock([
|
||||
{ timestamp: 1715000000, ecg: { signalid: 111, afib: 1 } },
|
||||
{ timestamp: 1715003600, ecg: { signalid: 222, afib: 0 } },
|
||||
]);
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
|
||||
id: "m-1",
|
||||
} as never);
|
||||
|
||||
const imported = await syncUserEcg("user-1");
|
||||
|
||||
@@ -152,7 +213,9 @@ describe("syncUserEcg", () => {
|
||||
{ timestamp: 1715003600 },
|
||||
{ timestamp: 1715007200, ecg: { signalid: 333, afib: 0 } },
|
||||
]);
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
|
||||
id: "m-1",
|
||||
} as never);
|
||||
|
||||
const imported = await syncUserEcg("user-1");
|
||||
|
||||
@@ -162,7 +225,9 @@ describe("syncUserEcg", () => {
|
||||
|
||||
it("falls back to a timestamp externalId when signalid is absent", async () => {
|
||||
installFetchMock([{ timestamp: 1715000000, ecg: { afib: 1 } }]);
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
|
||||
id: "m-1",
|
||||
} as never);
|
||||
|
||||
await syncUserEcg("user-1");
|
||||
|
||||
@@ -194,4 +259,94 @@ describe("syncUserEcg", () => {
|
||||
expect.objectContaining({ kind: "reauth_required", errorCode: "403" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("captures the full waveform for each recording with a numeric signalid", async () => {
|
||||
installFetchMock([
|
||||
{ timestamp: 1715000000, ecg: { signalid: 111, afib: 1 } },
|
||||
]);
|
||||
|
||||
await syncUserEcg("user-1");
|
||||
|
||||
// The AFib verdict EVENT row is still written (v1.18.11 behaviour).
|
||||
expect(prisma.measurement.upsert).toHaveBeenCalledTimes(1);
|
||||
// The waveform row is written, keyed for idempotent re-sync and carrying
|
||||
// the descriptors + the link back to the EVENT row.
|
||||
expect(prisma.ecgRecording.upsert).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.ecgRecording.upsert).mock.calls[0][0];
|
||||
expect(call.where).toEqual({
|
||||
userId_source_externalRecordingId: {
|
||||
userId: "user-1",
|
||||
source: "WITHINGS",
|
||||
externalRecordingId: "111",
|
||||
},
|
||||
});
|
||||
expect(call.create).toMatchObject({
|
||||
userId: "user-1",
|
||||
source: "WITHINGS",
|
||||
externalRecordingId: "111",
|
||||
samplingFrequency: 300,
|
||||
sampleCount: 4,
|
||||
durationSeconds: 4 / 300,
|
||||
averageHeartRate: 72,
|
||||
rhythmClassification: "IRREGULAR",
|
||||
measurementId: "m-1",
|
||||
});
|
||||
expect(call.create.waveformEncrypted).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("does not fetch a waveform for a recording without a numeric signalid", async () => {
|
||||
installFetchMock([{ timestamp: 1715000000, ecg: { afib: 1 } }]);
|
||||
|
||||
await syncUserEcg("user-1");
|
||||
|
||||
// Verdict still captured, but no signal GET / no waveform row.
|
||||
expect(prisma.measurement.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.ecgRecording.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("upserts the waveform idempotently so a re-sync overwrites in place", async () => {
|
||||
installFetchMock([
|
||||
{ timestamp: 1715000000, ecg: { signalid: 111, afib: 1 } },
|
||||
]);
|
||||
|
||||
await syncUserEcg("user-1");
|
||||
await syncUserEcg("user-1");
|
||||
|
||||
// Two syncs → two upserts, both keyed on the same composite, never a plain
|
||||
// create that would duplicate.
|
||||
expect(prisma.ecgRecording.upsert).toHaveBeenCalledTimes(2);
|
||||
const keys = vi
|
||||
.mocked(prisma.ecgRecording.upsert)
|
||||
.mock.calls.map((c) => c[0].where);
|
||||
expect(keys[0]).toEqual(keys[1]);
|
||||
});
|
||||
|
||||
it("still captures the AFib verdict when the waveform fetch fails", async () => {
|
||||
// List returns an entry, but the signal GET returns a hard-fail body.
|
||||
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
|
||||
if (init.body.includes("action=get")) {
|
||||
return { status: 200, json: async () => ({ status: 503 }) };
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 0,
|
||||
body: {
|
||||
series: [
|
||||
{ timestamp: 1715000000, ecg: { signalid: 111, afib: 1 } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const imported = await syncUserEcg("user-1");
|
||||
|
||||
// The verdict EVENT row still landed; only the waveform was skipped.
|
||||
expect(imported).toBe(1);
|
||||
expect(prisma.measurement.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.ecgRecording.upsert).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* AES-256-GCM ↔ `Bytes` codec for the ECG waveform column (v1.19.0).
|
||||
*
|
||||
* `ecg_recordings.waveform_encrypted` stores the JSON-encoded micro-volt
|
||||
* sample array (`number[]`) as the `encrypt()` string format from
|
||||
* `@/lib/crypto` (`"<keyId>.<base64>"`) encoded as UTF-8 bytes — the same
|
||||
* Bytes-codec shape the Coach's encrypted columns use. The waveform is raw
|
||||
* health data and is NEVER persisted as plaintext; crypto is fail-closed
|
||||
* (a missing / malformed key throws rather than writing plaintext).
|
||||
*
|
||||
* Prisma's `Bytes` maps to `Uint8Array<ArrayBuffer>`, so writes allocate a
|
||||
* fresh ArrayBuffer-backed `Uint8Array` to keep the structural type stable.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
|
||||
/** Encrypt a waveform sample array into the `Bytes` payload the schema stores. */
|
||||
export function encryptWaveformToBytes(
|
||||
samples: number[],
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
const ciphertext = encrypt(JSON.stringify(samples));
|
||||
const encoded = Buffer.from(ciphertext, "utf8");
|
||||
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
|
||||
out.set(encoded);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a `Bytes` waveform payload back to the sample array. Throws on a
|
||||
* bad key id (fail-closed) or a non-array payload.
|
||||
*/
|
||||
export function decryptWaveformFromBytes(buf: Uint8Array): number[] {
|
||||
const text = Buffer.from(buf).toString("utf8");
|
||||
const parsed: unknown = JSON.parse(decrypt(text));
|
||||
if (!Array.isArray(parsed) || !parsed.every((n) => typeof n === "number")) {
|
||||
throw new Error("ECG waveform payload did not decode to a number array");
|
||||
}
|
||||
return parsed as number[];
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
/**
|
||||
* v1.18.11 — Withings ECG / AFib capture.
|
||||
* v1.19.0 — full ECG waveform capture (the raw signal samples).
|
||||
*
|
||||
* The Withings Heart List endpoint (`POST /v2/heart?action=list`) returns one
|
||||
* entry per on-device ECG recording made by the ScanWatch family. Each entry
|
||||
* carries the device's OWN AFib screening verdict under `ecg.afib`. HealthLog
|
||||
* captured none of this before — the signal was fetched by no path and dropped.
|
||||
*
|
||||
* v1.19.0 additionally fetches each recording's full waveform via the Heart
|
||||
* `get` endpoint (`POST /v2/heart?action=get&signalid=<id>`), which returns the
|
||||
* micro-volt sample array (`body.signal`) plus `body.sampling_frequency`
|
||||
* (Hz) and an optional average `body.heart_rate`. The sample array is raw
|
||||
* health data, so it is stored AES-256-GCM ENCRYPTED at rest in the new
|
||||
* `EcgRecording.waveformEncrypted` Bytes column (fail-closed crypto, the
|
||||
* `CoachMessage.encryptedContent` precedent). The descriptors (frequency,
|
||||
* sample count, duration, lead, average HR, AFib verdict) stay plaintext so a
|
||||
* future trace renderer reads them without a per-row decrypt. RENDERING UI IS
|
||||
* OUT OF SCOPE for v1.19.0 — this remains ingest-only.
|
||||
*
|
||||
* This mirrors the v1.10.0 categorical-event treatment the Apple-Health bridge
|
||||
* already gives the Watch's irregular-rhythm notification: HealthLog stores
|
||||
* ONLY the verdict the device's certified algorithm produced. It never sees a
|
||||
@@ -39,8 +51,9 @@ import type {
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { encryptWaveformToBytes } from "./ecg-waveform-codec";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement,
|
||||
@@ -172,6 +185,160 @@ export async function fetchWithingsHeartList(
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Heart `get` response body for a single ECG signal. `signal` is the
|
||||
* micro-volt sample array; `sampling_frequency` is the rate in Hz used to map
|
||||
* a sample index to its time offset. `heart_rate` (average BPM for the strip)
|
||||
* and `wavelets` are optional and source-version dependent — we capture the
|
||||
* average HR when present and ignore the wavelet transform.
|
||||
*/
|
||||
export interface WithingsHeartSignal {
|
||||
signal?: number[] | null;
|
||||
sampling_frequency?: number | null;
|
||||
heart_rate?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full waveform for one ECG recording via `action=get`. Returns the
|
||||
* raw signal body (no DB write) so the caller composes the encrypt + persist
|
||||
* step. Returns `null` when the recording carries no usable signal array so
|
||||
* the caller can skip waveform storage without failing the whole sync.
|
||||
*/
|
||||
export async function fetchWithingsHeartSignal(
|
||||
accessToken: string,
|
||||
signalId: number,
|
||||
): Promise<WithingsHeartSignal | null> {
|
||||
const params = new URLSearchParams({
|
||||
action: "get",
|
||||
signalid: String(signalId),
|
||||
});
|
||||
|
||||
const callStart = performance.now();
|
||||
const res = await safeFetch(WITHINGS_HEART_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
const json = await res.json();
|
||||
const verdict = classifyWithingsResponse(res.status, json);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "withings",
|
||||
method: "fetchWithingsHeartSignal",
|
||||
duration_ms: Math.round(performance.now() - callStart),
|
||||
status: res.status,
|
||||
error: verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new WithingsApiError({
|
||||
verb: "heart",
|
||||
classification: verdict.classification,
|
||||
withingsStatus: verdict.withingsStatus,
|
||||
reason: verdict.reason,
|
||||
upstreamError: typeof json?.error === "string" ? json.error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const body = json.body ?? {};
|
||||
const signal = Array.isArray(body.signal)
|
||||
? body.signal.filter((n: unknown): n is number => typeof n === "number")
|
||||
: null;
|
||||
if (!signal || signal.length === 0) return null;
|
||||
|
||||
return {
|
||||
signal,
|
||||
sampling_frequency:
|
||||
typeof body.sampling_frequency === "number"
|
||||
? body.sampling_frequency
|
||||
: null,
|
||||
heart_rate: typeof body.heart_rate === "number" ? body.heart_rate : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + encrypt + persist one ECG waveform. Idempotent: upserts on
|
||||
* `(userId, source, externalRecordingId)` so a re-sync overwrites the same
|
||||
* recording in place rather than inserting a duplicate. Returns `true` when a
|
||||
* waveform row was written, `false` when the recording carried no usable
|
||||
* signal (nothing to store). Throws on an upstream / persist error so the
|
||||
* caller can warn without failing the verdict ingest.
|
||||
*/
|
||||
async function captureEcgWaveform(params: {
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
signalId: number;
|
||||
externalRecordingId: string;
|
||||
recordedAt: Date;
|
||||
classification: RhythmClassification;
|
||||
measurementId: string | null;
|
||||
}): Promise<boolean> {
|
||||
const signal = await fetchWithingsHeartSignal(
|
||||
params.accessToken,
|
||||
params.signalId,
|
||||
);
|
||||
if (!signal || !signal.signal || signal.signal.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const samples = signal.signal;
|
||||
const samplingFrequency =
|
||||
typeof signal.sampling_frequency === "number" &&
|
||||
Number.isFinite(signal.sampling_frequency) &&
|
||||
signal.sampling_frequency > 0
|
||||
? Math.round(signal.sampling_frequency)
|
||||
: 0;
|
||||
const sampleCount = samples.length;
|
||||
const durationSeconds =
|
||||
samplingFrequency > 0 ? sampleCount / samplingFrequency : null;
|
||||
const averageHeartRate =
|
||||
typeof signal.heart_rate === "number" && Number.isFinite(signal.heart_rate)
|
||||
? Math.round(signal.heart_rate)
|
||||
: null;
|
||||
|
||||
// Encrypt the raw sample array BEFORE it ever reaches the row. Fail-closed:
|
||||
// a crypto error throws here and the waveform is never written as plaintext.
|
||||
const waveformEncrypted = encryptWaveformToBytes(samples);
|
||||
|
||||
// Build the `data` object field-by-field (no mass assignment); `userId` is
|
||||
// the integration owner narrowed by the caller, never a client field.
|
||||
await prisma.ecgRecording.upsert({
|
||||
where: {
|
||||
userId_source_externalRecordingId: {
|
||||
userId: params.userId,
|
||||
source: "WITHINGS",
|
||||
externalRecordingId: params.externalRecordingId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: params.userId,
|
||||
source: "WITHINGS",
|
||||
externalRecordingId: params.externalRecordingId,
|
||||
recordedAt: params.recordedAt,
|
||||
waveformEncrypted,
|
||||
samplingFrequency,
|
||||
sampleCount,
|
||||
durationSeconds,
|
||||
averageHeartRate,
|
||||
rhythmClassification: params.classification,
|
||||
measurementId: params.measurementId,
|
||||
},
|
||||
update: {
|
||||
recordedAt: params.recordedAt,
|
||||
waveformEncrypted,
|
||||
samplingFrequency,
|
||||
sampleCount,
|
||||
durationSeconds,
|
||||
averageHeartRate,
|
||||
rhythmClassification: params.classification,
|
||||
measurementId: params.measurementId,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync ECG / AFib recordings for a single user. Walks the trailing 30-day
|
||||
* window, writing one `IRREGULAR_RHYTHM_NOTIFICATION` row per ECG recording
|
||||
@@ -226,6 +393,7 @@ export async function syncUserEcg(userId: string): Promise<number> {
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let waveformsCaptured = 0;
|
||||
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -235,11 +403,13 @@ export async function syncUserEcg(userId: string): Promise<number> {
|
||||
continue;
|
||||
}
|
||||
const measuredAt = new Date(entry.timestamp * 1000);
|
||||
const signalId = entry.ecg?.signalid ?? `ts-${entry.timestamp}`;
|
||||
const numericSignalId = entry.ecg?.signalid;
|
||||
const signalId = numericSignalId ?? `ts-${entry.timestamp}`;
|
||||
const externalId = `withings:ecg:${userId}:${signalId}`;
|
||||
|
||||
let measurementId: string | null = null;
|
||||
try {
|
||||
await prisma.measurement.upsert({
|
||||
const row = await prisma.measurement.upsert({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
userId,
|
||||
@@ -265,16 +435,50 @@ export async function syncUserEcg(userId: string): Promise<number> {
|
||||
measuredAt,
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
measurementId = row.id;
|
||||
touched.push({ type: ECG_TYPE, measuredAt });
|
||||
imported++;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`withings ecg: failed to upsert recording (${classification}, ${measuredAt.toISOString()}): ${err}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.19.0 — capture the full waveform. Only a recording with a numeric
|
||||
// `signalid` can be fetched (the `ts-` fallback has no signal to GET). A
|
||||
// waveform-fetch / persist failure never fails the verdict ingest — the
|
||||
// EVENT row already landed above.
|
||||
if (typeof numericSignalId === "number") {
|
||||
try {
|
||||
const captured = await captureEcgWaveform({
|
||||
userId,
|
||||
accessToken: tokenInfo.accessToken,
|
||||
signalId: numericSignalId,
|
||||
externalRecordingId: String(numericSignalId),
|
||||
recordedAt: measuredAt,
|
||||
classification,
|
||||
measurementId,
|
||||
});
|
||||
if (captured) waveformsCaptured++;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`withings ecg: failed to capture waveform for signal ${numericSignalId}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "withings.ecg.sync" },
|
||||
meta: {
|
||||
withings_ecg_verdicts_imported: imported,
|
||||
withings_ecg_waveforms_captured: waveformsCaptured,
|
||||
},
|
||||
});
|
||||
|
||||
// Refresh the persistent rollup tier for every distinct (type, day) touched,
|
||||
// then drop the per-metric assessment caches the new events dirty.
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user