mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge v1.25.1: inbound original-document view/download + age-stratified grip/waist bands
This commit is contained in:
commit
883b6bb5e6
14 files changed
+626
-39
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7830,6 +7830,7 @@
|
||||
"review": {
|
||||
"title": "Review extracted facts",
|
||||
"discard": "Discard",
|
||||
"viewOriginal": "View original",
|
||||
"confirm": "Save approved facts",
|
||||
"confirming": "Saving…",
|
||||
"needsReview": "Needs review",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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<Response>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
"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",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
<CardTitle className="text-base">
|
||||
{t("documents.review.title")}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => discard.mutate()}
|
||||
disabled={discard.isPending}
|
||||
>
|
||||
{t("documents.review.discard")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 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. */}
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a
|
||||
href={`/api/documents/inbound/${documentId}/original`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" aria-hidden />
|
||||
{t("documents.review.viewOriginal")}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => discard.mutate()}
|
||||
disabled={discard.isPending}
|
||||
>
|
||||
{t("documents.review.discard")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{pending.length === 0 ? (
|
||||
|
||||
@@ -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<ArrayBuffer> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Record<MetricStatusMetricId, NormRow[]>> = {
|
||||
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) {
|
||||
|
||||
@@ -238,6 +238,40 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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"],
|
||||
|
||||
Reference in new issue
Block a user