fix(doctor-report): name a managed record by its display name

The report cover, the PDF author field, the FHIR Patient and the share
page printed `fullName ?? username`, and the collector never read the
display name. A managed record has no way to get a full name, so its
generated `managed-<hex>` login landed on every clinical document made
for it; an own account printed the login instead of its display name.

The collector now resolves the name once (`patientName()`: full name,
display name, login, never a managed record's generated handle) and the
five readers print that one field. `patient.username` and
`patient.fullName` leave the report payload; nothing else read them.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:42:17 +01:00
1 parent aae029d94e
commit 5cf4a7d213
30 files changed
+169 -77

No files matched your search

+39 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { accountName } from "../account-name";
import { accountName, nameInitials, patientName } from "../account-name";
describe("accountName", () => {
it("names the account by its display name when one is set", () => {
@@ -19,3 +19,41 @@ describe("accountName", () => {
expect(accountName({ username: "jdoe" })).toBe("jdoe");
});
});
describe("patientName", () => {
const own = {
username: "jdoe",
displayName: null,
fullName: null,
managedProfileAt: null,
};
const managed = {
username: "managed-3f9a0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
displayName: null,
fullName: null,
managedProfileAt: new Date("2026-01-01T00:00:00.000Z"),
};
it("prefers the full name, then the display name, then the login", () => {
expect(
patientName({ ...own, fullName: "Jane Doe", displayName: "Jane" }),
).toBe("Jane Doe");
expect(patientName({ ...own, fullName: " ", displayName: "Jane" })).toBe(
"Jane",
);
expect(patientName(own)).toBe("jdoe");
});
it("never prints a managed record's generated login", () => {
expect(patientName({ ...managed, displayName: "Robin" })).toBe("Robin");
expect(patientName(managed)).toBeNull();
});
});
describe("nameInitials", () => {
it("takes the first letter of the first two words", () => {
expect(nameInitials("Jane Doe")).toBe("JD");
expect(nameInitials("max.mustermann")).toBe("MM");
expect(nameInitials("Robin")).toBe("R");
});
});
@@ -27,7 +27,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "testuser",
name: "testuser",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
@@ -1003,12 +1003,11 @@ describe("doctor-report emergency first page", () => {
});
describe("extracted doctor-report section boundaries", () => {
it("header/profile prefers the legal name and renders insurance identity", async () => {
it("header/profile prints the resolved name and renders insurance identity", async () => {
const data = makeData({
patient: {
...makeData().patient,
fullName: "Ada Lovelace",
username: "ignored-handle",
name: "Ada Lovelace",
insurerName: "Example Health",
},
});
@@ -1022,7 +1021,6 @@ describe("extracted doctor-report section boundaries", () => {
});
const text = await extractText(bytes);
expect(text).toContain("Ada Lovelace");
expect(text).not.toContain("ignored-handle");
expect(text).toContain("Example Health");
expect(text).toContain("A123456789");
});
+28
View File
@@ -11,3 +11,31 @@ export function accountName(user: {
const displayName = user.displayName?.trim();
return displayName ? displayName : user.username;
}
/**
* The name a clinical document gives a person: the full name when one is
* set, the display name next, the login last. A managed record's login is a
* handle generated when the record was created (`managed-<hex>`), not a name
* anybody chose, so it never prints; with no other name the line is left out.
*/
export function patientName(user: {
username: string;
displayName: string | null;
fullName: string | null;
managedProfileAt: Date | null;
}): string | null {
const fullName = user.fullName?.trim();
if (fullName) return fullName;
const displayName = user.displayName?.trim();
if (displayName) return displayName;
return user.managedProfileAt ? null : user.username;
}
/** Up to two letters for an avatar, from a name such as `accountName()`. */
export function nameInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
+1 -1
View File
@@ -164,7 +164,7 @@ export function buildDoctorReportPdfDocument(
title: t("doctorReport.title"),
subject: t("doctorReport.subtitle"),
creator: "HealthLog",
author: data.patient.fullName ?? data.patient.username ?? "HealthLog",
author: data.patient.name ?? "HealthLog",
});
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
@@ -97,7 +97,7 @@ function data(overrides: Partial<DoctorReportData> = {}): DoctorReportData {
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: null,
name: null,
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -32,8 +32,7 @@ function renderPatientLines(gender: string | null): string[] {
doc,
data: {
patient: {
username: "testuser",
fullName: null,
name: "testuser",
dateOfBirth: null,
gender,
heightCm: null,
@@ -62,12 +62,9 @@ export function buildHeaderProfileSection(
doc.setFontSize(9);
doc.setTextColor(80, 80, 80);
const patientInfo: string[] = [];
// v1.7.0 — prefer the legal full name on the cover; fall back to the
// username when no full name is set (collapses exactly like the
// practice name when neither is present).
const patientName = data.patient.fullName ?? data.patient.username ?? null;
if (patientName) {
patientInfo.push(`${t("doctorReport.patient")}: ${patientName}`);
// Collapses like the practice name when the record has no name to print.
if (data.patient.name) {
patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.name}`);
}
if (data.patient.dateOfBirth) {
patientInfo.push(
+10 -8
View File
@@ -80,17 +80,19 @@ export interface DoctorReportData {
*/
period: { days: number; since: string; start: string; end: string };
patient: {
username: string | null;
/**
* The name the cover, the PDF metadata, the FHIR Patient and the share
* page print, resolved once by `patientName()`: full name, display name,
* login, never a managed record's generated handle. Null prints no line.
*/
name: string | null;
dateOfBirth: string | null;
gender: string | null;
heightCm: number | null;
// v1.7.0 — optional patient-identity fields for the export cover +
// FHIR Patient. `fullName` + `insurerName` are plaintext columns; the
// KVNR is NOT carried here (it's encrypted at rest and decrypted by
// the route, then handed to the builders separately). Optional in the
// type so pre-v1.7.0 fixtures that omit them still typecheck — the
// renderer treats absent and null identically.
fullName?: string | null;
// `insurerName` is a plaintext column; the KVNR is NOT carried here (it's
// encrypted at rest and decrypted by the route, then handed to the
// builders separately). Optional so fixtures that omit it still
// typecheck; the renderer treats absent and null identically.
insurerName?: string | null;
// v1.8.6 — German insurer institution number (IKNR), plaintext. Carried
// here for the FHIR `Coverage` payor Organization identifier. Optional
@@ -130,6 +130,8 @@ function seed() {
rows.userUnique = [
{
username: "owner",
displayName: null,
managedProfileAt: null,
dateOfBirth: new Date("1980-05-05T00:00:00.000Z"),
gender: "OTHER",
heightCm: 180,
@@ -328,7 +330,7 @@ describe("per-leaf gating sweep", () => {
0,
);
expect(Object.keys(allPayload.compliance).length).toBeGreaterThan(0);
expect(allPayload.patient.username).toBe("owner");
expect(allPayload.patient.name).toBe("Owner Name");
expect(allPayload.patient.insurerName).toBe("Sample Insurer");
expect(allPayload.bmi).not.toBeNull();
expect(Object.keys(allPayload.glucoseStats).length).toBeGreaterThan(0);
@@ -457,6 +459,47 @@ describe("the derived figures ride their own leaf", () => {
});
});
describe("the name the report prints", () => {
const identity = selectionFromLeaves(["PATIENT_IDENTITY"]);
const asUser = (fields: Record<string, unknown>) => {
rows.userUnique = [{ ...(rows.userUnique[0] as object), ...fields }];
};
it("names a managed record by its display name, never its generated login", async () => {
asUser({
username: "managed-3f9a0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
displayName: "Robin",
fullName: null,
managedProfileAt: new Date("2026-01-01T00:00:00.000Z"),
});
const payload = await collectDoctorReportData("u1", RANGE, identity, {
moduleMap: moduleMap(),
});
expect(payload.patient.name).toBe("Robin");
});
it("prints no name for a managed record that has none", async () => {
asUser({
username: "managed-3f9a0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
displayName: null,
fullName: null,
managedProfileAt: new Date("2026-01-01T00:00:00.000Z"),
});
const payload = await collectDoctorReportData("u1", RANGE, identity, {
moduleMap: moduleMap(),
});
expect(payload.patient.name).toBeNull();
});
it("prefers the display name over the login on an own account", async () => {
asUser({ username: "owner", displayName: "Jane Doe", fullName: null });
const payload = await collectDoctorReportData("u1", RANGE, identity, {
moduleMap: moduleMap(),
});
expect(payload.patient.name).toBe("Jane Doe");
});
});
describe("fail-closed exclusion", () => {
it("excludes a measurement type the catalogue does not know", () => {
const gate = resolveReportGate(
@@ -571,7 +614,7 @@ describe("the empty selection serves nothing", () => {
selectionFromLeaves([]),
{ moduleMap: moduleMap() },
);
expect(payload.patient.username).toBeNull();
expect(payload.patient.name).toBeNull();
expect(payload.patient.insurerName).toBeNull();
expect(payload.labResults).toBeNull();
expect(payload.allergies).toBeNull();
+4 -2
View File
@@ -84,6 +84,7 @@ import {
loadLabResults,
} from "./clinical-records";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { patientName } from "@/lib/account-name";
import { readingsMean } from "@/lib/measurements/day-mean";
import { loadScreenerUnvalidatedTranslations } from "./screener-translations";
@@ -179,6 +180,8 @@ export async function collectDoctorReportData(
where: { id: userId },
select: {
username: true,
displayName: true,
managedProfileAt: true,
dateOfBirth: true,
gender: true,
heightCm: true,
@@ -720,14 +723,13 @@ export async function collectDoctorReportData(
end: end.toISOString(),
},
patient: {
username: identityOn ? (userProfile?.username ?? null) : null,
name: identityOn && userProfile ? patientName(userProfile) : null,
dateOfBirth:
identityOn && userProfile?.dateOfBirth
? userProfile.dateOfBirth.toISOString()
: null,
gender: identityOn ? (userProfile?.gender ?? null) : null,
heightCm: identityOn ? (userProfile?.heightCm ?? null) : null,
fullName: identityOn ? (userProfile?.fullName ?? null) : null,
insurerName: insuranceOn ? (userProfile?.insurerName ?? null) : null,
insurerIkNumber: insuranceOn
? (userProfile?.insurerIkNumber ?? null)
+7 -14
View File
@@ -69,11 +69,10 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "sample-user",
name: "Sample Patient",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
fullName: "Sample Patient",
insurerName: "Example Insurer",
},
practiceName: null,
@@ -181,11 +180,10 @@ describe("buildFhirDocumentBundle", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
username: "sample-user",
name: "Sample Patient",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
fullName: "Sample Patient",
insurerName: "Example Insurer",
insurerIkNumber: "101234567",
},
@@ -215,11 +213,10 @@ describe("buildFhirDocumentBundle", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
username: "sample-user",
name: "Sample Patient",
dateOfBirth: null,
gender: null,
heightCm: null,
fullName: "Sample Patient",
insurerName: "Example Insurer",
insurerIkNumber: "101234567",
},
@@ -238,11 +235,10 @@ describe("buildFhirDocumentBundle", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
username: "sample-user",
name: "Sample Patient",
dateOfBirth: null,
gender: null,
heightCm: null,
fullName: "Sample Patient",
insurerName: "Example Insurer",
// no insurerIkNumber
},
@@ -262,11 +258,10 @@ describe("buildFhirDocumentBundle", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
username: "sample-user",
name: "Sample Patient",
dateOfBirth: null,
gender: null,
heightCm: null,
fullName: "Sample Patient",
insurerName: "Example Insurer",
insurerIkNumber: "101234567",
},
@@ -284,11 +279,10 @@ describe("buildFhirDocumentBundle", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
username: "sample-user",
name: "Sample Patient",
dateOfBirth: null,
gender: null,
heightCm: null,
fullName: "Sample Patient",
// neither insurerName nor insurerIkNumber
},
}),
@@ -303,11 +297,10 @@ describe("buildFhirDocumentBundle", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
username: "sample-user",
name: "Sample Patient",
dateOfBirth: null,
gender: null,
heightCm: null,
fullName: "Sample Patient",
// neither insurerName nor insurerIkNumber — just a bare KVNR.
},
}),
+3 -4
View File
@@ -36,11 +36,10 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "sample-user",
name: "Sample Patient",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
fullName: "Sample Patient",
insurerName: "Example Insurer",
insurerIkNumber: "101234567",
},
@@ -286,7 +285,7 @@ describe("document Bundle — identity and resolvability", () => {
fullBundle(),
fullBundle({
patient: {
username: "u",
name: "u",
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -375,7 +374,7 @@ describe("Patient.gender", () => {
for (const [stored, expected] of cases) {
const bundle = fullBundle({
patient: {
username: "u",
name: "u",
dateOfBirth: null,
gender: stored,
heightCm: null,
@@ -15,7 +15,7 @@ function baseData(overrides?: Partial<DoctorReportData>): DoctorReportData {
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "sample-user",
name: "sample-user",
dateOfBirth: "1990-06-15T00:00:00.000Z",
gender: "FEMALE",
heightCm: 168,
@@ -22,7 +22,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "sample-user",
name: "sample-user",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
@@ -18,7 +18,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "sample-user",
name: "sample-user",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
@@ -17,7 +17,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "sample-user",
name: "sample-user",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
@@ -22,7 +22,7 @@ function codings(overrides: Partial<DoctorReportData>): FhirCoding[] {
start: "2026-02-02T00:00:00.000Z",
end: "2026-05-03T12:00:00.000Z",
},
patient: { username: "sample-user", dateOfBirth: null, gender: null },
patient: { name: "sample-user", dateOfBirth: null, gender: null },
practiceName: null,
measurements: {},
stats: {},
@@ -118,7 +118,7 @@ describe("medication codings", () => {
start: "2026-02-02T00:00:00.000Z",
end: "2026-05-03T12:00:00.000Z",
},
patient: { username: "sample-user", dateOfBirth: null, gender: null },
patient: { name: "sample-user", dateOfBirth: null, gender: null },
practiceName: null,
measurements: {},
stats: {},
+1 -1
View File
@@ -315,7 +315,7 @@ export function buildFhirDocumentBundle(
// v1.9.0 — when the aggregator capped the administration set, disclose
// it in the narrative so the export is honest: it carries the
// most-recent N of M acted intakes, the oldest having been omitted.
const displayName = data.patient.fullName ?? data.patient.username ?? null;
const displayName = data.patient.name;
const truncation = data.medicationAdministrationsTruncation;
const narrativeText = [
`Health record for ${escapeXml(displayName ?? "patient")}.`,
+1 -2
View File
@@ -49,8 +49,7 @@ export function patientResource(
resourceType: "Patient",
id: PATIENT_RESOURCE_ID,
};
const displayName = data.patient.fullName ?? data.patient.username ?? null;
if (displayName) patient.name = [{ text: displayName }];
if (data.patient.name) patient.name = [{ text: data.patient.name }];
const gender = administrativeGender(data.patient.gender);
if (gender) patient.gender = gender;
if (data.patient.dateOfBirth) {
@@ -74,7 +74,7 @@ const REPORT_FIXTURE = {
start: "2026-03-29T00:00:00.000Z",
end: "2026-06-27T00:00:00.000Z",
},
patient: { username: "tester" },
patient: { name: "tester" },
stats: { WEIGHT: { avg: 80, min: 79, max: 82, count: 30, latest: 80 } },
compliance: {},
medications: [],
@@ -29,7 +29,7 @@ function payload(overrides: Partial<DoctorReportData> = {}): DoctorReportData {
end: "2026-04-01T00:00:00.000Z",
},
patient: {
username: null,
name: null,
dateOfBirth: null,
gender: null,
heightCm: null,
+2 -4
View File
@@ -73,8 +73,7 @@ function reportFixture(
end: "2026-06-27T00:00:00.000Z",
},
patient: {
username: "tester",
fullName: "A Person",
name: "A Person",
dateOfBirth: "1990-01-01",
gender: "female",
heightCm: 170,
@@ -213,8 +212,7 @@ describe("doctor_visit_summary — grounding + structure", () => {
.text,
);
const patient = data.patient as Record<string, unknown>;
expect(patient).not.toHaveProperty("fullName");
expect(patient).not.toHaveProperty("username");
expect(patient).not.toHaveProperty("name");
expect(patient.dateOfBirth).toBe("1990-01-01");
});
});
+2 -4
View File
@@ -224,8 +224,7 @@ describe("healthlog://report/doctor-visit", () => {
end: "2026-06-27T00:00:00.000Z",
},
patient: {
username: "tester",
fullName: "A Person",
name: "A Person",
dateOfBirth: "1990-01-01",
gender: "female",
heightCm: 170,
@@ -267,8 +266,7 @@ describe("healthlog://report/doctor-visit", () => {
expect(result).toHaveProperty("illnessEpisodes");
// Patient name never leaks.
const patient = result.patient as Record<string, unknown>;
expect(patient).not.toHaveProperty("fullName");
expect(patient).not.toHaveProperty("username");
expect(patient).not.toHaveProperty("name");
});
it("omits absent sections honestly (no zero-fill)", async () => {