mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(doctor-report): add a surgical history section
New structured leaf SURGICAL_HISTORY in the history group and in the standard template: every procedure that took place, oldest first, with its date, what was done (the visit's reason), the body site with its side and the outcome. Lifetime reference data like the immunization record, so it ignores the report window. The PDF prints it as a table and the clinician share page renders it; saved selections do not widen. The FHIR bundle carries no Procedure resource, on the same terms as visits: no REST route or capability entry exists for it. Refs #1025
This commit is contained in:
1 parent
ad0d50fa28
commit
0b28498d83
16 files changed
+428
-16
No files matched your search
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(94);
|
||||
expect(ALL_LEAF_IDS.length).toBe(95);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(17);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18);
|
||||
});
|
||||
|
||||
it("places every leaf in exactly one group", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* is `STRUCTURED_LEAF_GROUP`, the other is HTML rendered by the real
|
||||
* `<ClinicianView>` over a real fixture. It proves four things per leaf.
|
||||
*
|
||||
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so an eighteenth
|
||||
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so a nineteenth
|
||||
* structured leaf cannot be added to the catalogue without deciding here
|
||||
* whether it renders — and saying so — or `pnpm typecheck` fails. That is
|
||||
* the lock, and it is the compiler's, not this suite's.
|
||||
@@ -332,6 +332,22 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
|
||||
},
|
||||
marker: "Palpitations",
|
||||
},
|
||||
SURGICAL_HISTORY: {
|
||||
kind: "rendered",
|
||||
data: {
|
||||
surgicalHistory: [
|
||||
{
|
||||
occurredAt: "2011-03-14T09:00:00.000Z",
|
||||
procedure: "Meniscus repair",
|
||||
bodySite: "Knee",
|
||||
laterality: "LEFT",
|
||||
outcome: null,
|
||||
practitionerName: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
marker: "Meniscus repair",
|
||||
},
|
||||
IMMUNIZATIONS: {
|
||||
kind: "rendered",
|
||||
data: {
|
||||
@@ -419,7 +435,7 @@ describe("clinician view — every catalogue leaf has a renderer or a reason", (
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a degraded import would satisfy every loop below
|
||||
// vacuously, which is the failure mode a pair guard dies of.
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(17);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
|
||||
expect(Object.keys(DISPOSITIONS).sort()).toEqual(
|
||||
[...STRUCTURED_LEAF_IDS].sort(),
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ import { EmergencySection, PatientIdentitySection } from "./identity-sections";
|
||||
import {
|
||||
IllnessSection,
|
||||
ImmunizationsSection,
|
||||
SurgicalHistorySection,
|
||||
LabResultsSection,
|
||||
VisitsSection,
|
||||
} from "./history-sections";
|
||||
@@ -256,6 +257,12 @@ export function ClinicianView({
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<SurgicalHistorySection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<ImmunizationsSection
|
||||
t={t}
|
||||
report={report}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `labs` and `history` groups on the clinician view: lab results, illness
|
||||
* episodes, visits and the immunization record.
|
||||
* episodes, visits, the surgical history and the immunization record.
|
||||
*
|
||||
* All four were selectable on a share link and none of them reached the page,
|
||||
* while the PDF download from the same link printed every one. What each
|
||||
@@ -12,8 +12,11 @@
|
||||
* A pure server component: no client hooks, no session, no markdown — every
|
||||
* value renders as escaped React text.
|
||||
*/
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { encounterKindLabelKey } from "@/lib/encounters/kind-label";
|
||||
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
|
||||
import {
|
||||
encounterKindLabelKey,
|
||||
lateralityLabelKey,
|
||||
} from "@/lib/encounters/kind-label";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import {
|
||||
@@ -221,6 +224,53 @@ export function VisitsSection({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The surgical history. Reference data, not window-bounded, like the
|
||||
* immunization record below: every procedure that happened, oldest first. What
|
||||
* was done is the row's label; the date, the site with its side and the
|
||||
* outcome compose onto the value side, the shape the immunization rows use.
|
||||
*/
|
||||
export function SurgicalHistorySection({
|
||||
t,
|
||||
report,
|
||||
scope,
|
||||
fmtDate,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtDate: (iso: string) => string;
|
||||
}) {
|
||||
const procedures = report.surgicalHistory ?? [];
|
||||
|
||||
const site = (row: (typeof procedures)[number]) => {
|
||||
if (!row.bodySite) return null;
|
||||
if (!row.laterality) return row.bodySite;
|
||||
return t("encounters.bodySiteWithSide", {
|
||||
site: row.bodySite,
|
||||
side: t(lateralityLabelKey(row.laterality as Laterality)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["SURGICAL_HISTORY"]}
|
||||
title={t("doctorReport.surgicalHistoryTitle")}
|
||||
empty={procedures.length === 0}
|
||||
>
|
||||
{procedures.map((row, index) => (
|
||||
<StatRow
|
||||
key={`${row.occurredAt}-${index}`}
|
||||
label={row.procedure ?? "—"}
|
||||
value={compose([fmtDate(row.occurredAt), site(row), row.outcome])}
|
||||
/>
|
||||
))}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The immunization record. Reference data, not window-bounded: an Impfpass is
|
||||
* a lifetime document and the whole of it rides when the leaf and the module
|
||||
|
||||
@@ -192,8 +192,8 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
expect(groupChip(html, "body")).toBe("0/12");
|
||||
expect(groupChip(html, "labs")).toBe("0/1");
|
||||
expect(groupChip(html, "medications")).toBe("0/4");
|
||||
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
|
||||
expect(groupChip(html, "history")).toBe("0/4");
|
||||
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
|
||||
expect(groupChip(html, "history")).toBe("0/5");
|
||||
expect(groupChip(html, "cardio")).toBe("0/14");
|
||||
expect(groupChip(html, "activity")).toBe("0/8");
|
||||
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
|
||||
|
||||
@@ -641,6 +641,82 @@ describe("doctor-report illness section", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.39.1 — the surgical history. Lifetime reference data like the
|
||||
// immunization record: every procedure that happened, with its date, what was
|
||||
// done (the visit's reason), where, and what came of it. Present only when the
|
||||
// SURGICAL_HISTORY leaf was admitted and at least one procedure exists.
|
||||
//
|
||||
// Mutation checks (each run, each seen red):
|
||||
// - drop the section's `if` block from the renderer → "prints the surgical
|
||||
// history table" goes red;
|
||||
// - print `bodySite` without the side → "names the side beside the site"
|
||||
// goes red.
|
||||
describe("doctor-report surgical history section", () => {
|
||||
const PROCEDURES: NonNullable<DoctorReportData["surgicalHistory"]> = [
|
||||
{
|
||||
occurredAt: "2011-03-14T09:00:00.000Z",
|
||||
procedure: "Meniscus repair",
|
||||
bodySite: "Knee",
|
||||
laterality: "LEFT",
|
||||
outcome: "Full recovery after physiotherapy",
|
||||
practitionerName: "Sample clinic",
|
||||
},
|
||||
{
|
||||
occurredAt: "2019-07-02T09:00:00.000Z",
|
||||
procedure: null,
|
||||
bodySite: null,
|
||||
laterality: null,
|
||||
outcome: null,
|
||||
practitionerName: null,
|
||||
},
|
||||
];
|
||||
|
||||
it("prints the surgical history table with procedure, site and outcome", async () => {
|
||||
const bytes = renderDoctorReportPdfBytes(
|
||||
makeData({ surgicalHistory: PROCEDURES }),
|
||||
{
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
},
|
||||
);
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Surgical history");
|
||||
expect(text).toContain("Meniscus repair");
|
||||
expect(text).toContain("Full recovery after physiotherapy");
|
||||
});
|
||||
|
||||
it("names the side beside the site", async () => {
|
||||
const bytes = renderDoctorReportPdfBytes(
|
||||
makeData({ surgicalHistory: PROCEDURES }),
|
||||
{
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
},
|
||||
);
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Operationen und Eingriffe");
|
||||
expect(text).toMatch(/Knee · Links/);
|
||||
});
|
||||
|
||||
it("omits the section when there is no procedure", async () => {
|
||||
const bytes = renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
expect(text).not.toContain("Surgical history");
|
||||
});
|
||||
});
|
||||
|
||||
// ── emergency first page ── the Notfalldaten sheet owns page one, but only
|
||||
// when the aggregator populated `data.emergency` (the EMERGENCY leaf admitted
|
||||
// AND the profile holds something). A null payload — which is exactly what the
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { encounterKindLabelKey } from "../encounters/kind-label";
|
||||
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
|
||||
import {
|
||||
encounterKindLabelKey,
|
||||
lateralityLabelKey,
|
||||
} from "../encounters/kind-label";
|
||||
import {
|
||||
classifyReferenceRange,
|
||||
formatReferenceRange,
|
||||
@@ -289,6 +292,72 @@ export function buildClinicalRecordsNotesSection(
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// The surgical history. Reference data (not time-windowed), populated when
|
||||
// the SURGICAL_HISTORY leaf is selected AND a procedure happened. Oldest
|
||||
// first, the order an intake form asks for it in. What was done is the
|
||||
// visit's reason; the site carries its side after it in the report's
|
||||
// language. Same calm register as the tables around it.
|
||||
if (data.surgicalHistory && data.surgicalHistory.length > 0) {
|
||||
y = ensureSpace(y, 6 + 18);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.surgicalHistoryTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const siteCell = (site: string | null, side: string | null): string => {
|
||||
if (!site) return "—";
|
||||
if (!side) return site;
|
||||
return t("encounters.bodySiteWithSide", {
|
||||
site,
|
||||
side: t(lateralityLabelKey(side as Laterality)),
|
||||
});
|
||||
};
|
||||
|
||||
const procedureRows = data.surgicalHistory.map((row) => [
|
||||
fmtDate(row.occurredAt),
|
||||
row.procedure ?? "—",
|
||||
siteCell(row.bodySite, row.laterality),
|
||||
row.outcome ?? "—",
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.surgicalHistoryColDate"),
|
||||
t("doctorReport.surgicalHistoryColProcedure"),
|
||||
t("doctorReport.surgicalHistoryColBodySite"),
|
||||
t("doctorReport.surgicalHistoryColOutcome"),
|
||||
],
|
||||
],
|
||||
body: procedureRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// The immunization history. Reference data (not time-windowed), populated
|
||||
// when the `IMMUNIZATIONS` leaf is selected AND the `vaccinations` module is
|
||||
// on AND live doses exist. A compact chronological table — date, vaccine
|
||||
|
||||
@@ -308,6 +308,27 @@ export interface DoctorReportData {
|
||||
/** Labels of the conditions this visit was filed against. */
|
||||
conditionLabels: string[];
|
||||
}> | null;
|
||||
/**
|
||||
* The surgical history (v1.39.1): every visit of kind PROCEDURE that
|
||||
* happened, oldest first. Reference data, not time-windowed, like the
|
||||
* immunization record below — an operation fifteen years ago is still the
|
||||
* answer to "what surgeries have you had". Null when the SURGICAL_HISTORY
|
||||
* leaf was not selected or no procedure exists; both builders then skip the
|
||||
* section. Optional so older fixtures still typecheck.
|
||||
*
|
||||
* `procedure` is the visit's reason, which is where a person writes what was
|
||||
* done. `procedure`, `bodySite` and `outcome` are decrypted free text and
|
||||
* null on a key-rotation gap. `laterality` rides as the enum constant; the
|
||||
* renderer names it in the report's language.
|
||||
*/
|
||||
surgicalHistory?: Array<{
|
||||
occurredAt: string;
|
||||
procedure: string | null;
|
||||
bodySite: string | null;
|
||||
laterality: string | null;
|
||||
outcome: string | null;
|
||||
practitionerName: string | null;
|
||||
}> | null;
|
||||
/**
|
||||
* The immunization history. Reference data, not time-windowed — an Impfpass
|
||||
* is a lifetime document and a doctor wants the whole of it, so this mirrors
|
||||
|
||||
@@ -299,6 +299,7 @@ describe("per-leaf gating sweep", () => {
|
||||
expect(allPayload.familyHistory).not.toBeNull();
|
||||
expect(allPayload.illnessEpisodes).not.toBeNull();
|
||||
expect(allPayload.visits).not.toBeNull();
|
||||
expect(allPayload.surgicalHistory).not.toBeNull();
|
||||
expect(allPayload.mood).not.toBeNull();
|
||||
expect(allPayload.cycle).not.toBeNull();
|
||||
expect(allPayload.anamnesis).not.toBeNull();
|
||||
@@ -454,18 +455,23 @@ describe("zero read for unchosen leaves", () => {
|
||||
["LAB_RESULTS", "labResult"],
|
||||
["ILLNESS_EPISODES", "illnessEpisode"],
|
||||
["VISITS", "encounter"],
|
||||
["SURGICAL_HISTORY", "encounter"],
|
||||
["IMMUNIZATIONS", "vaccinationRecord"],
|
||||
["ANAMNESIS", "userHealthProfile"],
|
||||
["EMERGENCY", "userHealthProfile"],
|
||||
["ANAMNESIS", "healthProfileFactRevision"],
|
||||
];
|
||||
|
||||
// ANAMNESIS and EMERGENCY both read `userHealthProfile`, so withholding one
|
||||
// ANAMNESIS and EMERGENCY both read `userHealthProfile` (and VISITS and
|
||||
// SURGICAL_HISTORY both read `encounter`), so withholding one
|
||||
// while the other stays selected still queries the table. Both must be off
|
||||
// before the read stops — the zero-read guarantee is over the SET of leaves
|
||||
// that touch a table, not each one in isolation.
|
||||
const TABLE_READERS: Record<string, ReportLeafId[]> = {
|
||||
userHealthProfile: ["ANAMNESIS", "EMERGENCY"],
|
||||
// The visits in the window and the lifetime surgical history both read
|
||||
// the encounter table, under their own leaves.
|
||||
encounter: ["VISITS", "SURGICAL_HISTORY"],
|
||||
};
|
||||
|
||||
it.each(gated)(
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The surgical history's read for the doctor report.
|
||||
*
|
||||
* Lifetime reference data, like the immunization record: a knee operated on
|
||||
* fifteen years ago is still the answer to "what surgeries have you had", so
|
||||
* the read ignores the report window. It reads only procedures that happened,
|
||||
* and the free text (what was done, where, what came of it) decrypts fail-soft
|
||||
* per row, so one key-rotation gap does not blank the section.
|
||||
*
|
||||
* Mutation checks (each run, each seen red):
|
||||
* - add the report window to the `where` → "ignores the report window" goes
|
||||
* red;
|
||||
* - drop `status: "DONE"` → "reads only procedures that happened" goes red;
|
||||
* - let a decrypt error throw → "reads a row it cannot decrypt as a blank"
|
||||
* goes red with the thrown error.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/db", () => ({ prisma: { encounter: { findMany } } }));
|
||||
vi.mock("@/lib/logging/context", () => ({ getEvent: () => undefined }));
|
||||
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { loadSurgicalHistory } from "../clinical-records";
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("loadSurgicalHistory", () => {
|
||||
it("reads only procedures that happened, over the whole record", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
await loadSurgicalHistory("u1");
|
||||
const args = findMany.mock.calls[0][0];
|
||||
expect(args.where).toEqual({
|
||||
userId: "u1",
|
||||
deletedAt: null,
|
||||
kind: "PROCEDURE",
|
||||
status: "DONE",
|
||||
});
|
||||
expect(args.orderBy).toEqual({ occurredAt: "asc" });
|
||||
});
|
||||
|
||||
it("ignores the report window", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
await loadSurgicalHistory("u1");
|
||||
expect(findMany.mock.calls[0][0].where).not.toHaveProperty("occurredAt");
|
||||
});
|
||||
|
||||
it("returns null when there is no procedure", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
expect(await loadSurgicalHistory("u1")).toBeNull();
|
||||
});
|
||||
|
||||
it("maps a procedure to date, what, where and outcome", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
occurredAt: new Date("2011-03-14T09:00:00.000Z"),
|
||||
reasonEncrypted: encryptToBytes("Meniscus repair"),
|
||||
outcomeEncrypted: encryptToBytes("Full recovery"),
|
||||
bodySiteEncrypted: encryptToBytes("Knee"),
|
||||
laterality: "LEFT",
|
||||
practitioner: { name: "Sample clinic" },
|
||||
},
|
||||
]);
|
||||
expect(await loadSurgicalHistory("u1")).toEqual([
|
||||
{
|
||||
occurredAt: "2011-03-14T09:00:00.000Z",
|
||||
procedure: "Meniscus repair",
|
||||
bodySite: "Knee",
|
||||
laterality: "LEFT",
|
||||
outcome: "Full recovery",
|
||||
practitionerName: "Sample clinic",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a row it cannot decrypt as a blank rather than failing the report", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
occurredAt: new Date("2019-07-02T09:00:00.000Z"),
|
||||
reasonEncrypted: new Uint8Array([1, 2, 3, 4]),
|
||||
outcomeEncrypted: null,
|
||||
bodySiteEncrypted: null,
|
||||
laterality: null,
|
||||
practitioner: null,
|
||||
},
|
||||
]);
|
||||
const rows = await loadSurgicalHistory("u1");
|
||||
expect(rows?.[0].procedure).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -199,6 +199,58 @@ export async function loadVisits(
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The surgical history: every procedure that happened, oldest first.
|
||||
*
|
||||
* Reference data, not windowed — like the immunization history below, it is a
|
||||
* lifetime answer, and a report over the last 90 days that left out a knee
|
||||
* operation from 2011 would answer "what surgeries have you had" wrongly.
|
||||
* Planned, cancelled and missed procedures are not surgical history. The three
|
||||
* free-text columns decrypt fail-soft per row, the same stance `loadVisits`
|
||||
* takes.
|
||||
*/
|
||||
export async function loadSurgicalHistory(
|
||||
userId: string,
|
||||
): Promise<DoctorReportData["surgicalHistory"]> {
|
||||
const rows = await prisma.encounter.findMany({
|
||||
where: { userId, deletedAt: null, kind: "PROCEDURE", status: "DONE" },
|
||||
orderBy: { occurredAt: "asc" },
|
||||
select: {
|
||||
occurredAt: true,
|
||||
reasonEncrypted: true,
|
||||
outcomeEncrypted: true,
|
||||
bodySiteEncrypted: true,
|
||||
laterality: true,
|
||||
practitioner: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const decrypt = (
|
||||
value: Uint8Array | null,
|
||||
field: "reason" | "outcome" | "bodySite",
|
||||
): string | null => {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(value);
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
`doctor-report: procedure ${field} decrypt failed for ${userId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return rows.map((row) => ({
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
procedure: decrypt(row.reasonEncrypted, "reason"),
|
||||
bodySite: decrypt(row.bodySiteEncrypted, "bodySite"),
|
||||
laterality: row.laterality,
|
||||
outcome: decrypt(row.outcomeEncrypted, "outcome"),
|
||||
practitionerName: row.practitioner?.name ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The immunization history. Reference data, not time-windowed — an Impfpass is
|
||||
* a lifetime document, so like allergies it ignores the report window.
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
loadIllnessEpisodes,
|
||||
loadImmunizations,
|
||||
loadVisits,
|
||||
loadSurgicalHistory,
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
|
||||
@@ -508,6 +509,7 @@ export async function collectDoctorReportData(
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
visits,
|
||||
surgicalHistory,
|
||||
immunizations,
|
||||
allergies,
|
||||
familyHistory,
|
||||
@@ -526,6 +528,10 @@ export async function collectDoctorReportData(
|
||||
gate.admits("VISITS")
|
||||
? loadVisits(userId, start, end)
|
||||
: Promise.resolve(null),
|
||||
// Reference data, not windowed, like the immunization history below.
|
||||
gate.admits("SURGICAL_HISTORY")
|
||||
? loadSurgicalHistory(userId)
|
||||
: Promise.resolve(null),
|
||||
// Reference data, not windowed — the immunization history is a lifetime
|
||||
// document, so the whole live set rides when the leaf and module admit it.
|
||||
gate.admits("IMMUNIZATIONS")
|
||||
@@ -601,6 +607,7 @@ export async function collectDoctorReportData(
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
visits,
|
||||
surgicalHistory,
|
||||
immunizations,
|
||||
allergies,
|
||||
familyHistory,
|
||||
|
||||
@@ -277,6 +277,13 @@ export function buildFhirDocumentBundle(
|
||||
// both carry the doses; the Bundle does not, and this comment is where a
|
||||
// reader who notices should land.
|
||||
//
|
||||
// The surgical history (v1.39.1) rides the same decision. A procedure is a
|
||||
// visit of kind PROCEDURE, and FHIR's `Procedure` resource would map it
|
||||
// cleanly (`code.text` from the reason, `bodySite.text` from the site), but
|
||||
// emitting one without its REST route and capability entry is the same
|
||||
// half-shipped state. The PDF and the clinician view carry the section; the
|
||||
// Bundle does not.
|
||||
//
|
||||
// held an episode (the aggregator gates `data.illnessEpisodes`). Each
|
||||
// episode emits a patient-reported Condition (generic SNOMED root, label on
|
||||
// `code.text`) plus a bounding Encounter that references it.
|
||||
|
||||
@@ -52,8 +52,8 @@ function resolves(key: string): boolean {
|
||||
describe("report selection catalogue", () => {
|
||||
it("carries every measurement type and every structured leaf", () => {
|
||||
expect(MEASUREMENT_LEAF_IDS).toHaveLength(77);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(17);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(94);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(95);
|
||||
});
|
||||
|
||||
it("resolves a label for every leaf", () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ export type StructuredLeafId =
|
||||
| "ALLERGIES"
|
||||
| "ILLNESS_EPISODES"
|
||||
| "VISITS"
|
||||
| "SURGICAL_HISTORY"
|
||||
| "IMMUNIZATIONS"
|
||||
| "FAMILY_HISTORY"
|
||||
| "MOOD"
|
||||
@@ -204,6 +205,7 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
|
||||
ALLERGIES: "history",
|
||||
ILLNESS_EPISODES: "history",
|
||||
VISITS: "history",
|
||||
SURGICAL_HISTORY: "history",
|
||||
IMMUNIZATIONS: "history",
|
||||
FAMILY_HISTORY: "sensitive",
|
||||
MOOD: "sensitive",
|
||||
@@ -211,7 +213,7 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
|
||||
ANAMNESIS: "sensitive",
|
||||
};
|
||||
|
||||
/** i18n label keys for the 17 structured leaves. */
|
||||
/** i18n label keys for the 18 structured leaves. */
|
||||
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
||||
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
|
||||
EMERGENCY: "reportSelection.leafEmergency",
|
||||
@@ -225,6 +227,7 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
||||
ALLERGIES: "reportSelection.leafAllergies",
|
||||
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
|
||||
VISITS: "reportSelection.leafVisits",
|
||||
SURGICAL_HISTORY: "reportSelection.leafSurgicalHistory",
|
||||
IMMUNIZATIONS: "reportSelection.leafImmunizations",
|
||||
FAMILY_HISTORY: "reportSelection.leafFamilyHistory",
|
||||
MOOD: "reportSelection.leafMood",
|
||||
@@ -323,6 +326,7 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
|
||||
// no module of its own — it is core, like the checkups page it lives on —
|
||||
// and the whole report is already gated by `doctorReport`. Reading the
|
||||
// absence as an oversight is the mistake this comment exists to prevent.
|
||||
// SURGICAL_HISTORY is absent for the same reason: a procedure is a visit.
|
||||
};
|
||||
|
||||
/** Every measurement leaf id, in enum order. */
|
||||
|
||||
@@ -16,7 +16,8 @@ import { type ReportLeafId } from "./catalogue";
|
||||
/**
|
||||
* Identity and insurer (the cover page a practice files the document under),
|
||||
* the three classic vitals, weight and BMI, the glucose series and its panel,
|
||||
* labs, the drug list and adherence, allergies and past illness episodes.
|
||||
* labs, the drug list and adherence, allergies, past illness episodes, the
|
||||
* surgical history and the immunization record.
|
||||
*
|
||||
* Deliberately not in it: everything a wearable produces, gait and mobility,
|
||||
* environment exposure, the per-dose administration ledger (a clinician asks
|
||||
@@ -39,6 +40,7 @@ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"ALLERGIES",
|
||||
"ILLNESS_EPISODES",
|
||||
"SURGICAL_HISTORY",
|
||||
"IMMUNIZATIONS",
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user