From 4d2f706dad45adeb9a28441649df76739af786bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:38:26 +0200 Subject: [PATCH] feat(doctor-report): a report can mention a visit, and know why it exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VISITS leaf ships in the release that made a visit possible, because a selectable checkbox that produces nothing is worse than no checkbox. Leaf, collector and PDF renderer land together, and the leaf carries no LEAF_MODULE entry — like ALLERGIES beside it — because a visit is core and the whole report is already gated by doctorReport. The absence is commented at the map so a later reader does not read it as an oversight. The rows print the date, the practice with its specialty, the kind, the reason, the outcome and the conditions the visit was filed against. The two encrypted columns decrypt at render, fail-soft, and never enter the stored selection. A condition the person has since deleted is not named: the link row survives a soft delete, and printing a label they withdrew would put it in front of a doctor. No FHIR resource type is added. That deferral is already written at the Encounter model, and the bundle builder now says so at the point a reader would look for the missing section: FHIR splits the future from the past across Appointment and Encounter, which is a serialisation question this release does not answer. Two properties are free and worth stating: a stored selection never grows (unknown ids drop) and a clinician share link's sections are frozen write-once, so no existing report and no already-shared link gains a section. The panel also offers a booked visit as the report's reason — one query, one prefilled window end, one dismissible offer that names its own source and never applies itself. A window control that moves on its own is what makes an export panel untrustworthy. --- docs/api/openapi.yaml | 6 +- messages/de.json | 16 ++- messages/en.json | 16 ++- messages/es.json | 16 ++- messages/fr.json | 16 ++- messages/it.json | 16 ++- messages/pl.json | 16 ++- ...doctor-report-control-gating-guard.test.ts | 4 +- .../inbound/[id]/__tests__/route.test.ts | 12 ++ .../documents/inbound/__tests__/route.test.ts | 18 +++ .../health-record/__tests__/route.test.ts | 2 + .../__tests__/report-selection-panel.test.tsx | 7 +- .../__tests__/report-visit-offer.test.tsx | 109 +++++++++++++++++ .../report-selection/report-visit-offer.tsx | 110 ++++++++++++++++++ .../report-selection/selection-panel.tsx | 10 ++ ...onfig-change-is-not-a-health-event.test.ts | 1 + .../clinical-records-notes-section.ts | 67 +++++++++++ src/lib/doctor-report-types.ts | 22 ++++ .../__tests__/leaf-gating-sweep.test.ts | 14 +++ src/lib/doctor-report/clinical-records.ts | 72 ++++++++++++ src/lib/doctor-report/collect.ts | 6 + src/lib/fhir/build-bundle.ts | 8 ++ .../__tests__/catalogue-guard.test.ts | 4 +- src/lib/report-selection/catalogue.ts | 9 +- 24 files changed, 550 insertions(+), 27 deletions(-) create mode 100644 src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx create mode 100644 src/components/settings/report-selection/report-visit-offer.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 572a7a7ca..9c44ed2fb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -932,7 +932,7 @@ paths: type: number const: 2 leaves: - maxItems: 91 + maxItems: 92 type: array items: type: string @@ -1037,7 +1037,7 @@ paths: type: number const: 2 leaves: - maxItems: 91 + maxItems: 92 type: array items: type: string @@ -15716,7 +15716,7 @@ components: type: number const: 2 leaves: - maxItems: 91 + maxItems: 92 type: array items: type: string diff --git a/messages/de.json b/messages/de.json index 45bbcd82c..8e83adbe3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Erkrankung", "familyHistoryColAgeAtOnset": "Alter bei Beginn", "labsSourceRangeNote": "* Der Referenzbereich stammt aus dem Befund und weicht vom gespeicherten Bereich ab:", - "labsCatalogRangeLabel": "gespeicherter Bereich" + "labsCatalogRangeLabel": "gespeicherter Bereich", + "visitsTitle": "Arztbesuche", + "visitsColDate": "Datum", + "visitsColPractice": "Praxis", + "visitsColKind": "Art", + "visitsColReason": "Anlass", + "visitsColOutcome": "Ergebnis", + "visitsColConditions": "Erkrankungen" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -5978,7 +5985,9 @@ "rangeCustomEnd": "Bis", "errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.", "errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.", - "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet." + "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.", + "visitOffer": "Am {when} steht ein Termin an: {what}.", + "visitOfferApply": "Bericht bis dahin" }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Nie angeboten und beim Erfassen abgelehnt, auch wenn eine Medikation sie bevorzugt.", @@ -9719,7 +9728,8 @@ "sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.", "scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.", "scopeCount": "{count} Einträge aus {groups} Bereichen", - "scopeSensitive": "inkl. {names}" + "scopeSensitive": "inkl. {names}", + "leafVisits": "Arztbesuche" }, "recordSharing": { "banner": { diff --git a/messages/en.json b/messages/en.json index b7e6f4594..c01f68359 100644 --- a/messages/en.json +++ b/messages/en.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Condition", "familyHistoryColAgeAtOnset": "Age at onset", "labsSourceRangeNote": "* The reference range comes from the report itself and differs from the saved range:", - "labsCatalogRangeLabel": "saved range" + "labsCatalogRangeLabel": "saved range", + "visitsTitle": "Visits", + "visitsColDate": "Date", + "visitsColPractice": "Practice", + "visitsColKind": "Kind", + "visitsColReason": "Reason", + "visitsColOutcome": "Outcome", + "visitsColConditions": "Conditions" }, "errorBoundary": { "title": "Something went wrong", @@ -5978,7 +5985,9 @@ "rangeCustomEnd": "To", "errorGeneric": "The export could not be generated. Please try again.", "errorRateLimit": "You have reached the export limit for this hour.", - "errorModuleDisabled": "The health record export is switched off for this account." + "errorModuleDisabled": "The health record export is switched off for this account.", + "visitOffer": "An appointment is coming up on {when}: {what}.", + "visitOfferApply": "Report up to then" }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Never offered and rejected when logging a dose, even if a medication prefers them.", @@ -9719,7 +9728,8 @@ "sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.", "scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.", "scopeCount": "{count} entries from {groups} areas", - "scopeSensitive": "incl. {names}" + "scopeSensitive": "incl. {names}", + "leafVisits": "Visits" }, "recordSharing": { "banner": { diff --git a/messages/es.json b/messages/es.json index 1e36a2261..18e3b80cc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Afección", "familyHistoryColAgeAtOnset": "Edad de inicio", "labsSourceRangeNote": "* El rango de referencia procede del propio informe y difiere del rango guardado:", - "labsCatalogRangeLabel": "rango guardado" + "labsCatalogRangeLabel": "rango guardado", + "visitsTitle": "Visitas médicas", + "visitsColDate": "Fecha", + "visitsColPractice": "Consulta", + "visitsColKind": "Tipo", + "visitsColReason": "Motivo", + "visitsColOutcome": "Resultado", + "visitsColConditions": "Enfermedades" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -5978,7 +5985,9 @@ "rangeCustomEnd": "Hasta", "errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.", "errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.", - "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta." + "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.", + "visitOffer": "El {when} hay una cita: {what}.", + "visitOfferApply": "Informe hasta esa fecha" }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Nunca se ofrecen y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -9719,7 +9728,8 @@ "sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.", "scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.", "scopeCount": "{count} entradas de {groups} áreas", - "scopeSensitive": "incl. {names}" + "scopeSensitive": "incl. {names}", + "leafVisits": "Visitas médicas" }, "recordSharing": { "banner": { diff --git a/messages/fr.json b/messages/fr.json index 345d8841b..9f2dae44c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Affection", "familyHistoryColAgeAtOnset": "Âge au début", "labsSourceRangeNote": "* La plage de référence provient du compte rendu lui-même et diffère de la plage enregistrée :", - "labsCatalogRangeLabel": "plage enregistrée" + "labsCatalogRangeLabel": "plage enregistrée", + "visitsTitle": "Consultations", + "visitsColDate": "Date", + "visitsColPractice": "Cabinet", + "visitsColKind": "Type", + "visitsColReason": "Motif", + "visitsColOutcome": "Résultat", + "visitsColConditions": "Maladies" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -5978,7 +5985,9 @@ "rangeCustomEnd": "Au", "errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.", "errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.", - "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte." + "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.", + "visitOffer": "Un rendez-vous est prévu le {when} : {what}.", + "visitOfferApply": "Rapport jusque-là" }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Jamais proposés et refusés à l'enregistrement, même si un médicament les privilégie.", @@ -9719,7 +9728,8 @@ "sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.", "scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.", "scopeCount": "{count} éléments issus de {groups} domaines", - "scopeSensitive": "dont {names}" + "scopeSensitive": "dont {names}", + "leafVisits": "Consultations" }, "recordSharing": { "banner": { diff --git a/messages/it.json b/messages/it.json index 91ac79e39..3f7c24a80 100644 --- a/messages/it.json +++ b/messages/it.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Condizione", "familyHistoryColAgeAtOnset": "Età di esordio", "labsSourceRangeNote": "* L'intervallo di riferimento proviene dal referto stesso e differisce da quello salvato:", - "labsCatalogRangeLabel": "intervallo salvato" + "labsCatalogRangeLabel": "intervallo salvato", + "visitsTitle": "Visite mediche", + "visitsColDate": "Data", + "visitsColPractice": "Studio", + "visitsColKind": "Tipo", + "visitsColReason": "Motivo", + "visitsColOutcome": "Esito", + "visitsColConditions": "Malattie" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -5978,7 +5985,9 @@ "rangeCustomEnd": "Al", "errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.", "errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.", - "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account." + "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.", + "visitOffer": "Il {when} c'è un appuntamento: {what}.", + "visitOfferApply": "Referto fino a quella data" }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "Mai proposti e rifiutati alla registrazione, anche se un farmaco li preferisce.", @@ -9719,7 +9728,8 @@ "sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.", "scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.", "scopeCount": "{count} voci da {groups} aree", - "scopeSensitive": "incl. {names}" + "scopeSensitive": "incl. {names}", + "leafVisits": "Visite mediche" }, "recordSharing": { "banner": { diff --git a/messages/pl.json b/messages/pl.json index 5d344b36a..83143dd75 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Schorzenie", "familyHistoryColAgeAtOnset": "Wiek zachorowania", "labsSourceRangeNote": "* Zakres referencyjny pochodzi z samego wyniku i różni się od zapisanego zakresu:", - "labsCatalogRangeLabel": "zapisany zakres" + "labsCatalogRangeLabel": "zapisany zakres", + "visitsTitle": "Wizyty lekarskie", + "visitsColDate": "Data", + "visitsColPractice": "Gabinet", + "visitsColKind": "Rodzaj", + "visitsColReason": "Powód", + "visitsColOutcome": "Wynik", + "visitsColConditions": "Choroby" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -5978,7 +5985,9 @@ "rangeCustomEnd": "Do", "errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.", "errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.", - "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta." + "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.", + "visitOffer": "{when} zaplanowano wizytę: {what}.", + "visitOfferApply": "Raport do tego dnia" }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Nigdy nieproponowane i odrzucane przy rejestracji, nawet jeśli lek je preferuje.", @@ -9719,7 +9728,8 @@ "sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.", "scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.", "scopeCount": "{count} pozycji z {groups} obszarów", - "scopeSensitive": "w tym {names}" + "scopeSensitive": "w tym {names}", + "leafVisits": "Wizyty lekarskie" }, "recordSharing": { "banner": { diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 7b7cbf95e..d9a137b7b 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -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(91); + expect(ALL_LEAF_IDS.length).toBe(92); expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77); - expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(14); + expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15); }); it("places every leaf in exactly one group", () => { diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index 6adcd7b5b..d4c5e77ef 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -23,6 +23,11 @@ vi.mock("@/lib/db", () => ({ deleteMany: vi.fn(), createMany: vi.fn(), }, + encounterDocumentLink: { + findMany: vi.fn(), + deleteMany: vi.fn(), + createMany: vi.fn(), + }, documentContentIndex: { findUnique: vi.fn(), }, @@ -32,6 +37,9 @@ vi.mock("@/lib/db", () => ({ illnessEpisode: { findMany: vi.fn(), }, + encounter: { + findMany: vi.fn(), + }, appSettings: { findUnique: vi.fn(), }, @@ -129,6 +137,10 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( [] as never, ); diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index 4b4f21999..dceb607f3 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -20,6 +20,8 @@ const { txCreateMany, txDocumentFindMany, txEpisodeFindMany, + txEncounterFindMany, + txEncounterLinkCreateMany, } = vi.hoisted(() => ({ txQueryRaw: vi.fn(), txCreate: vi.fn(), @@ -28,6 +30,8 @@ const { // writes, so the transaction double has to answer for them. txDocumentFindMany: vi.fn(), txEpisodeFindMany: vi.fn(), + txEncounterFindMany: vi.fn(), + txEncounterLinkCreateMany: vi.fn(), })); vi.mock("@/lib/db", () => { @@ -35,7 +39,9 @@ vi.mock("@/lib/db", () => { $queryRaw: txQueryRaw, inboundDocument: { create: txCreate, findMany: txDocumentFindMany }, illnessEpisode: { findMany: txEpisodeFindMany }, + encounter: { findMany: txEncounterFindMany }, documentConditionLink: { createMany: txCreateMany }, + encounterDocumentLink: { createMany: txEncounterLinkCreateMany }, }; return { prisma: { @@ -50,6 +56,9 @@ vi.mock("@/lib/db", () => { documentConditionLink: { findMany: vi.fn(), }, + encounterDocumentLink: { + findMany: vi.fn(), + }, documentContentIndex: { findMany: vi.fn(), }, @@ -59,6 +68,9 @@ vi.mock("@/lib/db", () => { illnessEpisode: { findMany: vi.fn(), }, + encounter: { + findMany: vi.fn(), + }, appSettings: { findUnique: vi.fn(), }, @@ -183,6 +195,12 @@ beforeEach(() => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); + txEncounterFindMany.mockResolvedValue([]); + txEncounterLinkCreateMany.mockResolvedValue({ count: 0 }); vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( [] as never, ); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 905d6c272..c442523cf 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({ moodEntry: { findMany: vi.fn() }, labResult: { findMany: vi.fn() }, illnessEpisode: { findMany: vi.fn() }, + encounter: { findMany: vi.fn() }, allergy: { findMany: vi.fn() }, familyHistoryEntry: { findMany: vi.fn() }, userHealthProfile: { findUnique: vi.fn() }, @@ -165,6 +166,7 @@ beforeEach(() => { vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index 7d0a34249..414be182d 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -44,6 +44,10 @@ vi.mock("@/hooks/use-auth", async () => { vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }), + // The panel's visit offer reads the encounter list. Pending forever here: + // this file is about the panel's own state, and a resolved read would put + // an offer in the markup every assertion below would have to know about. + useQuery: () => ({ data: undefined, isPending: true, isError: false }), })); import { I18nProvider } from "@/lib/i18n/context"; @@ -188,7 +192,8 @@ describe(" — the first run", () => { expect(groupChip(html, "body")).toBe("0/12"); expect(groupChip(html, "labs")).toBe("0/1"); expect(groupChip(html, "medications")).toBe("0/4"); - expect(groupChip(html, "history")).toBe("0/2"); + // ALLERGIES, ILLNESS_EPISODES and now VISITS. + expect(groupChip(html, "history")).toBe("0/3"); expect(groupChip(html, "cardio")).toBe("0/14"); expect(groupChip(html, "activity")).toBe("0/8"); expect(groupChip(html, "sleepRecovery")).toBe("0/16"); diff --git a/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx b/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx new file mode 100644 index 000000000..476fbbbfa --- /dev/null +++ b/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * The report's reason, pinned on the two properties that make it trustworthy: + * + * - it appears for an appointment inside the horizon and for nothing else; + * - it NEVER auto-applies. Applying is a press, and the component calls + * nothing on render — a window control that moves on its own is what makes + * an export panel untrustworthy. + * + * The fixtures are relative to the moment the test runs, so the horizon cannot + * rot out from under them as the calendar moves. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules: {} }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { ...actual, apiGet: () => new Promise(() => {}) }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { EncounterDTO } from "@/lib/encounters/dto"; +import { ReportVisitOffer } from "../report-visit-offer"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function visit(over: Partial & { id: string }): EncounterDTO { + return { + occurredAt: new Date(Date.now() + 5 * DAY_MS).toISOString(), + status: "PLANNED", + kind: "SPECIALIST", + practitioner: null, + reason: null, + outcome: null, + reminderNextDueAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...over, + }; +} + +function render(upcoming: EncounterDTO[], onUse = () => {}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData( + queryKeys.encounterList(null, null, undefined, undefined), + { + upcoming, + past: [], + }, + ); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + it("offers an appointment five days out, naming its own source", () => { + const html = render([visit({ id: "soon" })]); + expect(html).toContain('data-slot="report-visit-offer"'); + expect(html).toContain("An appointment is coming up"); + expect(html).toContain('data-slot="report-visit-offer-apply"'); + }); + + it("stays silent for an appointment thirty days out", () => { + const html = render([ + visit({ + id: "far", + occurredAt: new Date(Date.now() + 30 * DAY_MS).toISOString(), + }), + ]); + expect(html).not.toContain('data-slot="report-visit-offer"'); + }); + + it("stays silent when there is no appointment at all", () => { + expect(render([])).not.toContain('data-slot="report-visit-offer"'); + }); + + it("ignores a cancelled appointment inside the horizon", () => { + const html = render([visit({ id: "off", status: "CANCELLED" })]); + expect(html).not.toContain('data-slot="report-visit-offer"'); + }); + + it("changes the window only when pressed", () => { + const onUse = vi.fn(); + const html = render([visit({ id: "soon" })], onUse); + // The offer rendered — so the assertion below is about restraint, not + // about a component that produced nothing. + expect(html).toContain('data-slot="report-visit-offer-apply"'); + expect(onUse).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/settings/report-selection/report-visit-offer.tsx b/src/components/settings/report-selection/report-visit-offer.tsx new file mode 100644 index 000000000..5108a1588 --- /dev/null +++ b/src/components/settings/report-selection/report-visit-offer.tsx @@ -0,0 +1,110 @@ +"use client"; + +/** + * "Bericht für den Termin am … erstellen" — the report knows why it is being + * generated. + * + * The cheapest integration in this milestone and the one that changes the most: + * a person opening the export panel a week before an appointment is almost + * always opening it BECAUSE of the appointment, and the window they want ends + * on that day. One query, one prefilled field, one dismissible offer. + * + * **It never auto-applies.** The window is not silently changed and then left + * for the person to discover; the offer names its own source ("weil am … ein + * Termin ansteht") and does nothing until pressed. A control that moves on its + * own is the thing that makes an export panel untrustworthy. + * + * Nothing about the stored selection changes: this sets the custom-range end + * date and nothing else. + */ +import { CalendarClock, X } from "lucide-react"; +import { useState } from "react"; + +import { encounterKindText } from "@/components/encounters/encounter-labels"; +import { Button } from "@/components/ui/button"; +import { useEncounters } from "@/hooks/use-encounters"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { EncounterKind } from "@/generated/prisma/client"; + +/** + * How far ahead a booked visit is treated as the reason for this report. + * + * Two weeks: far enough that somebody preparing in advance is offered it, near + * enough that a routine check-up six months out does not colonise the panel. + */ +export const REPORT_VISIT_HORIZON_DAYS = 14; + +/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */ +function localDay(iso: string): string { + const at = new Date(iso); + const offset = at.getTimezoneOffset(); + return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10); +} + +export function ReportVisitOffer({ + onUseVisitDate, +}: { + /** Sets the custom window's END date. Called only on an explicit press. */ + onUseVisitDate: (endDay: string) => void; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + const [dismissed, setDismissed] = useState(false); + const visits = useEncounters(); + + // Read once, at mount. A clock read during render is impure — and here it + // would also mean the offer could vanish mid-interaction as a visit crossed + // the horizon between two renders. + const [now] = useState(() => Date.now()); + + if (dismissed) return null; + + const horizon = now + REPORT_VISIT_HORIZON_DAYS * 24 * 60 * 60 * 1000; + const next = (visits.data?.upcoming ?? []).find((visit) => { + if (visit.status !== "PLANNED") return false; + const at = new Date(visit.occurredAt).getTime(); + return at > now && at <= horizon; + }); + if (!next) return null; + + const what = + next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind); + + return ( +
+ {/* Content, not meta: it is the offer, and it names its own source. */} +

+ + + {t("settings.healthRecord.visitOffer", { + what, + when: format.date(next.occurredAt), + })} + +

+ + +
+ ); +} diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx index e661cd4bb..3f589a20f 100644 --- a/src/components/settings/report-selection/selection-panel.tsx +++ b/src/components/settings/report-selection/selection-panel.tsx @@ -44,6 +44,7 @@ import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; +import { ReportVisitOffer } from "./report-visit-offer"; import { Switch } from "@/components/ui/switch"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { apiFetchRaw } from "@/lib/api/api-fetch"; @@ -253,6 +254,15 @@ export function HealthRecordExportPanel() { + {/* Never auto-applies: pressing it is what opens the custom window and + sets its end date. */} + { + setCustomRange(true); + setEndDate(endDay); + }} + /> + {customRange ? (
diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 940dae220..df1894fcb 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -172,6 +172,7 @@ const fakeDb = vi.hoisted(() => { ecgRecording: { findFirst: async () => null }, personalRecord: { findMany: none }, arrivalReaction: { findMany: none }, + encounter: { findFirst: async () => null }, }; }); diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 464f1f85f..3461dacdd 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -217,6 +217,73 @@ export function buildClinicalRecordsNotesSection( .finalY + 8; } + // Visits inside the window — what happened at a practice, and what came out + // of it. Present only when the VISITS leaf was selected AND the window held a + // visit (the aggregator gates `data.visits`); the leaf carries no module of + // its own, because a visit is core and the whole report is already gated by + // `doctorReport`. Same calm factual register as the illness table above: no + // colour, no status tint. The encrypted reason and outcome decrypt at render + // and never enter the stored selection. + if (data.visits && data.visits.length > 0) { + y = ensureSpace(y, 6 + 18); + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.visitsTitle"), margin, y); + y += 6; + + const visitRows = data.visits.map((visit) => [ + fmtDate(visit.occurredAt), + // The practice, with its specialty when there is one; an em dash when + // the visit named neither, which is honest rather than invented. + [visit.practitionerName, visit.practitionerSpecialty] + .filter(Boolean) + .join(" · ") || "—", + t(`encounters.kind.${visit.kind}`), + visit.reason ?? "—", + visit.outcome ?? "—", + visit.conditionLabels.length > 0 ? visit.conditionLabels.join(", ") : "—", + ]); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.visitsColDate"), + t("doctorReport.visitsColPractice"), + t("doctorReport.visitsColKind"), + t("doctorReport.visitsColReason"), + t("doctorReport.visitsColOutcome"), + t("doctorReport.visitsColConditions"), + ], + ], + body: visitRows, + 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; + } + // v1.27.x — structured allergy / intolerance records. Reference data // (not time-windowed) the aggregator populates when the `allergies` // toggle is ON (default) and rows exist. Stored fields only — substance, diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 1d1de1c0d..af2b7fc85 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -274,6 +274,28 @@ export interface DoctorReportData { * module is off or no episode overlaps the window; both builders then skip * the section. Optional so pre-v1.18.1 fixtures still typecheck. */ + /** + * Visits inside the window — the contacts with the healthcare system this + * period held. Null when the leaf was not selected or none fell inside the + * window; both builders then skip the section. Optional so pre-v1.38 + * fixtures still typecheck. + * + * `kind` rides as the enum constant: the renderer names it in the REPORT's + * language, which is the reading clinician's, not whoever generated it. + * `reason` and `outcome` are the decrypted free text and are null on a + * key-rotation gap — an absence rather than a fabricated line. + */ + visits?: Array<{ + occurredAt: string; + kind: string; + status: string; + practitionerName: string | null; + practitionerSpecialty: string | null; + reason: string | null; + outcome: string | null; + /** Labels of the conditions this visit was filed against. */ + conditionLabels: string[]; + }> | null; illnessEpisodes?: Array<{ /** User-facing condition label (e.g. "Erkältung"). */ label: string; diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 38c28e9f0..18a428688 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -216,6 +216,17 @@ function seed() { resolvedAt: new Date("2026-01-20T00:00:00.000Z"), }, ]; + rows.encounter = [ + { + occurredAt: new Date("2026-02-05T09:00:00.000Z"), + kind: "SPECIALIST", + status: "DONE", + reasonEncrypted: null, + outcomeEncrypted: null, + practitioner: { name: "Sample practice", specialty: "Cardiology" }, + conditionLinks: [], + }, + ]; rows.allergy = [ { substance: "Penicillin", @@ -262,6 +273,7 @@ describe("per-leaf gating sweep", () => { expect(allPayload.allergies).not.toBeNull(); expect(allPayload.familyHistory).not.toBeNull(); expect(allPayload.illnessEpisodes).not.toBeNull(); + expect(allPayload.visits).not.toBeNull(); expect(allPayload.mood).not.toBeNull(); expect(allPayload.cycle).not.toBeNull(); expect(allPayload.anamnesis).not.toBeNull(); @@ -415,6 +427,7 @@ describe("zero read for unchosen leaves", () => { ["ALLERGIES", "allergy"], ["LAB_RESULTS", "labResult"], ["ILLNESS_EPISODES", "illnessEpisode"], + ["VISITS", "encounter"], ["ANAMNESIS", "userHealthProfile"], ["ANAMNESIS", "healthProfileFactRevision"], ]; @@ -474,6 +487,7 @@ describe("the empty selection serves nothing", () => { expect(payload.allergies).toBeNull(); expect(payload.familyHistory).toBeNull(); expect(payload.illnessEpisodes).toBeNull(); + expect(payload.visits).toBeNull(); expect(payload.anamnesis).toBeNull(); expect(payload.cycle).toBeNull(); expect(payload.mood).toBeNull(); diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts index 9553a15db..4ad0f456c 100644 --- a/src/lib/doctor-report/clinical-records.ts +++ b/src/lib/doctor-report/clinical-records.ts @@ -122,6 +122,78 @@ export async function loadIllnessEpisodes( return mapped.length > 0 ? mapped : null; } +/** + * Visits inside the window, with the conditions each was filed against. + * + * The two free-text columns are `Bytes` ciphertext and decrypt fail-soft, the + * same stance the allergy reaction takes: a key-rotation gap on one row reads + * as a visit with no reason recorded rather than as a failed report. `kind` and + * `status` cross as enum constants — the renderer names them in the REPORT's + * language, which is the reading clinician's. + * + * The condition labels come off the link rows directly rather than through the + * link service: this is a read of the OWNER's own record for the owner's own + * report, with no grant to narrow against and no redaction to apply, and the + * service's per-family visibility predicate has no meaning here. + */ +export async function loadVisits( + userId: string, + start: Date, + end: Date, +): Promise { + const rows = await prisma.encounter.findMany({ + where: { + userId, + deletedAt: null, + occurredAt: { gte: start, lte: end }, + }, + orderBy: { occurredAt: "asc" }, + select: { + occurredAt: true, + kind: true, + status: true, + reasonEncrypted: true, + outcomeEncrypted: true, + practitioner: { select: { name: true, specialty: true } }, + conditionLinks: { + select: { episode: { select: { label: true, deletedAt: true } } }, + }, + }, + }); + + const decrypt = ( + value: Uint8Array | null, + field: "reason" | "outcome", + ): string | null => { + if (!value || value.byteLength === 0) return null; + try { + return decryptFromBytes(value); + } catch { + getEvent()?.addWarning( + `doctor-report: visit ${field} decrypt failed for ${userId}`, + ); + return null; + } + }; + + const mapped = rows.map((row) => ({ + occurredAt: row.occurredAt.toISOString(), + kind: row.kind, + status: row.status, + practitionerName: row.practitioner?.name ?? null, + practitionerSpecialty: row.practitioner?.specialty ?? null, + reason: decrypt(row.reasonEncrypted, "reason"), + outcome: decrypt(row.outcomeEncrypted, "outcome"), + // A condition the person has since deleted is not named in a clinical + // document: the link row survives the soft delete, and printing a label + // they removed would put a withdrawn diagnosis in front of a doctor. + conditionLabels: row.conditionLinks + .filter((link) => link.episode?.deletedAt === null) + .map((link) => link.episode.label), + })); + return mapped.length > 0 ? mapped : null; +} + /** * Structured allergy records. Reference data, not time-windowed — a * penicillin allergy does not expire with the report window. diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 1c707d722..79c4ccc0f 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -64,6 +64,7 @@ import { loadAnamnesis, loadFamilyHistory, loadIllnessEpisodes, + loadVisits, loadLabResults, } from "./clinical-records"; @@ -498,6 +499,7 @@ export async function collectDoctorReportData( cycle, labResults, illnessEpisodes, + visits, allergies, familyHistory, anamnesis, @@ -511,6 +513,9 @@ export async function collectDoctorReportData( gate.admits("ILLNESS_EPISODES") ? loadIllnessEpisodes(userId, start, end) : Promise.resolve(null), + gate.admits("VISITS") + ? loadVisits(userId, start, end) + : Promise.resolve(null), gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null), gate.admits("FAMILY_HISTORY") ? loadFamilyHistory(userId) @@ -579,6 +584,7 @@ export async function collectDoctorReportData( cycle, labResults, illnessEpisodes, + visits, allergies, familyHistory, anamnesis, diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index a71b60600..1f46d8a24 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -261,6 +261,14 @@ export function buildFhirDocumentBundle( // --- Condition + Encounter per illness/condition episode (opt-in) ------ // v1.18.1 — present only when the illness module is enabled AND the window + // No visits section, and the absence is a decision written at the + // `Encounter` model in `prisma/schema.prisma`: FHIR splits the future from + // the past across `Appointment` and `Encounter`, which is a serialisation + // question this release does not answer. `FHIR_REST_RESOURCE_TYPES` stays + // untouched for the same reason — a capability statement naming a type with + // no route module lies. The PDF carries the visits; the Bundle does not, and + // a reader who notices the gap should find this comment rather than a bug. + // // 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. diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 0fb75962a..27d0c73cf 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -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(14); - expect(ALL_LEAF_IDS).toHaveLength(91); + expect(STRUCTURED_LEAF_IDS).toHaveLength(15); + expect(ALL_LEAF_IDS).toHaveLength(92); }); it("resolves a label for every leaf", () => { diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index f8e1b86da..d65fa64d3 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -46,6 +46,7 @@ export type StructuredLeafId = | "GLP1_THERAPY" | "ALLERGIES" | "ILLNESS_EPISODES" + | "VISITS" | "FAMILY_HISTORY" | "MOOD" | "CYCLE" @@ -199,13 +200,14 @@ export const STRUCTURED_LEAF_GROUP: Record = { GLP1_THERAPY: "medications", ALLERGIES: "history", ILLNESS_EPISODES: "history", + VISITS: "history", FAMILY_HISTORY: "sensitive", MOOD: "sensitive", CYCLE: "sensitive", ANAMNESIS: "sensitive", }; -/** i18n label keys for the 14 structured leaves. */ +/** i18n label keys for the 15 structured leaves. */ export const STRUCTURED_LEAF_LABEL_KEYS: Record = { PATIENT_IDENTITY: "reportSelection.leafPatientIdentity", INSURANCE: "reportSelection.leafInsurance", @@ -217,6 +219,7 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record = { GLP1_THERAPY: "reportSelection.leafGlp1Therapy", ALLERGIES: "reportSelection.leafAllergies", ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes", + VISITS: "reportSelection.leafVisits", FAMILY_HISTORY: "reportSelection.leafFamilyHistory", MOOD: "reportSelection.leafMood", CYCLE: "reportSelection.leafCycle", @@ -306,6 +309,10 @@ export const LEAF_MODULE: Partial> = { MEDICATION_ADMINISTRATIONS: "medications", MEDICATION_COMPLIANCE: "medications", GLP1_THERAPY: "medications", + // VISITS is deliberately absent, like ALLERGIES beside it. A visit carries + // 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. }; /** Every measurement leaf id, in enum order. */