From e292db1fbd3e55fd97409895a020baab6e872290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:25:18 +0200 Subject: [PATCH 1/4] feat(notifications): give guardian and discreet deliveries the app root as tap target Every visible delivery now carries a url. A managed-record delivery to a guardian and a discreet one open "/", which names no event and no record; only the silent intake sync push stays without one. The tap-target test walks every event type across own, guardian and discreet delivery and the channels that carry the absolute url. Document the forward-auth exemption for the quick-answer route. --- docs/self-hosting/notifications.md | 26 ++++- .../__tests__/delivery-links.test.ts | 4 +- .../__tests__/tap-target.test.ts | 97 +++++++++++++++++-- .../senders/__tests__/apns.test.ts | 21 +++- src/lib/notifications/senders/apns.ts | 12 ++- src/lib/notifications/tap-target.ts | 27 ++++-- 6 files changed, 161 insertions(+), 26 deletions(-) diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 509ac3351..08eed4b7f 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -472,7 +472,7 @@ once per event type, and every channel carries the same target: | Medication reminder, low supply, shared dose | `/medications` (low supply opens the medication) | | Check-up and measurement reminders | `/checkups` | | Proactive Coach nudge | the Coach conversation it started | -| Cycle reminders | `/cycle` (nothing with discreet notifications on) | +| Cycle reminders | `/cycle` (the dashboard with discreet notifications on) | | New sign-in | Settings, Security | | Integration sync failing | Settings, Integrations (an admin alert about another account opens the admin page) | | Illness red flag | the illness episode | @@ -496,8 +496,11 @@ How each channel carries it: ntfy, Telegram, the webhook and email need an absolute link, built from `APP_URL` (or `NEXT_PUBLIC_APP_URL`). Without one those channels deliver as before, just without a link. A notification about a profile you look after -as a Guardian carries no deep link, because your session opens on your own -record. +as a Guardian opens the dashboard rather than the profile's page, because +your session opens on your own record and the record switcher lives there. +A discreet notification opens the dashboard as well, so its link names +nothing. The silent background sync push for medication intake is the only +notification without a link. ### Health figures in notifications @@ -538,6 +541,23 @@ token is the only credential, so it is built to be harmless on its own: The buttons need `APP_URL`, because the ntfy app calls the URL from your phone. Your phone has to reach the instance at that address. +### Quick answers behind forward auth + +If the instance sits behind a forward-auth gateway (Authelia, Authentik, +oauth2-proxy, Cloudflare Access and the like), the gateway answers the +button's request with its login page and the answer is never logged: the +ntfy app has no session with the gateway. Exempt the one path from the +gateway: + +``` +/api/notifications/action/* +``` + +The route does not rely on the gateway. The signed token is its only +credential, it is rate-limited before the token is read, and it can log one +mood answer for one account and nothing else. Every other path stays behind +the gateway. + What the token cannot protect against is the topic itself. The button URLs, token included, travel through your ntfy server inside the message, and the message is delivered to everyone subscribed to the topic. For the six hours diff --git a/src/lib/notifications/__tests__/delivery-links.test.ts b/src/lib/notifications/__tests__/delivery-links.test.ts index b609b26b0..b1085d264 100644 --- a/src/lib/notifications/__tests__/delivery-links.test.ts +++ b/src/lib/notifications/__tests__/delivery-links.test.ts @@ -85,7 +85,9 @@ describe("withDeliveryLinks", () => { "guardian-1", ); expect(out.quickActions).toBeUndefined(); - expect(out.tap).toBeUndefined(); + // The app root, never the managed record's own surface. + expect(out.tap?.path).toBe("/"); + expect(out.tap?.url).toBe("https://health.example/"); }); it("keeps the path but drops absolute URLs and answers without a public origin", async () => { diff --git a/src/lib/notifications/__tests__/tap-target.test.ts b/src/lib/notifications/__tests__/tap-target.test.ts index 676e44e4b..258a4edce 100644 --- a/src/lib/notifications/__tests__/tap-target.test.ts +++ b/src/lib/notifications/__tests__/tap-target.test.ts @@ -6,10 +6,24 @@ * `Record` type already refuses to compile without one; this * pins that every entry is also a usable path). */ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { EVENT_TYPES, type EventType } from "@/lib/notifications/types"; +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn().mockResolvedValue({ locale: "en" }) }, + }, +})); + +import { withDeliveryLinks } from "@/lib/notifications/delivery-links"; +import { buildNtfyMessage } from "@/lib/notifications/senders/ntfy"; +import { buildWebhookBody } from "@/lib/notifications/senders/webhook"; import { + EVENT_TYPES, + type EventType, + type NotificationPayload, +} from "@/lib/notifications/types"; +import { + NEUTRAL_TAP_PATH, NOTIFICATION_TAP_PATHS, absoluteAppUrl, configuredAppOrigin, @@ -91,14 +105,15 @@ describe("resolveTapPath", () => { ).toBe("/coach"); }); - it("gives a managed Guardian delivery no path", () => { + it("gives a managed Guardian delivery the app root, not the event's path", () => { expect( resolveTapPath({ eventType: "MEDICATION_REMINDER", recordUserId: "record-1", recipientUserId: "guardian-1", + metadata: { url: "/medications/m1" }, }), - ).toBeNull(); + ).toBe(NEUTRAL_TAP_PATH); }); it("keeps a self delivery's path when both ids are the same", () => { @@ -111,10 +126,14 @@ describe("resolveTapPath", () => { ).toBe("/medications"); }); - it("gives a discreet delivery no path, so nothing names the cycle surface", () => { + it("gives a discreet delivery the app root, so nothing names the cycle surface", () => { expect( - resolveTapPath({ eventType: "CYCLE_PERIOD_SOON", discreet: true }), - ).toBeNull(); + resolveTapPath({ + eventType: "CYCLE_PERIOD_SOON", + discreet: true, + metadata: { url: "/cycle?day=1" }, + }), + ).toBe(NEUTRAL_TAP_PATH); }); it("gives the silent sync push no path even with an override", () => { @@ -164,3 +183,67 @@ describe("telegramLinkMode", () => { expect(telegramLinkMode(url)).toBe(mode); }); }); + +/** + * Every visible delivery carries a target on every channel: each event type, + * delivered to its own account, to a Guardian of a managed record, and in + * discreet mode. Only the silent sync push carries none. The channels that + * take the absolute URL are checked through their pure builders; APNs and Web + * Push take the path and are pinned in their own sender tests. + */ +describe("tap target on every kind and channel", () => { + const variants: Array<{ + name: string; + extra: Pick< + Partial, + "recordUserId" | "recipientUserId" | "discreet" + >; + neutral: boolean; + }> = [ + { name: "own account", extra: {}, neutral: false }, + { + name: "managed Guardian", + extra: { recordUserId: "record-1", recipientUserId: "guardian-1" }, + neutral: true, + }, + { name: "discreet", extra: { discreet: true }, neutral: true }, + ]; + + it.each(variants)("$name", async ({ extra, neutral }) => { + process.env.APP_URL = "https://health.example"; + let checked = 0; + for (const eventType of EVENT_TYPES) { + const payload = await withDeliveryLinks( + { userId: "u1", title: "t", message: "m", eventType, ...extra }, + "guardian-1", + ); + if (SILENT.has(eventType)) { + expect(payload.tap, `${eventType} is silent`).toBeUndefined(); + continue; + } + const expectedPath = neutral + ? NEUTRAL_TAP_PATH + : NOTIFICATION_TAP_PATHS[eventType]; + expect(payload.tap?.path, eventType).toBe(expectedPath); + const url = `https://health.example${expectedPath}`; + expect(payload.tap?.url, eventType).toBe(url); + + expect( + buildNtfyMessage("topic", payload).click, + `ntfy ${eventType}`, + ).toBe(url); + expect( + JSON.parse(buildWebhookBody({ format: "generic" }, payload)).url, + `webhook ${eventType}`, + ).toBe(url); + expect( + JSON.parse(buildWebhookBody({ format: "gotify" }, payload)).extras[ + "client::notification" + ].click.url, + `gotify ${eventType}`, + ).toBe(url); + checked += 1; + } + expect(checked).toBe(EVENT_TYPES.length - SILENT.size); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index f60b60d25..13bd6b975 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -726,7 +726,7 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.category).toBe("MOOD_REMINDER"); }); - it("never gives a managed Guardian a tap path, even when one is set", async () => { + it("gives a managed Guardian the app root, even when a deeper path is set", async () => { vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, ] as never); @@ -741,7 +741,24 @@ describe("sendViaApns — dispatcher fan-out", () => { tap: { path: "/medications", url: null, label: "Open" }, }); - expect(sendMock.mock.calls[0][0].payload.url).toBeUndefined(); + expect(sendMock.mock.calls[0][0].payload.url).toBe("/"); + }); + + it("carries the resolved root on a discreet delivery", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "CYCLE_PERIOD_SOON", + discreet: true, + tap: { path: "/", url: null, label: "Open" }, + }); + + expect(sendMock.mock.calls[0][0].payload.url).toBe("/"); }); it("forwards reminderId on MEASUREMENT_REMINDER and strips unlisted keys", async () => { diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 8fb2e9ac3..cb099faa5 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -33,6 +33,7 @@ import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-at import { plainPushText } from "@/lib/notifications/strip-emoji"; import { stripHtml } from "@/lib/notifications/strip-html"; import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; +import { NEUTRAL_TAP_PATH } from "@/lib/notifications/tap-target"; import type { NotificationTap } from "@/lib/notifications/types"; export interface ApnsPayload { @@ -755,10 +756,13 @@ export async function sendViaApns( ), // The resolved tap target (`tap-target.ts`): a same-origin path // the app opens on tap. Overrides a producer's `metadata.url`, - // which the resolver already took into account. Never on a - // managed delivery or a discreet one (the resolver returns null). - ...(!managedDelivery && payload.tap?.path - ? { url: payload.tap.path } + // which the resolver already took into account. A managed + // delivery and a discreet one carry the app root (the resolver + // answers `/`); the managed arm is pinned here as well, so a + // payload that bypassed the resolver still cannot deep-link a + // Guardian into the record they are only notified about. + ...(payload.tap?.path + ? { url: managedDelivery ? NEUTRAL_TAP_PATH : payload.tap.path } : {}), }, threadId: routingEvent, diff --git a/src/lib/notifications/tap-target.ts b/src/lib/notifications/tap-target.ts index 2995ecb8c..95fd59c40 100644 --- a/src/lib/notifications/tap-target.ts +++ b/src/lib/notifications/tap-target.ts @@ -70,16 +70,25 @@ export function isSafeAppPath(value: unknown): value is string { return true; } +/** + * The app root: where a delivery that may not name its event leads. + */ +export const NEUTRAL_TAP_PATH = "/"; + /** * The path a delivery opens, or `null` when it carries none. * - * - A Guardian receiving a managed-record notification gets no deep link: - * their session opens on their own record, and a path into the managed - * record's surfaces would land them on the wrong person. The app root is - * where the record switcher lives. - * - Discreet mode (cycle privacy) carries no path either, because the path - * names the surface (`/cycle`) and discreet mode promises that nothing on - * the wire names the event. + * Every visible delivery carries one (v1.43); only a silent background push + * (`null` in {@link NOTIFICATION_TAP_PATHS}) does not. Two deliveries get the + * app root instead of their event's path: + * + * - A Guardian receiving a managed-record notification: their session opens + * on their own record, and a path into the managed record's surfaces would + * land them on the wrong person. The app root is where the record switcher + * lives. + * - Discreet mode (cycle privacy): the event's path names the surface + * (`/cycle`), and discreet mode promises that nothing on the wire names the + * event. The root names nothing. */ export function resolveTapPath( payload: Pick< @@ -89,8 +98,8 @@ export function resolveTapPath( ): string | null { const fallback = NOTIFICATION_TAP_PATHS[payload.eventType]; if (fallback === null || fallback === undefined) return null; - if (isManagedGuardianDelivery(payload)) return null; - if (payload.discreet) return null; + if (isManagedGuardianDelivery(payload)) return NEUTRAL_TAP_PATH; + if (payload.discreet) return NEUTRAL_TAP_PATH; const override = payload.metadata?.url; return isSafeAppPath(override) ? override : fallback; } From ffd46b017b298104a9c6cba4491472e7f968b651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:57:27 +0200 Subject: [PATCH 2/4] feat(backup): carry the five relation link tables both ways Lab result to document, visit to medication, visit to dose, medication to document and medication to condition now ride the full backup in their own section file and come back with their original filing instant. A link whose end the restore did not write is dropped and reported under the new relationLink skip kind. The tables leave the coverage register for the two-ended list, guarded by relation-links-backup-completeness.test.ts and seeded in the round trip. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + ...relation-links-backup-completeness.test.ts | 139 +++++++ .../export/__tests__/per-type-routes.test.ts | 20 + .../__tests__/soft-delete-filter.test.ts | 5 + .../export/encrypted/__tests__/route.test.ts | 5 + src/components/admin/backups-section.tsx | 3 + .../__tests__/full-backup-payload.test.ts | 5 + .../restore-backup-round-trip.test.ts | 5 + src/lib/export/backup-plan.ts | 26 +- src/lib/export/backup-preview.ts | 1 + src/lib/export/full-backup-payload.ts | 16 + src/lib/export/relation-links-backup.ts | 378 ++++++++++++++++++ src/lib/export/restore-backup.ts | 15 + src/lib/export/restore-skips.ts | 7 + src/lib/validations/backup-summary.ts | 8 + src/lib/validations/backup.ts | 34 ++ tests/integration/backup-round-trip.test.ts | 48 +++ 23 files changed, 711 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/relation-links-backup-completeness.test.ts create mode 100644 src/lib/export/relation-links-backup.ts diff --git a/messages/de.json b/messages/de.json index 22defd4fa..319ae5a90 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Coach-Dokumentverknüpfung", "restoreSkippedCoachReference": "Coach-Verweis", "restoreSkippedDocumentConditionLink": "Dokumentzuordnung", + "restoreSkippedRelationLink": "Verknüpfung zwischen Einträgen", "restoreSkippedExtractedFact": "Dokumentbefund", "restoreSkippedFactCommitment": "Befundübernahme", "docsLink": "Dokumentation", diff --git a/messages/en.json b/messages/en.json index 874ee9856..a53ab20cd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Coach document link", "restoreSkippedCoachReference": "Coach reference", "restoreSkippedDocumentConditionLink": "Document filing", + "restoreSkippedRelationLink": "Link between records", "restoreSkippedExtractedFact": "Document fact", "restoreSkippedFactCommitment": "Fact commitment", "docsLink": "Documentation", diff --git a/messages/es.json b/messages/es.json index e27e98043..bc30b1b5c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Enlace de documento del Coach", "restoreSkippedCoachReference": "Referencia del Coach", "restoreSkippedDocumentConditionLink": "Clasificación de documento", + "restoreSkippedRelationLink": "Vínculo entre registros", "restoreSkippedExtractedFact": "Dato del documento", "restoreSkippedFactCommitment": "Registro del dato", "docsLink": "Documentación", diff --git a/messages/fr.json b/messages/fr.json index 2743484ab..e624f5a3b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Lien de document du Coach", "restoreSkippedCoachReference": "Référence du Coach", "restoreSkippedDocumentConditionLink": "Classement du document", + "restoreSkippedRelationLink": "Lien entre entrées", "restoreSkippedExtractedFact": "Donnée du document", "restoreSkippedFactCommitment": "Enregistrement de la donnée", "docsLink": "Documentation", diff --git a/messages/it.json b/messages/it.json index 80f4e0504..eee5718db 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Collegamento documento del Coach", "restoreSkippedCoachReference": "Riferimento del Coach", "restoreSkippedDocumentConditionLink": "Classificazione del documento", + "restoreSkippedRelationLink": "Collegamento tra voci", "restoreSkippedExtractedFact": "Dato del documento", "restoreSkippedFactCommitment": "Registrazione del dato", "docsLink": "Documentazione", diff --git a/messages/ko.json b/messages/ko.json index 29f8be638..e78bac6cf 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7853,6 +7853,7 @@ "restoreSkippedCoachAttachment": "코치 문서 연결", "restoreSkippedCoachReference": "코치 참조", "restoreSkippedDocumentConditionLink": "문서 분류", + "restoreSkippedRelationLink": "기록 간 연결", "restoreSkippedExtractedFact": "문서 추출 정보", "restoreSkippedFactCommitment": "추출 정보 확정", "docsLink": "문서", diff --git a/messages/pl.json b/messages/pl.json index 8b9d69342..1a9c56897 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Powiązanie dokumentu z Coachem", "restoreSkippedCoachReference": "Odwołanie Coacha", "restoreSkippedDocumentConditionLink": "Przypisanie dokumentu", + "restoreSkippedRelationLink": "Powiązanie między wpisami", "restoreSkippedExtractedFact": "Fakt z dokumentu", "restoreSkippedFactCommitment": "Zapis faktu", "docsLink": "Dokumentacja", diff --git a/src/__tests__/relation-links-backup-completeness.test.ts b/src/__tests__/relation-links-backup-completeness.test.ts new file mode 100644 index 000000000..e4237c9b6 --- /dev/null +++ b/src/__tests__/relation-links-backup-completeness.test.ts @@ -0,0 +1,139 @@ +/** + * v1.43 — pair guard binding the five relation link tables in + * `prisma/schema.prisma` to the backup that carries them + * (`src/lib/export/relation-links-backup.ts`), on the pattern of + * `environment-backup-completeness.test.ts`. + * + * A link table is two ids and an instant, and the failure this guards is the + * quiet one: a table the backup does not read comes back empty after a + * restore, every record it joined still there and no longer pointing at its + * partner, and nothing fails. So each end is compared as a literal: + * + * - every relation link table of the matrix is a two-ended model of the + * backup plan, not on the debt register; + * - every scalar column of each table is read by the export's select (or + * excluded here with a reason); + * - every read column is written back by the restore's `createMany`; + * - the wire schema names the same two ends for the section key. + * + * Mutation check: delete `documentId: true` from the lab-result select and + * the export case names it; delete the `MedicationConditionLink` line from + * `TWO_ENDED_MODELS` and the plan case names it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + BACKED_UP_MODELS, + COVERAGE_PENDING, + TWO_ENDED_MODELS, +} from "@/lib/export/backup-plan"; +import { RELATION_LINK_SECTION_KEYS } from "@/lib/export/relation-links-backup"; + +const ROOT = process.cwd(); +const schema = readFileSync(join(ROOT, "prisma", "schema.prisma"), "utf8"); +const source = readFileSync( + join(ROOT, "src", "lib", "export", "relation-links-backup.ts"), + "utf8", +); +const wire = readFileSync( + join(ROOT, "src", "lib", "validations", "backup.ts"), + "utf8", +); + +/** The five tables v1.43 adds to close the relation matrix. */ +const RELATION_LINK_MODELS = [ + "LabResultDocumentLink", + "EncounterMedicationLink", + "EncounterVaccinationLink", + "MedicationDocumentLink", + "MedicationConditionLink", +] as const; + +/** Columns the backup is right to leave out, with the reason. */ +const EXCLUDED: Record = { + id: "nothing addresses a link row by id; the pair is its identity (unique)", + userId: + "user-scoped backup; the owner is re-derived from the restoring account", +}; + +function block(text: string, start: string, end: string): string { + const from = text.indexOf(start); + expect(from, `${start} not found`).toBeGreaterThan(-1); + const to = text.indexOf(end, from); + expect(to, `${end} not found after ${start}`).toBeGreaterThan(from); + return text.slice(from, to); +} + +function scalarColumns(model: string): string[] { + const models = new Set( + [...schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)].map((m) => m[1]), + ); + const out: string[] = []; + for (const raw of block(schema, `model ${model} {`, "\n}") + .split("\n") + .slice(1)) { + const line = raw.trim(); + if (!line || line.startsWith("//") || line.startsWith("@@")) continue; + const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?/.exec(line); + if (!m || m[3] || models.has(m[2]) || line.includes("@relation")) continue; + out.push(m[1]); + } + return out; +} + +const delegate = (model: string) => model[0].toLowerCase() + model.slice(1); + +describe("relation link tables in the backup plan", () => { + it.each(RELATION_LINK_MODELS)("%s travels both ways", (model) => { + expect(BACKED_UP_MODELS as readonly string[]).toContain(model); + expect(TWO_ENDED_MODELS as readonly string[]).toContain(model); + expect(COVERAGE_PENDING).not.toHaveProperty(model); + }); + + it("names one section key per table", () => { + expect([...RELATION_LINK_SECTION_KEYS].sort()).toEqual( + RELATION_LINK_MODELS.map((m) => `${delegate(m)}s`).sort(), + ); + }); +}); + +describe.each(RELATION_LINK_MODELS)("%s backup completeness", (model) => { + const columns = scalarColumns(model); + const name = delegate(model); + const exported = block(source, `prisma.${name}.findMany(`, "}),"); + const restored = block(source, `tx.${name}.createMany(`, "skipDuplicates"); + const wired = block(wire, ` ${name}s: z`, ".default([])"); + const ends = columns.filter((c) => !(c in EXCLUDED) && c !== "createdAt"); + + it("parses both ends into a plausible shape", () => { + expect(columns).toEqual( + expect.arrayContaining(["id", "userId", "createdAt"]), + ); + expect(ends).toHaveLength(2); + }); + + it("the export reads every column", () => { + const missing = columns.filter( + (c) => !(c in EXCLUDED) && !new RegExp(`\\b${c}: true\\b`).test(exported), + ); + expect(missing, `the export omits these ${model} columns`).toEqual([]); + }); + + it("the restore writes every column back", () => { + const unwritten = ends.filter( + (c) => !new RegExp(`\\b${c}: r\\.${c}\\b`).test(restored), + ); + expect(unwritten, `the restore drops these ${model} columns`).toEqual([]); + // `stamp(...)` writes the owner and the original filing instant. + expect(restored).toMatch(/\.\.\.stamp\(r\.createdAt\)/); + }); + + it("the wire schema names the same two ends", () => { + for (const end of ends) { + expect(wired, `${name}s wire schema`).toContain(`"${end}"`); + } + }); +}); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 115bf4bc6..611488051 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -77,6 +77,11 @@ vi.mock("@/lib/db", () => ({ ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, lifeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -179,6 +184,21 @@ beforeEach(() => { vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never); vi.mocked(prisma.lifeEvent.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResultDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounterMedicationLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounterVaccinationLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationConditionLink.findMany).mockResolvedValue( + [] as never, + ); vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never); vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue( diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index edbfc9d0b..bf475f213 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -79,6 +79,11 @@ vi.mock("@/lib/db", () => ({ ecgRecording: { findMany: vi.fn() }, symptomDefinition: { findMany: vi.fn() }, lifeEvent: { findMany: vi.fn() }, + labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) }, environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 7ea794bdd..1eccf71b8 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -69,6 +69,11 @@ vi.mock("@/lib/db", () => ({ ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, lifeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index f9fe01412..d1047e4f0 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -448,6 +448,9 @@ function catalogueLabel( if (catalogue === "documentConditionLink") { return t("admin.section.backups.restoreSkippedDocumentConditionLink"); } + if (catalogue === "relationLink") { + return t("admin.section.backups.restoreSkippedRelationLink"); + } if (catalogue === "extractedFact") { return t("admin.section.backups.restoreSkippedExtractedFact"); } diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 38e639672..bc2dffa1f 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -446,6 +446,11 @@ function makePrisma() { ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, lifeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index a4dbcd92b..31de7e8c8 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -319,6 +319,11 @@ function sourceClient() { ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, lifeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index aeba3f647..7779c1bd4 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -210,7 +210,9 @@ export const BACKED_UP_MODELS = [ // A marker's other spellings and its merge ledger, a supplement's // composition, the Dawarich daily values, and the five relation links that // close the relation matrix. Each is owned data nothing recomputes; each is - // on COVERAGE_PENDING until its section carries it. + // on COVERAGE_PENDING until its section carries it. The relation links + // travel since the release that introduces them + // (`src/lib/export/relation-links-backup.ts`). "BiomarkerAlias", "BiomarkerMerge", "MedicationIngredient", @@ -294,6 +296,9 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ "src/lib/export/symptoms-backup.ts", // The person's life events, both ends beside each other (v1.42). "src/lib/export/life-events-backup.ts", + // The five relation links that close the relation matrix (v1.43), both + // ends beside each other, each through its OWN delegate. + "src/lib/export/relation-links-backup.ts", "src/lib/cycle/backup.ts", // The medication category is read through the one helper that normalises // it; the payload writer calls the helper rather than the delegate. The @@ -318,6 +323,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/ecg-backup.ts", "src/lib/export/symptoms-backup.ts", "src/lib/export/life-events-backup.ts", + "src/lib/export/relation-links-backup.ts", "src/lib/cycle/backup.ts", // The restore writes the medication category through the same helper. "src/lib/medication-category.ts", @@ -584,6 +590,14 @@ export const TWO_ENDED_MODELS = [ // a plain 404 instead of leading to the marker it went into. "BiomarkerAlias", "BiomarkerMerge", + // v1.43 — the five relation links that close the relation matrix, carried + // from the release that introduces them. Each is two ids and the instant it + // was filed, so carrying them cost no more than owing them would have. + "LabResultDocumentLink", + "EncounterMedicationLink", + "EncounterVaccinationLink", + "MedicationDocumentLink", + "MedicationConditionLink", ] as const; /** One model claimed to travel both ways. */ @@ -626,16 +640,6 @@ export const COVERAGE_PENDING: Readonly> = { "What one tablet or capsule of a supplement contains. Without it a restored account's supplements stop counting toward the day's micronutrients, and the composition has to be typed in again from the label.", DawarichDay: "The daily movement values fetched from Dawarich: distances, places visited, time away from home, travel days. Days the Dawarich instance has since pruned cannot be fetched again, so they are lost for good.", - LabResultDocumentLink: - "Which stored report a lab result was read from. Without it the result and the report stop pointing at each other, and the result loses its source.", - EncounterMedicationLink: - "Which medications a visit prescribed, changed or reviewed. Without it the visit and the medication stop pointing at each other.", - EncounterVaccinationLink: - "Which doses were given at a visit. Without it the visit and the dose stop pointing at each other.", - MedicationDocumentLink: - "Which documents belong to a medication: the prescription, the plan, the package insert. Without it they stop pointing at each other.", - MedicationConditionLink: - "Which condition a medication treats (its indication). Without it the medication and the condition stop pointing at each other.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: diff --git a/src/lib/export/backup-preview.ts b/src/lib/export/backup-preview.ts index 1109963f0..756f8d3bf 100644 --- a/src/lib/export/backup-preview.ts +++ b/src/lib/export/backup-preview.ts @@ -180,6 +180,7 @@ const SUMMARY_COUNTS = [ "encounterLinks", "vaccinations", "vaccinationLinks", + "relationLinks", "customVaccines", "measurementReminders", "measurementReminderEvents", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index a96f9c250..c3559798d 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -91,6 +91,12 @@ import { type DocumentFilingBackupCounts, type DocumentFilingBackupSection, } from "@/lib/export/document-filing-backup"; +import { + buildRelationLinksBackupSection, + countRelationLinksBackupSection, + type RelationLinksBackupCounts, + type RelationLinksBackupSection, +} from "@/lib/export/relation-links-backup"; import { buildAwardsBackupSection, countAwardsBackupSection, @@ -155,6 +161,7 @@ export interface FullBackupCounts CoachMemoryBackupCounts, SensitiveBackupCounts, DocumentFilingBackupCounts, + RelationLinksBackupCounts, AwardsBackupCounts, EnvironmentBackupCounts, OnboardingBackupCounts, @@ -736,6 +743,7 @@ export async function buildFullBackupPayload( ecg, symptoms, lifeEvents, + relationLinks, nutrientDays, accountSettings, ] = await Promise.all([ @@ -925,6 +933,11 @@ export async function buildFullBackupPayload( buildLifeEventsBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The five relation links that close the relation matrix (v1.43). Both + // ends live in `src/lib/export/relation-links-backup.ts`. + buildRelationLinksBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -980,6 +993,7 @@ export async function buildFullBackupPayload( const ecgSection: EcgBackupSection = ecg; const symptomsSection: SymptomsBackupSection = symptoms; const lifeEventsSection: LifeEventsBackupSection = lifeEvents; + const relationLinksSection: RelationLinksBackupSection = relationLinks; const accountSettingsSection: AccountSettingsBackupSection = accountSettings; // The three unbounded tables, either read into arrays or left as markers @@ -1387,6 +1401,7 @@ export async function buildFullBackupPayload( ...ecgSection, ...symptomsSection, ...lifeEventsSection, + ...relationLinksSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -1456,6 +1471,7 @@ export async function buildFullBackupPayload( ...countEcgBackupSection(ecg), ...countSymptomsBackupSection(symptoms), ...countLifeEventsBackupSection(lifeEvents), + ...countRelationLinksBackupSection(relationLinks), }, }; } diff --git a/src/lib/export/relation-links-backup.ts b/src/lib/export/relation-links-backup.ts new file mode 100644 index 000000000..e9a0f91c4 --- /dev/null +++ b/src/lib/export/relation-links-backup.ts @@ -0,0 +1,378 @@ +/** + * The relation links that close the relation matrix (v1.43), with both backup + * ends in one file. + * + * Same arrangement as `document-filing-backup.ts`, `visits-backup.ts` and the + * other section files: a reader asking "is this carried at both ends?" answers + * it here, and a reader who greps only the restore ROUTE gets a false negative + * because the route delegates. + * + * Five tables ride, each a pair of an account's own records: + * + * - `LabResultDocumentLink`: the report a lab result was read from; + * - `EncounterMedicationLink`: a medication prescribed, changed or reviewed + * at a visit; + * - `EncounterVaccinationLink`: a dose given at a visit; + * - `MedicationDocumentLink`: a prescription, a plan, a package insert; + * - `MedicationConditionLink`: the condition a medication treats. + * + * None holds anything but two ids and the instant the link was filed, so both + * purposes carry the same bytes. Both ends are foreign keys that cascade, so + * an end that cannot resolve does not dangle quietly: it violates the + * constraint and costs the operator the WHOLE restore. The builder therefore + * carries a link only when both of its ends are carried (mirroring the + * tombstone choices of the sections that carry the records), and the restore + * checks every link against the rows this transaction actually wrote and + * reports what it could not place, because a hand-edited or truncated file is + * the case the write-time filter cannot cover. + * + * Written directly rather than through the link service, under the standing + * restore exemption the visit and vaccination backups hold: a restore rebuilds + * an account rather than filing something in one, and it keeps the instant + * each link was originally filed at, which the service's signature cannot + * express. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export interface RelationLinksBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +export interface LabResultDocumentLinkBackupEntry { + labResultId: string; + documentId: string; + createdAt: string; +} + +export interface EncounterMedicationLinkBackupEntry { + encounterId: string; + medicationId: string; + createdAt: string; +} + +export interface EncounterVaccinationLinkBackupEntry { + encounterId: string; + vaccinationId: string; + createdAt: string; +} + +export interface MedicationDocumentLinkBackupEntry { + medicationId: string; + documentId: string; + createdAt: string; +} + +export interface MedicationConditionLinkBackupEntry { + medicationId: string; + episodeId: string; + createdAt: string; +} + +export interface RelationLinksBackupSection { + labResultDocumentLinks: LabResultDocumentLinkBackupEntry[]; + encounterMedicationLinks: EncounterMedicationLinkBackupEntry[]; + encounterVaccinationLinks: EncounterVaccinationLinkBackupEntry[]; + medicationDocumentLinks: MedicationDocumentLinkBackupEntry[]; + medicationConditionLinks: MedicationConditionLinkBackupEntry[]; +} + +/** The five tables summed, as the visit links are in the summary. */ +export interface RelationLinksBackupCounts { + relationLinks: number; +} + +/** The section keys, in wire order. */ +export const RELATION_LINK_SECTION_KEYS = [ + "labResultDocumentLinks", + "encounterMedicationLinks", + "encounterVaccinationLinks", + "medicationDocumentLinks", + "medicationConditionLinks", +] as const satisfies readonly (keyof RelationLinksBackupSection)[]; + +export async function buildRelationLinksBackupSection( + prisma: Pick< + PrismaClient, + | "labResultDocumentLink" + | "encounterMedicationLink" + | "encounterVaccinationLink" + | "medicationDocumentLink" + | "medicationConditionLink" + >, + userId: string, + options: RelationLinksBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + // The document reader skips tombstoned documents in BOTH purposes; lab + // results, visits, doses and conditions are skipped in a portable export + // only. A medication carries no tombstone. + const liveUnlessDr = disasterRecovery ? {} : { deletedAt: null }; + const order = { createdAt: "asc" } as const; + + const [labDocs, encounterMeds, encounterDoses, medDocs, medConditions] = + await Promise.all([ + prisma.labResultDocumentLink.findMany({ + where: { + userId, + labResult: liveUnlessDr, + document: { deletedAt: null }, + }, + orderBy: order, + select: { labResultId: true, documentId: true, createdAt: true }, + }), + prisma.encounterMedicationLink.findMany({ + where: { userId, encounter: liveUnlessDr }, + orderBy: order, + select: { encounterId: true, medicationId: true, createdAt: true }, + }), + prisma.encounterVaccinationLink.findMany({ + where: { + userId, + encounter: liveUnlessDr, + vaccination: liveUnlessDr, + }, + orderBy: order, + select: { encounterId: true, vaccinationId: true, createdAt: true }, + }), + prisma.medicationDocumentLink.findMany({ + where: { userId, document: { deletedAt: null } }, + orderBy: order, + select: { medicationId: true, documentId: true, createdAt: true }, + }), + prisma.medicationConditionLink.findMany({ + where: { userId, episode: liveUnlessDr }, + orderBy: order, + select: { medicationId: true, episodeId: true, createdAt: true }, + }), + ]); + + const at = (row: T) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + }); + return { + labResultDocumentLinks: labDocs.map(at), + encounterMedicationLinks: encounterMeds.map(at), + encounterVaccinationLinks: encounterDoses.map(at), + medicationDocumentLinks: medDocs.map(at), + medicationConditionLinks: medConditions.map(at), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countRelationLinksBackupSection( + section: RelationLinksBackupSection, +): RelationLinksBackupCounts { + return { + relationLinks: RELATION_LINK_SECTION_KEYS.reduce( + (total, key) => total + section[key].length, + 0, + ), + }; +} + +/** Counts this restore wiped, for the audit trail. */ +export type RelationLinksRestoreCleared = RelationLinksBackupCounts; + +/** The ids of every record this restore has written, by kind. */ +async function writtenIds( + tx: Prisma.TransactionClient, + ownerId: string, +): Promise<{ + labResults: Set; + documents: Set; + encounters: Set; + vaccinations: Set; + medications: Set; + episodes: Set; +}> { + const where = { userId: ownerId }; + const select = { id: true } as const; + const [ + labResults, + documents, + encounters, + vaccinations, + medications, + episodes, + ] = await Promise.all([ + tx.labResult.findMany({ where, select }), + tx.inboundDocument.findMany({ where, select }), + tx.encounter.findMany({ where, select }), + tx.vaccinationRecord.findMany({ where, select }), + tx.medication.findMany({ where, select }), + tx.illnessEpisode.findMany({ where, select }), + ]); + const ids = (rows: Array<{ id: string }>) => new Set(rows.map((r) => r.id)); + return { + labResults: ids(labResults), + documents: ids(documents), + encounters: ids(encounters), + vaccinations: ids(vaccinations), + medications: ids(medications), + episodes: ids(episodes), + }; +} + +/** + * Keep the links whose two ends both exist, and report the rest under the end + * that is missing, so an operator can tell "the report is gone" from "the + * result is gone". + */ +function placeable( + rows: readonly T[], + ends: [(row: T) => string, ReadonlySet][], + skips: RestoreSkipLog, +): T[] { + const dropped: string[] = []; + const kept = rows.filter((row) => { + for (const [end, written] of ends) { + const id = end(row); + if (!written.has(id)) { + dropped.push(id); + return false; + } + } + return true; + }); + recordUnknownKeys(skips, "relationLink", [...new Set(dropped)], dropped); + return kept; +} + +/** + * Re-create the five link tables. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. Each table cascades from both of its ends, so the record wipes + * earlier in the restore have already emptied them; they are counted and + * cleared explicitly anyway, so the cleared numbers are truthful. + * + * MUST be called AFTER the lab results, documents, visits, doses, medications + * and conditions are restored: every end is a foreign key, and a link written + * before its end exists aborts the transaction. + */ +export async function restoreRelationLinksData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: RelationLinksBackupSection, + skips: RestoreSkipLog, +): Promise { + const owned = { where: { userId: ownerId } }; + const [c1, c2, c3, c4, c5] = await Promise.all([ + tx.labResultDocumentLink.deleteMany(owned), + tx.encounterMedicationLink.deleteMany(owned), + tx.encounterVaccinationLink.deleteMany(owned), + tx.medicationDocumentLink.deleteMany(owned), + tx.medicationConditionLink.deleteMany(owned), + ]); + + const written = await writtenIds(tx, ownerId); + const stamp = (createdAt: string) => ({ + userId: ownerId, + createdAt: new Date(createdAt), + }); + + const labDocs = placeable( + payload.labResultDocumentLinks, + [ + [(r) => r.labResultId, written.labResults], + [(r) => r.documentId, written.documents], + ], + skips, + ); + if (labDocs.length > 0) { + await tx.labResultDocumentLink.createMany({ + data: labDocs.map((r) => ({ + labResultId: r.labResultId, + documentId: r.documentId, + ...stamp(r.createdAt), + })), + skipDuplicates: true, + }); + } + + const encounterMeds = placeable( + payload.encounterMedicationLinks, + [ + [(r) => r.encounterId, written.encounters], + [(r) => r.medicationId, written.medications], + ], + skips, + ); + if (encounterMeds.length > 0) { + await tx.encounterMedicationLink.createMany({ + data: encounterMeds.map((r) => ({ + encounterId: r.encounterId, + medicationId: r.medicationId, + ...stamp(r.createdAt), + })), + skipDuplicates: true, + }); + } + + const encounterDoses = placeable( + payload.encounterVaccinationLinks, + [ + [(r) => r.encounterId, written.encounters], + [(r) => r.vaccinationId, written.vaccinations], + ], + skips, + ); + if (encounterDoses.length > 0) { + await tx.encounterVaccinationLink.createMany({ + data: encounterDoses.map((r) => ({ + encounterId: r.encounterId, + vaccinationId: r.vaccinationId, + ...stamp(r.createdAt), + })), + skipDuplicates: true, + }); + } + + const medDocs = placeable( + payload.medicationDocumentLinks, + [ + [(r) => r.medicationId, written.medications], + [(r) => r.documentId, written.documents], + ], + skips, + ); + if (medDocs.length > 0) { + await tx.medicationDocumentLink.createMany({ + data: medDocs.map((r) => ({ + medicationId: r.medicationId, + documentId: r.documentId, + ...stamp(r.createdAt), + })), + skipDuplicates: true, + }); + } + + const medConditions = placeable( + payload.medicationConditionLinks, + [ + [(r) => r.medicationId, written.medications], + [(r) => r.episodeId, written.episodes], + ], + skips, + ); + if (medConditions.length > 0) { + await tx.medicationConditionLink.createMany({ + data: medConditions.map((r) => ({ + medicationId: r.medicationId, + episodeId: r.episodeId, + ...stamp(r.createdAt), + })), + skipDuplicates: true, + }); + } + + return { + relationLinks: c1.count + c2.count + c3.count + c4.count + c5.count, + }; +} diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 2ee7c4416..1cce836e8 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -116,6 +116,7 @@ import { restoreEnvironmentData } from "@/lib/export/environment-backup"; import { restoreEcgData } from "@/lib/export/ecg-backup"; import { restoreSymptomsData } from "@/lib/export/symptoms-backup"; import { restoreLifeEventsData } from "@/lib/export/life-events-backup"; +import { restoreRelationLinksData } from "@/lib/export/relation-links-backup"; import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at"; import { encryptCategoryLabel, @@ -181,6 +182,7 @@ export interface RestoreResponse { encounterLinks: number; vaccinations: number; vaccinationLinks: number; + relationLinks: number; customVaccines: number; measurementReminders: number; measurementReminderEvents: number; @@ -2655,6 +2657,17 @@ export async function restoreBackup( skips, ); + // The five relation links (v1.43), last of the record sections they + // join: every end is a foreign key against a lab result, document, + // visit, dose, medication or condition, all written above. Both ends + // of this section live in `src/lib/export/relation-links-backup.ts`. + const relationLinksCleared = await restoreRelationLinksData( + tx, + ownerId, + payload, + skips, + ); + // The Coach's threads, turns and document links. AFTER the // documents on purpose: an attachment carries a `documentId` that // is a foreign key against them, so running earlier would make @@ -2787,6 +2800,7 @@ export async function restoreBackup( encounterLinks: visitsCleared.encounterLinks, vaccinations: vaccinationsCleared.vaccinations, vaccinationLinks: vaccinationsCleared.vaccinationLinks, + relationLinks: relationLinksCleared.relationLinks, customVaccines: vaccinationsCleared.customVaccines, measurementReminders: remindersCleared.measurementReminders, measurementReminderEvents: remindersCleared.measurementReminderEvents, @@ -3066,6 +3080,7 @@ export async function restoreBackup( encounterLinks: summary.encounterLinks, vaccinations: summary.vaccinations, vaccinationLinks: summary.vaccinationLinks, + relationLinks: summary.relationLinks, customVaccines: summary.customVaccines, measurementReminders: summary.measurementReminders, measurementReminderEvents: summary.measurementReminderEvents, diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index e73dcb712..98003837c 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -166,6 +166,12 @@ * (`customMoodTags..labelEncrypted`): the tag or category restores, under * its key, without the person's own name for it. * + * `relationLink` (v1.43) is the same row kind as `documentConditionLink`, for + * the five link tables that close the relation matrix: a link whose lab + * result, document, visit, dose, medication or condition the restore did not + * put back. Every end is a real foreign key, so the LINK is dropped and both + * records it joined (whichever exist) restore. + * * The eighteenth and nineteenth, `visitCiphertext` and `vaccinationCiphertext`, * are the same again for the free text of the visits record (a practitioner's * note, address and phone; a visit's reason, outcome and body site) and of a @@ -183,6 +189,7 @@ export type SkippedCatalogue = | "coachAttachment" | "coachReference" | "documentConditionLink" + | "relationLink" | "extractedFact" | "factCommitment" | "personalRecordReference" diff --git a/src/lib/validations/backup-summary.ts b/src/lib/validations/backup-summary.ts index 855f6dea9..080adc01c 100644 --- a/src/lib/validations/backup-summary.ts +++ b/src/lib/validations/backup-summary.ts @@ -85,6 +85,8 @@ export interface BackupSummary { vaccinations: number; /** v1.37.19 (A6-8) — vaccination↔document links. */ vaccinationLinks: number; + /** v1.43 — the five relation link tables, summed. */ + relationLinks: number; /** v1.42 (#1005) — the person's own vaccine definitions. */ customVaccines: number; /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ @@ -178,6 +180,12 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { payload.encounterConditionLinks.length, vaccinations: payload.vaccinations.length, vaccinationLinks: payload.vaccinationDocumentLinks.length, + relationLinks: + payload.labResultDocumentLinks.length + + payload.encounterMedicationLinks.length + + payload.encounterVaccinationLinks.length + + payload.medicationDocumentLinks.length + + payload.medicationConditionLinks.length, customVaccines: payload.customVaccines.length, // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, // so the admin's "what did I just restore" answer never under-counts a diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 3cdb869bb..0c74fb251 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1409,6 +1409,23 @@ const vaccinationLinkBackupSchema = z }) .passthrough(); +/** + * v1.43 — one row of a relation link table: its two ends, by the column + * names of the table, and the instant it was filed. + */ +function relationLinkBackupSchema( + a: A, + b: B, +) { + return z + .object({ + [a]: z.string().min(1), + [b]: z.string().min(1), + createdAt: isoDateTime, + } as { [K in A | B]: z.ZodString } & { createdAt: typeof isoDateTime }) + .passthrough(); +} + /** * One Vorsorge reminder, and one row of its completion ledger (v1.37.20, * #223 / iOS #68). @@ -2183,6 +2200,23 @@ export const backupPayloadSchema = z customVaccines: z.array(customVaccineBackupSchema).default([]), vaccinations: z.array(vaccinationBackupSchema).default([]), vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), + // v1.43 — the five relation links that close the relation matrix. + // Defaulted: a file written before v1.43 carries no key. + labResultDocumentLinks: z + .array(relationLinkBackupSchema("labResultId", "documentId")) + .default([]), + encounterMedicationLinks: z + .array(relationLinkBackupSchema("encounterId", "medicationId")) + .default([]), + encounterVaccinationLinks: z + .array(relationLinkBackupSchema("encounterId", "vaccinationId")) + .default([]), + medicationDocumentLinks: z + .array(relationLinkBackupSchema("medicationId", "documentId")) + .default([]), + medicationConditionLinks: z + .array(relationLinkBackupSchema("medicationId", "episodeId")) + .default([]), // The Vorsorge reminders and their completion ledger (v1.37.20, #223 / // iOS #68). Defaulted for the same reason as the sections above: a file // written before the reminders travelled carries no key, and an account diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 1f2497960..e9227a777 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -291,6 +291,16 @@ const COUNT_BACK: Record< p.vaccinationRecord.count({ where: { userId } }), VaccinationDocumentLink: (p, userId) => p.vaccinationDocumentLink.count({ where: { userId } }), + LabResultDocumentLink: (p, userId) => + p.labResultDocumentLink.count({ where: { userId } }), + EncounterMedicationLink: (p, userId) => + p.encounterMedicationLink.count({ where: { userId } }), + EncounterVaccinationLink: (p, userId) => + p.encounterVaccinationLink.count({ where: { userId } }), + MedicationDocumentLink: (p, userId) => + p.medicationDocumentLink.count({ where: { userId } }), + MedicationConditionLink: (p, userId) => + p.medicationConditionLink.count({ where: { userId } }), CustomVaccine: (p, userId) => p.customVaccine.count({ where: { userId } }), MeasurementReminder: (p, userId) => p.measurementReminder.count({ where: { userId } }), @@ -1368,6 +1378,44 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, }); + // v1.43 — one row in each of the five relation link tables, joining the + // records seeded above. + await prisma.labResultDocumentLink.create({ + data: { + userId: OWNER_ID, + labResultId: labResult.id, + documentId: document.id, + }, + }); + await prisma.encounterMedicationLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + medicationId: medication.id, + }, + }); + await prisma.encounterVaccinationLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + vaccinationId: vaccination.id, + }, + }); + await prisma.medicationDocumentLink.create({ + data: { + userId: OWNER_ID, + medicationId: medication.id, + documentId: document.id, + }, + }); + await prisma.medicationConditionLink.create({ + data: { + userId: OWNER_ID, + medicationId: medication.id, + episodeId: episode.id, + }, + }); + // Three bests, chosen for what a count cannot see. // // The first points at the measurement it was found in. That column is a From b4bc039d165816fe68b9a09719889ff1a43b3bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:57:34 +0200 Subject: [PATCH 3/4] feat(links): close the relation matrix and publish related[] on six details The link service gains medication and lab result sources and the five new tables, each readable from both ends. POST/DELETE /api/medications/{id}/links and /api/labs/{id}/links replace their 501 stubs; the visit links route also files medications and doses, the dose links route its visit. A scan commit with a documentId files each written result against its report. GET on a lab result, visit, document, condition, medication and dose now carries related[]: stored links, the condition a result shares a visit with, and the medication whose efficacy target is the result's marker, each with its web path. A kind is listed only when its module is on and the caller's grant covers its section. relation-matrix.test.ts holds every one of the fifteen pairs to a table, a reader or a written exception. --- docs/api/openapi.yaml | 123 ++++- src/__tests__/contract-stub-inventory.test.ts | 4 +- .../inbound/[id]/__tests__/route.test.ts | 9 + src/app/api/documents/inbound/[id]/route.ts | 27 +- src/app/api/encounters/[id]/links/route.ts | 3 +- src/app/api/encounters/[id]/route.ts | 10 +- src/app/api/illness/episodes/[id]/route.ts | 12 +- src/app/api/labs/[id]/links/route.ts | 48 +- src/app/api/labs/[id]/route.ts | 15 +- src/app/api/labs/ocr/commit/route.ts | 11 + src/app/api/medications/[id]/links/route.ts | 48 +- src/app/api/medications/[id]/route.ts | 10 +- src/app/api/vaccinations/[id]/links/route.ts | 3 +- src/app/api/vaccinations/[id]/route.ts | 9 +- src/lib/encounters/service.ts | 1 + .../__tests__/link-surface-guard.test.ts | 11 +- .../links/__tests__/relation-matrix.test.ts | 128 +++++ src/lib/links/link-service.ts | 177 ++++++- src/lib/links/related.ts | 492 ++++++++++++++++++ src/lib/openapi/routes/encounters.ts | 2 +- src/lib/openapi/routes/relation-links.ts | 8 +- src/lib/openapi/routes/vaccinations.ts | 6 +- src/lib/validations/encounters.ts | 3 + src/lib/validations/vaccinations.ts | 10 +- tests/integration/relation-links.test.ts | 481 +++++++++++++++++ 25 files changed, 1569 insertions(+), 82 deletions(-) create mode 100644 src/lib/links/__tests__/relation-matrix.test.ts create mode 100644 src/lib/links/related.ts create mode 100644 tests/integration/relation-links.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5559a9937..8227b616e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15780,7 +15780,8 @@ paths: - Records summary: File targets against a visit description: "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated - only, matching document link and unlink." + only, matching document link and unlink. Since v1.43 `targetKind` also takes `medication` and `vaccination`; + those links show in the visit's `related` list (`GET /api/encounters/{id}`), not in `links`." parameters: - in: path name: id @@ -16393,9 +16394,11 @@ paths: post: tags: - Records - summary: File documents against a vaccination + summary: File documents or a visit against a vaccination description: "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated - only, matching document and visit link and unlink." + only, matching document and visit link and unlink. Since v1.43 `targetKind` also takes `encounter` (the visit + the dose was given at); that link shows in the dose's `related` list (`GET /api/vaccinations/{id}`), not in + `documents`." parameters: - in: path name: id @@ -16422,7 +16425,7 @@ paths: delete: tags: - Records - summary: Unfile documents from a vaccination + summary: Unfile documents or a visit from a vaccination description: Removes links. Idempotent, and it still works for a page the account has since deleted — refusing would leave a filing nobody can clear. parameters: @@ -24506,7 +24509,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a44 delete: tags: - Links @@ -24542,7 +24544,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a44 /api/labs/{id}/links: post: tags: @@ -24579,7 +24580,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a44 delete: tags: - Links @@ -24615,7 +24615,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a44 /api/bugreport: post: security: [] @@ -31558,6 +31557,8 @@ components: - document - labResult - conditionEpisode + - medication + - vaccination targetIds: minItems: 1 maxItems: 100 @@ -31966,6 +31967,7 @@ components: type: string enum: - document + - encounter targetIds: minItems: 1 maxItems: 100 @@ -41980,6 +41982,106 @@ components: - targetId - createdAt additionalProperties: {} + labResultDocumentLinks: + default: [] + type: array + items: + type: object + properties: + labResultId: + type: string + minLength: 1 + documentId: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - labResultId + - documentId + - createdAt + additionalProperties: {} + encounterMedicationLinks: + default: [] + type: array + items: + type: object + properties: + encounterId: + type: string + minLength: 1 + medicationId: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - encounterId + - medicationId + - createdAt + additionalProperties: {} + encounterVaccinationLinks: + default: [] + type: array + items: + type: object + properties: + encounterId: + type: string + minLength: 1 + vaccinationId: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - encounterId + - vaccinationId + - createdAt + additionalProperties: {} + medicationDocumentLinks: + default: [] + type: array + items: + type: object + properties: + medicationId: + type: string + minLength: 1 + documentId: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - medicationId + - documentId + - createdAt + additionalProperties: {} + medicationConditionLinks: + default: [] + type: array + items: + type: object + properties: + medicationId: + type: string + minLength: 1 + episodeId: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - medicationId + - episodeId + - createdAt + additionalProperties: {} coachConversations: default: [] type: array @@ -43204,6 +43306,11 @@ components: - customVaccines - vaccinations - vaccinationDocumentLinks + - labResultDocumentLinks + - encounterMedicationLinks + - encounterVaccinationLinks + - medicationDocumentLinks + - medicationConditionLinks - coachConversations - mentalHealthAssessments - consentReceipts diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 5d73a18ab..26c1c6fe4 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -51,17 +51,15 @@ const CONTRACT_STUBS: Readonly> = { "environment/travel/suggestions/route.ts": [ "environment.travel_suggestions.get", ], - "labs/[id]/links/route.ts": ["labs.link."], "medications/[id]/ingredients/extract/route.ts": [ "medications.ingredients.extract", ], "medications/[id]/ingredients/route.ts": ["medications.ingredients.put"], - "medications/[id]/links/route.ts": ["medication.link."], "nutrients/catalog/route.ts": ["nutrients.catalog.get"], }; /** The size of the register when it was written; see the header. */ -const REGISTER_SIZE = 21; +const REGISTER_SIZE = 19; function routeFiles(): string[] { return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) 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 b82bd9d65..e9a75d030 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -57,6 +57,15 @@ vi.mock("@/lib/db", () => ({ }, })); +// `related[]` has its own tests (`src/lib/links/__tests__`); here it is the +// empty list, so the detail assertions stay about the document. +vi.mock("@/lib/links/related", () => ({ + withRelated: async (_tx: unknown, detail: object) => ({ + ...detail, + related: [], + }), +})); + vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn((s: string) => `v1.${s}`), decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")), diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 0d3468b07..d4eff24b6 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -45,6 +45,7 @@ import { } from "@/lib/validations/inbound-documents"; import type { Prisma } from "@/generated/prisma/client"; import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; +import { withRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; @@ -132,17 +133,21 @@ export const GET = apiHandler( }); return apiSuccess( - serialiseDocumentDetail( - document, - document.facts, - links.get(document.id) ?? [], - contentIndex !== null, - toContentIndexSource(contentIndex?.source), - thumbnail !== null, - visitLinks.get(document.id) ?? [], - doseLinks === null ? null : (doseLinks.get(document.id) ?? []), - // v1.43 — the marker each pending lab value resolves to, or choices. - await markerIndexForFacts(user.id, document.facts), + await withRelated( + prisma, + serialiseDocumentDetail( + document, + document.facts, + links.get(document.id) ?? [], + contentIndex !== null, + toContentIndexSource(contentIndex?.source), + thumbnail !== null, + visitLinks.get(document.id) ?? [], + doseLinks === null ? null : (doseLinks.get(document.id) ?? []), + // v1.43 — the marker each pending lab value resolves to, or choices. + await markerIndexForFacts(user.id, document.facts), + ), + { userId: user.id, grantId, kind: "document", id: document.id }, ), ); }, diff --git a/src/app/api/encounters/[id]/links/route.ts b/src/app/api/encounters/[id]/links/route.ts index c22af968e..37251a8ed 100644 --- a/src/app/api/encounters/[id]/links/route.ts +++ b/src/app/api/encounters/[id]/links/route.ts @@ -1,5 +1,6 @@ /** - * `POST /api/encounters/{id}/links` — file things against a visit. + * `POST /api/encounters/{id}/links` — file things against a visit: documents, + * lab results, conditions and, since v1.43, medications and doses. * `DELETE /api/encounters/{id}/links` — unfile them. * * Deliberately bare `requireAuth()` rather than a delegable declaration, and diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index 9445b6416..a954f3a9b 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -54,6 +54,7 @@ import { } from "@/lib/export/restore-skips"; import type { Prisma } from "@/generated/prisma/client"; import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { withRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; @@ -81,7 +82,14 @@ export const GET = apiHandler( }, }); - return apiSuccess(toEncounterDTO(row, links)); + return apiSuccess( + await withRelated(prisma, toEncounterDTO(row, links), { + userId: user.id, + grantId, + kind: "encounter", + id, + }), + ); }, ); diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts index f614398c5..6aa5475a3 100644 --- a/src/app/api/illness/episodes/[id]/route.ts +++ b/src/app/api/illness/episodes/[id]/route.ts @@ -27,12 +27,13 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { illnessEpisodeUpdateSchema } from "@/lib/validations/illness"; import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; import type { Prisma } from "@/generated/prisma/client"; +import { withRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read", "illness"); + const { user, grantId } = await requireRecordAuth("read", "illness"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; @@ -51,7 +52,14 @@ export const GET = apiHandler( }, }); - return apiSuccess(toIllnessEpisodeDTO(row)); + return apiSuccess( + await withRelated(prisma, toIllnessEpisodeDTO(row), { + userId: user.id, + grantId, + kind: "conditionEpisode", + id, + }), + ); }, ); diff --git a/src/app/api/labs/[id]/links/route.ts b/src/app/api/labs/[id]/links/route.ts index 6ea66439a..114b4a23f 100644 --- a/src/app/api/labs/[id]/links/route.ts +++ b/src/app/api/labs/[id]/links/route.ts @@ -11,8 +11,15 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { returnAllZodIssues, safeJson } from "@/lib/api-response"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { linkTargets, unlinkTargets } from "@/lib/links"; +import { annotate } from "@/lib/logging/context"; import { labResultLinkSchema } from "@/lib/validations/relation-links"; type RouteParams = { params: Promise<{ id: string }> }; @@ -22,8 +29,8 @@ async function handle( { params }: RouteParams, verb: "link" | "unlink", ): Promise { - await requireAuth(); - await params; + const { user } = await requireAuth(); + const { id } = await params; const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, @@ -36,7 +43,38 @@ async function handle( }); } - return notImplemented(`labs.link.${verb}`); + const apply = verb === "link" ? linkTargets : unlinkTargets; + const result = await apply(prisma, { + userId: user.id, + sourceKind: "labResult", + sourceId: id, + targetKind: parsed.data.targetKind, + targetIds: parsed.data.targetIds, + }); + // Like the visit and vaccination link routes: a link to a source this + // account does not own is a 404; an unlink is scoped to the account's own + // link rows and so is an idempotent no-op instead. + if (result.unknownSource) { + return apiError("Lab result not found", 404); + } + + annotate({ + action: { + name: `labs.link.${verb}`, + entity_type: "lab_result", + entity_id: id, + }, + meta: { + target_kind: parsed.data.targetKind, + changed: result.changed, + unknown: result.unknownTargetIds.length, + }, + }); + + return apiSuccess({ + changed: result.changed, + unknown: result.unknownTargetIds, + }); } export const POST = apiHandler( diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index c828bdf1f..146a0b42a 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -20,6 +20,7 @@ import { } from "@/lib/labs/serialise"; import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; +import { withRelated } from "@/lib/links/related"; import { effectiveBound, isInvertedRange, @@ -66,7 +67,7 @@ function toResolved( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read", "labs"); + const { user, grantId } = await requireRecordAuth("read", "labs"); const { id } = await params; const row = await prisma.labResult.findFirst({ @@ -80,10 +81,14 @@ export const GET = apiHandler( annotate({ action: { name: "labs.get" }, meta: { labResultId: id } }); return apiSuccess( - serialiseLabResultDetail( - row, - toResolved(row.biomarker), - row.noteEncrypted ? decryptNoteFromBytes(row.noteEncrypted) : null, + await withRelated( + prisma, + serialiseLabResultDetail( + row, + toResolved(row.biomarker), + row.noteEncrypted ? decryptNoteFromBytes(row.noteEncrypted) : null, + ), + { userId: user.id, grantId, kind: "labResult", id }, ), ); }, diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 9dbc01d45..07c77e50e 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -371,6 +371,17 @@ async function commitOcrRows(request: NextRequest) { parsed.data.documentId, linkable, ).catch(() => {}); + // v1.43 — the stored relation as well: each written result points at the + // report it was read from (`lab_result_document_links`), so the result's + // and the document's `related` lists name each other. Best-effort for the + // same reason; the service narrows both ends to this record. + await linkTargets(prisma, { + userId: user.id, + sourceKind: "document", + sourceId: parsed.data.documentId, + targetKind: "labResult", + targetIds: linkable.map((row) => row.labResultId), + }).catch(() => {}); } // The visit the review step offered, if it offered one. ONE LINK PER RESULT diff --git a/src/app/api/medications/[id]/links/route.ts b/src/app/api/medications/[id]/links/route.ts index 80aae09de..7535fe98d 100644 --- a/src/app/api/medications/[id]/links/route.ts +++ b/src/app/api/medications/[id]/links/route.ts @@ -11,8 +11,15 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { returnAllZodIssues, safeJson } from "@/lib/api-response"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { linkTargets, unlinkTargets } from "@/lib/links"; +import { annotate } from "@/lib/logging/context"; import { medicationLinkSchema } from "@/lib/validations/relation-links"; type RouteParams = { params: Promise<{ id: string }> }; @@ -22,8 +29,8 @@ async function handle( { params }: RouteParams, verb: "link" | "unlink", ): Promise { - await requireAuth(); - await params; + const { user } = await requireAuth(); + const { id } = await params; const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, @@ -36,7 +43,38 @@ async function handle( }); } - return notImplemented(`medication.link.${verb}`); + const apply = verb === "link" ? linkTargets : unlinkTargets; + const result = await apply(prisma, { + userId: user.id, + sourceKind: "medication", + sourceId: id, + targetKind: parsed.data.targetKind, + targetIds: parsed.data.targetIds, + }); + // Like the visit and vaccination link routes: a link to a source this + // account does not own is a 404; an unlink is scoped to the account's own + // link rows and so is an idempotent no-op instead. + if (result.unknownSource) { + return apiError("Medication not found", 404); + } + + annotate({ + action: { + name: `medication.link.${verb}`, + entity_type: "medication", + entity_id: id, + }, + meta: { + target_kind: parsed.data.targetKind, + changed: result.changed, + unknown: result.unknownTargetIds.length, + }, + }); + + return apiSuccess({ + changed: result.changed, + unknown: result.unknownTargetIds, + }); } export const POST = apiHandler( diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 212716afe..9e76d99f6 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -62,6 +62,7 @@ import { import { floorWhole } from "@/lib/medications/units-per-dose"; import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { buildRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; @@ -103,7 +104,7 @@ function reconcileScheduleWindow( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read", "medications"); + const { user, grantId } = await requireRecordAuth("read", "medications"); const { id } = await params; const medication = await prisma.medication.findUnique({ @@ -294,6 +295,13 @@ export const GET = apiHandler( stockUnitsRemaining, stockDosesRemaining, runwayDays, + // v1.43 — the records this medication relates to (`src/lib/links/`). + related: await buildRelated(prisma, { + userId: user.id, + grantId, + kind: "medication", + id, + }), }); }, ); diff --git a/src/app/api/vaccinations/[id]/links/route.ts b/src/app/api/vaccinations/[id]/links/route.ts index d8ba5cbed..b6cd14fe9 100644 --- a/src/app/api/vaccinations/[id]/links/route.ts +++ b/src/app/api/vaccinations/[id]/links/route.ts @@ -1,5 +1,6 @@ /** - * `POST /api/vaccinations/{id}/links` — file a scanned page against a dose. + * `POST /api/vaccinations/{id}/links` — file a scanned page, or since v1.43 + * the visit it was given at, against a dose. * `DELETE /api/vaccinations/{id}/links` — unfile it. * * Deliberately bare `requireAuth()` rather than a delegable declaration, and diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 376016267..78111f637 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -41,6 +41,7 @@ import { } from "@/lib/vaccinations/service"; import type { Prisma } from "@/generated/prisma/client"; import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { withRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; @@ -71,7 +72,13 @@ export const GET = apiHandler( }, }); - return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); + return apiSuccess( + await withRelated( + prisma, + toVaccinationDTO(row, series.get(id) ?? [], documents), + { userId: user.id, grantId, kind: "vaccination", id }, + ), + ); }, ); diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 7d65b544c..e9d30108e 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -74,6 +74,7 @@ const TARGET_DOMAIN: Record = { encounter: "profile", // A dose lives in the health background, like the visit itself. vaccination: "profile", + medication: "medications", }; /** diff --git a/src/lib/links/__tests__/link-surface-guard.test.ts b/src/lib/links/__tests__/link-surface-guard.test.ts index 086af0bfe..aa4891133 100644 --- a/src/lib/links/__tests__/link-surface-guard.test.ts +++ b/src/lib/links/__tests__/link-surface-guard.test.ts @@ -42,6 +42,12 @@ const LINK_DELEGATES = [ "encounterLabLink", "encounterConditionLink", "vaccinationDocumentLink", + // v1.43 — the five tables that close the relation matrix. + "labResultDocumentLink", + "encounterMedicationLink", + "encounterVaccinationLink", + "medicationDocumentLink", + "medicationConditionLink", ] as const; /** @@ -72,6 +78,8 @@ const RESTORE_EXEMPTIONS = [ // above: it re-creates the link with the instant it was originally filed at, // which the service's filing signature has no way to express. "lib/export/document-filing-backup.ts", + // The five relation links (v1.43), same standing rule. + "lib/export/relation-links-backup.ts", ].sort(); /** Every non-test, non-generated source file under `src/`, relative to `src/`. */ @@ -128,7 +136,7 @@ describe("link-surface guard — the tables have one gateway", () => { } }); - it("the restore exemption set is exactly the three backup writers", () => { + it("the restore exemption set is exactly the four backup writers", () => { // Freeze the exemption list so widening it is a reviewed diff, not a quiet // addition. Every file must exist and must actually write a link table. for (const rel of RESTORE_EXEMPTIONS) { @@ -137,6 +145,7 @@ describe("link-surface guard — the tables have one gateway", () => { expect(RESTORE_EXEMPTIONS).toEqual( [ "lib/export/document-filing-backup.ts", + "lib/export/relation-links-backup.ts", "lib/export/vaccinations-backup.ts", "lib/export/visits-backup.ts", ].sort(), diff --git a/src/lib/links/__tests__/relation-matrix.test.ts b/src/lib/links/__tests__/relation-matrix.test.ts new file mode 100644 index 000000000..f7ad53c0b --- /dev/null +++ b/src/lib/links/__tests__/relation-matrix.test.ts @@ -0,0 +1,128 @@ +/** + * The relation matrix has no dead ends. + * + * Six kinds of record (lab result, visit, document, condition, medication, + * vaccination) make fifteen pairs. Each pair is either stored (a link table in + * `LINK_TABLES`, readable from both ends, so both detail pages list the + * other), derived (no table, a reader in `DERIVED_READERS` for both + * directions), or a written exception with its reason. A pair with none of + * the three is a place where the person reaches one record and cannot get to + * the other, which is the class of defect this guards. + * + * Mutation check: delete the `"document:medication"` entry from `LINK_TABLES` + * and the stored case names it; delete `"medication:labResult"` from + * `DERIVED_READERS` and the derived case names it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + DERIVED_READERS, + RELATED_KIND_DOMAIN, + RELATED_KIND_MODULE, + RELATION_MATRIX, + relationCell, + type RelatedKind, +} from "@/lib/links/related"; +import { RELATED_KINDS } from "@/lib/validations/relation-links"; + +const SERVICE = readFileSync( + join(process.cwd(), "src", "lib", "links", "link-service.ts"), + "utf8", +); + +/** `LINK_TABLES` as `"from:to" → Prisma delegate`, read from the source. */ +function linkTables(): Map { + const start = SERVICE.indexOf("const LINK_TABLES"); + expect(start).toBeGreaterThan(-1); + const body = SERVICE.slice(start, SERVICE.indexOf("\n};", start)); + const out = new Map(); + for (const m of body.matchAll( + /"(\w+:\w+)":\s*\{\s*delegate:\s*\(tx\)\s*=>\s*narrow\(\s*tx\.(\w+)\s*\)/g, + )) { + out.set(m[1], m[2]); + } + return out; +} + +const KINDS = [...RELATED_KINDS] as RelatedKind[]; + +function pairs(): Array<[RelatedKind, RelatedKind]> { + const out: Array<[RelatedKind, RelatedKind]> = []; + for (let i = 0; i < KINDS.length; i++) { + for (let j = i + 1; j < KINDS.length; j++) out.push([KINDS[i], KINDS[j]]); + } + return out; +} + +describe("relation matrix", () => { + const tables = linkTables(); + + it("reads the link table and covers fifteen pairs", () => { + // Non-vacuous: the matcher found the tables the service declares. + expect(tables.size).toBeGreaterThanOrEqual(15); + expect(pairs()).toHaveLength(15); + }); + + it("declares every pair exactly once", () => { + const keys = Object.keys(RELATION_MATRIX); + expect(keys).toHaveLength(15); + for (const [a, b] of pairs()) { + const forward = `${a}:${b}` in RELATION_MATRIX; + const reverse = `${b}:${a}` in RELATION_MATRIX; + expect(forward !== reverse, `${a} and ${b}`).toBe(true); + } + }); + + it.each(pairs())("%s and %s are not a dead end", (a, b) => { + const cell = relationCell(a, b); + expect(cell, `${a}:${b} has no cell`).toBeDefined(); + if (!cell) return; + if (cell.mode === "stored") { + const forward = tables.get(`${a}:${b}`); + const reverse = tables.get(`${b}:${a}`); + expect(forward, `LINK_TABLES lacks ${a}:${b}`).toBeDefined(); + expect(reverse, `LINK_TABLES lacks ${b}:${a}`).toBeDefined(); + // Both directions read ONE table, so the two pages cannot disagree. + expect(forward).toBe(reverse); + } else if (cell.mode === "derived") { + expect(DERIVED_READERS[`${a}:${b}`], `no reader ${a}:${b}`).toBeDefined(); + expect(DERIVED_READERS[`${b}:${a}`], `no reader ${b}:${a}`).toBeDefined(); + // A derived pair has no table on purpose; one appearing means the cell + // is stale. + expect(tables.has(`${a}:${b}`)).toBe(false); + } else { + expect(cell.reason.length, `${a}:${b} reason`).toBeGreaterThan(40); + expect(tables.has(`${a}:${b}`) || tables.has(`${b}:${a}`)).toBe(false); + } + }); + + it("every link table is a pair of the matrix", () => { + for (const key of tables.keys()) { + const [a, b] = key.split(":") as [RelatedKind, RelatedKind]; + expect(relationCell(a, b)?.mode, key).toBe("stored"); + } + }); + + it("the five v1.43 tables close their pairs", () => { + const delegates = new Set(tables.values()); + for (const delegate of [ + "labResultDocumentLink", + "encounterMedicationLink", + "encounterVaccinationLink", + "medicationDocumentLink", + "medicationConditionLink", + ]) { + expect(delegates.has(delegate), delegate).toBe(true); + } + }); + + it("names a module and a sharing section for every kind", () => { + for (const kind of KINDS) { + expect(kind in RELATED_KIND_MODULE, kind).toBe(true); + expect(RELATED_KIND_DOMAIN[kind], kind).toBeTruthy(); + } + }); +}); diff --git a/src/lib/links/link-service.ts b/src/lib/links/link-service.ts index 815e53a8c..08b750d60 100644 --- a/src/lib/links/link-service.ts +++ b/src/lib/links/link-service.ts @@ -13,11 +13,21 @@ export const MAX_TARGETS_PER_CALL = 100; /** The record a link hangs off. */ export type LinkSourceKind = - "encounter" | "document" | "vaccination" | "conditionEpisode"; + | "encounter" + | "document" + | "vaccination" + | "conditionEpisode" + | "medication" + | "labResult"; /** The record a link points at. */ export type LinkTargetKind = - "document" | "labResult" | "conditionEpisode" | "encounter" | "vaccination"; + | "document" + | "labResult" + | "conditionEpisode" + | "encounter" + | "vaccination" + | "medication"; export interface LinkRequest { userId: string; @@ -87,6 +97,12 @@ interface OwnedRowDelegate { interface EndpointSpec { /** The owning model's delegate, for the ownership re-read. */ delegate: (tx: Prisma.TransactionClient) => OwnedRowDelegate; + /** + * Whether the model carries a `deletedAt` tombstone. A medication does + * not (it is paused or ended, never tombstoned), so filtering it on the + * column would be a query error rather than a no-op. + */ + softDeletes: boolean; /** Columns the label and date are derived from. */ select: Record; label: (row: Record) => string; @@ -114,6 +130,11 @@ function narrow(delegate: unknown): T { return delegate as T; } +/** The "still live" filter for a model, empty for one without a tombstone. */ +function live(softDeletes: boolean): { deletedAt?: null } { + return softDeletes ? { deletedAt: null } : {}; +} + function asDate(value: unknown): string | null { return value instanceof Date ? value.toISOString() : null; } @@ -129,30 +150,48 @@ const SOURCES: Record< delegate: (tx: Prisma.TransactionClient) => OwnedRowDelegate; /** The link row's relation field back to the source, for a joined filter. */ relationField: string; + softDeletes: boolean; } > = { encounter: { delegate: (tx) => narrow(tx.encounter), relationField: "encounter", + softDeletes: true, }, document: { delegate: (tx) => narrow(tx.inboundDocument), relationField: "document", + softDeletes: true, }, vaccination: { delegate: (tx) => narrow(tx.vaccinationRecord), relationField: "vaccination", + softDeletes: true, }, - // A condition is a source only in the read direction of two tables that - // already exist (see the two `conditionEpisode:*` pairs below). + // A condition is a source in the read direction of the tables that file + // things against it (see the `conditionEpisode:*` pairs below). conditionEpisode: { delegate: (tx) => narrow(tx.illnessEpisode), relationField: "episode", + softDeletes: true, + }, + // v1.43 — a medication files its indication, its documents and its visits. + medication: { + delegate: (tx) => narrow(tx.medication), + relationField: "medication", + softDeletes: false, + }, + // v1.43 — a lab result files the report it was read from and its visits. + labResult: { + delegate: (tx) => narrow(tx.labResult), + relationField: "labResult", + softDeletes: true, }, }; const DOCUMENT_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.inboundDocument), + softDeletes: true, select: { id: true, title: true, @@ -171,6 +210,7 @@ const DOCUMENT_ENDPOINT: EndpointSpec = { const LAB_RESULT_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.labResult), + softDeletes: true, select: { id: true, analyte: true, panel: true, takenAt: true }, label: (row) => { const panel = asString(row.panel); @@ -182,6 +222,7 @@ const LAB_RESULT_ENDPOINT: EndpointSpec = { const ENCOUNTER_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.encounter), + softDeletes: true, select: { id: true, kind: true, occurredAt: true }, // The visit's KIND, as the enum constant, and this is the one endpoint whose // label is deliberately not prose. A document title and an analyte name are @@ -196,6 +237,7 @@ const ENCOUNTER_ENDPOINT: EndpointSpec = { const VACCINATION_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.vaccinationRecord), + softDeletes: true, select: { id: true, antigenSlug: true, vaccineName: true, occurredAt: true }, // The person's own wording first, then the catalogue slug. A caller that // renders a dose resolves the slug through its own bundle instead (see @@ -208,26 +250,38 @@ const VACCINATION_ENDPOINT: EndpointSpec = { const CONDITION_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.illnessEpisode), + softDeletes: true, select: { id: true, label: true, onsetAt: true }, label: (row) => String(row.label), date: (row) => asDate(row.onsetAt), }; +const MEDICATION_ENDPOINT: EndpointSpec = { + delegate: (tx) => narrow(tx.medication), + softDeletes: false, + select: { id: true, name: true, dose: true, startsOn: true, createdAt: true }, + label: (row) => { + const dose = asString(row.dose); + return dose ? `${String(row.name)} ${dose}` : String(row.name); + }, + date: (row) => asDate(row.startsOn) ?? asDate(row.createdAt), +}; + /** * Every legal (source, target) pair and the table behind it. * * A pair absent from here is not linkable, and asking for one is a programming * error rather than a user error — hence the throw in {@link tableFor} rather - * than a result field. Three is the number of encounter link tables this - * design allows; a fourth is the point at which this stops being a small set - * of named relations and starts being a general edge store. + * than a result field. * - * That ceiling is per source, and the `vaccination` source owns exactly ONE - * pair. A dose is administered once, so the practitioner and the visit attach - * by scalar FK rather than through a table; the many-to-many argument that - * justifies the lab and condition links does not transfer. A second - * vaccination pair is this becoming the edge store, and is the point to stop - * and raise it rather than to add a row here. + * Since v1.43 this is the storage half of the relation matrix: every pair of + * lab result, visit, document, condition, medication and vaccination either + * has a table here (written in one direction, read in both) or is a written + * exception in `src/lib/links/related.ts`, and `relation-matrix.test.ts` holds + * the two together. The earlier ceilings (three visit tables, one vaccination + * pair) were lifted by that decision rather than by drift: a visit that + * prescribed a medication or gave a dose was a dead end otherwise. Each table + * is still a named relation between two records, never a general edge store. */ const LINK_TABLES: Partial< Record<`${LinkSourceKind}:${LinkTargetKind}`, LinkTableSpec> @@ -305,6 +359,82 @@ const LINK_TABLES: Partial< targetColumn: "encounterId", target: ENCOUNTER_ENDPOINT, }, + + // ── v1.43 — the pairs that close the relation matrix ────────────────────── + // `lab_result_document_links`: the report a result was read from. Written + // from the result's links route and, one document to many results, by the + // scan commit. + "labResult:document": { + delegate: (tx) => narrow(tx.labResultDocumentLink), + sourceColumn: "labResultId", + targetColumn: "documentId", + target: DOCUMENT_ENDPOINT, + }, + "document:labResult": { + delegate: (tx) => narrow(tx.labResultDocumentLink), + sourceColumn: "documentId", + targetColumn: "labResultId", + target: LAB_RESULT_ENDPOINT, + }, + // `encounter_lab_links` read from the result's end. + "labResult:encounter": { + delegate: (tx) => narrow(tx.encounterLabLink), + sourceColumn: "labResultId", + targetColumn: "encounterId", + target: ENCOUNTER_ENDPOINT, + }, + // `encounter_medication_links`: prescribed, changed or reviewed at a visit. + "encounter:medication": { + delegate: (tx) => narrow(tx.encounterMedicationLink), + sourceColumn: "encounterId", + targetColumn: "medicationId", + target: MEDICATION_ENDPOINT, + }, + "medication:encounter": { + delegate: (tx) => narrow(tx.encounterMedicationLink), + sourceColumn: "medicationId", + targetColumn: "encounterId", + target: ENCOUNTER_ENDPOINT, + }, + // `encounter_vaccination_links`: a dose given at a visit. + "encounter:vaccination": { + delegate: (tx) => narrow(tx.encounterVaccinationLink), + sourceColumn: "encounterId", + targetColumn: "vaccinationId", + target: VACCINATION_ENDPOINT, + }, + "vaccination:encounter": { + delegate: (tx) => narrow(tx.encounterVaccinationLink), + sourceColumn: "vaccinationId", + targetColumn: "encounterId", + target: ENCOUNTER_ENDPOINT, + }, + // `medication_document_links`: a prescription, a plan, a package insert. + "medication:document": { + delegate: (tx) => narrow(tx.medicationDocumentLink), + sourceColumn: "medicationId", + targetColumn: "documentId", + target: DOCUMENT_ENDPOINT, + }, + "document:medication": { + delegate: (tx) => narrow(tx.medicationDocumentLink), + sourceColumn: "documentId", + targetColumn: "medicationId", + target: MEDICATION_ENDPOINT, + }, + // `medication_condition_links`: the indication. + "medication:conditionEpisode": { + delegate: (tx) => narrow(tx.medicationConditionLink), + sourceColumn: "medicationId", + targetColumn: "episodeId", + target: CONDITION_ENDPOINT, + }, + "conditionEpisode:medication": { + delegate: (tx) => narrow(tx.medicationConditionLink), + sourceColumn: "episodeId", + targetColumn: "medicationId", + target: MEDICATION_ENDPOINT, + }, }; function tableFor( @@ -340,8 +470,9 @@ async function ownsSource( sourceId, }: Omit, ): Promise { - const rows = await SOURCES[sourceKind].delegate(tx).findMany({ - where: { id: sourceId, userId, deletedAt: null }, + const source = SOURCES[sourceKind]; + const rows = await source.delegate(tx).findMany({ + where: { id: sourceId, userId, ...live(source.softDeletes) }, select: { id: true }, }); return rows.length === 1; @@ -356,7 +487,7 @@ async function ownedTargetIds( ): Promise> { if (targetIds.length === 0) return new Set(); const rows = await spec.target.delegate(tx).findMany({ - where: { id: { in: targetIds }, userId, deletedAt: null }, + where: { id: { in: targetIds }, userId, ...live(spec.target.softDeletes) }, select: { id: true }, }); return new Set(rows.map((row) => String(row.id))); @@ -527,7 +658,11 @@ export async function listTargets( // soft-delete filter belongs on the target, and a tombstoned row must drop // out of the list even though its link row survives the soft delete. const rows = await spec.target.delegate(tx).findMany({ - where: { id: { in: targetIds }, userId: request.userId, deletedAt: null }, + where: { + id: { in: targetIds }, + userId: request.userId, + ...live(spec.target.softDeletes), + }, select: spec.target.select, }); const byId = new Map(rows.map((row) => [String(row.id), row])); @@ -587,7 +722,7 @@ export async function listTargetsBySource( where: { id: { in: targetIds }, userId: request.userId, - ...(request.includeDeletedTargets ? {} : { deletedAt: null }), + ...(request.includeDeletedTargets ? {} : live(spec.target.softDeletes)), }, select: spec.target.select, }); @@ -626,12 +761,14 @@ export async function listDistinctTargets( }, ): Promise { const spec = tableFor(request.sourceKind, request.targetKind); - const sourceRelation = SOURCES[request.sourceKind].relationField; + const source = SOURCES[request.sourceKind]; const links = await spec.delegate(tx).findMany({ where: { userId: request.userId, - [sourceRelation]: { deletedAt: null }, + ...(source.softDeletes + ? { [source.relationField]: { deletedAt: null } } + : {}), }, select: { [spec.targetColumn]: true }, orderBy: { [spec.targetColumn]: "asc" }, diff --git a/src/lib/links/related.ts b/src/lib/links/related.ts new file mode 100644 index 000000000..77701f425 --- /dev/null +++ b/src/lib/links/related.ts @@ -0,0 +1,492 @@ +/** + * `related[]` on the six detail responses: every record the one shown points + * at, or is pointed at by, resolved on the server. + * + * The relation matrix (lab result, visit, document, condition, medication, + * vaccination; fifteen pairs) is declared once, in {@link RELATION_MATRIX}. + * Every pair is one of three things: + * + * - **stored**: a link table in `link-service.ts`, read in both directions; + * - **derived**: no table, the relation runs through something else the + * record already stores (a visit both are filed against, or the + * medication's efficacy target being this marker); + * - **exception**: no clinical relation the record stores, with the reason. + * + * `relation-matrix.test.ts` walks the fifteen pairs and fails when a pair has + * neither a table nor a reader nor a written exception, so a dead end between + * two features cannot come back quietly. + * + * Visibility is decided here, not at the call site: a kind is listed only when + * its module is on for the record and the caller's sharing grant covers its + * section. A derived relation through a visit also needs the visit's section, + * because the relation itself says the visit exists. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { decryptField } from "@/lib/encounters/dto"; +import { listTargets } from "@/lib/links/link-service"; +import type { LinkSourceKind, LinkTargetKind } from "@/lib/links/link-service"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import type { ModuleKey } from "@/lib/modules/registry"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import type { ShareDomain } from "@/lib/sharing/scope"; +import type { + RELATED_KINDS, + RELATED_VIA, +} from "@/lib/validations/relation-links"; + +export type RelatedKind = (typeof RELATED_KINDS)[number]; +export type RelatedVia = (typeof RELATED_VIA)[number]; + +/** One entry of `related[]`, the wire shape of `RelatedLink`. */ +export interface RelatedLink { + kind: RelatedKind; + id: string; + label: string; + date: string | null; + href: string; + via: RelatedVia; +} + +/** At most this many entries of one kind; a full panel stays readable. */ +export const RELATED_PER_KIND_CAP = 50; + +/** One cell of the matrix, seen from `from`. */ +export type RelationCell = + | { mode: "stored" } + | { mode: "derived"; via: Exclude } + | { mode: "exception"; reason: string }; + +type Pair = `${RelatedKind}:${RelatedKind}`; + +/** + * The fifteen unordered pairs, keyed in one direction. The reverse direction is + * the same cell; {@link relationCell} looks both up. + */ +export const RELATION_MATRIX: Readonly>> = { + "labResult:encounter": { mode: "stored" }, + "labResult:document": { mode: "stored" }, + "labResult:conditionEpisode": { mode: "derived", via: "encounter" }, + "labResult:medication": { mode: "derived", via: "efficacyTarget" }, + "labResult:vaccination": { + mode: "exception", + reason: + "A dose is not judged by a lab value the record stores; an antibody titre is a lab result in its own right and is filed against the visit or the report.", + }, + "encounter:document": { mode: "stored" }, + "encounter:conditionEpisode": { mode: "stored" }, + "encounter:medication": { mode: "stored" }, + "encounter:vaccination": { mode: "stored" }, + "document:conditionEpisode": { mode: "stored" }, + "document:medication": { mode: "stored" }, + "document:vaccination": { mode: "stored" }, + "conditionEpisode:medication": { mode: "stored" }, + "conditionEpisode:vaccination": { + mode: "exception", + reason: + "A vaccination prevents rather than treats, and the record stores no indication for a dose; the visit or the document carries the context.", + }, + "medication:vaccination": { + mode: "exception", + reason: + "A vaccine is logged in the immunization log, not as a medication, and nothing the record stores relates one to the other.", + }, +}; + +export function relationCell( + a: RelatedKind, + b: RelatedKind, +): RelationCell | undefined { + return RELATION_MATRIX[`${a}:${b}`] ?? RELATION_MATRIX[`${b}:${a}`]; +} + +/** The module that owns each kind; `null` for the visit, which is core. */ +export const RELATED_KIND_MODULE: Readonly< + Record +> = { + labResult: "labs", + encounter: null, + document: "inboundDocuments", + conditionEpisode: "illness", + medication: "medications", + vaccination: "vaccinations", +}; + +/** The sharing section each kind lives in. */ +export const RELATED_KIND_DOMAIN: Readonly> = { + labResult: "labs", + encounter: "profile", + document: "documents", + conditionEpisode: "illness", + medication: "medications", + // A dose lives in the health background, like the visit itself. + vaccination: "profile", +}; + +const ALL_KINDS: readonly RelatedKind[] = [ + "labResult", + "encounter", + "document", + "conditionEpisode", + "medication", + "vaccination", +]; + +/* ── hydration: one query per kind, label + date + href ──────────────────── */ + +interface Hydrated { + label: string; + date: string | null; + href: string; +} + +function iso(value: Date | null | undefined): string | null { + return value instanceof Date ? value.toISOString() : null; +} + +async function hydrate( + tx: Prisma.TransactionClient, + userId: string, + kind: RelatedKind, + ids: string[], +): Promise> { + const out = new Map(); + if (ids.length === 0) return out; + const scope = { id: { in: ids }, userId }; + switch (kind) { + case "labResult": { + const rows = await tx.labResult.findMany({ + where: { ...scope, deletedAt: null }, + select: { id: true, analyte: true, takenAt: true, biomarkerId: true }, + }); + for (const row of rows) { + out.set(row.id, { + label: row.analyte, + date: iso(row.takenAt), + href: row.biomarkerId + ? `/labs/${encodeURIComponent(row.biomarkerId)}` + : `/labs?analyte=${encodeURIComponent(row.analyte)}`, + }); + } + return out; + } + case "encounter": { + const rows = await tx.encounter.findMany({ + where: { ...scope, deletedAt: null }, + select: { + id: true, + kind: true, + occurredAt: true, + reasonEncrypted: true, + practitioner: { select: { name: true } }, + }, + }); + for (const row of rows) { + out.set(row.id, { + // The person's own reason first, then who they saw, then the + // visit's kind as its enum constant (the one closed value a client + // names from its own bundle). + label: + decryptField(row.reasonEncrypted, "reason") ?? + row.practitioner?.name ?? + row.kind, + date: iso(row.occurredAt), + href: `/checkups?visit=${encodeURIComponent(row.id)}`, + }); + } + return out; + } + case "document": { + const rows = await tx.inboundDocument.findMany({ + where: { ...scope, deletedAt: null }, + select: { + id: true, + title: true, + filename: true, + documentDate: true, + reportDate: true, + createdAt: true, + }, + }); + for (const row of rows) { + out.set(row.id, { + label: row.title || row.filename || row.id, + date: + iso(row.documentDate) ?? iso(row.reportDate) ?? iso(row.createdAt), + href: `/documents?doc=${encodeURIComponent(row.id)}`, + }); + } + return out; + } + case "conditionEpisode": { + const rows = await tx.illnessEpisode.findMany({ + where: { ...scope, deletedAt: null }, + select: { id: true, label: true, onsetAt: true }, + }); + for (const row of rows) { + out.set(row.id, { + label: row.label, + date: iso(row.onsetAt), + href: `/illness/${encodeURIComponent(row.id)}`, + }); + } + return out; + } + case "medication": { + const rows = await tx.medication.findMany({ + where: scope, + select: { + id: true, + name: true, + dose: true, + startsOn: true, + createdAt: true, + }, + }); + for (const row of rows) { + out.set(row.id, { + label: row.dose ? `${row.name} ${row.dose}` : row.name, + date: iso(row.startsOn) ?? iso(row.createdAt), + href: `/medications/${encodeURIComponent(row.id)}`, + }); + } + return out; + } + case "vaccination": { + const rows = await tx.vaccinationRecord.findMany({ + where: { ...scope, deletedAt: null }, + select: { + id: true, + vaccineName: true, + antigenSlug: true, + occurredAt: true, + customVaccine: { select: { name: true } }, + }, + }); + for (const row of rows) { + out.set(row.id, { + label: + row.vaccineName ?? + row.customVaccine?.name ?? + row.antigenSlug ?? + row.id, + date: iso(row.occurredAt), + href: `/vaccinations?dose=${encodeURIComponent(row.id)}`, + }); + } + return out; + } + } +} + +/* ── readers: the ids related to one record, per kind ────────────────────── */ + +interface Candidate { + kind: RelatedKind; + id: string; + via: RelatedVia; +} + +async function stored( + tx: Prisma.TransactionClient, + userId: string, + from: RelatedKind, + sourceId: string, + to: RelatedKind, +): Promise { + const rows = await listTargets(tx, { + userId, + sourceKind: from as LinkSourceKind, + sourceId, + targetKind: to as LinkTargetKind, + }); + return rows.map((row) => ({ kind: to, id: row.id, via: "direct" })); +} + +/** The things filed against the visits this record is filed against. */ +async function throughEncounters( + tx: Prisma.TransactionClient, + userId: string, + from: "labResult" | "conditionEpisode", + sourceId: string, + to: "labResult" | "conditionEpisode", +): Promise { + const visits = await listTargets(tx, { + userId, + sourceKind: from, + sourceId, + targetKind: "encounter", + }); + const seen = new Set(); + const out: Candidate[] = []; + for (const visit of visits) { + const rows = await listTargets(tx, { + userId, + sourceKind: "encounter", + sourceId: visit.id, + targetKind: to, + }); + for (const row of rows) { + if (row.id === sourceId || seen.has(row.id)) continue; + seen.add(row.id); + out.push({ kind: to, id: row.id, via: "encounter" }); + } + } + return out; +} + +/** Medications whose efficacy target is this result's marker. */ +async function medicationsTargetingLab( + tx: Prisma.TransactionClient, + userId: string, + labResultId: string, +): Promise { + const lab = await tx.labResult.findFirst({ + where: { id: labResultId, userId, deletedAt: null }, + select: { biomarkerId: true }, + }); + if (!lab?.biomarkerId) return []; + const targets = await tx.medicationEfficacyTarget.findMany({ + where: { biomarkerId: lab.biomarkerId, medication: { userId } }, + select: { medicationId: true }, + orderBy: { createdAt: "asc" }, + }); + return [...new Set(targets.map((t) => t.medicationId))].map((id) => ({ + kind: "medication" as const, + id, + via: "efficacyTarget" as const, + })); +} + +/** The newest reading of each marker this medication targets. */ +async function labsTargetedByMedication( + tx: Prisma.TransactionClient, + userId: string, + medicationId: string, +): Promise { + const targets = await tx.medicationEfficacyTarget.findMany({ + where: { + medicationId, + medication: { userId }, + biomarkerId: { not: null }, + }, + select: { biomarkerId: true }, + }); + const biomarkerIds = [ + ...new Set(targets.flatMap((t) => (t.biomarkerId ? [t.biomarkerId] : []))), + ]; + if (biomarkerIds.length === 0) return []; + const rows = await tx.labResult.findMany({ + where: { userId, deletedAt: null, biomarkerId: { in: biomarkerIds } }, + select: { id: true, biomarkerId: true }, + orderBy: { takenAt: "desc" }, + distinct: ["biomarkerId"], + }); + return rows.map((row) => ({ + kind: "labResult" as const, + id: row.id, + via: "efficacyTarget" as const, + })); +} + +type Reader = ( + tx: Prisma.TransactionClient, + userId: string, + sourceId: string, +) => Promise; + +/** + * The reader of every derived cell, in both directions. A derived cell + * without both readers here is a dead end, which `relation-matrix.test.ts` + * refuses. + */ +export const DERIVED_READERS: Readonly>> = { + "labResult:conditionEpisode": (tx, userId, id) => + throughEncounters(tx, userId, "labResult", id, "conditionEpisode"), + "conditionEpisode:labResult": (tx, userId, id) => + throughEncounters(tx, userId, "conditionEpisode", id, "labResult"), + "labResult:medication": medicationsTargetingLab, + "medication:labResult": labsTargetedByMedication, +}; + +async function readCell( + tx: Prisma.TransactionClient, + userId: string, + from: RelatedKind, + sourceId: string, + to: RelatedKind, + cell: RelationCell, +): Promise { + if (cell.mode === "exception") return []; + if (cell.mode === "stored") return stored(tx, userId, from, sourceId, to); + const reader = DERIVED_READERS[`${from}:${to}`]; + return reader ? reader(tx, userId, sourceId) : []; +} + +/** + * The `related` list for one record. + * + * `userId` is the record's owner (the one `requireRecordAuth` resolved), and + * `grantId` the grant the caller acts under, or `null` for the owner. The + * record itself must already have been read and authorised by the caller. + */ +export async function buildRelated( + tx: Prisma.TransactionClient, + input: { + userId: string; + grantId: string | null; + kind: RelatedKind; + id: string; + }, +): Promise { + const [visible, modules] = await Promise.all([ + actingDomainVisibility(tx, input.grantId), + resolveModuleMap(input.userId), + ]); + const shown = (kind: RelatedKind) => { + const mod = RELATED_KIND_MODULE[kind]; + return (mod === null || modules[mod]) && visible(RELATED_KIND_DOMAIN[kind]); + }; + + const out: RelatedLink[] = []; + for (const to of ALL_KINDS) { + if (to === input.kind || !shown(to)) continue; + const cell = relationCell(input.kind, to); + if (!cell) continue; + // A relation through a visit says the visit exists, so it needs the + // visit's own visibility as well. + if ( + cell.mode === "derived" && + cell.via === "encounter" && + !shown("encounter") + ) { + continue; + } + const candidates = ( + await readCell(tx, input.userId, input.kind, input.id, to, cell) + ).slice(0, RELATED_PER_KIND_CAP); + const hydrated = await hydrate( + tx, + input.userId, + to, + candidates.map((c) => c.id), + ); + for (const candidate of candidates) { + const row = hydrated.get(candidate.id); + if (!row) continue; + out.push({ kind: to, id: candidate.id, via: candidate.via, ...row }); + } + } + return out; +} + +/** + * `{ ...detail, related }` for a detail route, so each route adds one call. + */ +export async function withRelated( + tx: Prisma.TransactionClient, + detail: T, + input: Parameters[1], +): Promise { + return { ...detail, related: await buildRelated(tx, input) }; +} diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 0b1c06033..bd6bade34 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -552,7 +552,7 @@ export const encounterPaths: NonNullable = { tags: ["Records"], summary: "File targets against a visit", description: - "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document link and unlink.", + "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document link and unlink. Since v1.43 `targetKind` also takes `medication` and `vaccination`; those links show in the visit's `related` list (`GET /api/encounters/{id}`), not in `links`.", requestParams: idPath, requestBody: { required: true, diff --git a/src/lib/openapi/routes/relation-links.ts b/src/lib/openapi/routes/relation-links.ts index 25fde948b..becbde597 100644 --- a/src/lib/openapi/routes/relation-links.ts +++ b/src/lib/openapi/routes/relation-links.ts @@ -16,12 +16,7 @@ import { medicationLinkSchema, } from "@/lib/validations/relation-links"; -import { - dataEnvelope, - errorEnvelope, - notImplementedResponse, - stdResponses, -} from "./shared"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; export const relatedLink = z .object({ @@ -118,7 +113,6 @@ function linkOperations( content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, - ...notImplementedResponse, }, }); return { [path]: { post: op("post"), delete: op("delete") } }; diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 35ccdeafd..1cb6c1730 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -628,9 +628,9 @@ export const vaccinationPaths: NonNullable = { "/api/vaccinations/{id}/links": { post: { tags: ["Records"], - summary: "File documents against a vaccination", + summary: "File documents or a visit against a vaccination", description: - "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document and visit link and unlink.", + "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document and visit link and unlink. Since v1.43 `targetKind` also takes `encounter` (the visit the dose was given at); that link shows in the dose's `related` list (`GET /api/vaccinations/{id}`), not in `documents`.", requestParams: idPath, requestBody: { required: true, @@ -657,7 +657,7 @@ export const vaccinationPaths: NonNullable = { }, delete: { tags: ["Records"], - summary: "Unfile documents from a vaccination", + summary: "Unfile documents or a visit from a vaccination", description: "Removes links. Idempotent, and it still works for a page the account has since deleted — refusing would leave a filing nobody can clear.", requestParams: idPath, diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 4d8126699..5bd8f3cb3 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -217,6 +217,9 @@ export const encounterLinkTargetEnum = z.enum([ "document", "labResult", "conditionEpisode", + // v1.43 — prescribed, changed or reviewed at the visit; a dose given there. + "medication", + "vaccination", ]); export const encounterLinkSchema = z diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts index 1299afa87..4155b7564 100644 --- a/src/lib/validations/vaccinations.ts +++ b/src/lib/validations/vaccinations.ts @@ -253,13 +253,11 @@ export type CustomVaccineUpdate = z.infer; /* ── links ────────────────────────────────────────────────────────── */ /** - * The one link family a dose has. - * - * A single-member enum rather than a bare constant, so the wire shape matches - * the encounter link route's and a second family — if this venture ever loses - * the argument against one — is an added member rather than a changed shape. + * The link families a dose has: the page it was transcribed from and, since + * v1.43, the visit it was given at (`encounter_vaccination_links`, the same + * table the visit's own links route writes from its end). */ -export const vaccinationLinkTargetEnum = z.enum(["document"]); +export const vaccinationLinkTargetEnum = z.enum(["document", "encounter"]); export const vaccinationLinkSchema = z .object({ diff --git a/tests/integration/relation-links.test.ts b/tests/integration/relation-links.test.ts new file mode 100644 index 000000000..def5458c9 --- /dev/null +++ b/tests/integration/relation-links.test.ts @@ -0,0 +1,481 @@ +/** + * The relation links and `related[]` against real Postgres (v1.43). + * + * What is asserted here lives in `where` clauses and joins: the new link + * pairs refuse another account's ids, both detail pages of a stored pair list + * each other, the derived pairs run through a visit and through a medication's + * efficacy target, a module that is off and a grant that does not cover a + * section hide their kind, and the backup carries the five tables both ways. + * A mocked client would report every one of them as working. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { + buildRelationLinksBackupSection, + restoreRelationLinksData, +} from "@/lib/export/relation-links-backup"; +import type { RestoreSkipLog } from "@/lib/export/restore-skips"; +import { linkTargets, unlinkTargets } from "@/lib/links"; +import { buildRelated } from "@/lib/links/related"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "relation-owner"; +const STRANGER = "relation-stranger"; +const READER = "relation-reader"; + +async function seedAccount(id: string) { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { id, username: id, email: `${id}@example.test` }, + }); + const encounter = await prisma.encounter.create({ + data: { + userId: id, + occurredAt: new Date("2026-07-01T09:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + }, + }); + const document = await prisma.inboundDocument.create({ + data: { + userId: id, + kind: "LAB_RESULT", + title: `${id} report`, + mimeType: "application/pdf", + byteSize: 12, + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + documentDate: new Date("2026-06-30T00:00:00.000Z"), + }, + }); + const biomarker = await prisma.biomarker.create({ + data: { userId: id, name: "TSH", unit: "mU/L" }, + }); + const labResult = await prisma.labResult.create({ + data: { + userId: id, + biomarkerId: biomarker.id, + analyte: "TSH", + value: 2.1, + unit: "mU/L", + takenAt: new Date("2026-06-30T09:00:00.000Z"), + }, + }); + const episode = await prisma.illnessEpisode.create({ + data: { + userId: id, + label: `${id} thyroid`, + type: "CHRONIC", + onsetAt: new Date("2026-01-20T00:00:00.000Z"), + }, + }); + const medication = await prisma.medication.create({ + data: { userId: id, name: "Levothyroxine", dose: "50 µg" }, + }); + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId: id, + occurredAt: new Date("2026-05-14T00:00:00.000Z"), + antigenSlug: "tdap", + vaccineName: "Tdap", + }, + }); + return { + encounter, + document, + biomarker, + labResult, + episode, + medication, + vaccination, + }; +} + +let owner: Awaited>; +let stranger: Awaited>; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + owner = await seedAccount(OWNER); + stranger = await seedAccount(STRANGER); +}); + +const related = ( + kind: Parameters[1]["kind"], + id: string, +) => + buildRelated(getPrismaClient(), { userId: OWNER, grantId: null, kind, id }); + +describe("new link pairs", () => { + it("files a medication's indication, document and visit, and refuses foreign ids", async () => { + const prisma = getPrismaClient(); + const cases = [ + ["conditionEpisode", owner.episode.id, stranger.episode.id], + ["document", owner.document.id, stranger.document.id], + ["encounter", owner.encounter.id, stranger.encounter.id], + ] as const; + for (const [targetKind, mine, theirs] of cases) { + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "medication", + sourceId: owner.medication.id, + targetKind, + targetIds: [mine, theirs], + }); + expect(result.changed, targetKind).toBe(1); + expect(result.unknownTargetIds, targetKind).toEqual([theirs]); + } + expect(await prisma.medicationConditionLink.count()).toBe(1); + expect(await prisma.medicationDocumentLink.count()).toBe(1); + expect(await prisma.encounterMedicationLink.count()).toBe(1); + + // Re-posting is a success that changes nothing. + const again = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "medication", + sourceId: owner.medication.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(again.changed).toBe(0); + }); + + it("refuses another account's medication as the source", async () => { + const result = await linkTargets(getPrismaClient(), { + userId: OWNER, + sourceKind: "medication", + sourceId: stranger.medication.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(result.unknownSource).toBe(true); + }); + + it("files a lab result against its report and visit, and unfiles it", async () => { + const prisma = getPrismaClient(); + for (const [targetKind, id] of [ + ["document", owner.document.id], + ["encounter", owner.encounter.id], + ] as const) { + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "labResult", + sourceId: owner.labResult.id, + targetKind, + targetIds: [id], + }); + expect(result.changed, targetKind).toBe(1); + } + expect(await prisma.labResultDocumentLink.count()).toBe(1); + expect(await prisma.encounterLabLink.count()).toBe(1); + + const removed = await unlinkTargets(prisma, { + userId: OWNER, + sourceKind: "labResult", + sourceId: owner.labResult.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(removed.changed).toBe(1); + expect(await prisma.labResultDocumentLink.count()).toBe(0); + }); + + it("files a dose against a visit from either end, into one table", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "encounter", + targetIds: [owner.encounter.id], + }); + const fromVisit = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "vaccination", + targetIds: [owner.vaccination.id], + }); + expect(fromVisit.changed).toBe(0); + expect(await prisma.encounterVaccinationLink.count()).toBe(1); + }); +}); + +describe("related[]", () => { + it("lists a stored pair on both detail pages, with its web path", async () => { + await linkTargets(getPrismaClient(), { + userId: OWNER, + sourceKind: "medication", + sourceId: owner.medication.id, + targetKind: "conditionEpisode", + targetIds: [owner.episode.id], + }); + + const fromMedication = await related("medication", owner.medication.id); + expect(fromMedication).toContainEqual({ + kind: "conditionEpisode", + id: owner.episode.id, + label: `${OWNER} thyroid`, + date: "2026-01-20T00:00:00.000Z", + href: `/illness/${owner.episode.id}`, + via: "direct", + }); + + const fromCondition = await related("conditionEpisode", owner.episode.id); + expect(fromCondition).toContainEqual( + expect.objectContaining({ + kind: "medication", + id: owner.medication.id, + label: "Levothyroxine 50 µg", + href: `/medications/${owner.medication.id}`, + via: "direct", + }), + ); + }); + + it("derives a lab result's condition through the visit both are filed against", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "labResult", + targetIds: [owner.labResult.id], + }); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "conditionEpisode", + targetIds: [owner.episode.id], + }); + + const fromLab = await related("labResult", owner.labResult.id); + expect(fromLab).toContainEqual( + expect.objectContaining({ + kind: "conditionEpisode", + id: owner.episode.id, + via: "encounter", + }), + ); + expect(fromLab).toContainEqual( + expect.objectContaining({ + kind: "encounter", + id: owner.encounter.id, + href: `/checkups?visit=${owner.encounter.id}`, + via: "direct", + }), + ); + const fromCondition = await related("conditionEpisode", owner.episode.id); + expect(fromCondition).toContainEqual( + expect.objectContaining({ + kind: "labResult", + id: owner.labResult.id, + href: `/labs/${owner.biomarker.id}`, + via: "encounter", + }), + ); + }); + + it("derives a medication and its marker's newest reading through the efficacy target", async () => { + const prisma = getPrismaClient(); + await prisma.medicationEfficacyTarget.create({ + data: { + medicationId: owner.medication.id, + biomarkerId: owner.biomarker.id, + }, + }); + const older = await prisma.labResult.create({ + data: { + userId: OWNER, + biomarkerId: owner.biomarker.id, + analyte: "TSH", + value: 4.4, + unit: "mU/L", + takenAt: new Date("2026-01-30T09:00:00.000Z"), + }, + }); + + const fromLab = await related("labResult", older.id); + expect(fromLab).toContainEqual( + expect.objectContaining({ + kind: "medication", + id: owner.medication.id, + via: "efficacyTarget", + }), + ); + const fromMedication = await related("medication", owner.medication.id); + const labs = fromMedication.filter((r) => r.kind === "labResult"); + expect(labs).toEqual([ + expect.objectContaining({ + id: owner.labResult.id, + via: "efficacyTarget", + }), + ]); + }); + + it("never lists another account's records, nor a deleted one", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "document", + sourceId: owner.document.id, + targetKind: "labResult", + targetIds: [owner.labResult.id, stranger.labResult.id], + }); + expect(await related("document", owner.document.id)).toEqual([ + expect.objectContaining({ kind: "labResult", id: owner.labResult.id }), + ]); + + await prisma.labResult.update({ + where: { id: owner.labResult.id }, + data: { deletedAt: new Date() }, + }); + expect(await related("document", owner.document.id)).toEqual([]); + }); + + it("hides a kind whose module is off", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "medication", + targetIds: [owner.medication.id], + }); + expect( + (await related("encounter", owner.encounter.id)).map((r) => r.kind), + ).toContain("medication"); + + await prisma.user.update({ + where: { id: OWNER }, + data: { modulePreferencesJson: { medications: false } }, + }); + expect( + (await related("encounter", owner.encounter.id)).map((r) => r.kind), + ).not.toContain("medication"); + }); + + it("hides a kind the caller's grant does not cover", async () => { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { id: READER, username: READER, email: `${READER}@example.test` }, + }); + const grant = await prisma.accountGrant.create({ + data: { + grantorId: OWNER, + granteeId: READER, + access: "READ", + acceptedAt: new Date(), + scopeJson: ["documents"], + }, + }); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "document", + sourceId: owner.document.id, + targetKind: "medication", + targetIds: [owner.medication.id], + }); + + const asOwner = await related("document", owner.document.id); + expect(asOwner.map((r) => r.kind)).toContain("medication"); + const asReader = await buildRelated(prisma, { + userId: OWNER, + grantId: grant.id, + kind: "document", + id: owner.document.id, + }); + expect(asReader).toEqual([]); + }); +}); + +describe("backup of the five relation link tables", () => { + it("carries every table out and back, and reports a link whose end is gone", async () => { + const prisma = getPrismaClient(); + const link = ( + sourceKind: Parameters[1]["sourceKind"], + sourceId: string, + targetKind: Parameters[1]["targetKind"], + targetId: string, + ) => + linkTargets(prisma, { + userId: OWNER, + sourceKind, + sourceId, + targetKind, + targetIds: [targetId], + }); + await link("labResult", owner.labResult.id, "document", owner.document.id); + await link( + "encounter", + owner.encounter.id, + "medication", + owner.medication.id, + ); + await link( + "encounter", + owner.encounter.id, + "vaccination", + owner.vaccination.id, + ); + await link( + "medication", + owner.medication.id, + "document", + owner.document.id, + ); + await link( + "medication", + owner.medication.id, + "conditionEpisode", + owner.episode.id, + ); + + const section = await buildRelationLinksBackupSection(prisma, OWNER, { + purpose: "disaster-recovery", + }); + for (const [key, rows] of Object.entries(section)) { + expect(rows, key).toHaveLength(1); + } + + // A file naming a medication this account does not have. + const edited = { + ...section, + medicationDocumentLinks: [ + ...section.medicationDocumentLinks, + { + medicationId: "medication-not-in-this-file", + documentId: owner.document.id, + createdAt: "2026-07-01T00:00:00.000Z", + }, + ], + }; + const skips: RestoreSkipLog = []; + const cleared = await prisma.$transaction((tx) => + restoreRelationLinksData(tx, OWNER, edited, skips), + ); + expect(cleared.relationLinks).toBe(5); + expect(await prisma.labResultDocumentLink.count()).toBe(1); + expect(await prisma.encounterMedicationLink.count()).toBe(1); + expect(await prisma.encounterVaccinationLink.count()).toBe(1); + expect(await prisma.medicationDocumentLink.count()).toBe(1); + expect(await prisma.medicationConditionLink.count()).toBe(1); + // The original filing instant survives the trip. + const restored = await prisma.medicationConditionLink.findFirstOrThrow(); + expect(restored.createdAt.toISOString()).toBe( + section.medicationConditionLinks[0].createdAt, + ); + expect(skips).toEqual([ + { + catalogue: "relationLink", + key: "medication-not-in-this-file", + links: 1, + }, + ]); + }); +}); From 1481e38fae4c5b3ad5f7b30d6acd00fb4df1aad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 00:25:04 +0200 Subject: [PATCH 4/4] feat(links): show related records and file the new pairs on the web A shared related-records component renders related[] as rows that open the server-resolved path, with how a derived relation runs. The medication overview lists them and, in the account's own record, files its condition, documents and visits; the visit sheet files medications and doses. The condition page, the document sheet and the dose sheet list what their own blocks do not already show. A dose's visit named by the form's field counts as the same relation as the link table. --- messages/de.json | 24 + messages/en.json | 24 + messages/es.json | 24 + messages/fr.json | 24 + messages/it.json | 24 + messages/ko.json | 24 + messages/pl.json | 24 + .../destructive-control-guard.test.ts | 14 + .../documents/document-detail-sheet.tsx | 10 + .../encounters/encounter-related-links.tsx | 66 +++ src/components/encounters/encounter-sheet.tsx | 7 + .../illness/illness-episode-detail.tsx | 12 + .../links/__tests__/related-records.test.tsx | 76 +++ src/components/links/related-records.tsx | 527 ++++++++++++++++++ .../detail/medication-detail-tabs.tsx | 10 + .../detail/medication-related-section.tsx | 61 ++ .../vaccinations/vaccination-sheet.tsx | 12 + src/lib/links/related.ts | 20 +- tests/integration/relation-links.test.ts | 24 + 19 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 src/components/encounters/encounter-related-links.tsx create mode 100644 src/components/links/__tests__/related-records.test.tsx create mode 100644 src/components/links/related-records.tsx create mode 100644 src/components/medications/detail/medication-related-section.tsx diff --git a/messages/de.json b/messages/de.json index 319ae5a90..220f3240b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12342,6 +12342,30 @@ "period": { "showing": "Zeitraum: {period}" } + }, + "related": { + "title": "Verwandt", + "kind": { + "labResult": "Laborwert", + "encounter": "Besuch", + "document": "Dokument", + "conditionEpisode": "Erkrankung", + "medication": "Medikament", + "vaccination": "Impfung" + }, + "via": { + "encounter": "über einen Besuch", + "efficacyTarget": "über das Wirkziel" + }, + "pick": { + "conditionEpisode": "Erkrankungen", + "document": "Dokumente", + "encounter": "Besuche", + "medication": "Medikamente", + "vaccination": "Impfungen" + }, + "nothingToOffer": "Noch nichts zum Verknüpfen.", + "saveError": "Die Verknüpfung konnte nicht gespeichert werden." } }, "bodySites": { diff --git a/messages/en.json b/messages/en.json index a53ab20cd..af02d7890 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12342,6 +12342,30 @@ "period": { "showing": "Showing {period}" } + }, + "related": { + "title": "Related", + "kind": { + "labResult": "Lab result", + "encounter": "Visit", + "document": "Document", + "conditionEpisode": "Condition", + "medication": "Medication", + "vaccination": "Vaccination" + }, + "via": { + "encounter": "through a visit", + "efficacyTarget": "through the efficacy target" + }, + "pick": { + "conditionEpisode": "Conditions", + "document": "Documents", + "encounter": "Visits", + "medication": "Medications", + "vaccination": "Vaccinations" + }, + "nothingToOffer": "Nothing to link yet.", + "saveError": "The link could not be saved." } }, "bodySites": { diff --git a/messages/es.json b/messages/es.json index bc30b1b5c..1e5ea3cd1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12342,6 +12342,30 @@ "period": { "showing": "Mostrando {period}" } + }, + "related": { + "title": "Relacionado", + "kind": { + "labResult": "Resultado de laboratorio", + "encounter": "Consulta", + "document": "Documento", + "conditionEpisode": "Enfermedad", + "medication": "Medicamento", + "vaccination": "Vacuna" + }, + "via": { + "encounter": "a través de una consulta", + "efficacyTarget": "a través del objetivo de eficacia" + }, + "pick": { + "conditionEpisode": "Enfermedades", + "document": "Documentos", + "encounter": "Consultas", + "medication": "Medicamentos", + "vaccination": "Vacunas" + }, + "nothingToOffer": "Todavía no hay nada que vincular.", + "saveError": "No se pudo guardar el vínculo." } }, "bodySites": { diff --git a/messages/fr.json b/messages/fr.json index e624f5a3b..89798f425 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12342,6 +12342,30 @@ "period": { "showing": "Période : {period}" } + }, + "related": { + "title": "Liés", + "kind": { + "labResult": "Résultat d’analyse", + "encounter": "Consultation", + "document": "Document", + "conditionEpisode": "Maladie", + "medication": "Médicament", + "vaccination": "Vaccination" + }, + "via": { + "encounter": "via une consultation", + "efficacyTarget": "via la cible d’efficacité" + }, + "pick": { + "conditionEpisode": "Maladies", + "document": "Documents", + "encounter": "Consultations", + "medication": "Médicaments", + "vaccination": "Vaccinations" + }, + "nothingToOffer": "Rien à lier pour l’instant.", + "saveError": "Le lien n’a pas pu être enregistré." } }, "bodySites": { diff --git a/messages/it.json b/messages/it.json index eee5718db..64ae2ac4c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12342,6 +12342,30 @@ "period": { "showing": "Periodo: {period}" } + }, + "related": { + "title": "Correlati", + "kind": { + "labResult": "Valore di laboratorio", + "encounter": "Visita", + "document": "Documento", + "conditionEpisode": "Malattia", + "medication": "Farmaco", + "vaccination": "Vaccinazione" + }, + "via": { + "encounter": "tramite una visita", + "efficacyTarget": "tramite l’obiettivo di efficacia" + }, + "pick": { + "conditionEpisode": "Malattie", + "document": "Documenti", + "encounter": "Visite", + "medication": "Farmaci", + "vaccination": "Vaccinazioni" + }, + "nothingToOffer": "Ancora niente da collegare.", + "saveError": "Non è stato possibile salvare il collegamento." } }, "bodySites": { diff --git a/messages/ko.json b/messages/ko.json index e78bac6cf..a1dc11c14 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12342,6 +12342,30 @@ "period": { "showing": "{period} 표시 중" } + }, + "related": { + "title": "관련 항목", + "kind": { + "labResult": "검사 결과", + "encounter": "진료", + "document": "문서", + "conditionEpisode": "질환", + "medication": "약", + "vaccination": "예방접종" + }, + "via": { + "encounter": "진료를 통해", + "efficacyTarget": "효과 목표를 통해" + }, + "pick": { + "conditionEpisode": "질환", + "document": "문서", + "encounter": "진료", + "medication": "약", + "vaccination": "예방접종" + }, + "nothingToOffer": "아직 연결할 항목이 없습니다.", + "saveError": "연결을 저장하지 못했습니다." } }, "bodySites": { diff --git a/messages/pl.json b/messages/pl.json index 1a9c56897..93796098e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12342,6 +12342,30 @@ "period": { "showing": "Okres: {period}" } + }, + "related": { + "title": "Powiązane", + "kind": { + "labResult": "Wynik badania", + "encounter": "Wizyta", + "document": "Dokument", + "conditionEpisode": "Choroba", + "medication": "Lek", + "vaccination": "Szczepienie" + }, + "via": { + "encounter": "przez wizytę", + "efficacyTarget": "przez cel skuteczności" + }, + "pick": { + "conditionEpisode": "Choroby", + "document": "Dokumenty", + "encounter": "Wizyty", + "medication": "Leki", + "vaccination": "Szczepienia" + }, + "nothingToOffer": "Na razie nie ma czego powiązać.", + "saveError": "Nie udało się zapisać powiązania." } }, "bodySites": { diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index a6284a586..d1f96bf77 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -256,6 +256,20 @@ const REGISTRY: DestructiveEntry[] = [ }, ], }, + { + file: "components/links/related-records.tsx", + destroys: + "a relation link between two of the person's own records (a medication and its condition, document or visit; a visit and a medication or dose)", + recovery: "permanent", + confirm: [], + unconfirmed: [ + { + control: "remove a chip in a relation link picker", + reason: + "Removing a chip deletes one join row and nothing the person wrote: both records stay untouched, the unlink toast names what was unlinked, and the same picker re-files it with one tap. This is the shape every link picker in the app already has (the visit form, the document sheet, the dose form), and a dialog on one of them alone would make the same act cost two different amounts.", + }, + ], + }, { file: "components/insights/coach-panel/coach-conversation.tsx", destroys: diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 56052eb88..b11eab8cb 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -86,6 +86,8 @@ import { import { DocumentAiSection } from "./document-ai-section"; import { DocumentEncounterSuggestion } from "./document-encounter-suggestion"; import { DocumentRecordLinks } from "./document-record-links"; +import { RelatedRecordsBlock } from "@/components/links/related-records"; +import type { RelatedLink } from "@/lib/links/related"; import { DocumentFactsSection } from "./document-facts-review"; import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion"; import { DocumentSummaryBlock } from "./document-summary-block"; @@ -1203,6 +1205,14 @@ export function DocumentDetailSheet({ onChange={(part) => patch.mutate(part)} /> + {/* v1.43 — the lab results read from this page and the + medications it belongs to. Conditions, visits and doses + have their own blocks above. */} + + {/* Staged-facts review + the stored-text extract recovery. The existing extract/confirm chain finally gets its control on the document itself: pending facts open the review, a read diff --git a/src/components/encounters/encounter-related-links.tsx b/src/components/encounters/encounter-related-links.tsx new file mode 100644 index 000000000..4114b5a1a --- /dev/null +++ b/src/components/encounters/encounter-related-links.tsx @@ -0,0 +1,66 @@ +"use client"; + +/** + * The visit sheet's v1.43 relation links: the medications prescribed, + * changed or reviewed at the visit and the doses given there, plus whatever + * else the visit relates to that the form above does not already pick. + * + * Unlike the form's documents, lab results and conditions (a replace-set sent + * with Save), these two write straight through the visit's links route when + * picked, so they need a saved visit and render in edit mode only. + */ +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + RelatedRecordsBlock, + RelationLinkPickers, +} from "@/components/links/related-records"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { RelatedLink } from "@/lib/links/related"; + +export function EncounterRelatedLinks({ + encounterId, + occurredAt, +}: { + encounterId: string; + occurredAt: string | null; +}) { + const qc = useQueryClient(); + const detail = useQuery({ + queryKey: queryKeys.encounter(encounterId), + queryFn: () => + apiGet<{ related?: RelatedLink[] }>( + `/api/encounters/${encodeURIComponent(encounterId)}`, + ), + }); + // The pickers seed from this read; before it lands they would offer an + // empty selection that the first tap could not compute a change from. + if (!detail.data) return null; + const related = detail.data.related; + return ( +
+ + { + // The other end lists the visit too. + for (const queryKey of [ + queryKeys.encounter(encounterId), + queryKeys.medications(), + queryKeys.vaccinations(), + ]) { + void qc.invalidateQueries({ queryKey }); + } + }} + /> +
+ ); +} diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 654c6ac8f..4c0eefcd0 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -60,6 +60,7 @@ import { nowLocalValue, type EncounterDraft, } from "./encounter-form"; +import { EncounterRelatedLinks } from "./encounter-related-links"; export function EncounterSheet({ open, @@ -163,6 +164,12 @@ export function EncounterSheet({ >
+ {isEdit ? ( + + ) : null} {error ? (

{error} diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 91e36a332..bad38802b 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -14,6 +14,8 @@ import Link from "next/link"; import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card"; import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card"; +import { RelatedRecordsCard } from "@/components/links/related-records"; +import type { RelatedLink } from "@/lib/links/related"; import { EpisodeSymptomsCard } from "@/components/symptoms/episode-symptoms-card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -204,6 +206,16 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { so the reverse match would be wrong often enough to train the person to ignore every suggestion in the product. */} {episode ? : null} + + {/* v1.43 — what else the condition relates to: the medications that + treat it and the lab results it shares a visit with. Documents and + visits have their own cards above. */} + {episode ? ( + + ) : null} {episode ? ( diff --git a/src/components/links/__tests__/related-records.test.tsx b/src/components/links/__tests__/related-records.test.tsx new file mode 100644 index 000000000..5639e5ba9 --- /dev/null +++ b/src/components/links/__tests__/related-records.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { RelatedLink } from "@/lib/links/related"; + +import { RelatedRecordsBlock, RelatedRecordsList } from "../related-records"; + +const RELATED: RelatedLink[] = [ + { + kind: "conditionEpisode", + id: "c1", + label: "Hypothyroidism", + date: "2026-01-20T00:00:00.000Z", + href: "/illness/c1", + via: "direct", + }, + { + kind: "encounter", + id: "e1", + label: "SPECIALIST", + date: "2026-07-01T09:00:00.000Z", + href: "/checkups?visit=e1", + via: "direct", + }, + { + kind: "labResult", + id: "l1", + label: "TSH", + date: "2026-06-30T09:00:00.000Z", + href: "/labs/b1", + via: "efficacyTarget", + }, +]; + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("links every row to the path the server resolved", () => { + const html = render(); + expect(html).toContain('href="/illness/c1"'); + expect(html).toContain('href="/checkups?visit=e1"'); + expect(html).toContain('href="/labs/b1"'); + expect(html).toContain("Hypothyroidism"); + }); + + it("names a visit labelled by its kind from the reader's bundle", () => { + const html = render(); + expect(html).not.toContain(">SPECIALIST<"); + }); + + it("says how a derived relation runs", () => { + const html = render(); + expect(html).toContain("through the efficacy target"); + }); + + it("leaves out the kinds the surface lists elsewhere, and renders nothing when empty", () => { + const html = render( + , + ); + expect(html).not.toContain("/checkups?visit=e1"); + expect(render()).toBe(""); + expect( + render( + , + ), + ).toBe(""); + }); +}); diff --git a/src/components/links/related-records.tsx b/src/components/links/related-records.tsx new file mode 100644 index 000000000..68ce45373 --- /dev/null +++ b/src/components/links/related-records.tsx @@ -0,0 +1,527 @@ +"use client"; + +/** + * "Verwandt": the records related to the one on screen, and the pickers that + * file the v1.43 relation links. + * + * Every row comes from the detail read's `related[]`, resolved on the server: + * the label, the date, the web path and how the relation runs (a stored link, + * a visit both are filed against, or a medication's efficacy target). The + * client renders it and never re-derives it, so the web, iOS and a push link + * all open the same place. + * + * The pickers write through the record's own links route + * (`POST/DELETE /api//{id}/links`) one change at a time and refresh + * the detail read afterwards. Those routes are own-account only, so the + * pickers render only in the caller's own record. A module that is off leaves + * no picker behind, the same rule the visit form follows. + */ +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + CalendarDays, + ChevronRight, + FlaskConical, + FolderOpen, + Link2, + Pill, + Syringe, + type LucideIcon, +} from "lucide-react"; + +import { + EntityLinkPicker, + serverPagingFrom, + type EntityLinkOption, +} from "@/components/links/entity-link-picker"; +import { useVaultDocumentOptions } from "@/components/links/vault-document-options"; +import { + pickerPivot, + useLinkPickerPages, +} from "@/components/links/use-link-picker-pages"; +import { fetchEpisodePage } from "@/components/encounters/encounter-link-pickers"; +import { + ENCOUNTER_KINDS, + encounterKindText, +} from "@/components/encounters/encounter-labels"; +import { + fetchEncounterPage, + recordOptions, +} from "@/components/documents/document-record-links"; +import { useVaccinations } from "@/components/vaccinations/use-vaccinations"; +import { TileHeader } from "@/components/insights/tile-header"; +import { Card, CardContent } from "@/components/ui/card"; +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { EncounterDTO } from "@/lib/encounters/dto"; +import type { IllnessEpisodeDTO } from "@/lib/illness/dto"; +import type { RelatedLink } from "@/lib/links/related"; +import type { EncounterKind } from "@/generated/prisma/client"; + +type Translate = ReturnType["t"]; +type RelatedKind = RelatedLink["kind"]; + +const KIND_ICONS: Record = { + labResult: FlaskConical, + encounter: CalendarDays, + document: FolderOpen, + conditionEpisode: Activity, + medication: Pill, + vaccination: Syringe, +}; + +/** Literal calls, so the i18n coverage guard sees every key. */ +function kindText(t: Translate, kind: RelatedKind): string { + switch (kind) { + case "labResult": + return t("links.related.kind.labResult"); + case "encounter": + return t("links.related.kind.encounter"); + case "document": + return t("links.related.kind.document"); + case "conditionEpisode": + return t("links.related.kind.conditionEpisode"); + case "medication": + return t("links.related.kind.medication"); + case "vaccination": + return t("links.related.kind.vaccination"); + } +} + +function viaText(t: Translate, via: RelatedLink["via"]): string | null { + if (via === "encounter") return t("links.related.via.encounter"); + if (via === "efficacyTarget") return t("links.related.via.efficacyTarget"); + return null; +} + +/** + * A visit without a reason or a practitioner is labelled by its kind, sent as + * the enum constant so each reader names it from their own bundle. + */ +function rowLabel(t: Translate, row: RelatedLink): string { + if ( + row.kind === "encounter" && + (ENCOUNTER_KINDS as string[]).includes(row.label) + ) { + return encounterKindText(t, row.label as EncounterKind); + } + return row.label; +} + +/** The related rows, grouped in the server's order. Nothing when empty. */ +export function RelatedRecordsList({ + related, + omitKinds = [], +}: { + related: readonly RelatedLink[] | undefined; + /** Kinds this surface already lists in a block of its own. */ + omitKinds?: readonly RelatedKind[]; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + const rows = (related ?? []).filter((row) => !omitKinds.includes(row.kind)); + if (rows.length === 0) return null; + return ( +

    + {rows.map((row) => { + const Icon = KIND_ICONS[row.kind]; + const via = viaText(t, row.via); + const meta = [ + kindText(t, row.kind), + row.date ? format.date(row.date) : null, + via, + ] + .filter(Boolean) + .join(", "); + return ( +
  • + + + + + {rowLabel(t, row)} + + + {meta} + + + + +
  • + ); + })} +
+ ); +} + +/** + * The list in its own tile, for a page made of cards. Nothing when the + * record relates to nothing the surface does not already show. + */ +export function RelatedRecordsCard({ + related, + omitKinds = [], +}: { + related: readonly RelatedLink[] | undefined; + omitKinds?: readonly RelatedKind[]; +}) { + const { t } = useTranslations(); + if (!(related ?? []).some((row) => !omitKinds.includes(row.kind))) { + return null; + } + return ( + + + + + + + ); +} + +/** The list under a field-style label, for a sheet. */ +export function RelatedRecordsBlock({ + related, + omitKinds = [], +}: { + related: readonly RelatedLink[] | undefined; + omitKinds?: readonly RelatedKind[]; +}) { + const { t } = useTranslations(); + if (!(related ?? []).some((row) => !omitKinds.includes(row.kind))) { + return null; + } + return ( +
+

+ {t("links.related.title")} +

+ +
+ ); +} + +/* ── pickers ──────────────────────────────────────────────────────────── */ + +type LinkableKind = Exclude; + +interface PickerProps { + selected: string[]; + onChange: (ids: string[]) => void; + anchor: string | null; + known: EntityLinkOption[]; +} + +function ConditionPicker({ selected, onChange, anchor, known }: PickerProps) { + const { t } = useTranslations(); + const format = useFormatters(); + const pages = useLinkPickerPages({ + enabled: true, + pivot: pickerPivot(anchor), + queryKey: queryKeys.illnessEpisodesPicker, + fetchPage: fetchEpisodePage, + }); + const options: EntityLinkOption[] = pages.items.map((episode) => ({ + id: episode.id, + label: episode.label, + dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null, + href: `/illness/${encodeURIComponent(episode.id)}`, + })); + return ( + + ); +} + +function DocumentPicker({ selected, onChange, anchor, known }: PickerProps) { + const { t } = useTranslations(); + const documents = useVaultDocumentOptions({ enabled: true, anchor }); + return ( + + ); +} + +function VisitPicker({ selected, onChange, anchor, known }: PickerProps) { + const { t } = useTranslations(); + const format = useFormatters(); + const pages = useLinkPickerPages({ + enabled: true, + pivot: pickerPivot(anchor), + queryKey: queryKeys.encounterPicker, + fetchPage: fetchEncounterPage, + serverSearch: false, + }); + const options = recordOptions( + [...pages.items] + .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)) + .map((visit) => ({ + id: visit.id, + label: encounterKindText(t, visit.kind as EncounterKind), + meta: visit.practitioner?.name ?? null, + occurredAt: visit.occurredAt, + href: `/checkups?visit=${encodeURIComponent(visit.id)}`, + })), + anchor, + { + suggested: t("links.picker.suggested"), + date: (iso: string) => format.date(iso), + }, + ); + return ( + + ); +} + +interface MedicationListRow { + id: string; + name: string; + dose: string; + active: boolean; +} + +function MedicationPicker({ selected, onChange, known }: PickerProps) { + const { t } = useTranslations(); + const list = useQuery({ + queryKey: queryKeys.medications(), + queryFn: () => apiGet("/api/medications"), + }); + const options: EntityLinkOption[] = ( + Array.isArray(list.data) ? list.data : [] + ).map((med) => ({ + id: med.id, + label: med.dose ? `${med.name} ${med.dose}` : med.name, + href: `/medications/${encodeURIComponent(med.id)}`, + })); + return ( + void list.refetch()} + selected={selected} + onChange={onChange} + options={options} + knownOptions={known} + searchPlaceholder={t("links.picker.searchPlaceholder")} + emptyLabel={t("links.related.nothingToOffer")} + /> + ); +} + +function DosePicker({ selected, onChange, anchor, known }: PickerProps) { + const { t } = useTranslations(); + const format = useFormatters(); + const doses = useVaccinations(null, true); + const options = recordOptions( + (doses.data?.vaccinations ?? []).map((dose) => ({ + id: dose.id, + label: dose.catalogEntry?.slug + ? t(`vaccinations.catalog.${dose.catalogEntry.slug}`) + : (dose.vaccineName ?? t("vaccinations.suggestion.unnamed")), + occurredAt: dose.occurredAt, + href: `/vaccinations?dose=${encodeURIComponent(dose.id)}`, + })), + anchor, + { + suggested: t("links.picker.suggested"), + date: (iso: string) => format.date(iso), + }, + ); + return ( + void doses.refetch()} + selected={selected} + onChange={onChange} + options={options} + knownOptions={known} + searchPlaceholder={t("links.picker.searchPlaceholder")} + emptyLabel={t("links.related.nothingToOffer")} + /> + ); +} + +const PICKERS: Record React.ReactNode> = { + conditionEpisode: ConditionPicker, + document: DocumentPicker, + encounter: VisitPicker, + medication: MedicationPicker, + vaccination: DosePicker, +}; + +/** The module each pickable kind belongs to; the visit is core. */ +const KIND_MODULE: Record = { + conditionEpisode: "illness", + document: "inboundDocuments", + encounter: null, + medication: "medications", + vaccination: "vaccinations", +}; + +/** + * One picker per kind the record may file, each writing through `linksPath`. + * + * The caller mounts it keyed on the record so the local selection starts from + * the server's answer whenever the record changes; a failed write puts the + * selection back and says so. + */ +export function RelationLinkPickers({ + linksPath, + kinds, + related, + anchor, + onSaved, +}: { + /** `/api/medications/{id}/links` and the like. */ + linksPath: string; + kinds: readonly LinkableKind[]; + related: readonly RelatedLink[] | undefined; + /** The record's own date, where the pickers open. */ + anchor: string | null; + /** Refresh the detail read after a write. */ + onSaved: () => void; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + const { user } = useAuth(); + const { inSharedRecord } = useRecordCapabilities(); + + const direct = useMemo(() => { + const out = new Map(); + for (const row of related ?? []) { + if (row.via !== "direct" || row.kind === "labResult") continue; + out.set(row.kind, [...(out.get(row.kind) ?? []), row]); + } + return out; + }, [related]); + + const [selection, setSelection] = useState< + Partial> + >({}); + + if (inSharedRecord) return null; + + const visible = kinds.filter((kind) => { + const key = KIND_MODULE[kind]; + return ( + key === null || + (user?.modules as Record | undefined)?.[key] !== false + ); + }); + if (visible.length === 0) return null; + + const write = async (kind: LinkableKind, next: string[]) => { + const before = + selection[kind] ?? (direct.get(kind) ?? []).map((row) => row.id); + setSelection((current) => ({ ...current, [kind]: next })); + const added = next.filter((id) => !before.includes(id)); + const removed = before.filter((id) => !next.includes(id)); + try { + if (added.length > 0) { + await apiPost(linksPath, { targetKind: kind, targetIds: added }); + } + if (removed.length > 0) { + await apiDelete(linksPath, { targetKind: kind, targetIds: removed }); + } + onSaved(); + } catch (cause) { + setSelection((current) => ({ ...current, [kind]: before })); + showErrorToast(t("links.related.saveError"), { cause }); + } + }; + + return ( +
+ {visible.map((kind) => { + const Picker = PICKERS[kind]; + const known: EntityLinkOption[] = (direct.get(kind) ?? []).map( + (row) => ({ + id: row.id, + label: rowLabel(t, row), + dateLabel: row.date ? format.date(row.date) : null, + href: row.href, + }), + ); + return ( + row.id) + } + onChange={(ids) => void write(kind, ids)} + anchor={anchor} + known={known} + /> + ); + })} +
+ ); +} diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 69f9cb55f..402b26090 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -95,6 +95,8 @@ import type { MedicationPayload } from "@/components/medications/wizard/wizard-p import type { ComplianceDisplay } from "@/lib/analytics/compliance"; import { AutoMarkTakenBody } from "@/components/medications/sections/auto-mark-taken-section"; import { IntakeTrackingBody } from "@/components/medications/sections/intake-tracking-section"; +import { MedicationRelatedSection } from "@/components/medications/detail/medication-related-section"; +import type { RelatedLink } from "@/lib/links/related"; // v1.15.20 — Recharts stays out of the detail page's initial bundle: // both chart bodies load through `next/dynamic` (client-only) with the @@ -184,6 +186,8 @@ export interface MedicationDetailSnapshot { canStartCourse?: boolean; recordedSchedules?: ScheduleSnapshot[]; schedules: ScheduleSnapshot[]; + /** v1.43 — the records this medication relates to (server-resolved). */ + related?: RelatedLink[]; } // Slugs are ASCII / locale-independent so the URL stays stable across @@ -660,6 +664,12 @@ export function MedicationDetailTabs({ )} )} + + {/* WIRKUNG — the per-medication efficacy view: the outcome metric(s)/ diff --git a/src/components/medications/detail/medication-related-section.tsx b/src/components/medications/detail/medication-related-section.tsx new file mode 100644 index 000000000..a2b487a2d --- /dev/null +++ b/src/components/medications/detail/medication-related-section.tsx @@ -0,0 +1,61 @@ +"use client"; + +/** + * The medication's "Verwandt" section on the overview tab: what the detail + * read's `related[]` lists (its indication, its documents, the visits it was + * prescribed or reviewed at, and the readings of the marker it targets), and + * in the caller's own record the pickers that file the first three. + */ +import { useQueryClient } from "@tanstack/react-query"; + +import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; +import { + RelatedRecordsList, + RelationLinkPickers, +} from "@/components/links/related-records"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { RelatedLink } from "@/lib/links/related"; + +export function MedicationRelatedSection({ + medicationId, + startsOn, + related, +}: { + medicationId: string; + startsOn: string | null; + related: RelatedLink[] | undefined; +}) { + const { t } = useTranslations(); + const qc = useQueryClient(); + const { inSharedRecord } = useRecordCapabilities(); + if ((related ?? []).length === 0 && inSharedRecord) return null; + return ( + +
+ + { + // The other end lists the medication too. + for (const queryKey of [ + queryKeys.medicationDetail(medicationId), + queryKeys.encounters(), + ]) { + void qc.invalidateQueries({ queryKey }); + } + }} + /> +
+
+ ); +} diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 8597b1b84..40a8cce42 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -37,6 +37,8 @@ import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { useTranslations } from "@/lib/i18n/context"; +import { RelatedRecordsBlock } from "@/components/links/related-records"; +import type { RelatedLink } from "@/lib/links/related"; import { useVaccination, useVaccinationMutations, @@ -149,6 +151,16 @@ export function VaccinationSheet({ : undefined } /> + {/* v1.43 — the visit the dose was given at and anything else it + relates to; its pages have their own picker above. */} + {vaccination ? ( + + ) : null} {error ? (

{error} diff --git a/src/lib/links/related.ts b/src/lib/links/related.ts index 77701f425..24bd01927 100644 --- a/src/lib/links/related.ts +++ b/src/lib/links/related.ts @@ -300,7 +300,25 @@ async function stored( sourceId, targetKind: to as LinkTargetKind, }); - return rows.map((row) => ({ kind: to, id: row.id, via: "direct" })); + const ids = rows.map((row) => row.id); + // A dose also names its visit by scalar (`VaccinationRecord.encounterId`, + // the form's visit field). Both say the same thing, so both are read and + // the visit is listed once. + if (from === "vaccination" && to === "encounter") { + const dose = await tx.vaccinationRecord.findFirst({ + where: { id: sourceId, userId }, + select: { encounterId: true }, + }); + if (dose?.encounterId) ids.push(dose.encounterId); + } else if (from === "encounter" && to === "vaccination") { + const doses = await tx.vaccinationRecord.findMany({ + where: { encounterId: sourceId, userId, deletedAt: null }, + select: { id: true }, + orderBy: { occurredAt: "asc" }, + }); + ids.push(...doses.map((dose) => dose.id)); + } + return [...new Set(ids)].map((id) => ({ kind: to, id, via: "direct" })); } /** The things filed against the visits this record is filed against. */ diff --git a/tests/integration/relation-links.test.ts b/tests/integration/relation-links.test.ts index def5458c9..d2fe65994 100644 --- a/tests/integration/relation-links.test.ts +++ b/tests/integration/relation-links.test.ts @@ -318,6 +318,30 @@ describe("related[]", () => { ]); }); + it("lists a dose's visit named by the form's scalar field, once", async () => { + const prisma = getPrismaClient(); + await prisma.vaccinationRecord.update({ + where: { id: owner.vaccination.id }, + data: { encounterId: owner.encounter.id }, + }); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "encounter", + targetIds: [owner.encounter.id], + }); + const fromDose = await related("vaccination", owner.vaccination.id); + expect(fromDose.filter((r) => r.kind === "encounter")).toHaveLength(1); + const fromVisit = await related("encounter", owner.encounter.id); + expect(fromVisit.filter((r) => r.kind === "vaccination")).toEqual([ + expect.objectContaining({ + id: owner.vaccination.id, + href: `/vaccinations?dose=${owner.vaccination.id}`, + }), + ]); + }); + it("never lists another account's records, nor a deleted one", async () => { const prisma = getPrismaClient(); await linkTargets(prisma, {