mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
The report resolved an absent key to "included". A caller that sent no sections object, or an empty group inside one, received the full record at server-chosen defaults: roughly fifty metrics plus the medication list, labs, allergies and family history. The web panel sent every key explicitly so it looked fine from the browser, but the share link, the FHIR REST face, the MCP doctor-visit resource and the cycle-only export card all went through paths where absence meant yes. No arrangement of booleans fixes a default that means yes, so the shape goes. A selection is now an inclusion list of leaf ids. A leaf is the smallest thing that can be released or withheld: every measurement type, and thirteen structured sections — identity, insurer, the glucose panel, labs, the drug list, the administration ledger, adherence, the GLP-1 block, allergies, illness episodes, family history, mood, cycle. Membership is inclusion, absence is exclusion, and there is no defaults constant anywhere to drift. Three locks keep a metric added later off a report chosen earlier. A stored selection is a list of what was chosen, so a leaf that did not exist then is not in it. The catalogue is keyed over the measurement enum, so adding a member without deciding its group fails typecheck in the same commit. And the aggregator's filter inverts: a type the catalogue does not know is excluded and annotated, where it used to be passed through. The aggregator's third parameter is now required and brand-typed, which turned the three surfaces that had no scope at all into compile errors. Each one had to name a source, and each now replays the owner's saved selection — the same object reviewed in Settings. An account that never saved one gets nothing there rather than a guess. Identity and insurance become selectable, and a share link refuses the insurance leaf outright. The share view never decrypted the number; that is structural now instead of incidental. The KVNR is not even read unless the leaf was chosen. Two surfaces gain a record they always implied. A share link serves its frozen selection as a FHIR bundle and as the PDF, behind the same unlock cookie the document route uses, with its own rate bucket, its own audit row and the same blunt 404 for every miss. That is what the removed FHIR toggle promised, in the form a practice actually uses. The AI summary leaves the report. Its text came from the cached briefing with no relation to the selection, so its scope could not be proven, and prose whose scope cannot be proven has no place on a document whose whole point is a proven scope. The PDF table stops being a nine-type whitelist. It prints every selected leaf that has data, sub-headed by group, in the same order the panel shows — so selecting resting heart rate no longer means seeing it in the bundle and not in the PDF. The clinician view is grouped the same way and reads real labels instead of "Phq9 score". Migration 0273 adds the saved-selection column, drops the orphaned prefs column and the two retired share-link scope columns, and revokes every live record share link minted before this model. Their frozen scope was "everything except mood and cycle", which is a scope nobody chose; narrowing it would still be a scope nobody chose. Documents-only shares are untouched. The sharing list shows each closed link in a pick-again state with its label, window and expiry kept. The retired allowFhirApi and resourceTypes stay on the create and list responses as the constant false and empty list. The request refuses them. They come off the response once the native client confirms it does not decode them. Six files are split at seams this change already touched: the aggregator into its gate, its dense-bucket query, its series, its glucose panel, its medication ledger and its clinical records; the export route's three emitters into their own module; the clinician view into its sections, its document list and its downloads.
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
/**
|
||
* Isomorphic PDF renderer for the doctor report.
|
||
*
|
||
* Runs identically in the browser (settings page download) and in Node
|
||
* (server-rendered `/api/doctor-report/pdf` endpoint). All locale-sensitive
|
||
* strings and number/date formatting are driven by the injected
|
||
* `{ t, locale }` so DE and EN output match the user's UI language.
|
||
*
|
||
* jsPDF is fully isomorphic: `doc.output("arraybuffer")` returns a valid
|
||
* `%PDF-` byte stream in both environments.
|
||
*/
|
||
import { jsPDF } from "jspdf";
|
||
import type { MeasurementType } from "@/generated/prisma/client";
|
||
import {
|
||
isStructuredLeafId,
|
||
REPORT_GROUPS,
|
||
} from "./report-selection/catalogue";
|
||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "./measurements/type-label-keys";
|
||
import { getUnitForType } from "./validations/measurement";
|
||
import {
|
||
makeFormatters,
|
||
DISPLAY_TIMEZONE,
|
||
type TimeFormatPreference,
|
||
} from "./format-locale";
|
||
import type { Locale } from "./i18n/config";
|
||
import { isValidTimezone } from "./tz/format";
|
||
import type { DoctorReportData } from "./doctor-report-data";
|
||
import { buildHeaderProfileSection } from "./doctor-report-pdf/header-profile-section";
|
||
import { buildMeasurementsChartsSection } from "./doctor-report-pdf/measurements-charts-section";
|
||
import { buildMedicationMoodWellnessSection } from "./doctor-report-pdf/medication-mood-wellness-section";
|
||
import { buildClinicalRecordsNotesSection } from "./doctor-report-pdf/clinical-records-notes-section";
|
||
import { buildReportFooter } from "./doctor-report-pdf/footer-section";
|
||
import {
|
||
pdfCursorState,
|
||
type DoctorReportPdfRenderContext,
|
||
} from "./doctor-report-pdf/render-context";
|
||
|
||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||
|
||
/**
|
||
* jsPDF's built-in Helvetica is WinAnsi-encoded. Latin-1 glyphs (umlauts,
|
||
* ß, em-/en-dash, typographic quotes, °, µ) carry correct metrics, but any
|
||
* code point outside WinAnsi resolves to the `.notdef` box at a single
|
||
* fallback advance width. The widths then disagree with the drawn glyph and
|
||
* the surrounding words stretch / shift — the visible "stretched line" bug.
|
||
*
|
||
* This sanitiser maps every glyph the report can emit that falls outside
|
||
* WinAnsi onto a WinAnsi-safe equivalent, applied centrally to every string
|
||
* just before it reaches `doc.text` / `doc.splitTextToSize` (see
|
||
* `patchPdfTextSanitiser`). The offenders that actually occur in the report
|
||
* are the trend arrows (↑ ↓ →) injected by `trendArrow()` + the `glp1WeightSummary`
|
||
* separator, and the superscript-two in "kg/m²". The mapping is exhaustive
|
||
* for those and degrades gracefully for any future stray symbol.
|
||
*
|
||
* Premium follow-up (documented, not a hotfix blocker): embed a Unicode TTF
|
||
* (e.g. DejaVuSans) via `doc.addFileToVFS` / `addFont` so the arrows and
|
||
* superscripts render as their true glyphs instead of ASCII equivalents.
|
||
*/
|
||
const WINANSI_REPLACEMENTS: Record<string, string> = {
|
||
// Trend arrows → ASCII so the metrics match the drawn glyph.
|
||
"↑": "^", // ↑ up
|
||
"↓": "v", // ↓ down
|
||
"→": "->", // → right (also the glp1 weight separator)
|
||
"←": "<-", // ←
|
||
"↔": "<->", // ↔
|
||
// Super-/subscripts used in "kg/m²".
|
||
"²": "2", // ²
|
||
"³": "3", // ³
|
||
"¹": "1", // ¹
|
||
// Defensive: a few maths/symbol glyphs that are outside WinAnsi but read
|
||
// fine as ASCII, in case a future string introduces them.
|
||
"≈": "~", // ≈
|
||
"≤": "<=", // ≤
|
||
"≥": ">=", // ≥
|
||
"×": "x", // ×
|
||
"€": "EUR", // €
|
||
};
|
||
|
||
const WINANSI_REPLACE_RE = new RegExp(
|
||
`[${Object.keys(WINANSI_REPLACEMENTS).join("")}]`,
|
||
"g",
|
||
);
|
||
|
||
/** Map non-WinAnsi glyphs onto safe equivalents. Pure; exported for tests. */
|
||
export function sanitiseForPdf(text: string): string {
|
||
return text.replace(
|
||
WINANSI_REPLACE_RE,
|
||
(ch) => WINANSI_REPLACEMENTS[ch] ?? ch,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Patch `doc.text` + `doc.splitTextToSize` on a single jsPDF instance so
|
||
* every drawn string (direct text, wrapped paragraphs, AND `jspdf-autotable`
|
||
* cells — which route their content through `doc.text` too) passes through
|
||
* `sanitiseForPdf` first. One choke point instead of a sanitiser call at
|
||
* every site keeps the renderer readable and guarantees coverage.
|
||
*/
|
||
function patchPdfTextSanitiser(doc: jsPDF): void {
|
||
const clean = (value: unknown): unknown =>
|
||
typeof value === "string"
|
||
? sanitiseForPdf(value)
|
||
: Array.isArray(value)
|
||
? value.map((v) => (typeof v === "string" ? sanitiseForPdf(v) : v))
|
||
: value;
|
||
|
||
const originalText = doc.text.bind(doc);
|
||
doc.text = function patchedText(
|
||
this: jsPDF,
|
||
...args: Parameters<jsPDF["text"]>
|
||
): jsPDF {
|
||
const next = [...args] as unknown[];
|
||
next[0] = clean(next[0]);
|
||
return originalText(...(next as Parameters<jsPDF["text"]>));
|
||
} as jsPDF["text"];
|
||
|
||
const originalSplit = doc.splitTextToSize.bind(doc);
|
||
doc.splitTextToSize = function patchedSplit(
|
||
this: jsPDF,
|
||
...args: Parameters<jsPDF["splitTextToSize"]>
|
||
): ReturnType<jsPDF["splitTextToSize"]> {
|
||
const next = [...args] as unknown[];
|
||
next[0] = clean(next[0]);
|
||
return originalSplit(...(next as Parameters<jsPDF["splitTextToSize"]>));
|
||
} as jsPDF["splitTextToSize"];
|
||
}
|
||
|
||
export interface DoctorReportRenderOptions {
|
||
t: T;
|
||
locale: Locale;
|
||
/**
|
||
* Optional fixed timestamp for "createdOn"/footer. Useful for deterministic
|
||
* tests; defaults to `new Date()`.
|
||
*/
|
||
now?: Date;
|
||
/**
|
||
* v1.4.25 W7 — per-user display timezone. When omitted the report
|
||
* renders timestamps in Europe/Berlin (legacy contract). Server
|
||
* callers pass `resolveUserTimezone(user.id)`; client callers pass
|
||
* the value from auth context so a US user's PDF carries
|
||
* Eastern-time rows even when generated in the browser.
|
||
*/
|
||
userTz?: string;
|
||
/**
|
||
* v1.25.4 — the user's hour-cycle preference. Threaded into the formatters
|
||
* so the footer "generated at" timestamp (and any other clock the report
|
||
* prints) honours H12 / H24 rather than falling to the locale default. When
|
||
* omitted it stays AUTO (locale default), matching the legacy contract.
|
||
*/
|
||
timeFormat?: TimeFormatPreference;
|
||
/**
|
||
* v1.7.0 — decrypted KVNR (German insurance number). Printed on the
|
||
* cover when present; the column is encrypted at rest, so the route
|
||
* decrypts it and hands the plaintext in here. Null/undefined omits
|
||
* the cover line exactly like an unset practice name.
|
||
*/
|
||
insuranceNumber?: string | null;
|
||
/**
|
||
* v1.7.0 — embed jsPDF-native trend sparklines per primary vital.
|
||
* Defaults to `true`. Off produces a compact text-only report.
|
||
*/
|
||
includeCharts?: boolean;
|
||
}
|
||
|
||
// Per-type label keys live in a lucide-free module so leaf consumers — the
|
||
// public clinician share view, this renderer — can import the map without
|
||
// dragging icon components into their graph. Re-exported here so the PDF core
|
||
// stays the single import surface its existing callers already use.
|
||
export { MEASUREMENT_TYPE_LABEL_KEYS };
|
||
|
||
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||
WEIGHT: "kg",
|
||
BLOOD_PRESSURE_SYS: "mmHg",
|
||
BLOOD_PRESSURE_DIA: "mmHg",
|
||
PULSE: "bpm",
|
||
BODY_FAT: "%",
|
||
SLEEP_DURATION: "h",
|
||
ACTIVITY_STEPS: null, // translated unit
|
||
TOTAL_BODY_WATER: "kg",
|
||
BONE_MASS: "kg",
|
||
OXYGEN_SATURATION: "%",
|
||
};
|
||
|
||
/**
|
||
* The measurement table is driven by the selection, not by a whitelist.
|
||
*
|
||
* It used to be a fixed nine types, which meant a user could select resting
|
||
* heart rate, see it in the FHIR bundle and in the share view, and not find it
|
||
* in the PDF — a per-format divergence on a document whose whole point is that
|
||
* every format describes the same record. The table now prints every selected
|
||
* measurement leaf that has data, sub-headed by the same twelve groups the
|
||
* selection panel shows, so the panel reads as a table of contents for the
|
||
* artefact.
|
||
*
|
||
* The aggregator has already applied the selection, so `data.stats` carries
|
||
* exactly the admitted leaves and nothing here can over-serve.
|
||
*/
|
||
export const DOCTOR_REPORT_VITAL_GROUPS = REPORT_GROUPS.map((group) => ({
|
||
id: group.id,
|
||
labelKey: group.labelKey,
|
||
types: group.leaves.filter(
|
||
(leaf): leaf is MeasurementType => !isStructuredLeafId(leaf),
|
||
),
|
||
})).filter((group) => group.types.length > 0);
|
||
|
||
/**
|
||
* Render the doctor report into a `jsPDF` instance.
|
||
*
|
||
* Used internally by both the client wrapper (which calls `.save()` on the
|
||
* returned doc) and the server renderer (which calls `.output("arraybuffer")`
|
||
* via `renderDoctorReportPdfBytes`).
|
||
*/
|
||
export function buildDoctorReportPdfDocument(
|
||
data: DoctorReportData,
|
||
options: DoctorReportRenderOptions,
|
||
): jsPDF {
|
||
const {
|
||
t,
|
||
locale,
|
||
now = new Date(),
|
||
userTz,
|
||
timeFormat = "AUTO",
|
||
insuranceNumber = null,
|
||
includeCharts = true,
|
||
} = options;
|
||
const formatters = makeFormatters(locale, userTz, timeFormat);
|
||
const num = (value: number, decimals = 1) =>
|
||
formatters.number(value, decimals);
|
||
const fmtDate = (iso: string) => formatters.date(iso);
|
||
const footerTz =
|
||
userTz && isValidTimezone(userTz) ? userTz : DISPLAY_TIMEZONE;
|
||
|
||
const unitFor = (type: string): string => {
|
||
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
|
||
if (staticUnit === null && type === "ACTIVITY_STEPS") {
|
||
return t("doctorReport.unitSteps");
|
||
}
|
||
if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
|
||
// Every other type falls back to the canonical unit the measurement
|
||
// validation layer already records. A type with no recorded unit prints
|
||
// none — an absent unit is absent, not a guessed symbol.
|
||
const canonical = getUnitForType(type);
|
||
return canonical === "unknown" ? "" : canonical;
|
||
};
|
||
|
||
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
||
patchPdfTextSanitiser(doc);
|
||
doc.setProperties({
|
||
title: t("doctorReport.title"),
|
||
subject: t("doctorReport.subtitle"),
|
||
creator: "HealthLog",
|
||
author: data.patient.fullName ?? data.patient.username ?? "HealthLog",
|
||
});
|
||
const pageWidth = doc.internal.pageSize.getWidth();
|
||
const pageHeight = doc.internal.pageSize.getHeight();
|
||
const margin = 20;
|
||
const footerHeight = 16;
|
||
const bottomMargin = 6;
|
||
const contentMaxY = pageHeight - bottomMargin - footerHeight;
|
||
const tableBottomMargin = bottomMargin + footerHeight;
|
||
const ensureSpace = (current: number, needed: number): number => {
|
||
if (current + needed > contentMaxY) {
|
||
doc.addPage();
|
||
return margin;
|
||
}
|
||
return current;
|
||
};
|
||
|
||
const context: DoctorReportPdfRenderContext = {
|
||
doc,
|
||
data,
|
||
t,
|
||
num,
|
||
fmtDate,
|
||
dateShort: formatters.dateShort,
|
||
dateTime: formatters.dateTime,
|
||
now,
|
||
insuranceNumber,
|
||
includeCharts,
|
||
footerTz,
|
||
margin,
|
||
pageWidth,
|
||
pageHeight,
|
||
contentMaxY,
|
||
tableBottomMargin,
|
||
vitalGroups: DOCTOR_REPORT_VITAL_GROUPS,
|
||
unitFor,
|
||
ensureSpace,
|
||
};
|
||
|
||
let cursor = pdfCursorState(doc, margin);
|
||
cursor = buildHeaderProfileSection(context, cursor);
|
||
cursor = buildMeasurementsChartsSection(context, cursor);
|
||
cursor = buildMedicationMoodWellnessSection(context, cursor);
|
||
cursor = buildClinicalRecordsNotesSection(context, cursor);
|
||
buildReportFooter(context, cursor);
|
||
return doc;
|
||
}
|
||
|
||
/**
|
||
* Render the doctor report and return the PDF as a `Uint8Array`.
|
||
* Isomorphic — works in browser and Node.
|
||
*/
|
||
export function renderDoctorReportPdfBytes(
|
||
data: DoctorReportData,
|
||
options: DoctorReportRenderOptions,
|
||
): Uint8Array {
|
||
const doc = buildDoctorReportPdfDocument(data, options);
|
||
const ab = doc.output("arraybuffer");
|
||
return new Uint8Array(ab);
|
||
}
|