Merge branch 'v143/links' into release/v1.43.0

# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
#	src/app/api/medications/[id]/route.ts
#	src/components/medications/detail/medication-detail-tabs.tsx
#	src/lib/export/backup-plan.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 00:34:53 +02:00
commit 0dd634c9c8
64 files changed
+3445 -119

No files matched your search

+115 -8
View File
@@ -15771,7 +15771,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
@@ -16384,9 +16385,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
@@ -16413,7 +16416,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:
@@ -24601,7 +24604,6 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"501": *a69
delete:
tags:
- Links
@@ -24637,7 +24639,6 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"501": *a69
/api/labs/{id}/links:
post:
tags:
@@ -24674,7 +24675,6 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"501": *a69
delete:
tags:
- Links
@@ -24710,7 +24710,6 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"501": *a69
/api/bugreport:
post:
security: []
@@ -31672,6 +31671,8 @@ components:
- document
- labResult
- conditionEpisode
- medication
- vaccination
targetIds:
minItems: 1
maxItems: 100
@@ -32080,6 +32081,7 @@ components:
type: string
enum:
- document
- encounter
targetIds:
minItems: 1
maxItems: 100
@@ -42198,6 +42200,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
@@ -43422,6 +43524,11 @@ components:
- customVaccines
- vaccinations
- vaccinationDocumentLinks
- labResultDocumentLinks
- encounterMedicationLinks
- encounterVaccinationLinks
- medicationDocumentLinks
- medicationConditionLinks
- coachConversations
- mentalHealthAssessments
- consentReceipts
+23 -3
View File
@@ -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
+25
View File
@@ -7949,6 +7949,7 @@
"restoreSkippedCoachAttachment": "Coach-Dokumentverknüpfung",
"restoreSkippedCoachReference": "Coach-Verweis",
"restoreSkippedDocumentConditionLink": "Dokumentzuordnung",
"restoreSkippedRelationLink": "Verknüpfung zwischen Einträgen",
"restoreSkippedExtractedFact": "Dokumentbefund",
"restoreSkippedFactCommitment": "Befundübernahme",
"docsLink": "Dokumentation",
@@ -12430,6 +12431,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": {
+25
View File
@@ -7949,6 +7949,7 @@
"restoreSkippedCoachAttachment": "Coach document link",
"restoreSkippedCoachReference": "Coach reference",
"restoreSkippedDocumentConditionLink": "Document filing",
"restoreSkippedRelationLink": "Link between records",
"restoreSkippedExtractedFact": "Document fact",
"restoreSkippedFactCommitment": "Fact commitment",
"docsLink": "Documentation",
@@ -12430,6 +12431,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": {
+25
View File
@@ -7949,6 +7949,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",
@@ -12430,6 +12431,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": {
+25
View File
@@ -7949,6 +7949,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",
@@ -12430,6 +12431,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": {
+25
View File
@@ -7949,6 +7949,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",
@@ -12430,6 +12431,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": {
+25
View File
@@ -7924,6 +7924,7 @@
"restoreSkippedCoachAttachment": "코치 문서 연결",
"restoreSkippedCoachReference": "코치 참조",
"restoreSkippedDocumentConditionLink": "문서 분류",
"restoreSkippedRelationLink": "기록 간 연결",
"restoreSkippedExtractedFact": "문서 추출 정보",
"restoreSkippedFactCommitment": "추출 정보 확정",
"docsLink": "문서",
@@ -12430,6 +12431,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": {
+25
View File
@@ -7949,6 +7949,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",
@@ -12430,6 +12431,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": {
@@ -50,12 +50,10 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"environment/travel/suggestions/route.ts": [
"environment.travel_suggestions.get",
],
"labs/[id]/links/route.ts": ["labs.link."],
"medications/[id]/links/route.ts": ["medication.link."],
};
/** The size of the register when it was written; see the header. */
const REGISTER_SIZE = 17;
const REGISTER_SIZE = 15;
function routeFiles(): string[] {
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".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:
@@ -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<string, string> = {
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}"`);
}
});
});
@@ -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, "")),
+16 -11
View File
@@ -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 },
),
);
},
+2 -1
View File
@@ -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
+9 -1
View File
@@ -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,
}),
);
},
);
@@ -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(
@@ -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([]) },
@@ -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([]) },
+10 -2
View File
@@ -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,
}),
);
},
);
+43 -5
View File
@@ -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<Response> {
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(
+10 -5
View File
@@ -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 },
),
);
},
+11
View File
@@ -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
+43 -5
View File
@@ -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<Response> {
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(
+9 -1
View File
@@ -67,6 +67,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 }> };
@@ -108,7 +109,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({
@@ -312,6 +313,13 @@ export const GET = apiHandler(
stockDosesRemaining,
runwayDays,
ingredients: toIngredientDtos(ingredientRows, user.locale ?? null),
// v1.43 — the records this medication relates to (`src/lib/links/`).
related: await buildRelated(prisma, {
userId: user.id,
grantId,
kind: "medication",
id,
}),
});
},
);
+2 -1
View File
@@ -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
+8 -1
View File
@@ -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 },
),
);
},
);
+3
View File
@@ -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");
}
@@ -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. */}
<RelatedRecordsBlock
related={(doc as { related?: RelatedLink[] }).related}
omitKinds={["conditionEpisode", "encounter", "vaccination"]}
/>
{/* 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
@@ -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 (
<div className="space-y-4 border-t pt-4" data-slot="encounter-related">
<RelatedRecordsBlock
related={related}
omitKinds={["document", "labResult", "conditionEpisode"]}
/>
<RelationLinkPickers
key={encounterId}
linksPath={`/api/encounters/${encodeURIComponent(encounterId)}/links`}
kinds={["medication", "vaccination"]}
related={related}
anchor={occurredAt}
onSaved={() => {
// The other end lists the visit too.
for (const queryKey of [
queryKeys.encounter(encounterId),
queryKeys.medications(),
queryKeys.vaccinations(),
]) {
void qc.invalidateQueries({ queryKey });
}
}}
/>
</div>
);
}
@@ -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({
>
<div className="space-y-4">
<EncounterForm draft={draft} onChange={setDraft} isEdit={isEdit} />
{isEdit ? (
<EncounterRelatedLinks
encounterId={encounter.id}
occurredAt={encounter.occurredAt}
/>
) : null}
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
@@ -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 ? <EpisodeVisitsCard episodeId={episode.id} /> : 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 ? (
<RelatedRecordsCard
related={(episode as { related?: RelatedLink[] }).related}
omitKinds={["document", "encounter"]}
/>
) : null}
</RevealWhenSettled>
{episode ? (
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<RelatedRecordsList>", () => {
it("links every row to the path the server resolved", () => {
const html = render(<RelatedRecordsList related={RELATED} />);
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(<RelatedRecordsList related={RELATED} />);
expect(html).not.toContain(">SPECIALIST<");
});
it("says how a derived relation runs", () => {
const html = render(<RelatedRecordsList related={RELATED} />);
expect(html).toContain("through the efficacy target");
});
it("leaves out the kinds the surface lists elsewhere, and renders nothing when empty", () => {
const html = render(
<RelatedRecordsList related={RELATED} omitKinds={["encounter"]} />,
);
expect(html).not.toContain("/checkups?visit=e1");
expect(render(<RelatedRecordsList related={[]} />)).toBe("");
expect(
render(
<RelatedRecordsBlock
related={RELATED}
omitKinds={["conditionEpisode", "encounter", "labResult"]}
/>,
),
).toBe("");
});
});
+527
View File
@@ -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/<record>/{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<typeof useTranslations>["t"];
type RelatedKind = RelatedLink["kind"];
const KIND_ICONS: Record<RelatedKind, LucideIcon> = {
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 (
<ul className="divide-y" data-slot="related-records">
{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 (
<li key={`${row.kind}:${row.id}`}>
<Link
href={row.href}
data-slot="related-record"
className="hover:bg-muted/50 focus-visible:ring-ring/50 -mx-2 flex min-h-11 items-center gap-3 rounded-md px-2 py-2 focus-visible:ring-[3px] focus-visible:outline-none"
>
<Icon
className="text-muted-foreground size-4 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate text-sm">
{rowLabel(t, row)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{meta}
</span>
</span>
<ChevronRight
className="text-muted-foreground size-4 shrink-0"
aria-hidden
/>
</Link>
</li>
);
})}
</ul>
);
}
/**
* 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 (
<Card data-slot="related-records-card">
<CardContent className="space-y-4">
<TileHeader icon={Link2} title={t("links.related.title")} />
<RelatedRecordsList related={related} omitKinds={omitKinds} />
</CardContent>
</Card>
);
}
/** 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 (
<div className="space-y-1.5" data-slot="related-records-block">
<p className="text-sm leading-none font-medium">
{t("links.related.title")}
</p>
<RelatedRecordsList related={related} omitKinds={omitKinds} />
</div>
);
}
/* ── pickers ──────────────────────────────────────────────────────────── */
type LinkableKind = Exclude<RelatedKind, "labResult">;
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<IllnessEpisodeDTO>({
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 (
<EntityLinkPicker
icon={Activity}
title={t("links.related.pick.conditionEpisode")}
slot="related-link-conditionEpisode"
pending={pages.pending}
error={pages.error}
errorLabel={t("links.picker.loadError")}
onRetry={pages.retry}
selected={selected}
onChange={onChange}
options={options}
server={serverPagingFrom(pages)}
knownOptions={known}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("links.related.nothingToOffer")}
/>
);
}
function DocumentPicker({ selected, onChange, anchor, known }: PickerProps) {
const { t } = useTranslations();
const documents = useVaultDocumentOptions({ enabled: true, anchor });
return (
<EntityLinkPicker
icon={FolderOpen}
title={t("links.related.pick.document")}
slot="related-link-document"
pending={documents.pending}
error={documents.error}
errorLabel={t("links.picker.loadError")}
onRetry={documents.retry}
selected={selected}
onChange={onChange}
options={documents.options}
server={documents.server}
knownOptions={known}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("links.related.nothingToOffer")}
/>
);
}
function VisitPicker({ selected, onChange, anchor, known }: PickerProps) {
const { t } = useTranslations();
const format = useFormatters();
const pages = useLinkPickerPages<EncounterDTO>({
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 (
<EntityLinkPicker
icon={CalendarDays}
title={t("links.related.pick.encounter")}
slot="related-link-encounter"
pending={pages.pending}
error={pages.error}
errorLabel={t("links.picker.loadError")}
onRetry={pages.retry}
selected={selected}
onChange={onChange}
options={options}
server={serverPagingFrom(pages)}
knownOptions={known}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("links.related.nothingToOffer")}
/>
);
}
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<MedicationListRow[]>("/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 (
<EntityLinkPicker
icon={Pill}
title={t("links.related.pick.medication")}
slot="related-link-medication"
pending={list.isPending}
error={list.isError}
errorLabel={t("links.picker.loadError")}
onRetry={() => 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 (
<EntityLinkPicker
icon={Syringe}
title={t("links.related.pick.vaccination")}
slot="related-link-vaccination"
pending={doses.isPending}
error={doses.isError}
errorLabel={t("links.picker.loadError")}
onRetry={() => void doses.refetch()}
selected={selected}
onChange={onChange}
options={options}
knownOptions={known}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("links.related.nothingToOffer")}
/>
);
}
const PICKERS: Record<LinkableKind, (props: PickerProps) => 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<LinkableKind, string | null> = {
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<LinkableKind, RelatedLink[]>();
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<Record<LinkableKind, string[]>>
>({});
if (inSharedRecord) return null;
const visible = kinds.filter((kind) => {
const key = KIND_MODULE[kind];
return (
key === null ||
(user?.modules as Record<string, boolean> | 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 (
<div className="space-y-4" data-slot="relation-link-pickers">
{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 (
<Picker
key={kind}
selected={
selection[kind] ?? (direct.get(kind) ?? []).map((row) => row.id)
}
onChange={(ids) => void write(kind, ids)}
anchor={anchor}
known={known}
/>
);
})}
</div>
);
}
@@ -102,6 +102,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
@@ -193,6 +195,8 @@ export interface MedicationDetailSnapshot {
schedules: ScheduleSnapshot[];
/** v1.43 — the composition per unit, as the server delivered it. */
ingredients?: MedicationIngredientDto[];
/** v1.43 — the records this medication relates to (server-resolved). */
related?: RelatedLink[];
}
// Slugs are ASCII / locale-independent so the URL stays stable across
@@ -686,6 +690,12 @@ export function MedicationDetailTabs({
ownRecord={!inSharedRecord}
/>
)}
<MedicationRelatedSection
medicationId={medication.id}
startsOn={medication.startsOn ?? null}
related={medication.related}
/>
</TabsContent>
{/* WIRKUNG — the per-medication efficacy view: the outcome metric(s)/
@@ -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 (
<MedicationDetailSection
titleId="medication-uebersicht-related-heading"
title={t("links.related.title")}
dataSlot="medication-uebersicht-related"
>
<div className="space-y-4">
<RelatedRecordsList related={related} />
<RelationLinkPickers
key={medicationId}
linksPath={`/api/medications/${encodeURIComponent(medicationId)}/links`}
kinds={["conditionEpisode", "document", "encounter"]}
related={related}
anchor={startsOn}
onSaved={() => {
// The other end lists the medication too.
for (const queryKey of [
queryKeys.medicationDetail(medicationId),
queryKeys.encounters(),
]) {
void qc.invalidateQueries({ queryKey });
}
}}
/>
</div>
</MedicationDetailSection>
);
}
@@ -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 ? (
<RelatedRecordsBlock
related={
(detail.data as { related?: RelatedLink[] } | undefined)?.related
}
omitKinds={["document"]}
/>
) : null}
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
+1
View File
@@ -74,6 +74,7 @@ const TARGET_DOMAIN: Record<LinkTargetKind, ShareDomain> = {
encounter: "profile",
// A dose lives in the health background, like the visit itself.
vaccination: "profile",
medication: "medications",
};
/**
@@ -459,6 +459,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.
@@ -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([
{
+14 -11
View File
@@ -211,7 +211,8 @@ export const BACKED_UP_MODELS = [
// 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 (the composition does:
// TWO_ENDED_MODELS).
// TWO_ENDED_MODELS). The relation links travel since the release that
// introduces them (`src/lib/export/relation-links-backup.ts`).
"BiomarkerAlias",
"BiomarkerMerge",
"MedicationIngredient",
@@ -295,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
@@ -319,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",
@@ -587,6 +592,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. */
@@ -627,16 +640,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
// TWO_ENDED_MODELS when their section writer and restore branch land.
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:
+1
View File
@@ -180,6 +180,7 @@ const SUMMARY_COUNTS = [
"encounterLinks",
"vaccinations",
"vaccinationLinks",
"relationLinks",
"customVaccines",
"measurementReminders",
"measurementReminderEvents",
+16
View File
@@ -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([
@@ -928,6 +936,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").
@@ -983,6 +996,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
@@ -1404,6 +1418,7 @@ export async function buildFullBackupPayload(
...ecgSection,
...symptomsSection,
...lifeEventsSection,
...relationLinksSection,
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
@@ -1473,6 +1488,7 @@ export async function buildFullBackupPayload(
...countEcgBackupSection(ecg),
...countSymptomsBackupSection(symptoms),
...countLifeEventsBackupSection(lifeEvents),
...countRelationLinksBackupSection(relationLinks),
},
};
}
+378
View File
@@ -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<RelationLinksBackupSection> {
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 = <T extends { createdAt: Date }>(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<string>;
documents: Set<string>;
encounters: Set<string>;
vaccinations: Set<string>;
medications: Set<string>;
episodes: Set<string>;
}> {
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<T>(
rows: readonly T[],
ends: [(row: T) => string, ReadonlySet<string>][],
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<RelationLinksRestoreCleared> {
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,
};
}
+15
View File
@@ -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;
@@ -2671,6 +2673,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
@@ -2803,6 +2816,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,
@@ -3082,6 +3096,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,
+7
View File
@@ -166,6 +166,12 @@
* (`customMoodTags.<key>.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"
@@ -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(),
@@ -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<string, string> {
const start = SERVICE.indexOf("const LINK_TABLES");
expect(start).toBeGreaterThan(-1);
const body = SERVICE.slice(start, SERVICE.indexOf("\n};", start));
const out = new Map<string, string>();
for (const m of body.matchAll(
/"(\w+:\w+)":\s*\{\s*delegate:\s*\(tx\)\s*=>\s*narrow<LinkRowDelegate>\(\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();
}
});
});
+157 -20
View File
@@ -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<string, boolean>;
label: (row: Record<string, unknown>) => string;
@@ -114,6 +130,11 @@ function narrow<T>(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<OwnedRowDelegate>(tx.encounter),
relationField: "encounter",
softDeletes: true,
},
document: {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.inboundDocument),
relationField: "document",
softDeletes: true,
},
vaccination: {
delegate: (tx) => narrow<OwnedRowDelegate>(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<OwnedRowDelegate>(tx.illnessEpisode),
relationField: "episode",
softDeletes: true,
},
// v1.43 — a medication files its indication, its documents and its visits.
medication: {
delegate: (tx) => narrow<OwnedRowDelegate>(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<OwnedRowDelegate>(tx.labResult),
relationField: "labResult",
softDeletes: true,
},
};
const DOCUMENT_ENDPOINT: EndpointSpec = {
delegate: (tx) => narrow<OwnedRowDelegate>(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<OwnedRowDelegate>(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<OwnedRowDelegate>(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<OwnedRowDelegate>(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<OwnedRowDelegate>(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<OwnedRowDelegate>(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<LinkRowDelegate>(tx.labResultDocumentLink),
sourceColumn: "labResultId",
targetColumn: "documentId",
target: DOCUMENT_ENDPOINT,
},
"document:labResult": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.labResultDocumentLink),
sourceColumn: "documentId",
targetColumn: "labResultId",
target: LAB_RESULT_ENDPOINT,
},
// `encounter_lab_links` read from the result's end.
"labResult:encounter": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterLabLink),
sourceColumn: "labResultId",
targetColumn: "encounterId",
target: ENCOUNTER_ENDPOINT,
},
// `encounter_medication_links`: prescribed, changed or reviewed at a visit.
"encounter:medication": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterMedicationLink),
sourceColumn: "encounterId",
targetColumn: "medicationId",
target: MEDICATION_ENDPOINT,
},
"medication:encounter": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterMedicationLink),
sourceColumn: "medicationId",
targetColumn: "encounterId",
target: ENCOUNTER_ENDPOINT,
},
// `encounter_vaccination_links`: a dose given at a visit.
"encounter:vaccination": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterVaccinationLink),
sourceColumn: "encounterId",
targetColumn: "vaccinationId",
target: VACCINATION_ENDPOINT,
},
"vaccination:encounter": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterVaccinationLink),
sourceColumn: "vaccinationId",
targetColumn: "encounterId",
target: ENCOUNTER_ENDPOINT,
},
// `medication_document_links`: a prescription, a plan, a package insert.
"medication:document": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.medicationDocumentLink),
sourceColumn: "medicationId",
targetColumn: "documentId",
target: DOCUMENT_ENDPOINT,
},
"document:medication": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.medicationDocumentLink),
sourceColumn: "documentId",
targetColumn: "medicationId",
target: MEDICATION_ENDPOINT,
},
// `medication_condition_links`: the indication.
"medication:conditionEpisode": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.medicationConditionLink),
sourceColumn: "medicationId",
targetColumn: "episodeId",
target: CONDITION_ENDPOINT,
},
"conditionEpisode:medication": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.medicationConditionLink),
sourceColumn: "episodeId",
targetColumn: "medicationId",
target: MEDICATION_ENDPOINT,
},
};
function tableFor(
@@ -340,8 +470,9 @@ async function ownsSource(
sourceId,
}: Omit<LinkRequest, "targetKind" | "targetIds">,
): Promise<boolean> {
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<Set<string>> {
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<LinkedTarget[]> {
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" },
+510
View File
@@ -0,0 +1,510 @@
/**
* `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<RelatedVia, "direct"> }
| { 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<Partial<Record<Pair, RelationCell>>> = {
"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<RelatedKind, ModuleKey | null>
> = {
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<Record<RelatedKind, ShareDomain>> = {
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<Map<string, Hydrated>> {
const out = new Map<string, Hydrated>();
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<Candidate[]> {
const rows = await listTargets(tx, {
userId,
sourceKind: from as LinkSourceKind,
sourceId,
targetKind: to as LinkTargetKind,
});
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. */
async function throughEncounters(
tx: Prisma.TransactionClient,
userId: string,
from: "labResult" | "conditionEpisode",
sourceId: string,
to: "labResult" | "conditionEpisode",
): Promise<Candidate[]> {
const visits = await listTargets(tx, {
userId,
sourceKind: from,
sourceId,
targetKind: "encounter",
});
const seen = new Set<string>();
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<Candidate[]> {
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<Candidate[]> {
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<Candidate[]>;
/**
* 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<Partial<Record<Pair, Reader>>> = {
"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<Candidate[]> {
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<RelatedLink[]> {
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<T extends object>(
tx: Prisma.TransactionClient,
detail: T,
input: Parameters<typeof buildRelated>[1],
): Promise<T & { related: RelatedLink[] }> {
return { ...detail, related: await buildRelated(tx, input) };
}
@@ -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 () => {
@@ -6,10 +6,24 @@
* `Record<EventType, …>` 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<NotificationPayload>,
"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);
});
});
@@ -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 () => {
+8 -4
View File
@@ -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,
+18 -9
View File
@@ -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;
}
+1 -1
View File
@@ -552,7 +552,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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,
+1 -7
View File
@@ -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") } };
+3 -3
View File
@@ -628,9 +628,9 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["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.",
requestParams: idPath,
+8
View File
@@ -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
+34
View File
@@ -1456,6 +1456,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 extends string, B extends string>(
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).
@@ -2230,6 +2247,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
+3
View File
@@ -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
+4 -6
View File
@@ -253,13 +253,11 @@ export type CustomVaccineUpdate = z.infer<typeof customVaccineUpdateSchema>;
/* ── 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({
@@ -293,6 +293,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 } }),
@@ -1385,6 +1395,44 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
},
});
// 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
+505
View File
@@ -0,0 +1,505 @@
/**
* 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<ReturnType<typeof seedAccount>>;
let stranger: Awaited<ReturnType<typeof seedAccount>>;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
owner = await seedAccount(OWNER);
stranger = await seedAccount(STRANGER);
});
const related = (
kind: Parameters<typeof buildRelated>[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("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, {
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<typeof linkTargets>[1]["sourceKind"],
sourceId: string,
targetKind: Parameters<typeof linkTargets>[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,
},
]);
});
});