feat(01-19): expose privacy-safe ECG import outcomes

- return and render bounded ECG result counters with partial-failure wording
- document archive limits, encrypted storage, and exact WHOOP constraints
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 14:47:22 +02:00
1 parent a686c7200b
commit 4b64556b38
3 files changed
+118 -11

No files matched your search

+68 -9
View File
@@ -30,8 +30,10 @@ re-uploading the same archive merges instead of duplicating.
The archive contains `apple_health_export/export.xml` (the canonical
record stream) plus a handful of auxiliary files (workout routes,
clinical records, electrocardiograms). The importer reads
`export.xml` and ignores the rest.
clinical records, electrocardiograms). The importer reads `export.xml`
and supported ECG CSVs under
`apple_health_export/electrocardiograms/`; unsupported auxiliary files
remain ignored.
## 2. Upload to HealthLog
@@ -76,7 +78,15 @@ canonical envelope:
"uploadBytes": 412345678,
"exportedAt": "2026-04-30T18:12:00.000Z",
"progress": { "records_seen": 124000, "workouts_seen": 230 },
"result": null,
"result": {
"ecg": {
"discovered": 2,
"imported": 1,
"updated": 0,
"skipped": 0,
"failed": 1
}
},
"failureReason": null
},
"error": null
@@ -94,8 +104,8 @@ they triggered.
## What gets imported
The parser folds four XML element types into the existing HealthLog
schema (CHANGELOG v1.4.34):
The parser folds four XML element types and supported auxiliary ECG
CSVs into the existing HealthLog schema (CHANGELOG v1.4.34):
| XML element | Maps to | Notes |
| ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -103,11 +113,45 @@ schema (CHANGELOG v1.4.34):
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
| ECG CSV | `EcgRecording` rows | The source-provided waveform is encrypted before persistence; descriptors and the device's classification are stored without diagnostic inference. |
The HK quantity types that map to HealthLog metric types live in
`src/lib/measurements/apple-health-mapping.ts`. Anything outside the
mapped set (workout routes, ECG voltage traces) is counted in
`progress.records_skipped` but not stored.
mapped set (for example workout routes) is counted as unsupported and
not stored.
### ECG archive safety, privacy, and idempotency
ECG support is source-agnostic: if HealthKit includes a valid
`HKElectrocardiogram` CSV, HealthLog can import it regardless of which
compatible device originally wrote it. Archive member names are never
used as filesystem paths and ECGs are never extracted to plaintext
temporary files.
The auxiliary reader enforces limits before and during decompression:
- at most 20,000 total archive members and 2,000 ECG CSV members;
- at most 16 MiB per decompressed ECG CSV and 512 MiB across all ECG CSVs;
- at most a 200:1 declared compression ratio; and
- at most 2,000,000 waveform samples per recording.
Encrypted ZIP members, unsupported compression, unsafe or duplicate
paths, forged size metadata, malformed timestamps or samples, and
limit overruns are rejected. An ECG failure is fail-soft: valid
`export.xml` measurements and workouts still commit, while the
terminal result reports only
`discovered/imported/updated/skipped/failed` ECG counts. It never
returns filenames, source labels, sample values, or raw parser errors.
Waveforms are converted to integer microvolts and encrypted with the
existing AES-256-GCM ECG codec before the transaction writes an
`EcgRecording` with `source=APPLE_HEALTH`. Stable identity is derived
from normalized recording content rather than the CSV filename, so a
renamed re-export updates the same user-scoped row while distinct
recordings and different users remain independent. Known Apple
device classifications are preserved as source-reported facts;
unknown or localized classifications are stored as `null`. HealthLog
does not inspect the waveform to infer AFib or make a diagnosis.
### Cumulative HK type → daily aggregate
@@ -148,8 +192,8 @@ The two-axis idempotency story:
1. **File-level** — re-uploading the same `export.zip` (same SHA-256
of bytes) short-circuits to the previous job only when that job
used the current parser revision. Existing jobs are revision 1;
new jobs use revision 2, so a pre-fix successful archive can be
used the current parser revision. Existing jobs may use revisions 1
or 2; new jobs use revision 3, so an earlier successful archive can be
deliberately processed again under the corrected aggregation.
2. **Record-level** — when an older `export.zip` is re-exported from
iOS (same history, slightly newer device timestamps), every
@@ -162,6 +206,21 @@ sees fresh data merged in without duplicates. The job result line
in the status response reports `recordsInserted` and
`recordsUpdated` separately.
### WHOOP records and current export limits
Supported measurements written into HealthKit by another app are
handled by HealthKit type, not by a raw writer label. For example, a
supported heart-rate record written by WHOOP is imported as an
Apple Health measurement and participates in the normal source
priority rules.
WHOOP currently exports neither ECG recordings nor Blood Pressure
Insights to Apple Health. Those two WHOOP data classes therefore
cannot arrive through an Apple Health `export.zip`; a WHOOP writer
label on some other HealthKit record is not evidence of a WHOOP ECG
export. HealthLog's direct WHOOP OAuth/API integration remains
supported for the WHOOP data the API exposes.
## Admin variant — import on behalf of another user
`POST /api/admin/import-apple-health-export` is the cookie-only admin
@@ -36,6 +36,32 @@ interface ImportJobStatusResponse {
failureReason: string | null;
}
function boundedCount(value: unknown): number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
? value
: 0;
}
function privacySafeResult(
value: Record<string, unknown> | null,
): Record<string, unknown> | null {
if (!value) return null;
const ecg =
typeof value.ecg === "object" && value.ecg !== null
? (value.ecg as Record<string, unknown>)
: {};
return {
...value,
ecg: {
discovered: boundedCount(ecg.discovered),
imported: boundedCount(ecg.imported),
updated: boundedCount(ecg.updated),
skipped: boundedCount(ecg.skipped),
failed: boundedCount(ecg.failed),
},
};
}
export const GET = apiHandler(
async (_request: NextRequest, ctx: RouteContext) => {
const { user } = await requireAuth();
@@ -68,7 +94,9 @@ export const GET = apiHandler(
uploadBytes: row.uploadBytes,
exportedAt: row.exportedAt?.toISOString() ?? null,
progress: (row.progress as Record<string, unknown>) ?? {},
result: (row.result as Record<string, unknown> | null) ?? null,
result: privacySafeResult(
(row.result as Record<string, unknown> | null) ?? null,
),
failureReason: row.failureReason,
};
@@ -30,6 +30,13 @@ interface JobStatus {
totals?: { recordsRead?: number; rowsUpserted?: number };
clinical?: { skipped?: number };
cumulativeEstimates?: { days?: number; rows?: number };
ecg?: {
discovered?: number;
imported?: number;
updated?: number;
skipped?: number;
failed?: number;
};
} | null;
failureReason: string | null;
}
@@ -152,6 +159,8 @@ export function AppleHealthImportCard() {
const isFailed = status?.status === "failed";
const busy = uploading || isRunning;
const percent = status?.progress?.percent ?? null;
const ecg = status?.result?.ecg;
const ecgWritten = (ecg?.imported ?? 0) + (ecg?.updated ?? 0);
return (
<ImportCardShell
@@ -241,7 +250,7 @@ export function AppleHealthImportCard() {
{isDone && (
<WrittenOutcomeLine
outcome={classifyWrittenOutcome({
written: status?.result?.totals?.rowsUpserted ?? 0,
written: (status?.result?.totals?.rowsUpserted ?? 0) + ecgWritten,
// The clinical count is a documented, deliberate exclusion
// (electrocardiograms, lab documents), not a refused row — it
// must not tip the archive into a warning. Only "nothing
@@ -262,6 +271,17 @@ export function AppleHealthImportCard() {
testId="import-apple-health-result"
/>
)}
{isDone && (ecg?.discovered ?? 0) > 0 && (
<p
data-testid="import-apple-health-ecg-result"
className="text-muted-foreground text-xs"
>
ECGs: {ecg?.discovered ?? 0} discovered, {ecg?.imported ?? 0}{" "}
imported, {ecg?.updated ?? 0} updated, {ecg?.skipped ?? 0} skipped,{" "}
{ecg?.failed ?? 0} failed. ECG failures do not discard supported
records imported from export.xml.
</p>
)}
{isDone && (status?.result?.cumulativeEstimates?.days ?? 0) > 0 && (
<AppleHealthEstimateWarning
days={status?.result?.cumulativeEstimates?.days ?? 0}