diff --git a/CHANGELOG.md b/CHANGELOG.md index 88109be85..66418c187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.37.22] — 2026-08-15 + +### Fixed + +- The doctor-report export refuses a selection that includes nothing instead of rendering an empty document. The selection panel already disabled its own button in that state, so this only ever reached the server through a hand-built request, and it spent an export slot on a file with no content in it. +- A lab value in the FHIR export no longer names UCUM as its unit system when it carries no UCUM code. That combination told a receiving system the unit was coded when it was not, which is worse than saying nothing: the unit still travels as a display string, and the coded form appears only when the analyte and unit actually resolve to one. + ## [1.37.21] — 2026-08-14 ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 33df1201c..a1f24e691 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.21 + version: 1.37.22 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index d7cff0ed2..3a19812db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.21", + "version": "1.37.22", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 82e5879f3..be5aaff9e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.21"; + /* @sw-version-fallback */ "v1.37.22"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 3e7b5185b..9a761419c 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -205,6 +205,18 @@ describe("POST /api/export/health-record — validation", () => { expect(res.status).toBe(422); }); + it("rejects a selection that admits nothing with 422", async () => { + // The panel disables its own submit in that state, so only a hand-built + // request gets here. Rendering an empty report and spending a rate-limit + // slot on it would be the wrong answer; this route has no documents-only + // mode that would make an empty leaf list a legitimate scope. + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "pdf", selection: sel() })); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("export.selection.empty"); + }); + it("rejects a userId smuggled into the body with 422", async () => { const { POST } = await import("../route"); const res = await POST( @@ -540,15 +552,11 @@ describe("POST /api/export/health-record — the selection reaches every format" // leaves: the totals must stay in the database. expect(await observationCodes(sel("LAB_RESULTS"))).toEqual([]); // And an empty selection — the "I said nothing" case, which used to mean - // "give me everything". + // "give me everything" and is now refused outright, so the totals cannot + // ride out on a scope nobody chose. const { POST } = await import("../route"); const res = await POST(mkReq({ format: "fhir", selection: sel() })); - const bundle = (await res.json()) as { - entry: { resource: { resourceType: string } }[]; - }; - expect( - bundle.entry.filter((e) => e.resource.resourceType === "Observation"), - ).toEqual([]); + expect(res.status).toBe(422); // Asked for by name, they are emitted — the data is withheld, not lost. const asked = await observationCodes(sel("PHQ9_SCORE", "GAD7_SCORE")); diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index a9b7bfb14..758fc31e4 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -129,6 +129,17 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } const selection = minted.selection; + // A selection that admits nothing would render an empty report and still + // spend a rate-limit slot on it. The panel disables its own submit button + // in that state; a hand-built request has to be refused here, because this + // route has no documents-only mode that would make an empty leaf list a + // legitimate scope (the share-link route, which does, mints EMPTY itself). + if (selection.leaves.length === 0) { + annotate({ meta: { empty_selection: true } }); + return apiError("Select at least one section to export", 422, { + errorCode: "export.selection.empty", + }); + } const range = normaliseDateRange(input.range ?? undefined); const practiceName = sanitisePracticeName(input.practiceName); diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index 9c2c4080b..0feb2cc46 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -181,7 +181,12 @@ describe("lab-result FHIR Observations", () => { }); describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { - function labOf(type: string, unit: string, value = 5.4) { + function labOf( + type: string, + unit: string, + value = 5.4, + bounds?: { low: number; high: number }, + ) { const bundle = buildFhirDocumentBundle( makeData({ labResults: [ @@ -191,8 +196,8 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { value, valueText: null, unit, - referenceLow: null, - referenceHigh: null, + referenceLow: bounds?.low ?? null, + referenceHigh: bounds?.high ?? null, catalogReferenceLow: null, catalogReferenceHigh: null, sourceReferenceText: null, @@ -250,13 +255,15 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { expect(tsh?.valueQuantity?.code).toBe("m[IU]/L"); }); - it("omits the UCUM code when the unit does not match the mapped canonical", () => { + it("omits the UCUM code AND its system when the unit does not match the mapped canonical", () => { // HbA1c mapped (LOINC present) but recorded in an mmol/mol unit we don't // canonicalise → keep the LOINC coding, drop the UCUM code, keep display. + // The system goes with the code: a valueQuantity naming UCUM without a + // code claims a coding the bundle does not carry. const o = labOf("HbA1c", "mmol/mol", 36); expect(o?.code.coding?.[0].code).toBe("4548-4"); - expect(o?.valueQuantity?.system).toBe("http://unitsofmeasure.org"); expect(o?.valueQuantity?.code).toBeUndefined(); + expect(o?.valueQuantity?.system).toBeUndefined(); expect(o?.valueQuantity?.unit).toBe("mmol/mol"); }); @@ -264,10 +271,25 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { const o = labOf("Selenium", "ug/L", 95); expect(o?.code.text).toBe("Selenium"); expect(o?.code.coding).toBeUndefined(); - // Display unit stays; no coerced UCUM code is invented. + // Display unit stays; no coerced UCUM code is invented, and no code + // system is named for a code that is not there. expect(o?.valueQuantity?.unit).toBe("ug/L"); expect(o?.valueQuantity?.code).toBeUndefined(); - expect(o?.valueQuantity?.system).toBe("http://unitsofmeasure.org"); + expect(o?.valueQuantity?.system).toBeUndefined(); + }); + + it("keeps system and code together on the reference range bounds too", () => { + // The bounds are stated in the SAME unit as the value, so an uncoded + // unit must leave them uncoded as well, on both halves. + const uncoded = labOf("HbA1c", "mmol/mol", 36, { low: 20, high: 42 }); + expect(uncoded?.referenceRange?.[0].low?.system).toBeUndefined(); + expect(uncoded?.referenceRange?.[0].low?.code).toBeUndefined(); + expect(uncoded?.referenceRange?.[0].high?.system).toBeUndefined(); + const coded = labOf("HDL", "mg/dL", 55, { low: 40, high: 60 }); + expect(coded?.referenceRange?.[0].low?.system).toBe( + "http://unitsofmeasure.org", + ); + expect(coded?.referenceRange?.[0].low?.code).toBe("mg/dL"); }); }); diff --git a/src/lib/fhir/resources/labs.ts b/src/lib/fhir/resources/labs.ts index 43509310a..60b70d019 100644 --- a/src/lib/fhir/resources/labs.ts +++ b/src/lib/fhir/resources/labs.ts @@ -80,11 +80,14 @@ export function labObservations( // A lab quantity: the recorded `unit` is always the display string, and // the canonical UCUM `code` is stamped only when the analyte map resolved // one for exactly that unit (no coerced symbol). + // The `system` rides along WITH the code and never alone: naming UCUM as + // the code system while withholding the code claims a coding the bundle + // does not carry, and leaves a receiver unable to machine-compare the + // value it is attached to. const quantity = (value: number) => ({ value, unit: lab.unit, - system: UCUM_SYSTEM, - ...(coding?.ucum ? { code: coding.ucum } : {}), + ...(coding?.ucum ? { system: UCUM_SYSTEM, code: coding.ucum } : {}), }); // The bounds are stated in the SAME unit as the value, so they carry the // SAME coding — a bare unit beside a coded value leaves a receiver unable