diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5697416df..3cdd13167 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8696,6 +8696,40 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/documents/inbound/{id}/original: + get: + tags: + - Documents + summary: View / download the original document + description: "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under + `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. The response + carries the stored `mimeType`: a PDF / image is served inline (`Content-Disposition: inline`) so the browser can + render it; any other type downloads as an attachment. Fails closed (500) on a decrypt error — it never returns + the ciphertext." + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: The decrypted original document bytes with the stored MIME type. + content: + application/pdf: + schema: + type: string + format: binary + image/*: + schema: + type: string + format: binary + text/plain: + schema: + type: string + "401": *a1 + "422": *a3 + "429": *a4 /api/documents/inbound/{id}/facts/{factId}: patch: tags: diff --git a/messages/de.json b/messages/de.json index 23d0ff46c..61eb457e4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7830,6 +7830,7 @@ "review": { "title": "Erkannte Angaben prüfen", "discard": "Verwerfen", + "viewOriginal": "Original ansehen", "confirm": "Bestätigte Angaben speichern", "confirming": "Wird gespeichert …", "needsReview": "Prüfung nötig", diff --git a/messages/en.json b/messages/en.json index 810ecfc9b..6509d1040 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7830,6 +7830,7 @@ "review": { "title": "Review extracted facts", "discard": "Discard", + "viewOriginal": "View original", "confirm": "Save approved facts", "confirming": "Saving…", "needsReview": "Needs review", diff --git a/messages/es.json b/messages/es.json index 595bdcb39..77b13c0a7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7830,6 +7830,7 @@ "review": { "title": "Revisar datos extraídos", "discard": "Descartar", + "viewOriginal": "Ver original", "confirm": "Guardar datos aprobados", "confirming": "Guardando…", "needsReview": "Requiere revisión", diff --git a/messages/fr.json b/messages/fr.json index 0bd9561de..671adcb73 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7830,6 +7830,7 @@ "review": { "title": "Vérifier les données extraites", "discard": "Écarter", + "viewOriginal": "Voir l'original", "confirm": "Enregistrer les données approuvées", "confirming": "Enregistrement…", "needsReview": "À vérifier", diff --git a/messages/it.json b/messages/it.json index b2717dd0a..62c90045d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7830,6 +7830,7 @@ "review": { "title": "Rivedi i dati estratti", "discard": "Scarta", + "viewOriginal": "Vedi originale", "confirm": "Salva i dati approvati", "confirming": "Salvataggio…", "needsReview": "Da rivedere", diff --git a/messages/pl.json b/messages/pl.json index 07760e057..f43772172 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7830,6 +7830,7 @@ "review": { "title": "Przejrzyj wyodrębnione dane", "discard": "Odrzuć", + "viewOriginal": "Zobacz oryginał", "confirm": "Zapisz zatwierdzone dane", "confirming": "Zapisywanie…", "needsReview": "Wymaga przeglądu", diff --git a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts new file mode 100644 index 000000000..153a3dc15 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Buffer } from "node:buffer"; +import { NextRequest } from "next/server"; + +/** + * v1.25.1 (W-DOCS-IN) — `GET /api/documents/inbound/[id]/original`. + * + * The route decrypts and serves the raw uploaded document. Load-bearing + * behaviour under test: it is owner-scoped (the query narrows on the session + * userId, so another user's document is a 404), it returns the decrypted bytes + * with the stored MIME type, and it fails closed on a decrypt error. + */ + +// A reversible crypto stub so encryptDocumentToBytes / decryptDocumentFromBytes +// round-trip without real keys. `decrypt` is overridden per-test for the +// fail-closed path. +const decryptMock = vi.fn((s: string) => + Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"), +); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `v1.${Buffer.from(s, "utf8").toString("base64")}`, + decrypt: (s: string) => decryptMock(s), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { findFirst: vi.fn() }, + }, +})); + +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { encryptDocumentToBytes } from "@/lib/documents/store"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +type Ctx = { params: Promise<{ id: string }> }; +const callGet = GET as unknown as ( + req: NextRequest, + ctx: Ctx, +) => Promise; + +function makeReq(id: string): NextRequest { + return new NextRequest( + new URL(`http://localhost/api/documents/inbound/${id}/original`), + ); +} +function ctx(id: string): Ctx { + return { params: Promise.resolve({ id }) }; +} + +const PDF_BYTES = Buffer.from("%PDF-1.7 fake report body"); + +beforeEach(() => { + vi.clearAllMocks(); + decryptMock.mockImplementation((s: string) => + Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"), + ); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/documents/inbound/[id]/original", () => { + it("returns the decrypted original with the stored MIME type, owner-scoped", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + filename: "report.pdf", + mimeType: "application/pdf", + contentEncrypted: encryptDocumentToBytes(PDF_BYTES), + } as never); + + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/pdf"); + expect(res.headers.get("Content-Disposition")).toContain("inline"); + expect(res.headers.get("Content-Disposition")).toContain("report.pdf"); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + const out = Buffer.from(await res.arrayBuffer()); + expect(out.equals(PDF_BYTES)).toBe(true); + + // The query is narrowed on the session userId + live rows only. + const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0][0] + ?.where as { id: string; userId: string; deletedAt: null }; + expect(where.userId).toBe("user-1"); + expect(where.id).toBe("doc-1"); + expect(where.deletedAt).toBeNull(); + }); + + it("serves a non-inline type (text) as an attachment", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-2", + filename: null, + mimeType: "text/plain", + contentEncrypted: encryptDocumentToBytes(Buffer.from("plain text")), + } as never); + + const res = await callGet(makeReq("doc-2"), ctx("doc-2")); + expect(res.status).toBe(200); + const disposition = res.headers.get("Content-Disposition") ?? ""; + expect(disposition).toContain("attachment"); + // No filename → generated fallback keyed by id + extension. + expect(disposition).toContain("document-doc-2.txt"); + }); + + it("404s for another user's document (the userId narrows it out)", async () => { + // The owner-scoped where clause yields no row for a non-owner. + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue( + null as never, + ); + + const res = await callGet(makeReq("other-doc"), ctx("other-doc")); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBeTruthy(); + }); + + it("fails closed (500) on a decrypt error — never serves ciphertext", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-3", + filename: "report.pdf", + mimeType: "application/pdf", + contentEncrypted: encryptDocumentToBytes(PDF_BYTES), + } as never); + decryptMock.mockImplementation(() => { + throw new Error("unknown key id"); + }); + + const res = await callGet(makeReq("doc-3"), ctx("doc-3")); + expect(res.status).toBe(500); + expect(res.headers.get("Content-Type")).toContain("application/json"); + const body = (await res.json()) as { error: string }; + expect(body.error).toBeTruthy(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/original/route.ts b/src/app/api/documents/inbound/[id]/original/route.ts new file mode 100644 index 000000000..c2c445e6b --- /dev/null +++ b/src/app/api/documents/inbound/[id]/original/route.ts @@ -0,0 +1,136 @@ +/** + * v1.25.1 (W-DOCS-IN) — view / download the original uploaded document. + * + * The raw doctor report / discharge letter is stored encrypted at rest + * (`InboundDocument.contentEncrypted`, AES-256-GCM). This route is the ONLY + * path that decrypts and serves it, so the user can re-read the source the + * extracted facts came from. Owner-scoped + module-gated; the bytes are PHI: + * never logged, never served cross-user, fail-closed on a decrypt error. + * + * The response carries the stored `mimeType`. A PDF / image is served inline + * (`Content-Disposition: inline`) so the browser can render it in a tab; any + * other type (e.g. the text-mode upload) is sent as an attachment. The + * filename is sanitised before it reaches the header. + */ +import { NextResponse } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { decryptDocumentFromBytes } from "@/lib/documents/store"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const dynamic = "force-dynamic"; + +/** MIME types we render inline; everything else downloads as an attachment. */ +const INLINE_MIME_TYPES = new Set([ + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", +]); + +/** Extension fallback when the stored filename is absent / unusable. */ +const MIME_EXTENSION: Record = { + "application/pdf": "pdf", + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "text/plain": "txt", +}; + +/** + * Build a header-safe ASCII filename. Strips control characters (incl. CR/LF, + * the header-injection vector), quotes, and path separators (path traversal), + * caps the length, and falls back to a generated name keyed by the document + * id + MIME type when nothing usable remains. + */ +function safeDownloadName( + filename: string | null, + id: string, + mimeType: string, +): string { + const ext = MIME_EXTENSION[mimeType] ?? "bin"; + const cleaned = (filename ?? "") + // Strip control chars (incl. CR/LF — header injection), quotes, and path + // separators (path traversal) before the value reaches the header. + .replace(/[\u0000-\u001f"\\\/]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120); + return cleaned.length > 0 ? cleaned : `document-${id}.${ext}`; +} + +export const GET = apiHandler( + async (_request: Request, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const document = await prisma.inboundDocument.findFirst({ + // Owner-scoped: the userId narrows the row so a cuid guess cannot reach + // another user's document. A cookie session and a Bearer token both + // resolve through requireAuth(); neither can cross the userId boundary. + where: { id, userId: user.id, deletedAt: null }, + select: { + id: true, + filename: true, + mimeType: true, + contentEncrypted: true, + }, + }); + if (!document) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + + let bytes: Buffer; + try { + bytes = decryptDocumentFromBytes(document.contentEncrypted); + } catch { + // Fail closed — never fall back to the raw ciphertext. The error reason + // (bad / missing key id) is logged by the annotation, never the bytes. + annotate({ + action: { name: "documents.inbound.original.decryptFailed" }, + meta: { documentId: document.id }, + }); + return apiError("Could not read the stored document", 500, { + errorCode: "documents.inbound.decryptFailed", + }); + } + + const inline = INLINE_MIME_TYPES.has(document.mimeType); + const downloadName = safeDownloadName( + document.filename, + document.id, + document.mimeType, + ); + + annotate({ + action: { name: "documents.inbound.original.get" }, + meta: { + documentId: document.id, + mimeType: document.mimeType, + bytes: bytes.byteLength, + disposition: inline ? "inline" : "attachment", + }, + }); + + return new NextResponse(bytes as unknown as BodyInit, { + status: 200, + headers: { + "Content-Type": document.mimeType, + "Content-Length": String(bytes.byteLength), + "Content-Disposition": `${inline ? "inline" : "attachment"}; filename="${downloadName}"`, + // Private PHI — never cache in a shared / disk cache. + "Cache-Control": "private, no-store", + }, + }); + }, +); diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx index 83a300b1a..0336d362e 100644 --- a/src/components/documents/inbound-documents-view.tsx +++ b/src/components/documents/inbound-documents-view.tsx @@ -12,7 +12,13 @@ import { useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { AlertTriangle, FileScan, Loader2, Upload } from "lucide-react"; +import { + AlertTriangle, + ExternalLink, + FileScan, + Loader2, + Upload, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -283,14 +289,29 @@ function DocumentReview({ {t("documents.review.title")} - +
+ {/* View the original uploaded document. The route is same-origin and + cookie-authenticated, so a new tab carries the session; a PDF / + image renders inline, other types download. */} + + +
{pending.length === 0 ? ( diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 690a80e4f..2514d64c7 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -9,7 +9,7 @@ import { Buffer } from "node:buffer"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { encrypt } from "@/lib/crypto"; +import { decrypt, encrypt } from "@/lib/crypto"; import type { ExtractedFact, ExtractedFactStatus, @@ -35,6 +35,17 @@ export function encryptDocumentToBytes(bytes: Buffer): Uint8Array { return out; } +/** + * Decrypt a stored document back to its raw bytes. Throws on a bad / missing + * key id (fail-closed) — the caller must treat a throw as "cannot serve" and + * never fall back to returning the ciphertext. The original document is PHI; + * the only path that reads it is the owner-scoped download route. + */ +export function decryptDocumentFromBytes(buf: Uint8Array): Buffer { + const base64 = decrypt(Buffer.from(buf).toString("utf8")); + return Buffer.from(base64, "base64"); +} + /** * Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the * schema stores. The structured clinical values (diagnosis text, lab values, diff --git a/src/lib/insights/derived/__tests__/norms.test.ts b/src/lib/insights/derived/__tests__/norms.test.ts index 221c46e83..6851f634e 100644 --- a/src/lib/insights/derived/__tests__/norms.test.ts +++ b/src/lib/insights/derived/__tests__/norms.test.ts @@ -89,6 +89,89 @@ describe("lookupNormalRange — the age/sex reference-range enabler", () => { }); }); +describe("grip strength — age × sex bands (Dodds 2014 / EWGSOP2 floor)", () => { + it("steps the band down with age (younger reads against a higher norm)", () => { + // Male 30s centre 34.5 → {38,51}; male 70s centre 74.5 → {29,39}. + const young = lookupNormalRange("GRIP_STRENGTH", 34.5, "MALE")!; + const old = lookupNormalRange("GRIP_STRENGTH", 74.5, "MALE")!; + expect(young).toEqual({ low: 38, high: 51 }); + expect(old).toEqual({ low: 29, high: 39 }); + expect(young.low).toBeGreaterThan(old.low); + expect(young.high).toBeGreaterThan(old.high); + }); + + it("classifies by sex within the same age (male band sits above female)", () => { + const male = lookupNormalRange("GRIP_STRENGTH", 44.5, "MALE")!; + const female = lookupNormalRange("GRIP_STRENGTH", 44.5, "FEMALE")!; + expect(male.low).toBeGreaterThan(female.low); + expect(male.high).toBeGreaterThan(female.high); + }); + + it("never drops the low below the EWGSOP2 cut-off at the oldest band", () => { + // 80+ centre 100: male low floored at 27, female low floored at 16. + expect(lookupNormalRange("GRIP_STRENGTH", 100, "MALE")!.low).toBe(27); + expect(lookupNormalRange("GRIP_STRENGTH", 100, "FEMALE")!.low).toBe(16); + // Even past the oldest centre the clamp holds the floor. + expect(lookupNormalRange("GRIP_STRENGTH", 110, "MALE")!.low).toBe(27); + }); + + it("falls back to the sex cut-off band when age is absent", () => { + expect(lookupNormalRange("GRIP_STRENGTH", null, "MALE")).toEqual({ + low: 27, + high: 60, + }); + expect(lookupNormalRange("GRIP_STRENGTH", undefined, "FEMALE")).toEqual({ + low: 16, + high: 60, + }); + }); + + it("returns null when sex is absent (no honest sex-specific band)", () => { + expect(lookupNormalRange("GRIP_STRENGTH", 40, null)).toBeNull(); + expect(lookupNormalRange("GRIP_STRENGTH", null, null)).toBeNull(); + }); +}); + +describe("waist circumference — age × sex bands (WHO, raised for elderly)", () => { + it("holds the WHO threshold through midlife, raising it for older adults", () => { + // Male 18-49 centre 33.5 → {0,94}; 80+ centre 100 → {0,102}. + const adult = lookupNormalRange("WAIST_CIRCUMFERENCE", 33.5, "MALE")!; + const elderly = lookupNormalRange("WAIST_CIRCUMFERENCE", 100, "MALE")!; + expect(adult).toEqual({ low: 0, high: 94 }); + expect(elderly).toEqual({ low: 0, high: 102 }); + expect(elderly.high).toBeGreaterThan(adult.high); + }); + + it("classifies by sex (the female threshold sits below the male)", () => { + const male = lookupNormalRange("WAIST_CIRCUMFERENCE", 100, "MALE")!; + const female = lookupNormalRange("WAIST_CIRCUMFERENCE", 100, "FEMALE")!; + expect(female.high).toBeLessThan(male.high); + expect(female).toEqual({ low: 0, high: 88 }); + }); + + it("never lowers the threshold below the standard WHO floor", () => { + for (const age of [18, 25, 40, 55, 65, 75, 90]) { + expect( + lookupNormalRange("WAIST_CIRCUMFERENCE", age, "MALE")!.high, + ).toBeGreaterThanOrEqual(94); + expect( + lookupNormalRange("WAIST_CIRCUMFERENCE", age, "FEMALE")!.high, + ).toBeGreaterThanOrEqual(80); + } + }); + + it("falls back to the standard WHO threshold when age is absent", () => { + expect(lookupNormalRange("WAIST_CIRCUMFERENCE", null, "MALE")).toEqual({ + low: 0, + high: 94, + }); + expect(lookupNormalRange("WAIST_CIRCUMFERENCE", null, "FEMALE")).toEqual({ + low: 0, + high: 80, + }); + }); +}); + describe("hasSharpenedNorm", () => { it("mirrors lookupNormalRange truthiness", () => { expect(hasSharpenedNorm("VO2_MAX", 35, "MALE")).toBe(true); diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts index e43fc8979..5e5c0f0d2 100644 --- a/src/lib/insights/derived/norms.ts +++ b/src/lib/insights/derived/norms.ts @@ -31,6 +31,20 @@ * "ATS Statement: Guidelines for the Six-Minute Walk Test", * Am J Respir Crit Care Med 166(1):111–117. Surfaced as a * percent-of-predicted re-frame, never a HealthLog-derived equation. + * - Grip strength by age × sex — Dodds et al. 2014, "Grip Strength + * across the Life Course: Normative Data from Twelve British Studies", + * PLOS ONE 9(12):e113637 (n≈49,964). The age×sex band low is the 10th + * centile, FLOORED at the EWGSOP2 "probable sarcopenia" cut-off + * (Cruz-Jentoft et al., Age Ageing 2019: men < 27 kg, women < 16 kg) so + * the clinical floor is never crossed; the band high is the 50th-centile + * (median) for the age×sex cell. + * - Waist circumference by age × sex — WHO increased-risk thresholds + * (WHO 2008/2011; men > 94 cm, women > 80 cm) as the adult floor, raised + * for older adults: the WHO cut-points derive from cohorts aged 20–74, + * and evidence in the elderly supports cut-points set at higher waist + * values (toward the WHO substantially-increased-risk line, men > 102 cm / + * women > 88 cm). No ethnicity adjustment — HealthLog does not collect + * ancestry. * * Client-safe — pure data + a pure lookup, no server imports. */ @@ -54,6 +68,15 @@ interface NormRow { /** Sex this row applies to; `null` = applies to either. */ sex: "MALE" | "FEMALE" | null; range: NormRange; + /** + * Age-INDEPENDENT clinical cut-off band (the EWGSOP2 grip floor / the WHO + * waist threshold). It is excluded from the age-interpolation set and used + * only as the no-age fallback for a sex-specific table — so a profile with + * a known sex but no date of birth still gets the sex cut-off rather than + * the coarse sex-flat registry anchor. The age-stratified rows already bake + * this floor into their bounds, so it never widens the band for a known age. + */ + ageIndependent?: boolean; } /** @@ -102,28 +125,85 @@ const RESPIRATORY_RATE_NORMS: NormRow[] = [ ]; /** - * Grip-strength normal band (kg) by sex — the EWGSOP2 "probable sarcopenia" - * cut-off (Cruz-Jentoft et al., Age Ageing 2019): men < 27 kg, women < 16 kg. - * The band brackets the at-or-above-cut-off "normal" placement; `direction` - * is `higher-better`, so the read flags a value below the low bound. A single - * adult bracket per sex — the cut-off is sex-specific, not age-banded — so the - * resolver returns it whenever the profile sex is known, even with no age. + * Grip-strength bands (kg) by age × sex — Dodds et al. 2014 (PLOS ONE + * 9(12):e113637). `direction` is `higher-better`, so the read flags a value + * below the low bound. Per age×sex cell: low = the Dodds 10th centile floored + * at the EWGSOP2 "probable sarcopenia" cut-off (men 27 kg, women 16 kg; + * Cruz-Jentoft et al., Age Ageing 2019) so the clinical floor is never + * crossed; high = the Dodds 50th-centile (median). Strength peaks in the 30s + * and declines from midlife, so the bands step down with age — a 70-year-old + * is no longer measured against a 30-year-old's typical grip. The trailing + * `ageIndependent` row is the EWGSOP2 sex cut-off used when the profile has a + * sex but no date of birth (preserves the pre-v1.25.1 no-age behaviour). */ const GRIP_STRENGTH_NORMS: NormRow[] = [ - { minAge: 0, maxAge: 120, sex: "MALE", range: { low: 27, high: 60 } }, - { minAge: 0, maxAge: 120, sex: "FEMALE", range: { low: 16, high: 60 } }, + { minAge: 20, maxAge: 29, sex: "MALE", range: { low: 30, high: 40 } }, + { minAge: 30, maxAge: 39, sex: "MALE", range: { low: 38, high: 51 } }, + { minAge: 40, maxAge: 49, sex: "MALE", range: { low: 38, high: 50 } }, + { minAge: 50, maxAge: 59, sex: "MALE", range: { low: 35, high: 48 } }, + { minAge: 60, maxAge: 69, sex: "MALE", range: { low: 33, high: 45 } }, + { minAge: 70, maxAge: 79, sex: "MALE", range: { low: 29, high: 39 } }, + { minAge: 80, maxAge: 120, sex: "MALE", range: { low: 27, high: 32 } }, + { minAge: 20, maxAge: 29, sex: "FEMALE", range: { low: 21, high: 28 } }, + { minAge: 30, maxAge: 39, sex: "FEMALE", range: { low: 24, high: 31 } }, + { minAge: 40, maxAge: 49, sex: "FEMALE", range: { low: 23, high: 31 } }, + { minAge: 50, maxAge: 59, sex: "FEMALE", range: { low: 21, high: 29 } }, + { minAge: 60, maxAge: 69, sex: "FEMALE", range: { low: 18, high: 27 } }, + { minAge: 70, maxAge: 79, sex: "FEMALE", range: { low: 16, high: 24 } }, + { minAge: 80, maxAge: 120, sex: "FEMALE", range: { low: 16, high: 19 } }, + // EWGSOP2 sex cut-off — no-age fallback only (not age-interpolated). + { + minAge: 18, + maxAge: 120, + sex: "MALE", + range: { low: 27, high: 60 }, + ageIndependent: true, + }, + { + minAge: 18, + maxAge: 120, + sex: "FEMALE", + range: { low: 16, high: 60 }, + ageIndependent: true, + }, ]; /** - * Waist-circumference normal band (cm) by sex — the WHO European-origin - * increased-risk threshold (WHO 2008/2011; NIH/NHLBI 1998): men > 94 cm, - * women > 80 cm. `direction` is `lower-better`, so the normal band runs from - * 0 up to the sex cut-off. Single adult bracket per sex (sex-specific, not - * age-banded). No ethnicity adjustment — HealthLog does not collect ancestry. + * Waist-circumference bands (cm) by age × sex — `direction` is `lower-better`, + * so the normal band runs from 0 up to the sex cut-off (the `high`). The WHO + * increased-risk threshold (WHO 2008/2011; NIH/NHLBI 1998: men > 94 cm, + * women > 80 cm) is the adult floor and is held flat through midlife. The WHO + * cut-points derive from cohorts aged 20–74; evidence in the elderly supports + * higher cut-points, so the threshold rises with age toward the WHO + * substantially-increased-risk line (men > 102 cm, women > 88 cm). The floor + * is never lowered — the standard threshold is the minimum. The trailing + * `ageIndependent` row is the standard WHO threshold used when the profile has + * a sex but no date of birth. No ethnicity adjustment. */ const WAIST_CIRCUMFERENCE_NORMS: NormRow[] = [ - { minAge: 0, maxAge: 120, sex: "MALE", range: { low: 0, high: 94 } }, - { minAge: 0, maxAge: 120, sex: "FEMALE", range: { low: 0, high: 80 } }, + { minAge: 18, maxAge: 49, sex: "MALE", range: { low: 0, high: 94 } }, + { minAge: 50, maxAge: 64, sex: "MALE", range: { low: 0, high: 94 } }, + { minAge: 65, maxAge: 79, sex: "MALE", range: { low: 0, high: 98 } }, + { minAge: 80, maxAge: 120, sex: "MALE", range: { low: 0, high: 102 } }, + { minAge: 18, maxAge: 49, sex: "FEMALE", range: { low: 0, high: 80 } }, + { minAge: 50, maxAge: 64, sex: "FEMALE", range: { low: 0, high: 80 } }, + { minAge: 65, maxAge: 79, sex: "FEMALE", range: { low: 0, high: 84 } }, + { minAge: 80, maxAge: 120, sex: "FEMALE", range: { low: 0, high: 88 } }, + // Standard WHO threshold — no-age fallback only (not age-interpolated). + { + minAge: 18, + maxAge: 120, + sex: "MALE", + range: { low: 0, high: 94 }, + ageIndependent: true, + }, + { + minAge: 18, + maxAge: 120, + sex: "FEMALE", + range: { low: 0, high: 80 }, + ageIndependent: true, + }, ]; /** @@ -135,7 +215,8 @@ const NORM_TABLES: Partial> = { VO2_MAX: VO2_MAX_NORMS, RESTING_HEART_RATE: RESTING_HEART_RATE_NORMS, RESPIRATORY_RATE: RESPIRATORY_RATE_NORMS, - // v1.25 — sex-specific (age-independent) cut-offs. + // v1.25.1 — age × sex bands, with a sex-specific clinical cut-off as the + // floor + the no-age fallback. GRIP_STRENGTH: GRIP_STRENGTH_NORMS, WAIST_CIRCUMFERENCE: WAIST_CIRCUMFERENCE_NORMS, }; @@ -174,6 +255,13 @@ function resolveSexRows(rows: NormRow[], sex: NormSex): NormRow[] | null { * sex; when the profile sex is absent it falls back to the sex-agnostic * rows; a sex-specific-only table with no profile sex yields no honest band. * + * Clinical-floor fallback: a table may carry an `ageIndependent` cut-off row + * per sex (the EWGSOP2 grip floor / the WHO waist threshold). It is excluded + * from age interpolation and returned only when no usable age is present, so a + * grip / waist read still classifies against the sex cut-off for a profile + * with a known sex but no date of birth. A table with no such row (VO₂max, …) + * returns `null` without an age, keeping the flat registry anchor. + * * Age handling: the bracket tables are coarse (decade / paediatric bands), * so a fractional age that lands near a bracket edge would otherwise read a * hard step at the boundary (e.g. 39.9 → 30s band, 40.0 → 40s band). Instead @@ -195,30 +283,38 @@ export function lookupNormalRange( const rows = resolveSexRows(table, sex); if (!rows || rows.length === 0) return null; - // Single band — no neighbour to interpolate against. A sex-specific, - // age-independent cut-off (grip strength, waist circumference) resolves - // here without an age, so the sex-aware band still applies when the - // profile carries sex but no date of birth. - if (rows.length === 1) return { ...rows[0].range }; + // Split the age-independent clinical cut-off (the EWGSOP2 grip floor / the + // WHO waist threshold) from the age-stratified bands. The cut-off is never + // interpolated — it is the no-age fallback for a sex-specific table. + const ageRows = rows.filter((row) => !row.ageIndependent); + const floorBand = rows.find((row) => row.ageIndependent)?.range ?? null; - // Multi-bracket tables are age-banded — without a usable age there is no - // honest placement, so the caller keeps the flat registry anchor. + // No usable age. A sex-specific clinical cut-off still applies (grip / waist + // are flagged against the sex threshold even with no date of birth); a + // purely age-banded metric (VO₂max, …) has no honest placement, so the + // caller keeps the flat registry anchor. if (ageYears == null || !Number.isFinite(ageYears) || ageYears < 0) { - return null; + return floorBand ? { ...floorBand } : null; } + // No age-stratified rows (a cut-off-only table) — return the cut-off band. + if (ageRows.length === 0) return floorBand ? { ...floorBand } : null; + + // Single band — no neighbour to interpolate against. + if (ageRows.length === 1) return { ...ageRows[0].range }; + // Clamp below the youngest centre / above the oldest centre: hold the // nearest cited band flat rather than extrapolate past the table. - const first = rows[0]; - const last = rows[rows.length - 1]; + const first = ageRows[0]; + const last = ageRows[ageRows.length - 1]; if (ageYears <= bracketCentre(first)) return { ...first.range }; if (ageYears >= bracketCentre(last)) return { ...last.range }; // Find the two adjacent brackets whose centres bracket the age, then blend // their ranges by the fractional position between the centres. - for (let i = 0; i < rows.length - 1; i++) { - const lo = rows[i]; - const hi = rows[i + 1]; + for (let i = 0; i < ageRows.length - 1; i++) { + const lo = ageRows[i]; + const hi = ageRows[i + 1]; const loCentre = bracketCentre(lo); const hiCentre = bracketCentre(hi); if (ageYears >= loCentre && ageYears <= hiCentre) { diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 4853192b7..5abee6a51 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -238,6 +238,40 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + "/api/documents/inbound/{id}/original": { + get: { + tags: ["Documents"], + summary: "View / download the original document", + description: + "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. The response carries the stored `mimeType`: a PDF / image is served inline (`Content-Disposition: inline`) so the browser can render it; any other type downloads as an attachment. Fails closed (500) on a decrypt error — it never returns the ciphertext.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: + "The decrypted original document bytes with the stored MIME type.", + content: { + "application/pdf": { + schema: { type: "string", format: "binary" }, + }, + "image/*": { + schema: { type: "string", format: "binary" }, + }, + "text/plain": { + schema: { type: "string" }, + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/documents/inbound/{id}/facts/{factId}": { patch: { tags: ["Documents"],