mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
commit
0dd634c9c8
64 files changed
+3445
-119
No files matched your search
+115
-8
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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, "")),
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]) },
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -180,6 +180,7 @@ const SUMMARY_COUNTS = [
|
||||
"encounterLinks",
|
||||
"vaccinations",
|
||||
"vaccinationLinks",
|
||||
"relationLinks",
|
||||
"customVaccines",
|
||||
"measurementReminders",
|
||||
"measurementReminderEvents",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") } };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user