Merge branch 'feat/procedure-history' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 08:23:34 +02:00
commit c327ae81d5
59 files changed
+2959 -79

No files matched your search

+169 -9
View File
@@ -14003,6 +14003,39 @@ paths:
"409": *a7
"422": *a4
"429": *a10
/api/encounters/procedures:
get:
tags:
- Records
summary: List procedures and surgeries
description: "The caller's procedure and surgery history: every live visit of kind PROCEDURE with status DONE, newest
first, bounded at 500. Planned, cancelled and missed procedures are not in it; a booked one stays in the visit
list's `upcoming` half. Read at the same section and level as the visit list."
parameters:
- in: query
name: q
schema:
type: string
maxLength: 200
- in: query
name: laterality
schema:
type: string
enum:
- LEFT
- RIGHT
- BOTH
responses:
"200":
description: The procedure history.
content:
application/json:
schema:
$ref: "#/components/schemas/ListProceduresEnvelope"
"401": *a1
"403": *a9
"422": *a4
"429": *a5
/api/encounters/suggest:
get:
tags:
@@ -21442,7 +21475,7 @@ components:
type: number
const: 2
leaves:
maxItems: 94
maxItems: 95
type: array
items:
type: string
@@ -21501,7 +21534,7 @@ components:
type: number
const: 2
leaves:
maxItems: 94
maxItems: 95
type: array
items:
type: string
@@ -24172,7 +24205,7 @@ components:
type: number
const: 2
leaves:
maxItems: 94
maxItems: 95
type: array
items:
type: string
@@ -26965,6 +26998,7 @@ components:
- HOSPITAL
- THERAPY
- OTHER
- PROCEDURE
practitionerId:
anyOf:
- type: string
@@ -26981,6 +27015,19 @@ components:
- type: string
maxLength: 4000
- type: "null"
bodySite:
anyOf:
- type: string
maxLength: 200
- type: "null"
laterality:
anyOf:
- type: string
enum:
- LEFT
- RIGHT
- BOTH
- type: "null"
reminderId:
anyOf:
- type: string
@@ -27013,8 +27060,10 @@ components:
additionalProperties: false
description: "File a visit, or book one. `occurredAt` is the only required field — a visit saves with a date and nothing
else. A future instant with `status: PLANNED` books an appointment and mints exactly one reminder for it.
`reason` and `outcome` are encrypted at rest. The three id arrays pre-link documents, lab results and condition
episodes; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save."
`reason`, `outcome` and `bodySite` are encrypted at rest. `kind: PROCEDURE` marks a procedure or surgery;
`bodySite` (free text) and `laterality` (LEFT / RIGHT / BOTH, null when not stated) say where, and are accepted
on any kind. The three id arrays pre-link documents, lab results and condition episodes; an id naming nothing
the caller owns is dropped rather than refused, so a link never blocks a save."
UpdateEncounterRequest:
type: object
properties:
@@ -27040,6 +27089,7 @@ components:
- HOSPITAL
- THERAPY
- OTHER
- PROCEDURE
practitionerId:
anyOf:
- type: string
@@ -27056,6 +27106,19 @@ components:
- type: string
maxLength: 4000
- type: "null"
bodySite:
anyOf:
- type: string
maxLength: 200
- type: "null"
laterality:
anyOf:
- type: string
enum:
- LEFT
- RIGHT
- BOTH
- type: "null"
reminderId:
anyOf:
- type: string
@@ -27084,10 +27147,12 @@ components:
minLength: 1
maxLength: 40
additionalProperties: false
description: Partial edit of a visit; an omitted key leaves the column untouched, and a body naming nothing is a 422.
description: "Partial edit of a visit; an omitted key leaves the column untouched, and a body naming nothing is a 422.
Moving `occurredAt` re-anchors the appointment reminder the visit already owns and never mints a second. Moving
`status` to DONE closes the checkup named by `reminderId`, on the transition only. CANCELLED and NO_SHOW stop
the reminder without deleting the row. A present id array replaces that link family, empty array included.
Switching an existing visit to `kind: PROCEDURE` is an ordinary edit of this kind; nothing has to be entered
again."
EncounterLinkRequest:
type: object
properties:
@@ -35844,6 +35909,7 @@ components:
- HOSPITAL
- THERAPY
- OTHER
- PROCEDURE
practitionerId:
anyOf:
- type: string
@@ -35858,6 +35924,19 @@ components:
- type: string
minLength: 1
- type: "null"
bodySiteEncrypted:
anyOf:
- type: string
minLength: 1
- type: "null"
laterality:
anyOf:
- type: string
enum:
- LEFT
- RIGHT
- BOTH
- type: "null"
reminderId:
anyOf:
- type: string
@@ -60679,6 +60758,7 @@ components:
- HOSPITAL
- THERAPY
- OTHER
- PROCEDURE
practitioner:
anyOf:
- $ref: "#/components/schemas/Practitioner"
@@ -60691,6 +60771,18 @@ components:
anyOf:
- type: string
- type: "null"
bodySite:
anyOf:
- type: string
- type: "null"
laterality:
anyOf:
- type: string
enum:
- LEFT
- RIGHT
- BOTH
- type: "null"
reminderNextDueAt:
anyOf:
- type: string
@@ -60711,14 +60803,18 @@ components:
- practitioner
- reason
- outcome
- bodySite
- laterality
- reminderNextDueAt
- createdAt
- updatedAt
additionalProperties: false
description: "One contact with the healthcare system: a visit that happened, or one that is booked. `practitioner` is
resolved rather than an id. `reason` and `outcome` are the decrypted free text (null on a key-rotation gap).
`reminderNextDueAt` is the server-computed instant a booked appointment next nudges, and is null for a visit
that already happened or whose one-shot reminder is spent."
resolved rather than an id. `reason`, `outcome` and `bodySite` are the decrypted free text (null on a
key-rotation gap). `kind: PROCEDURE` marks a procedure or surgery, and `bodySite` with `laterality` say where;
both are carried on every kind so switching a visit's kind away and back loses nothing. `reminderNextDueAt` is
the server-computed instant a booked appointment next nudges, and is null for a visit that already happened or
whose one-shot reminder is spent."
Practitioner:
type: object
properties:
@@ -60863,6 +60959,69 @@ components:
- data
- error
additionalProperties: false
ListProceduresEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/ProcedureList"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
ProcedureList:
type: object
properties:
procedures:
type: array
items:
$ref: "#/components/schemas/Encounter"
bodySites:
type: array
items:
$ref: "#/components/schemas/BodySiteFacet"
total:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- procedures
- bodySites
- total
additionalProperties: false
description: The procedure history. `procedures` is the filtered list, newest first. `bodySites` and `total` are
computed over the whole history before the filter, so the filter choices never shrink to the one just picked.
BodySiteFacet:
type: object
properties:
bodySite:
type: string
laterality:
anyOf:
- type: string
enum:
- LEFT
- RIGHT
- BOTH
- type: "null"
count:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- bodySite
- laterality
- count
additionalProperties: false
description: One body site and side in the procedure history, with how many procedures carry it. Keyed case- and
space-insensitively; `bodySite` is the first spelling seen.
SuggestEncounterEnvelope:
type: object
properties:
@@ -60943,6 +61102,7 @@ components:
- HOSPITAL
- THERAPY
- OTHER
- PROCEDURE
practitionerName:
anyOf:
- type: string
+36 -3
View File
@@ -293,6 +293,11 @@
"visitsColReason": "Anlass",
"visitsColOutcome": "Ergebnis",
"visitsColConditions": "Erkrankungen",
"surgicalHistoryTitle": "Operationen und Eingriffe",
"surgicalHistoryColDate": "Datum",
"surgicalHistoryColProcedure": "Eingriff",
"surgicalHistoryColBodySite": "Körperstelle",
"surgicalHistoryColOutcome": "Ergebnis",
"immunizationsTitle": "Impfungen",
"immunizationsColDate": "Datum",
"immunizationsColVaccine": "Impfstoff",
@@ -10427,6 +10432,7 @@
"scopeCount": "{count} Einträge aus {groups} Bereichen",
"scopeSensitive": "inkl. {names}",
"leafVisits": "Arztbesuche",
"leafSurgicalHistory": "Operationen und Eingriffe",
"leafImmunizations": "Impfungen",
"leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)"
},
@@ -10811,8 +10817,16 @@
"emergency": "Notfall",
"hospital": "Krankenhaus",
"therapy": "Therapietermin",
"other": "Arzttermin"
"other": "Arzttermin",
"procedure": "Eingriff oder Operation"
},
"laterality": {
"left": "Links",
"right": "Rechts",
"both": "Beidseitig",
"none": "Keine Angabe"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Termine",
"sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.",
"add": "Termin",
@@ -10854,6 +10868,9 @@
"reason": "Anlass",
"outcome": "Ergebnis",
"outcomeReveal": "Ergebnis eintragen",
"bodySite": "Körperstelle",
"bodySiteHint": "Zum Beispiel Knie oder Gallenblase.",
"laterality": "Seite",
"linkDocuments": "Dokumente",
"linkLabResults": "Laborwerte",
"linkConditions": "Erkrankungen",
@@ -10873,12 +10890,28 @@
},
"dashboard": {
"nextVisit": "Nächster Termin: {what} am {when}"
},
"procedures": {
"title": "Eingriffe und Operationen",
"description": "Alle Eingriffe und Operationen, die du hattest, die neuesten zuerst.",
"add": "Eingriff",
"searchLabel": "Nach Körperstelle filtern",
"searchPlaceholder": "Zum Beispiel Knie",
"sitesLabel": "Körperstellen",
"allSites": "Alle",
"count": "{shown} von {total}",
"emptyTitle": "Noch kein Eingriff erfasst",
"emptyDescription": "Erfasse einen Termin der Art Eingriff oder Operation, oder stelle einen vorhandenen Termin darauf um.",
"noMatchTitle": "Nichts passt zu diesem Filter",
"clearFilter": "Filter zurücksetzen",
"loadError": "Die Eingriffe konnten nicht geladen werden."
}
},
"checkups": {
"viewSwitchLabel": "Vorsorge oder Termine anzeigen",
"viewSwitchLabel": "Vorsorge, Termine oder Eingriffe anzeigen",
"tabVorsorge": "Vorsorge",
"tabVisits": "Termine"
"tabVisits": "Termine",
"tabProcedures": "Eingriffe"
},
"practitioners": {
"title": "Praxen und Ärzte",
+36 -3
View File
@@ -329,6 +329,11 @@
"visitsColReason": "Reason",
"visitsColOutcome": "Outcome",
"visitsColConditions": "Conditions",
"surgicalHistoryTitle": "Surgical history",
"surgicalHistoryColDate": "Date",
"surgicalHistoryColProcedure": "Procedure",
"surgicalHistoryColBodySite": "Body site",
"surgicalHistoryColOutcome": "Outcome",
"immunizationsTitle": "Immunizations",
"immunizationsColDate": "Date",
"immunizationsColVaccine": "Vaccine",
@@ -10427,6 +10432,7 @@
"scopeCount": "{count} entries from {groups} areas",
"scopeSensitive": "incl. {names}",
"leafVisits": "Visits",
"leafSurgicalHistory": "Surgical history",
"leafImmunizations": "Immunizations",
"leafEmergency": "Emergency information (blood type, contacts, directives)"
},
@@ -10811,8 +10817,16 @@
"emergency": "Emergency",
"hospital": "Hospital",
"therapy": "Therapy session",
"other": "Appointment"
"other": "Appointment",
"procedure": "Procedure or surgery"
},
"laterality": {
"left": "Left",
"right": "Right",
"both": "Both sides",
"none": "Not stated"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Visits",
"sectionDescription": "Doctor's visits and booked appointments in one place.",
"add": "Visit",
@@ -10854,6 +10868,9 @@
"reason": "Reason",
"outcome": "Outcome",
"outcomeReveal": "Add an outcome",
"bodySite": "Body site",
"bodySiteHint": "For example knee or gallbladder.",
"laterality": "Side",
"linkDocuments": "Documents",
"linkLabResults": "Lab results",
"linkConditions": "Conditions",
@@ -10873,12 +10890,28 @@
},
"dashboard": {
"nextVisit": "Next appointment: {what} on {when}"
},
"procedures": {
"title": "Procedures and surgeries",
"description": "Every procedure and surgery you have had, newest first.",
"add": "Procedure",
"searchLabel": "Filter by body site",
"searchPlaceholder": "For example knee",
"sitesLabel": "Body sites",
"allSites": "All",
"count": "{shown} of {total}",
"emptyTitle": "No procedure recorded yet",
"emptyDescription": "Record a visit of the kind Procedure or surgery, or switch an existing visit to it.",
"noMatchTitle": "Nothing matches this filter",
"clearFilter": "Clear filter",
"loadError": "Procedures could not be loaded."
}
},
"checkups": {
"viewSwitchLabel": "Show checkups or visits",
"viewSwitchLabel": "Show checkups, visits or procedures",
"tabVorsorge": "Checkups",
"tabVisits": "Visits"
"tabVisits": "Visits",
"tabProcedures": "Procedures"
},
"practitioners": {
"title": "Practices and doctors",
+36 -3
View File
@@ -293,6 +293,11 @@
"visitsColReason": "Motivo",
"visitsColOutcome": "Resultado",
"visitsColConditions": "Enfermedades",
"surgicalHistoryTitle": "Antecedentes quirúrgicos",
"surgicalHistoryColDate": "Fecha",
"surgicalHistoryColProcedure": "Procedimiento",
"surgicalHistoryColBodySite": "Zona del cuerpo",
"surgicalHistoryColOutcome": "Resultado",
"immunizationsTitle": "Vacunas",
"immunizationsColDate": "Fecha",
"immunizationsColVaccine": "Vacuna",
@@ -10427,6 +10432,7 @@
"scopeCount": "{count} entradas de {groups} áreas",
"scopeSensitive": "incl. {names}",
"leafVisits": "Visitas médicas",
"leafSurgicalHistory": "Antecedentes quirúrgicos",
"leafImmunizations": "Vacunas",
"leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)"
},
@@ -10811,8 +10817,16 @@
"emergency": "Urgencia",
"hospital": "Hospital",
"therapy": "Sesión de terapia",
"other": "Cita médica"
"other": "Cita médica",
"procedure": "Procedimiento o cirugía"
},
"laterality": {
"left": "Izquierda",
"right": "Derecha",
"both": "Ambos lados",
"none": "Sin indicar"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Citas",
"sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.",
"add": "Cita",
@@ -10854,6 +10868,9 @@
"reason": "Motivo",
"outcome": "Resultado",
"outcomeReveal": "Añadir un resultado",
"bodySite": "Zona del cuerpo",
"bodySiteHint": "Por ejemplo, rodilla o vesícula.",
"laterality": "Lado",
"linkDocuments": "Documentos",
"linkLabResults": "Resultados de laboratorio",
"linkConditions": "Enfermedades",
@@ -10873,12 +10890,28 @@
},
"dashboard": {
"nextVisit": "Próxima cita: {what} el {when}"
},
"procedures": {
"title": "Procedimientos y cirugías",
"description": "Todos los procedimientos y cirugías que has tenido, del más reciente al más antiguo.",
"add": "Procedimiento",
"searchLabel": "Filtrar por zona del cuerpo",
"searchPlaceholder": "Por ejemplo, rodilla",
"sitesLabel": "Zonas del cuerpo",
"allSites": "Todas",
"count": "{shown} de {total}",
"emptyTitle": "Aún no hay ningún procedimiento registrado",
"emptyDescription": "Registra una cita del tipo Procedimiento o cirugía, o cambia una cita existente a ese tipo.",
"noMatchTitle": "Nada coincide con este filtro",
"clearFilter": "Quitar filtro",
"loadError": "No se pudieron cargar los procedimientos."
}
},
"checkups": {
"viewSwitchLabel": "Mostrar revisiones o citas",
"viewSwitchLabel": "Mostrar revisiones, citas o procedimientos",
"tabVorsorge": "Revisiones",
"tabVisits": "Citas"
"tabVisits": "Citas",
"tabProcedures": "Procedimientos"
},
"practitioners": {
"title": "Consultas y médicos",
+36 -3
View File
@@ -293,6 +293,11 @@
"visitsColReason": "Motif",
"visitsColOutcome": "Résultat",
"visitsColConditions": "Maladies",
"surgicalHistoryTitle": "Antécédents chirurgicaux",
"surgicalHistoryColDate": "Date",
"surgicalHistoryColProcedure": "Intervention",
"surgicalHistoryColBodySite": "Partie du corps",
"surgicalHistoryColOutcome": "Résultat",
"immunizationsTitle": "Vaccinations",
"immunizationsColDate": "Date",
"immunizationsColVaccine": "Vaccin",
@@ -10427,6 +10432,7 @@
"scopeCount": "{count} éléments issus de {groups} domaines",
"scopeSensitive": "dont {names}",
"leafVisits": "Consultations",
"leafSurgicalHistory": "Antécédents chirurgicaux",
"leafImmunizations": "Vaccinations",
"leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)"
},
@@ -10811,8 +10817,16 @@
"emergency": "Urgence",
"hospital": "Hôpital",
"therapy": "Séance de thérapie",
"other": "Rendez-vous médical"
"other": "Rendez-vous médical",
"procedure": "Intervention ou chirurgie"
},
"laterality": {
"left": "Gauche",
"right": "Droite",
"both": "Des deux côtés",
"none": "Non précisé"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Rendez-vous",
"sectionDescription": "Consultations et rendez-vous pris, au même endroit.",
"add": "Rendez-vous",
@@ -10854,6 +10868,9 @@
"reason": "Motif",
"outcome": "Résultat",
"outcomeReveal": "Ajouter un résultat",
"bodySite": "Partie du corps",
"bodySiteHint": "Par exemple genou ou vésicule biliaire.",
"laterality": "Côté",
"linkDocuments": "Documents",
"linkLabResults": "Résultats de laboratoire",
"linkConditions": "Maladies",
@@ -10873,12 +10890,28 @@
},
"dashboard": {
"nextVisit": "Prochain rendez-vous : {what} le {when}"
},
"procedures": {
"title": "Interventions et chirurgies",
"description": "Toutes les interventions et chirurgies que vous avez eues, les plus récentes d’abord.",
"add": "Intervention",
"searchLabel": "Filtrer par partie du corps",
"searchPlaceholder": "Par exemple genou",
"sitesLabel": "Parties du corps",
"allSites": "Toutes",
"count": "{shown} sur {total}",
"emptyTitle": "Aucune intervention enregistrée",
"emptyDescription": "Enregistrez un rendez-vous du type Intervention ou chirurgie, ou passez un rendez-vous existant à ce type.",
"noMatchTitle": "Rien ne correspond à ce filtre",
"clearFilter": "Effacer le filtre",
"loadError": "Les interventions n’ont pas pu être chargées."
}
},
"checkups": {
"viewSwitchLabel": "Afficher les dépistages ou les rendez-vous",
"viewSwitchLabel": "Afficher les dépistages, les rendez-vous ou les interventions",
"tabVorsorge": "Dépistages",
"tabVisits": "Rendez-vous"
"tabVisits": "Rendez-vous",
"tabProcedures": "Interventions"
},
"practitioners": {
"title": "Cabinets et médecins",
+36 -3
View File
@@ -293,6 +293,11 @@
"visitsColReason": "Motivo",
"visitsColOutcome": "Esito",
"visitsColConditions": "Malattie",
"surgicalHistoryTitle": "Anamnesi chirurgica",
"surgicalHistoryColDate": "Data",
"surgicalHistoryColProcedure": "Intervento",
"surgicalHistoryColBodySite": "Parte del corpo",
"surgicalHistoryColOutcome": "Esito",
"immunizationsTitle": "Vaccinazioni",
"immunizationsColDate": "Data",
"immunizationsColVaccine": "Vaccino",
@@ -10427,6 +10432,7 @@
"scopeCount": "{count} voci da {groups} aree",
"scopeSensitive": "incl. {names}",
"leafVisits": "Visite mediche",
"leafSurgicalHistory": "Anamnesi chirurgica",
"leafImmunizations": "Vaccinazioni",
"leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)"
},
@@ -10811,8 +10817,16 @@
"emergency": "Emergenza",
"hospital": "Ospedale",
"therapy": "Seduta di terapia",
"other": "Appuntamento medico"
"other": "Appuntamento medico",
"procedure": "Procedura o intervento chirurgico"
},
"laterality": {
"left": "Sinistra",
"right": "Destra",
"both": "Entrambi i lati",
"none": "Non indicato"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Appuntamenti",
"sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.",
"add": "Appuntamento",
@@ -10854,6 +10868,9 @@
"reason": "Motivo",
"outcome": "Esito",
"outcomeReveal": "Aggiungi un esito",
"bodySite": "Parte del corpo",
"bodySiteHint": "Per esempio ginocchio o cistifellea.",
"laterality": "Lato",
"linkDocuments": "Documenti",
"linkLabResults": "Esami di laboratorio",
"linkConditions": "Malattie",
@@ -10873,12 +10890,28 @@
},
"dashboard": {
"nextVisit": "Prossimo appuntamento: {what} il {when}"
},
"procedures": {
"title": "Procedure e interventi chirurgici",
"description": "Tutte le procedure e gli interventi che hai avuto, dal più recente.",
"add": "Intervento",
"searchLabel": "Filtra per parte del corpo",
"searchPlaceholder": "Per esempio ginocchio",
"sitesLabel": "Parti del corpo",
"allSites": "Tutte",
"count": "{shown} di {total}",
"emptyTitle": "Nessun intervento registrato",
"emptyDescription": "Registra un appuntamento del tipo Procedura o intervento chirurgico, oppure cambia un appuntamento esistente in questo tipo.",
"noMatchTitle": "Nessun risultato per questo filtro",
"clearFilter": "Rimuovi filtro",
"loadError": "Non è stato possibile caricare gli interventi."
}
},
"checkups": {
"viewSwitchLabel": "Mostra controlli o appuntamenti",
"viewSwitchLabel": "Mostra controlli, appuntamenti o interventi",
"tabVorsorge": "Controlli",
"tabVisits": "Appuntamenti"
"tabVisits": "Appuntamenti",
"tabProcedures": "Interventi"
},
"practitioners": {
"title": "Studi e medici",
+36 -3
View File
@@ -329,6 +329,11 @@
"visitsColReason": "사유",
"visitsColOutcome": "결과",
"visitsColConditions": "질환",
"surgicalHistoryTitle": "수술 이력",
"surgicalHistoryColDate": "날짜",
"surgicalHistoryColProcedure": "시술",
"surgicalHistoryColBodySite": "신체 부위",
"surgicalHistoryColOutcome": "결과",
"immunizationsTitle": "예방접종",
"immunizationsColDate": "날짜",
"immunizationsColVaccine": "백신",
@@ -10427,6 +10432,7 @@
"scopeCount": "{groups}개 영역에서 {count}개 항목",
"scopeSensitive": "{names} 포함",
"leafVisits": "진료",
"leafSurgicalHistory": "수술 이력",
"leafImmunizations": "예방접종",
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)"
},
@@ -10811,8 +10817,16 @@
"emergency": "응급",
"hospital": "병원",
"therapy": "치료 세션",
"other": "예약"
"other": "예약",
"procedure": "시술 또는 수술"
},
"laterality": {
"left": "왼쪽",
"right": "오른쪽",
"both": "양쪽",
"none": "지정 안 함"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "진료",
"sectionDescription": "병원 방문과 잡아 둔 예약을 한곳에서 봐요.",
"add": "진료",
@@ -10854,6 +10868,9 @@
"reason": "사유",
"outcome": "결과",
"outcomeReveal": "결과 추가",
"bodySite": "신체 부위",
"bodySiteHint": "예: 무릎, 담낭",
"laterality": "방향",
"linkDocuments": "문서",
"linkLabResults": "검사 결과",
"linkConditions": "질환",
@@ -10873,12 +10890,28 @@
},
"dashboard": {
"nextVisit": "다음 예약: {what} ({when})"
},
"procedures": {
"title": "시술 및 수술",
"description": "지금까지 받은 모든 시술과 수술을 최신순으로 보여 줍니다.",
"add": "시술",
"searchLabel": "신체 부위로 필터",
"searchPlaceholder": "예: 무릎",
"sitesLabel": "신체 부위",
"allSites": "전체",
"count": "{total}개 중 {shown}개",
"emptyTitle": "아직 기록된 시술이 없습니다",
"emptyDescription": "종류를 '시술 또는 수술'로 진료를 기록하거나, 기존 진료의 종류를 바꾸세요.",
"noMatchTitle": "이 필터에 맞는 항목이 없습니다",
"clearFilter": "필터 지우기",
"loadError": "시술 목록을 불러올 수 없습니다."
}
},
"checkups": {
"viewSwitchLabel": "건강검진 또는 진료 보기",
"viewSwitchLabel": "건강검진, 진료 또는 시술 보기",
"tabVorsorge": "건강검진",
"tabVisits": "진료"
"tabVisits": "진료",
"tabProcedures": "시술·수술"
},
"practitioners": {
"title": "병원과 의사",
+36 -3
View File
@@ -293,6 +293,11 @@
"visitsColReason": "Powód",
"visitsColOutcome": "Wynik",
"visitsColConditions": "Choroby",
"surgicalHistoryTitle": "Historia operacji",
"surgicalHistoryColDate": "Data",
"surgicalHistoryColProcedure": "Zabieg",
"surgicalHistoryColBodySite": "Miejsce na ciele",
"surgicalHistoryColOutcome": "Wynik",
"immunizationsTitle": "Szczepienia",
"immunizationsColDate": "Data",
"immunizationsColVaccine": "Szczepionka",
@@ -10427,6 +10432,7 @@
"scopeCount": "{count} pozycji z {groups} obszarów",
"scopeSensitive": "w tym {names}",
"leafVisits": "Wizyty lekarskie",
"leafSurgicalHistory": "Historia operacji",
"leafImmunizations": "Szczepienia",
"leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)"
},
@@ -10811,8 +10817,16 @@
"emergency": "Nagły przypadek",
"hospital": "Szpital",
"therapy": "Sesja terapeutyczna",
"other": "Wizyta lekarska"
"other": "Wizyta lekarska",
"procedure": "Zabieg lub operacja"
},
"laterality": {
"left": "Lewa strona",
"right": "Prawa strona",
"both": "Obie strony",
"none": "Nie podano"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Wizyty",
"sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.",
"add": "Wizyta",
@@ -10854,6 +10868,9 @@
"reason": "Powód",
"outcome": "Wynik",
"outcomeReveal": "Dodaj wynik",
"bodySite": "Miejsce na ciele",
"bodySiteHint": "Na przykład kolano lub pęcherzyk żółciowy.",
"laterality": "Strona",
"linkDocuments": "Dokumenty",
"linkLabResults": "Wyniki badań",
"linkConditions": "Choroby",
@@ -10873,12 +10890,28 @@
},
"dashboard": {
"nextVisit": "Najbliższa wizyta: {what}, {when}"
},
"procedures": {
"title": "Zabiegi i operacje",
"description": "Wszystkie zabiegi i operacje, które przeszedłeś, od najnowszych.",
"add": "Zabieg",
"searchLabel": "Filtruj według miejsca na ciele",
"searchPlaceholder": "Na przykład kolano",
"sitesLabel": "Miejsca na ciele",
"allSites": "Wszystkie",
"count": "{shown} z {total}",
"emptyTitle": "Nie zapisano jeszcze żadnego zabiegu",
"emptyDescription": "Zapisz wizytę rodzaju Zabieg lub operacja albo zmień rodzaj istniejącej wizyty.",
"noMatchTitle": "Nic nie pasuje do tego filtra",
"clearFilter": "Wyczyść filtr",
"loadError": "Nie udało się wczytać zabiegów."
}
},
"checkups": {
"viewSwitchLabel": "Pokaż badania profilaktyczne lub wizyty",
"viewSwitchLabel": "Pokaż badania profilaktyczne, wizyty lub zabiegi",
"tabVorsorge": "Profilaktyka",
"tabVisits": "Wizyty"
"tabVisits": "Wizyty",
"tabProcedures": "Zabiegi"
},
"practitioners": {
"title": "Gabinety i lekarze",
@@ -0,0 +1,24 @@
-- Procedure and surgery history on visits.
--
-- A visit can now be marked as a procedure or surgery (`PROCEDURE` kind) and
-- carry the body site it was done on: free text, encrypted at rest like the
-- visit's reason and outcome, plus an optional side. The reasoning for a kind
-- rather than a flag, and for searching the site by decrypting server-side
-- rather than storing a plaintext copy, is written at the `Encounter` model.
--
-- Purely additive. `ADD VALUE IF NOT EXISTS` makes a rerun safe, and the new
-- value is not used elsewhere in this migration, which is the one thing
-- Postgres forbids inside the transaction that adds it. No backfill: an
-- existing visit keeps its kind until the person switches it, which is an
-- ordinary edit. Both columns are nullable, so no row is rewritten.
--
-- Reversibility: Postgres cannot drop an enum value, so `PROCEDURE` would stay
-- (inert with no rows); the two columns and the `laterality` type can be
-- dropped.
ALTER TYPE "encounter_kind" ADD VALUE IF NOT EXISTS 'PROCEDURE';
CREATE TYPE "laterality" AS ENUM ('LEFT', 'RIGHT', 'BOTH');
ALTER TABLE "encounters"
ADD COLUMN "body_site_encrypted" BYTEA,
ADD COLUMN "laterality" "laterality";
+55 -12
View File
@@ -8208,12 +8208,12 @@ model NutrientIntakeDay {
/// relocated to the link pickers, which follow the modules that own the
/// things they point at.
model Encounter {
id String @id @default(cuid())
userId String @map("user_id")
id String @id @default(cuid())
userId String @map("user_id")
/// The visit instant (UTC; wall-clock anchoring is the reader's
/// concern, per the schema's UTC-only convention). Future instants are
/// legal — that is the whole appointment half.
occurredAt DateTime @map("occurred_at")
occurredAt DateTime @map("occurred_at")
/// Where in its lifecycle. PLANNED is a future appointment; DONE is a
/// visit that happened; CANCELLED and NO_SHOW are terminal and keep
/// the row so the history stays honest.
@@ -8227,27 +8227,49 @@ model Encounter {
/// `FHIR_REST_RESOURCE_TYPES` (`src/lib/fhir/rest.ts`) is deliberately
/// untouched — a capability statement that names a type with no route
/// module lies.
status EncounterStatus @default(DONE)
kind EncounterKind @default(OTHER)
practitionerId String? @map("practitioner_id")
status EncounterStatus @default(DONE)
kind EncounterKind @default(OTHER)
practitionerId String? @map("practitioner_id")
/// The person's own framing of why they went or are going. Encrypted
/// at rest (AES-256-GCM, the `*Encrypted` Bytes convention).
reasonEncrypted Bytes? @map("reason_encrypted")
reasonEncrypted Bytes? @map("reason_encrypted")
/// What came out of it — plan, follow-up, what to watch. Same codec.
/// One field rather than a treatment/follow-up pair whose boundary
/// nobody can state: two columns would be two chances to put the text
/// in the wrong one, and the actionable half of a follow-up is not
/// prose at all, it is the next appointment.
outcomeEncrypted Bytes? @map("outcome_encrypted")
outcomeEncrypted Bytes? @map("outcome_encrypted")
/// Where on the body a procedure was done, in the person's own words
/// ("knee", "gallbladder", "L4/L5"). Free text, because no picker covers
/// everything a surgeon operates on, and a coded vocabulary the person
/// cannot read is worse than their own word. Health data, so encrypted
/// like `reasonEncrypted`.
///
/// Searching it does not need plaintext at rest: the procedures read
/// (`src/lib/encounters/procedures.ts`) decrypts the account's own
/// procedures server-side and matches there. That set is a person's
/// surgical history — tens of rows, not thousands — so a decrypt pass
/// per search is cheap, and a plaintext or hashed search column would be
/// a second copy of the most identifying field on the row.
///
/// Allowed on any kind, surfaced for PROCEDURE: switching a visit's kind
/// away and back must not lose what was typed.
bodySiteEncrypted Bytes? @map("body_site_encrypted")
/// Which side, when the site has one. Separate from the text so "left
/// knee" and "right knee" group and filter as the same site on two sides
/// without parsing prose, and plaintext because a side alone says
/// nothing. NULL means not stated or not applicable, deliberately one
/// value rather than a NONE beside NULL that would mean the same.
laterality Laterality?
/// Optional link to the reminder this visit closes. Set when the user
/// files the visit from a due checkup, or minted here when the visit
/// is PLANNED. Satisfying the reminder is the side effect, not this
/// column.
reminderId String? @map("reminder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
reminderId String? @map("reminder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull)
@@ -8278,6 +8300,16 @@ enum EncounterStatus {
/// What kind of contact it was. Defaulted to OTHER so it is never a
/// required pick, and small enough to stay readable at a glance in a
/// list. Maps to FHIR `Encounter.class` at the export boundary.
///
/// PROCEDURE (v1.39.1) marks a surgery or procedure. A kind rather than a
/// boolean beside the kind, because the kind already answers "what was
/// this contact" and a flag would make HOSPITAL-plus-flag and PROCEDURE two
/// spellings of one visit with no rule for which to use. The cost is that a
/// surgery done in an emergency is filed as PROCEDURE and not EMERGENCY; the
/// operation is the fact a surgical history asks for, and the reason text
/// keeps the rest. Additive for every client in the field: the shipped iOS
/// app does not decode visits at all, and the web client ships with the
/// server.
enum EncounterKind {
ROUTINE
ACUTE
@@ -8287,10 +8319,21 @@ enum EncounterKind {
HOSPITAL
THERAPY
OTHER
PROCEDURE
@@map("encounter_kind")
}
/// The side of the body a procedure's site is on. See
/// `Encounter.laterality`.
enum Laterality {
LEFT
RIGHT
BOTH
@@map("laterality")
}
/// The account's own address book of doctors and practices.
///
/// User-scoped, and the scoping is the point: a global registry with an
+4
View File
@@ -660,6 +660,10 @@ async function main() {
results.push(
await rotateBytesColumn("Encounter", "outcomeEncrypted", prisma.encounter),
);
// v1.39.1 — a procedure's body site.
results.push(
await rotateBytesColumn("Encounter", "bodySiteEncrypted", prisma.encounter),
);
results.push(
await rotateBytesColumn(
"Practitioner",
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(94);
expect(ALL_LEAF_IDS.length).toBe(95);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(17);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18);
});
it("places every leaf in exactly one group", () => {
@@ -13,7 +13,7 @@
* is `STRUCTURED_LEAF_GROUP`, the other is HTML rendered by the real
* `<ClinicianView>` over a real fixture. It proves four things per leaf.
*
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so an eighteenth
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so a nineteenth
* structured leaf cannot be added to the catalogue without deciding here
* whether it renders — and saying so — or `pnpm typecheck` fails. That is
* the lock, and it is the compiler's, not this suite's.
@@ -332,6 +332,22 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
},
marker: "Palpitations",
},
SURGICAL_HISTORY: {
kind: "rendered",
data: {
surgicalHistory: [
{
occurredAt: "2011-03-14T09:00:00.000Z",
procedure: "Meniscus repair",
bodySite: "Knee",
laterality: "LEFT",
outcome: null,
practitionerName: null,
},
],
},
marker: "Meniscus repair",
},
IMMUNIZATIONS: {
kind: "rendered",
data: {
@@ -419,7 +435,7 @@ describe("clinician view — every catalogue leaf has a renderer or a reason", (
it("reads a plausible catalogue", () => {
// Sanity floor: a degraded import would satisfy every loop below
// vacuously, which is the failure mode a pair guard dies of.
expect(STRUCTURED_LEAF_IDS).toHaveLength(17);
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
expect(Object.keys(DISPOSITIONS).sort()).toEqual(
[...STRUCTURED_LEAF_IDS].sort(),
);
+8 -1
View File
@@ -943,6 +943,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
},
"app/api/encounters/procedures/route.ts": {
domain: "profile",
why: "The record's procedure and surgery history: the visits of kind PROCEDURE that happened, searched by body site. The same rows the visit list serves, read one way, so it sits under the same domain and level; the search runs over the resolved user's own rows after the decrypt and cannot address anyone else's.",
},
"app/api/encounters/suggest/route.ts": {
domain: "profile",
why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
@@ -1945,8 +1949,11 @@ const ACTOR_ROUTES: Record<string, string> = {
* The screener detail read adds one: the per-administration read that
* decrypts the stored item answers, on the record list at the same level as
* the screener history beside it. 229 -> 230.
*
* v1.39.1 -- the procedure history adds one: a read on the record list at the
* level and domain of the visit list it is a view of. 230 -> 231.
*/
const FROZEN_ENTRY_COUNT = 230;
const FROZEN_ENTRY_COUNT = 231;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+8
View File
@@ -170,6 +170,12 @@ export const PATCH = apiHandler(
? encryptToBytes(entry.outcome)
: null;
}
if (entry.bodySite !== undefined) {
data.bodySiteEncrypted = entry.bodySite?.trim()
? encryptToBytes(entry.bodySite.trim())
: null;
}
if (entry.laterality !== undefined) data.laterality = entry.laterality;
if (entry.practitionerId !== undefined) {
data.practitioner = practitionerId
? { connect: { id: practitionerId } }
@@ -331,12 +337,14 @@ export const PATCH = apiHandler(
occurredAt: existing.occurredAt,
status: existing.status,
kind: existing.kind,
laterality: existing.laterality,
practitionerId: existing.practitionerId,
},
after: {
occurredAt: outcome.updated.occurredAt,
status: outcome.updated.status,
kind: outcome.updated.kind,
laterality: outcome.updated.laterality,
practitionerId: outcome.updated.practitionerId,
},
}),
@@ -0,0 +1,179 @@
/**
* `GET /api/encounters/procedures` — the procedure history, searched after the
* decrypt.
*
* The body site is ciphertext in the fixture as it is in the database, so a
* route that searched the stored bytes instead of the decrypted text would
* find nothing and go red here. The fake honours nothing about the `where`, so
* the test asserts the `where` it was handed directly: a procedure history
* that forgot the kind or the status filter would otherwise pass against a
* fixture of procedures only.
*
* Mutation checks (each run, each seen red):
* - drop `kind: "PROCEDURE"` from the read → "reads only procedures that
* happened" goes red;
* - compute the facets over the filtered list → "offers every body site
* whatever the filter" goes red with one facet;
* - search `reasonEncrypted` bytes instead of the DTO → the knee search
* returns nothing and goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const mocks = vi.hoisted(() => ({
encounterFindMany: vi.fn(),
userFindUnique: vi.fn(),
requireRecordAuth: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireRecordAuth: mocks.requireRecordAuth,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => undefined,
}));
vi.mock("@/lib/db", () => ({
prisma: {
encounter: { findMany: mocks.encounterFindMany },
user: { findUnique: mocks.userFindUnique },
},
}));
vi.mock("@/lib/sharing/acting-domains", () => ({
actingDomainVisibility: vi.fn(async () => () => true),
}));
vi.mock("@/lib/links", () => ({
listTargets: vi.fn(async () => []),
listTargetsBySource: vi.fn(async () => new Map()),
replaceTargets: vi.fn(),
}));
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { GET } from "../route";
const handler = GET as unknown as (req: NextRequest) => Promise<Response>;
function request(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/encounters/procedures${query ? `?${query}` : ""}`,
);
}
function row(
id: string,
day: number,
bodySite: string | null,
laterality: "LEFT" | "RIGHT" | "BOTH" | null,
reason: string | null,
) {
const at = new Date(Date.UTC(2020, 0, day));
return {
id,
userId: "user-1",
occurredAt: at,
status: "DONE",
kind: "PROCEDURE",
practitionerId: null,
practitioner: null,
reminder: null,
reminderId: null,
reasonEncrypted: reason ? encryptToBytes(reason) : null,
outcomeEncrypted: null,
bodySiteEncrypted: bodySite ? encryptToBytes(bodySite) : null,
laterality,
createdAt: at,
updatedAt: at,
deletedAt: null,
};
}
interface Body {
data: {
procedures: Array<{ id: string; bodySite: string | null }>;
bodySites: Array<{ bodySite: string; laterality: string | null }>;
total: number;
};
}
beforeEach(() => {
vi.clearAllMocks();
mocks.requireRecordAuth.mockResolvedValue({
user: { id: "user-1" },
actor: { id: "user-1" },
grantId: null,
});
mocks.userFindUnique.mockResolvedValue({
timezone: "Europe/Berlin",
locale: "de",
});
mocks.encounterFindMany.mockResolvedValue([
row("p3", 30, "Knie", "RIGHT", "Arthroskopie"),
row("p2", 20, "Gallenblase", null, "Cholezystektomie"),
row("p1", 10, "Knie", "LEFT", "Meniskus"),
]);
});
describe("GET /api/encounters/procedures", () => {
it("reads the health background at READ", async () => {
await handler(request());
expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "profile");
});
it("reads only procedures that happened, for the caller", async () => {
await handler(request());
const args = mocks.encounterFindMany.mock.calls[0][0];
expect(args.where).toMatchObject({
userId: "user-1",
deletedAt: null,
kind: "PROCEDURE",
status: "DONE",
});
expect(args.orderBy).toEqual({ occurredAt: "desc" });
expect(args.take).toBeGreaterThan(0);
});
it("returns the whole history, newest first, with the decrypted site", async () => {
const res = await handler(request());
expect(res.status).toBe(200);
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p3", "p2", "p1"]);
expect(body.data.procedures[0].bodySite).toBe("Knie");
expect(body.data.total).toBe(3);
});
it("searches the decrypted site together with the side, in the owner's language", async () => {
const res = await handler(request("q=knie%20links"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p1"]);
});
it("finds the English side word as well", async () => {
const res = await handler(request("q=left"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p1"]);
});
it("filters the side exactly", async () => {
const res = await handler(request("laterality=RIGHT"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p3"]);
});
it("offers every body site whatever the filter", async () => {
const res = await handler(request("q=galle"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p2"]);
expect(body.data.bodySites).toHaveLength(3);
expect(body.data.total).toBe(3);
});
it("refuses an unknown side with 422", async () => {
const res = await handler(request("laterality=UP"));
expect(res.status).toBe(422);
expect(mocks.encounterFindMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,60 @@
/**
* `GET /api/encounters/procedures` — the procedure and surgery history.
*
* Every visit filed as PROCEDURE that happened, newest first, with the body
* sites it holds as filter choices. `q` searches the body site, the side and
* the reason; `laterality` filters the side exactly. Both run after the
* decrypt, because the body site is ciphertext at rest; the reasoning is
* written at `Encounter.bodySiteEncrypted` and in
* `src/lib/encounters/procedures.ts`.
*
* Same section and level as the visit list (`("read", "profile")`): a
* procedure is a visit, and a grant that reads the visits reads this.
* `userId` is narrowed from auth and fed to the Prisma `where`.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { procedureListQuerySchema } from "@/lib/validations/encounters";
import { resolveOwnerNotificationContext } from "@/lib/encounters/service";
import { loadProcedureHistory } from "@/lib/encounters/procedures";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
export const GET = apiHandler(async (request: NextRequest) => {
const { user, grantId } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = procedureListQuerySchema.safeParse({
q: params.get("q") ?? undefined,
laterality: params.get("laterality") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
// The side words are the record owner's, like every other label a visit
// resolves: a delegate searching somebody's history types in that person's
// record, and the English word is matched as well either way.
const { locale } = await resolveOwnerNotificationContext(user.id);
const visible = await actingDomainVisibility(prisma, grantId);
const body = await loadProcedureHistory(prisma, user.id, parsed.data, {
locale,
visible,
});
annotate({
action: { name: "encounter.procedure.list", entity_type: "encounter" },
meta: {
total: body.total,
matched: body.procedures.length,
filtered: Boolean(parsed.data.q?.trim() || parsed.data.laterality),
},
});
return apiSuccess(body);
});
+4
View File
@@ -216,6 +216,10 @@ async function postEncounter(request: NextRequest): Promise<Response> {
practitionerId: practitioner?.id ?? null,
reasonEncrypted: entry.reason ? encryptToBytes(entry.reason) : null,
outcomeEncrypted: entry.outcome ? encryptToBytes(entry.outcome) : null,
bodySiteEncrypted: entry.bodySite?.trim()
? encryptToBytes(entry.bodySite.trim())
: null,
laterality: entry.laterality ?? null,
},
select: { id: true },
});
+12
View File
@@ -12,6 +12,7 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
import { VisitsSection } from "@/components/encounters/visits-section";
import { ProceduresSection } from "@/components/encounters/procedures-section";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -30,6 +31,11 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
* did I actually attend — and the page's own sentence already covers both. A
* visit carries no module gate for the same reason this page never had one.
*
* v1.39.1 — a third tab, the procedure and surgery history. It is the visits
* list read one way (the visits of kind PROCEDURE, searchable by body site),
* so it sits beside the visits rather than behind a nav entry of its own, and
* its read hangs off the same `["encounters"]` root the pull already evicts.
*
* The pull invalidates both reads. Refreshing one and leaving the other stale
* is how a page ends up showing a visit that closed a checkup still listed as
* due.
@@ -69,6 +75,9 @@ export default function CheckupsPageClient() {
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
{t("checkups.tabVisits")}
</TabsTrigger>
<TabsTrigger value="procedures" data-testid="checkups-tab-procedures">
{t("checkups.tabProcedures")}
</TabsTrigger>
</TabsList>
<TabsContent value="vorsorge">
<VorsorgeSection enabled={isAuthenticated} variant="page" />
@@ -78,6 +87,9 @@ export default function CheckupsPageClient() {
for a person who never opens it. */}
<VisitsSection enabled={isAuthenticated} />
</TabsContent>
<TabsContent value="procedures">
<ProceduresSection enabled={isAuthenticated} />
</TabsContent>
</Tabs>
</div>
);
@@ -801,6 +801,8 @@ describe("a visit row's edit sheet", () => {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
@@ -46,6 +46,7 @@ import { EmergencySection, PatientIdentitySection } from "./identity-sections";
import {
IllnessSection,
ImmunizationsSection,
SurgicalHistorySection,
LabResultsSection,
VisitsSection,
} from "./history-sections";
@@ -256,6 +257,12 @@ export function ClinicianView({
scope={scope}
fmtDate={fmtDate}
/>
<SurgicalHistorySection
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
/>
<ImmunizationsSection
t={t}
report={report}
+53 -3
View File
@@ -1,6 +1,6 @@
/**
* The `labs` and `history` groups on the clinician view: lab results, illness
* episodes, visits and the immunization record.
* episodes, visits, the surgical history and the immunization record.
*
* All four were selectable on a share link and none of them reached the page,
* while the PDF download from the same link printed every one. What each
@@ -12,8 +12,11 @@
* A pure server component: no client hooks, no session, no markdown — every
* value renders as escaped React text.
*/
import type { EncounterKind } from "@/generated/prisma/client";
import { encounterKindLabelKey } from "@/lib/encounters/kind-label";
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
import {
encounterKindLabelKey,
lateralityLabelKey,
} from "@/lib/encounters/kind-label";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import {
@@ -221,6 +224,53 @@ export function VisitsSection({
);
}
/**
* The surgical history. Reference data, not window-bounded, like the
* immunization record below: every procedure that happened, oldest first. What
* was done is the row's label; the date, the site with its side and the
* outcome compose onto the value side, the shape the immunization rows use.
*/
export function SurgicalHistorySection({
t,
report,
scope,
fmtDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
}) {
const procedures = report.surgicalHistory ?? [];
const site = (row: (typeof procedures)[number]) => {
if (!row.bodySite) return null;
if (!row.laterality) return row.bodySite;
return t("encounters.bodySiteWithSide", {
site: row.bodySite,
side: t(lateralityLabelKey(row.laterality as Laterality)),
});
};
return (
<LeafSection
t={t}
scope={scope}
leaves={["SURGICAL_HISTORY"]}
title={t("doctorReport.surgicalHistoryTitle")}
empty={procedures.length === 0}
>
{procedures.map((row, index) => (
<StatRow
key={`${row.occurredAt}-${index}`}
label={row.procedure ?? "—"}
value={compose([fmtDate(row.occurredAt), site(row), row.outcome])}
/>
))}
</LeafSection>
);
}
/**
* The immunization record. Reference data, not window-bounded: an Impfpass is
* a lifetime document and the whole of it rides when the leaf and the module
@@ -132,6 +132,8 @@ describe("editing never unfiles what the visit already collected", () => {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
links: {
documents: [{ id: "d1", label: "Letter", date: null, redacted: false }],
@@ -216,3 +218,96 @@ describe("the link pickers follow the modules that own their targets", () => {
expect(html).toContain('data-slot="encounter-link-pickers"');
});
});
/**
* v1.39.1 — the body site. Shown for a procedure, where it means something,
* and kept visible on any visit that already carries one, so switching the
* kind away never hides text the person typed. Never required.
*
* Mutation checks (each run, each seen red):
* - show the field only for PROCEDURE → "keeps a typed site visible after
* the kind moves away" goes red;
* - send `bodySite` untrimmed → the whitespace case goes red.
*/
describe("the body site", () => {
function renderDraft(over: Parameters<typeof emptyDraft>[0]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<EncounterForm
draft={emptyDraft({ occurredAt: "2026-08-08T10:00", ...over })}
onChange={() => {}}
isEdit
/>
</I18nProvider>
</QueryClientProvider>,
);
}
it("offers the site and the side for a procedure", () => {
const html = renderDraft({ kind: "PROCEDURE" });
expect(html).toContain('id="encounter-body-site"');
expect(html).toContain('id="encounter-laterality"');
expect(html).toContain("Body site");
expect(html).toContain("Not stated");
});
it("keeps the fields off an ordinary visit", () => {
const html = renderDraft({ kind: "ROUTINE" });
expect(html).not.toContain('id="encounter-body-site"');
});
it("keeps a typed site visible after the kind moves away", () => {
const html = renderDraft({ kind: "HOSPITAL", bodySite: "Knee" });
expect(html).toContain('id="encounter-body-site"');
});
it("offers Procedure or surgery among the kinds", () => {
const html = renderDraft({ kind: "OTHER" });
expect(html).toContain('value="PROCEDURE"');
expect(html).toContain("Procedure or surgery");
});
it("sends the site trimmed and the side, and null for an empty site", () => {
const withSite = draftToBody(
emptyDraft({
occurredAt: "2026-08-08T10:00",
kind: "PROCEDURE",
bodySite: " Knee ",
laterality: "LEFT",
}),
{ isEdit: false },
);
expect(withSite?.bodySite).toBe("Knee");
expect(withSite?.laterality).toBe("LEFT");
const empty = draftToBody(
emptyDraft({ occurredAt: "2026-08-08T10:00", bodySite: " " }),
{ isEdit: false },
);
expect(empty?.bodySite).toBeNull();
expect(empty?.laterality).toBeNull();
});
it("seeds the site and the side from the row being edited", () => {
const draft = draftFromEncounter({
id: "e1",
occurredAt: "2026-08-01T09:00:00.000Z",
status: "DONE",
kind: "PROCEDURE",
practitioner: null,
reason: null,
outcome: null,
bodySite: "Knee",
laterality: "RIGHT",
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
});
expect(draft.bodySite).toBe("Knee");
expect(draft.laterality).toBe("RIGHT");
});
});
@@ -0,0 +1,205 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The procedure history's designed states, pinned by single-pass static
* renders (the repository's component-test convention; typing into the filter
* is driven in the browser check, not here).
*
* - an account with no procedure gets an empty state that says how to make
* one — including switching a visit already on file — and one action;
* - a history lists what the server sent, in the server's order, each row
* naming its body site with the side;
* - the body sites are offered as filter choices, with "All" pressed while
* nothing is filtered;
* - a failed read is an error with a retry, never the empty state.
*
* Mutation checks (each run, each seen red):
* - render `procedures.length === 0` as the account-empty state instead of
* keying it on `total` → "keeps the filter when nothing matches" goes red;
* - drop the side from `bodySiteText` → "names the site with its side" goes
* red.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: {} },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canWrite: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
apiGet: () => new Promise(() => {}),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO, ProcedureListDTO } from "@/lib/encounters/dto";
import { ProceduresSection } from "../procedures-section";
function procedure(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
return {
occurredAt: "2011-03-14T09:00:00.000Z",
status: "DONE",
kind: "PROCEDURE",
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: "2011-03-14T09:00:00.000Z",
updatedAt: "2011-03-14T09:00:00.000Z",
...over,
};
}
function render(data: ProcedureListDTO | "error") {
const queryClient = new QueryClient({
// `retryOnMount: false` so a seeded error stays an error through the
// render instead of being reported as a fresh fetch.
defaultOptions: { queries: { retry: false, retryOnMount: false } },
});
const key = queryKeys.encounterProcedures("", null);
if (data === "error") {
queryClient
.getQueryCache()
.build(queryClient, { queryKey: key })
.setState({
status: "error",
error: new Error("boom"),
fetchStatus: "idle",
});
} else {
queryClient.setQueryData(key, data);
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<ProceduresSection />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<ProceduresSection>", () => {
it("explains how to get a first procedure, including switching a visit", () => {
const html = render({ procedures: [], bodySites: [], total: 0 });
expect(html).toContain("No procedure recorded yet");
expect(html).toContain("switch an existing visit to it");
expect(html).toContain('data-slot="procedures-add"');
expect(html).not.toContain('data-slot="procedures-filter"');
expect(html).not.toContain("Procedures could not be loaded.");
});
it("lists the history in the server's order and names the site with its side", () => {
const html = render({
procedures: [
procedure({
id: "newer",
occurredAt: "2019-07-02T09:00:00.000Z",
reason: "Cholecystectomy",
bodySite: "Gallbladder",
}),
procedure({
id: "older",
reason: "Meniscus repair",
bodySite: "Knee",
laterality: "LEFT",
}),
],
bodySites: [
{ bodySite: "Gallbladder", laterality: null, count: 1 },
{ bodySite: "Knee", laterality: "LEFT", count: 1 },
],
total: 2,
});
expect(html.indexOf('data-encounter-id="newer"')).toBeLessThan(
html.indexOf('data-encounter-id="older"'),
);
expect(html).toContain("Meniscus repair");
expect(html).toContain("Knee · Left");
expect(html).toContain("2 of 2");
});
it("heads a procedure with what was done, and keeps the practice on its own line", () => {
const html = render({
procedures: [
procedure({
id: "p1",
reason: "Meniscus repair",
practitioner: {
id: "pr1",
name: "Sample clinic",
specialty: null,
practice: null,
location: null,
phone: null,
note: null,
createdAt: "2011-01-01T00:00:00.000Z",
updatedAt: "2011-01-01T00:00:00.000Z",
},
}),
],
bodySites: [],
total: 1,
});
expect(html).toMatch(/font-medium">Meniscus repair</);
expect(html).toContain("Sample clinic");
expect(html.match(/Meniscus repair/g)).toHaveLength(1);
});
it("offers every body site as a filter, with All pressed while nothing is filtered", () => {
const html = render({
procedures: [
procedure({ id: "p1", bodySite: "Knee", laterality: "LEFT" }),
],
bodySites: [{ bodySite: "Knee", laterality: "LEFT", count: 1 }],
total: 1,
});
expect(html).toContain('data-slot="procedures-filter"');
expect(html).toMatch(
/data-slot="procedures-site-all"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="procedures-site-all"/,
);
expect(html).toMatch(
/aria-pressed="false"[^>]*>Knee · Left|data-slot="procedures-site"[^>]*aria-pressed="false"/,
);
});
it("keeps the filter when nothing matches, and offers to clear it", () => {
const html = render({
procedures: [],
bodySites: [{ bodySite: "Knee", laterality: null, count: 1 }],
total: 1,
});
expect(html).toContain("Nothing matches this filter");
expect(html).toContain('data-slot="procedures-filter"');
expect(html).not.toContain("No procedure recorded yet");
});
it("paints an error with a retry, never the empty state", () => {
const html = render("error");
expect(html).toContain("Procedures could not be loaded.");
expect(html).not.toContain("No procedure recorded yet");
});
});
@@ -60,6 +60,8 @@ function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
@@ -27,6 +27,7 @@ import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/hooks/use-auth";
@@ -36,8 +37,10 @@ import type { Practitioner } from "@/hooks/use-practitioners";
import {
ENCOUNTER_KINDS,
ENCOUNTER_STATUSES,
LATERALITIES,
encounterKindText,
encounterStatusText,
lateralityText,
} from "./encounter-labels";
import { PractitionerCombobox } from "./practitioner-combobox";
import { EncounterLinkPickers } from "./encounter-link-pickers";
@@ -50,6 +53,9 @@ export interface EncounterDraft {
practitioner: Practitioner | null;
reason: string;
outcome: string;
/** Where on the body; free text, empty when not stated. */
bodySite: string;
laterality: "LEFT" | "RIGHT" | "BOTH" | null;
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
@@ -82,6 +88,8 @@ export function emptyDraft(
practitioner: null,
reason: "",
outcome: "",
bodySite: "",
laterality: null,
documentIds: [],
labResultIds: [],
episodeIds: [],
@@ -97,6 +105,8 @@ export function draftFromEncounter(row: Encounter): EncounterDraft {
practitioner: row.practitioner,
reason: row.reason ?? "",
outcome: row.outcome ?? "",
bodySite: row.bodySite ?? "",
laterality: row.laterality,
documentIds: row.links?.documents.map((link) => link.id) ?? [],
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
@@ -131,6 +141,8 @@ export function draftToBody(
practitionerId: draft.practitioner?.id ?? null,
reason: draft.reason.trim() || null,
outcome: draft.outcome.trim() || null,
bodySite: draft.bodySite.trim() || null,
laterality: draft.laterality,
...(options.reminderId ? { reminderId: options.reminderId } : {}),
documentIds: draft.documentIds,
labResultIds: draft.labResultIds,
@@ -168,6 +180,15 @@ export function EncounterForm({
const [revealed, setRevealed] = useState(false);
const outcomeOpen = revealed || !planned || draft.outcome.length > 0;
// The body site belongs to a procedure, so that is where it is offered. A
// visit that already carries one keeps the fields in view whatever its kind:
// switching the kind away must not hide text the person typed, and the
// server keeps it either way.
const showBodySite =
draft.kind === "PROCEDURE" ||
draft.bodySite.trim().length > 0 ||
draft.laterality !== null;
return (
<div className="space-y-4" data-slot="encounter-form">
<FieldGroup
@@ -234,6 +255,47 @@ export function EncounterForm({
</NativeSelect>
</FieldGroup>
{showBodySite ? (
<div className="grid gap-4 sm:grid-cols-[2fr_1fr]">
<FieldGroup
htmlFor="encounter-body-site"
label={t("encounters.form.bodySite")}
hint={t("encounters.form.bodySiteHint")}
>
<Input
id="encounter-body-site"
value={draft.bodySite}
maxLength={200}
autoComplete="off"
onChange={(event) => patch({ bodySite: event.target.value })}
/>
</FieldGroup>
<FieldGroup
htmlFor="encounter-laterality"
label={t("encounters.form.laterality")}
>
<NativeSelect
id="encounter-laterality"
value={draft.laterality ?? ""}
onChange={(event) =>
patch({
laterality:
(event.target.value as EncounterDraft["laterality"]) ||
null,
})
}
>
<option value="">{t("encounters.laterality.none")}</option>
{LATERALITIES.map((side) => (
<option key={side} value={side}>
{lateralityText(t, side)}
</option>
))}
</NativeSelect>
</FieldGroup>
</div>
) : null}
<FieldGroup
htmlFor="encounter-reason"
label={t("encounters.form.reason")}
+42 -2
View File
@@ -12,9 +12,13 @@
* An interpolated key is invisible to `i18n-call-site-coverage`, so a renamed
* or missing label would ship as raw dot notation instead of failing CI.
*/
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
import type {
EncounterKind,
EncounterStatus,
Laterality,
} from "@/generated/prisma/client";
type Translate = (key: string) => string;
type Translate = (key: string, values?: Record<string, string>) => string;
/** Every kind, in the order the picker offers them. */
export const ENCOUNTER_KINDS: EncounterKind[] = [
@@ -25,9 +29,13 @@ export const ENCOUNTER_KINDS: EncounterKind[] = [
"EMERGENCY",
"HOSPITAL",
"THERAPY",
"PROCEDURE",
"OTHER",
];
/** Every side a body site can carry; "not stated" is the absence, not a value. */
export const LATERALITIES: Laterality[] = ["LEFT", "RIGHT", "BOTH"];
/** Every status a visit can be edited into. */
export const ENCOUNTER_STATUSES: EncounterStatus[] = [
"PLANNED",
@@ -52,6 +60,8 @@ export function encounterKindText(t: Translate, kind: EncounterKind): string {
return t("encounters.kind.hospital");
case "THERAPY":
return t("encounters.kind.therapy");
case "PROCEDURE":
return t("encounters.kind.procedure");
default:
return t("encounters.kind.other");
}
@@ -72,3 +82,33 @@ export function encounterStatusText(
return t("encounters.status.noShow");
}
}
export function lateralityText(t: Translate, laterality: Laterality): string {
switch (laterality) {
case "LEFT":
return t("encounters.laterality.left");
case "RIGHT":
return t("encounters.laterality.right");
default:
return t("encounters.laterality.both");
}
}
/**
* The body site as one line: the person's own words, with the side after it
* when one is stated. Null when the visit names no site, so a caller renders
* nothing rather than a lone side.
*/
export function bodySiteText(
t: Translate,
bodySite: string | null,
laterality: Laterality | null,
): string | null {
const site = bodySite?.trim();
if (!site) return null;
if (!laterality) return site;
return t("encounters.bodySiteWithSide", {
site,
side: lateralityText(t, laterality),
});
}
@@ -67,6 +67,7 @@ export function EncounterSheet({
encounter,
reminderId,
episodeId,
kind,
onSaved,
}: {
open: boolean;
@@ -77,6 +78,8 @@ export function EncounterSheet({
reminderId?: string | null;
/** A condition to pre-link, when the sheet was opened from an episode. */
episodeId?: string | null;
/** The kind a new visit starts as — PROCEDURE from the procedure history. */
kind?: EncounterDraft["kind"];
onSaved?: (saved: Encounter) => void;
}) {
const { t } = useTranslations();
@@ -92,6 +95,7 @@ export function EncounterSheet({
: emptyDraft({
occurredAt: nowLocalValue(),
episodeIds: episodeId ? [episodeId] : [],
...(kind ? { kind } : {}),
}),
);
const [error, setError] = useState<string | null>(null);
@@ -0,0 +1,231 @@
"use client";
/**
* The procedure and surgery history — the third tab of the checkups page.
*
* It answers the intake-form question "what surgeries have you had" at a
* glance, and "what was done on the left knee" with one tap. A procedure is a
* visit of kind PROCEDURE, so there is no second record type to keep in step:
* the rows here are the same rows the visits tab lists, opened into the same
* sheet, and a visit filed before this view existed joins it by switching its
* kind.
*
* The search runs on the server. The body site is encrypted at rest, so the
* server decrypts the account's own procedures and matches there; this view
* sends the words and renders the answer. The body-site choices and the total
* come back with every answer, computed over the whole history, so picking one
* never makes the others disappear.
*/
import { Plus, Scissors, Search } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useProcedures, type Encounter } from "@/hooks/use-encounters";
import { useTranslations } from "@/lib/i18n/context";
import type { BodySiteFacetDTO } from "@/lib/encounters/dto";
import { bodySiteText } from "./encounter-labels";
import { EncounterSheet } from "./encounter-sheet";
import { VisitCard } from "./visit-card";
type Side = "LEFT" | "RIGHT" | "BOTH" | null;
export function ProceduresSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
// Same grant questions as the visits tab: a procedure is a visit.
const canAdd = canWriteDomain("profile");
const canManage = canManageDomain("profile");
const [query, setQuery] = useState("");
const [side, setSide] = useState<Side>(null);
const q = useDebouncedValue(query.trim(), 250);
const list = useProcedures(enabled, { q, laterality: side });
const [editing, setEditing] = useState<Encounter | "new" | null>(null);
const [session, setSession] = useState(0);
const openSheet = (target: Encounter | "new") => {
setEditing(target);
setSession((n) => n + 1);
};
const procedures = list.data?.procedures ?? [];
const bodySites = list.data?.bodySites ?? [];
const total = list.data?.total ?? 0;
const filtered = query.trim().length > 0 || side !== null;
const pick = (facet: BodySiteFacetDTO | null) => {
setQuery(facet?.bodySite ?? "");
setSide(facet?.laterality ?? null);
};
const isPicked = (facet: BodySiteFacetDTO) =>
query.trim() === facet.bodySite && side === facet.laterality;
const addButton = canAdd ? (
<Button
type="button"
className="min-h-11"
data-slot="procedures-add"
onClick={() => openSheet("new")}
>
<Plus className="size-4" aria-hidden />
{t("encounters.procedures.add")}
</Button>
) : null;
return (
<section aria-labelledby="procedures-section-title" className="space-y-4">
<PageHeader
titleId="procedures-section-title"
title={t("encounters.procedures.title")}
description={t("encounters.procedures.description")}
actions={addButton}
/>
{list.isError ? (
<QueryErrorCard
title={t("encounters.procedures.loadError")}
onRetry={() => void list.refetch()}
/>
) : list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-20 w-full rounded-xl" />
))}
</div>
) : total === 0 ? (
<EmptyState
icon={<Scissors className="size-6" aria-hidden />}
title={t("encounters.procedures.emptyTitle")}
description={t("encounters.procedures.emptyDescription")}
action={addButton ?? undefined}
ctaSize="lg"
/>
) : (
<div className="space-y-4">
<div className="space-y-3" data-slot="procedures-filter">
<FieldGroup
htmlFor="procedures-search"
label={t("encounters.procedures.searchLabel")}
>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
id="procedures-search"
type="search"
value={query}
autoComplete="off"
maxLength={200}
placeholder={t("encounters.procedures.searchPlaceholder")}
className="pl-9"
onChange={(event) => {
setQuery(event.target.value);
// Typing starts a new question; a side picked from a chip
// belonged to the old one.
setSide(null);
}}
/>
</div>
</FieldGroup>
{bodySites.length > 0 ? (
<div
role="group"
aria-label={t("encounters.procedures.sitesLabel")}
className="flex flex-wrap gap-2"
>
<Button
type="button"
size="sm"
variant={filtered ? "outline" : "secondary"}
aria-pressed={!filtered}
data-slot="procedures-site-all"
className="min-h-11 rounded-full sm:min-h-9"
onClick={() => pick(null)}
>
{t("encounters.procedures.allSites")}
</Button>
{bodySites.map((facet) => {
const picked = isPicked(facet);
return (
<Button
key={`${facet.bodySite}|${facet.laterality ?? ""}`}
type="button"
size="sm"
variant={picked ? "secondary" : "outline"}
aria-pressed={picked}
data-slot="procedures-site"
className="min-h-11 rounded-full sm:min-h-9"
onClick={() => pick(picked ? null : facet)}
>
{bodySiteText(t, facet.bodySite, facet.laterality)}
<span className="text-muted-foreground tabular-nums">
{facet.count}
</span>
</Button>
);
})}
</div>
) : null}
</div>
<p className="text-muted-foreground text-xs" aria-live="polite">
{t("encounters.procedures.count", {
shown: String(procedures.length),
total: String(total),
})}
</p>
{procedures.length === 0 ? (
<EmptyState
icon={<Search className="size-6" aria-hidden />}
title={t("encounters.procedures.noMatchTitle")}
action={
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => pick(null)}
>
{t("encounters.procedures.clearFilter")}
</Button>
}
/>
) : (
<div className="space-y-2" data-slot="procedures-list">
{procedures.map((encounter) => (
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={canManage ? openSheet : undefined}
/>
))}
</div>
)}
</div>
)}
{/* Remounted per open, like the visits tab's sheet, so a new procedure
starts as a procedure and an edit starts from its own row. */}
<EncounterSheet
key={session}
open={editing !== null}
onOpenChange={(open) => {
if (!open) setEditing(null);
}}
encounter={editing === "new" ? null : editing}
kind="PROCEDURE"
/>
</section>
);
}
+34 -3
View File
@@ -23,7 +23,11 @@ import { Card, CardContent } from "@/components/ui/card";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Encounter } from "@/hooks/use-encounters";
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
import { encounterKindText, encounterStatusText } from "./encounter-labels";
import {
bodySiteText,
encounterKindText,
encounterStatusText,
} from "./encounter-labels";
/** The two statuses worth calling out on a row; the rest the section implies. */
function isNoteworthy(status: EncounterStatus): boolean {
@@ -67,9 +71,23 @@ export function VisitCard({
// The practice, or the kind when there is none. A visit with neither is not
// nameless — the kind always resolves, because it defaults to one.
//
// A procedure leads with what was done instead: in a surgical history the
// operation is the thing being scanned for, and a column of rows headed
// "Procedure or surgery" beside a badge saying the same would say nothing.
// The practice then moves to its own line, still foreground.
const isProcedure = encounter.kind === "PROCEDURE";
const procedureHeading = isProcedure ? encounter.reason : null;
const heading =
procedureHeading ??
encounter.practitioner?.name ??
encounterKindText(t, encounter.kind as EncounterKind);
const reasonLine = procedureHeading ? null : encounter.reason;
const practiceLine = procedureHeading
? (encounter.practitioner?.name ?? null)
: null;
// Where on the body, with the side — what the person wrote, so foreground.
const site = bodySiteText(t, encounter.bodySite, encounter.laterality);
return (
<Card
@@ -97,9 +115,22 @@ export function VisitCard({
</Badge>
</div>
{encounter.reason ? (
{reasonLine ? (
<p className="text-foreground line-clamp-1 text-sm">{reasonLine}</p>
) : null}
{site ? (
<p
className="text-foreground line-clamp-1 text-sm"
data-slot="visit-card-body-site"
>
{site}
</p>
) : null}
{practiceLine ? (
<p className="text-foreground line-clamp-1 text-sm">
{encounter.reason}
{practiceLine}
</p>
) : null}
@@ -192,8 +192,8 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "body")).toBe("0/12");
expect(groupChip(html, "labs")).toBe("0/1");
expect(groupChip(html, "medications")).toBe("0/4");
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
expect(groupChip(html, "history")).toBe("0/4");
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
expect(groupChip(html, "history")).toBe("0/5");
expect(groupChip(html, "cardio")).toBe("0/14");
expect(groupChip(html, "activity")).toBe("0/8");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
@@ -44,6 +44,8 @@ function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
+42 -2
View File
@@ -12,7 +12,12 @@
* Writes reach two roots (`encounterDependentKeys`), because renaming a
* practice changes the label every visit that names it renders.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
@@ -21,7 +26,11 @@ import {
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
import type {
EncounterDTO,
EncounterListDTO,
ProcedureListDTO,
} from "@/lib/encounters/dto";
import type { EncounterSuggestionResult } from "@/lib/encounters/suggest-window";
export type Encounter = EncounterDTO;
@@ -34,6 +43,8 @@ export interface EncounterWriteBody {
practitionerId?: string | null;
reason?: string | null;
outcome?: string | null;
bodySite?: string | null;
laterality?: "LEFT" | "RIGHT" | "BOTH" | null;
/** The checkup this visit closes, when it was filed from a due one. */
reminderId?: string | null;
documentIds?: string[];
@@ -75,6 +86,35 @@ export function useEncounters(
});
}
/**
* The procedure and surgery history, searched server-side.
*
* The body site is ciphertext at rest, so the search runs on the server after
* the decrypt, and the query is part of the key. The previous answer stays on
* screen while the next one loads, so typing does not flash the list empty;
* the body-site choices and the total arrive with every answer, computed over
* the whole history.
*/
export function useProcedures(
enabled: boolean,
filter: { q: string; laterality: "LEFT" | "RIGHT" | "BOTH" | null },
) {
return useQuery({
queryKey: queryKeys.encounterProcedures(filter.q, filter.laterality),
queryFn: () => {
const sp = new URLSearchParams();
if (filter.q.trim()) sp.set("q", filter.q.trim());
if (filter.laterality) sp.set("laterality", filter.laterality);
const qs = sp.toString();
return apiGet<ProcedureListDTO>(
qs ? `${BASE}/procedures?${qs}` : `${BASE}/procedures`,
);
},
placeholderData: keepPreviousData,
enabled,
});
}
/**
* "Does this belong to a visit?" for one anchor date.
*
@@ -641,6 +641,82 @@ describe("doctor-report illness section", () => {
});
});
// v1.39.1 — the surgical history. Lifetime reference data like the
// immunization record: every procedure that happened, with its date, what was
// done (the visit's reason), where, and what came of it. Present only when the
// SURGICAL_HISTORY leaf was admitted and at least one procedure exists.
//
// Mutation checks (each run, each seen red):
// - drop the section's `if` block from the renderer → "prints the surgical
// history table" goes red;
// - print `bodySite` without the side → "names the side beside the site"
// goes red.
describe("doctor-report surgical history section", () => {
const PROCEDURES: NonNullable<DoctorReportData["surgicalHistory"]> = [
{
occurredAt: "2011-03-14T09:00:00.000Z",
procedure: "Meniscus repair",
bodySite: "Knee",
laterality: "LEFT",
outcome: "Full recovery after physiotherapy",
practitionerName: "Sample clinic",
},
{
occurredAt: "2019-07-02T09:00:00.000Z",
procedure: null,
bodySite: null,
laterality: null,
outcome: null,
practitionerName: null,
},
];
it("prints the surgical history table with procedure, site and outcome", async () => {
const bytes = renderDoctorReportPdfBytes(
makeData({ surgicalHistory: PROCEDURES }),
{
timeFormat: "AUTO",
dateFormat: "AUTO",
t: getServerTranslator("en").t,
locale: "en",
now: FIXED_NOW,
},
);
const text = await extractText(bytes);
expect(text).toContain("Surgical history");
expect(text).toContain("Meniscus repair");
expect(text).toContain("Full recovery after physiotherapy");
});
it("names the side beside the site", async () => {
const bytes = renderDoctorReportPdfBytes(
makeData({ surgicalHistory: PROCEDURES }),
{
timeFormat: "AUTO",
dateFormat: "AUTO",
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
},
);
const text = await extractText(bytes);
expect(text).toContain("Operationen und Eingriffe");
expect(text).toMatch(/Knee · Links/);
});
it("omits the section when there is no procedure", async () => {
const bytes = renderDoctorReportPdfBytes(makeData(), {
timeFormat: "AUTO",
dateFormat: "AUTO",
t: getServerTranslator("en").t,
locale: "en",
now: FIXED_NOW,
});
const text = await extractText(bytes);
expect(text).not.toContain("Surgical history");
});
});
// ── emergency first page ── the Notfalldaten sheet owns page one, but only
// when the aggregator populated `data.emergency` (the EMERGENCY leaf admitted
// AND the profile holds something). A null payload — which is exactly what the
@@ -102,6 +102,42 @@ describe("buildVisitsSnapshotBlock", () => {
expect(recentArg.orderBy).toEqual({ occurredAt: "desc" });
});
// v1.39.1 — a procedure's body site rides the entry so "the knee operation
// in March" reads as one fact, and stays off every other entry so the block
// does not grow for visits that never carry one.
//
// Mutation check: always emit `bodySite` → the absent-key assertion goes red.
it("names the body site and side of a procedure, and only when one is set", async () => {
prismaMock.encounter.findFirst.mockResolvedValue({
occurredAt: new Date(NOW.getTime() - 30 * DAY),
kind: "PROCEDURE",
reasonEncrypted: Buffer.from("arthroscopy", "utf8"),
outcomeEncrypted: null,
bodySiteEncrypted: Buffer.from("knee", "utf8"),
laterality: "LEFT",
practitioner: null,
});
prismaMock.encounter.findMany.mockResolvedValue([
{
occurredAt: new Date(NOW.getTime() + 3 * DAY),
kind: "ROUTINE",
reasonEncrypted: null,
outcomeEncrypted: null,
bodySiteEncrypted: null,
laterality: null,
practitioner: null,
},
]);
const block = await buildVisitsSnapshotBlock("user_1", NOW);
expect(block!.mostRecent).toMatchObject({
kind: "PROCEDURE",
bodySite: "knee",
laterality: "LEFT",
});
expect(block!.upcoming[0]).not.toHaveProperty("bodySite");
expect(block!.upcoming[0]).not.toHaveProperty("laterality");
});
it("fail-softs a decrypt error to null rather than throwing the whole block", async () => {
vi.mocked(decryptFromBytes).mockImplementation(() => {
throw new Error("bad key id");
+14
View File
@@ -55,6 +55,14 @@ export interface CoachVisitEntry {
reason: string | null;
/** What came out of it, decrypted + sanitised; null (always) for an upcoming visit. */
outcome: string | null;
/**
* Where on the body, decrypted + sanitised (v1.39.1). Present only when the
* visit names a site, so the block does not grow for the visits that never
* carry one.
*/
bodySite?: string;
/** The side of `bodySite`, when one is stated. */
laterality?: string;
}
export interface CoachVisitsBlock {
@@ -70,6 +78,8 @@ interface EncounterRow {
kind: string;
reasonEncrypted: Uint8Array | null;
outcomeEncrypted: Uint8Array | null;
bodySiteEncrypted?: Uint8Array | null;
laterality?: string | null;
practitioner: { name: string; specialty: string | null } | null;
}
@@ -87,6 +97,7 @@ function decryptText(value: Uint8Array | null): string | null {
}
function toEntry(row: EncounterRow): CoachVisitEntry {
const bodySite = decryptText(row.bodySiteEncrypted ?? null);
return {
occurredAt: row.occurredAt.toISOString(),
kind: row.kind,
@@ -98,6 +109,9 @@ function toEntry(row: EncounterRow): CoachVisitEntry {
: null,
reason: decryptText(row.reasonEncrypted),
outcome: decryptText(row.outcomeEncrypted),
...(bodySite
? { bodySite, ...(row.laterality ? { laterality: row.laterality } : {}) }
: {}),
};
}
+3
View File
@@ -305,6 +305,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// structured health records above make.
{ model: "Encounter", field: "reasonEncrypted", kind: "bytes" },
{ model: "Encounter", field: "outcomeEncrypted", kind: "bytes" },
// v1.39.1 — where on the body a procedure was done. The side beside it
// (`laterality`) stays plaintext: a side alone says nothing.
{ model: "Encounter", field: "bodySiteEncrypted", kind: "bytes" },
{ model: "Practitioner", field: "noteEncrypted", kind: "bytes" },
// ───── v1.38.0 dose free text (Bytes column) ─────
@@ -1,7 +1,10 @@
import type { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import type { EncounterKind } from "@/generated/prisma/client";
import { encounterKindLabelKey } from "../encounters/kind-label";
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
import {
encounterKindLabelKey,
lateralityLabelKey,
} from "../encounters/kind-label";
import {
classifyReferenceRange,
formatReferenceRange,
@@ -289,6 +292,72 @@ export function buildClinicalRecordsNotesSection(
.finalY + 8;
}
// The surgical history. Reference data (not time-windowed), populated when
// the SURGICAL_HISTORY leaf is selected AND a procedure happened. Oldest
// first, the order an intake form asks for it in. What was done is the
// visit's reason; the site carries its side after it in the report's
// language. Same calm register as the tables around it.
if (data.surgicalHistory && data.surgicalHistory.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.surgicalHistoryTitle"), margin, y);
y += 6;
const siteCell = (site: string | null, side: string | null): string => {
if (!site) return "—";
if (!side) return site;
return t("encounters.bodySiteWithSide", {
site,
side: t(lateralityLabelKey(side as Laterality)),
});
};
const procedureRows = data.surgicalHistory.map((row) => [
fmtDate(row.occurredAt),
row.procedure ?? "—",
siteCell(row.bodySite, row.laterality),
row.outcome ?? "—",
]);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.surgicalHistoryColDate"),
t("doctorReport.surgicalHistoryColProcedure"),
t("doctorReport.surgicalHistoryColBodySite"),
t("doctorReport.surgicalHistoryColOutcome"),
],
],
body: procedureRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
// The immunization history. Reference data (not time-windowed), populated
// when the `IMMUNIZATIONS` leaf is selected AND the `vaccinations` module is
// on AND live doses exist. A compact chronological table — date, vaccine
+21
View File
@@ -308,6 +308,27 @@ export interface DoctorReportData {
/** Labels of the conditions this visit was filed against. */
conditionLabels: string[];
}> | null;
/**
* The surgical history (v1.39.1): every visit of kind PROCEDURE that
* happened, oldest first. Reference data, not time-windowed, like the
* immunization record below — an operation fifteen years ago is still the
* answer to "what surgeries have you had". Null when the SURGICAL_HISTORY
* leaf was not selected or no procedure exists; both builders then skip the
* section. Optional so older fixtures still typecheck.
*
* `procedure` is the visit's reason, which is where a person writes what was
* done. `procedure`, `bodySite` and `outcome` are decrypted free text and
* null on a key-rotation gap. `laterality` rides as the enum constant; the
* renderer names it in the report's language.
*/
surgicalHistory?: Array<{
occurredAt: string;
procedure: string | null;
bodySite: string | null;
laterality: string | null;
outcome: string | null;
practitionerName: string | null;
}> | null;
/**
* The immunization history. Reference data, not time-windowed — an Impfpass
* is a lifetime document and a doctor wants the whole of it, so this mirrors
@@ -299,6 +299,7 @@ describe("per-leaf gating sweep", () => {
expect(allPayload.familyHistory).not.toBeNull();
expect(allPayload.illnessEpisodes).not.toBeNull();
expect(allPayload.visits).not.toBeNull();
expect(allPayload.surgicalHistory).not.toBeNull();
expect(allPayload.mood).not.toBeNull();
expect(allPayload.cycle).not.toBeNull();
expect(allPayload.anamnesis).not.toBeNull();
@@ -454,18 +455,23 @@ describe("zero read for unchosen leaves", () => {
["LAB_RESULTS", "labResult"],
["ILLNESS_EPISODES", "illnessEpisode"],
["VISITS", "encounter"],
["SURGICAL_HISTORY", "encounter"],
["IMMUNIZATIONS", "vaccinationRecord"],
["ANAMNESIS", "userHealthProfile"],
["EMERGENCY", "userHealthProfile"],
["ANAMNESIS", "healthProfileFactRevision"],
];
// ANAMNESIS and EMERGENCY both read `userHealthProfile`, so withholding one
// ANAMNESIS and EMERGENCY both read `userHealthProfile` (and VISITS and
// SURGICAL_HISTORY both read `encounter`), so withholding one
// while the other stays selected still queries the table. Both must be off
// before the read stops — the zero-read guarantee is over the SET of leaves
// that touch a table, not each one in isolation.
const TABLE_READERS: Record<string, ReportLeafId[]> = {
userHealthProfile: ["ANAMNESIS", "EMERGENCY"],
// The visits in the window and the lifetime surgical history both read
// the encounter table, under their own leaves.
encounter: ["VISITS", "SURGICAL_HISTORY"],
};
it.each(gated)(
@@ -0,0 +1,95 @@
/**
* The surgical history's read for the doctor report.
*
* Lifetime reference data, like the immunization record: a knee operated on
* fifteen years ago is still the answer to "what surgeries have you had", so
* the read ignores the report window. It reads only procedures that happened,
* and the free text (what was done, where, what came of it) decrypts fail-soft
* per row, so one key-rotation gap does not blank the section.
*
* Mutation checks (each run, each seen red):
* - add the report window to the `where` → "ignores the report window" goes
* red;
* - drop `status: "DONE"` → "reads only procedures that happened" goes red;
* - let a decrypt error throw → "reads a row it cannot decrypt as a blank"
* goes red with the thrown error.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const findMany = vi.hoisted(() => vi.fn());
vi.mock("@/lib/db", () => ({ prisma: { encounter: { findMany } } }));
vi.mock("@/lib/logging/context", () => ({ getEvent: () => undefined }));
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { loadSurgicalHistory } from "../clinical-records";
beforeEach(() => {
findMany.mockReset();
});
describe("loadSurgicalHistory", () => {
it("reads only procedures that happened, over the whole record", async () => {
findMany.mockResolvedValue([]);
await loadSurgicalHistory("u1");
const args = findMany.mock.calls[0][0];
expect(args.where).toEqual({
userId: "u1",
deletedAt: null,
kind: "PROCEDURE",
status: "DONE",
});
expect(args.orderBy).toEqual({ occurredAt: "asc" });
});
it("ignores the report window", async () => {
findMany.mockResolvedValue([]);
await loadSurgicalHistory("u1");
expect(findMany.mock.calls[0][0].where).not.toHaveProperty("occurredAt");
});
it("returns null when there is no procedure", async () => {
findMany.mockResolvedValue([]);
expect(await loadSurgicalHistory("u1")).toBeNull();
});
it("maps a procedure to date, what, where and outcome", async () => {
findMany.mockResolvedValue([
{
occurredAt: new Date("2011-03-14T09:00:00.000Z"),
reasonEncrypted: encryptToBytes("Meniscus repair"),
outcomeEncrypted: encryptToBytes("Full recovery"),
bodySiteEncrypted: encryptToBytes("Knee"),
laterality: "LEFT",
practitioner: { name: "Sample clinic" },
},
]);
expect(await loadSurgicalHistory("u1")).toEqual([
{
occurredAt: "2011-03-14T09:00:00.000Z",
procedure: "Meniscus repair",
bodySite: "Knee",
laterality: "LEFT",
outcome: "Full recovery",
practitionerName: "Sample clinic",
},
]);
});
it("reads a row it cannot decrypt as a blank rather than failing the report", async () => {
findMany.mockResolvedValue([
{
occurredAt: new Date("2019-07-02T09:00:00.000Z"),
reasonEncrypted: new Uint8Array([1, 2, 3, 4]),
outcomeEncrypted: null,
bodySiteEncrypted: null,
laterality: null,
practitioner: null,
},
]);
const rows = await loadSurgicalHistory("u1");
expect(rows?.[0].procedure).toBeNull();
});
});
+52
View File
@@ -199,6 +199,58 @@ export async function loadVisits(
return mapped.length > 0 ? mapped : null;
}
/**
* The surgical history: every procedure that happened, oldest first.
*
* Reference data, not windowed — like the immunization history below, it is a
* lifetime answer, and a report over the last 90 days that left out a knee
* operation from 2011 would answer "what surgeries have you had" wrongly.
* Planned, cancelled and missed procedures are not surgical history. The three
* free-text columns decrypt fail-soft per row, the same stance `loadVisits`
* takes.
*/
export async function loadSurgicalHistory(
userId: string,
): Promise<DoctorReportData["surgicalHistory"]> {
const rows = await prisma.encounter.findMany({
where: { userId, deletedAt: null, kind: "PROCEDURE", status: "DONE" },
orderBy: { occurredAt: "asc" },
select: {
occurredAt: true,
reasonEncrypted: true,
outcomeEncrypted: true,
bodySiteEncrypted: true,
laterality: true,
practitioner: { select: { name: true } },
},
});
if (rows.length === 0) return null;
const decrypt = (
value: Uint8Array | null,
field: "reason" | "outcome" | "bodySite",
): string | null => {
if (!value || value.byteLength === 0) return null;
try {
return decryptFromBytes(value);
} catch {
getEvent()?.addWarning(
`doctor-report: procedure ${field} decrypt failed for ${userId}`,
);
return null;
}
};
return rows.map((row) => ({
occurredAt: row.occurredAt.toISOString(),
procedure: decrypt(row.reasonEncrypted, "reason"),
bodySite: decrypt(row.bodySiteEncrypted, "bodySite"),
laterality: row.laterality,
outcome: decrypt(row.outcomeEncrypted, "outcome"),
practitionerName: row.practitioner?.name ?? null,
}));
}
/**
* The immunization history. Reference data, not time-windowed — an Impfpass is
* a lifetime document, so like allergies it ignores the report window.
+7
View File
@@ -70,6 +70,7 @@ import {
loadIllnessEpisodes,
loadImmunizations,
loadVisits,
loadSurgicalHistory,
loadLabResults,
} from "./clinical-records";
@@ -508,6 +509,7 @@ export async function collectDoctorReportData(
labResults,
illnessEpisodes,
visits,
surgicalHistory,
immunizations,
allergies,
familyHistory,
@@ -526,6 +528,10 @@ export async function collectDoctorReportData(
gate.admits("VISITS")
? loadVisits(userId, start, end)
: Promise.resolve(null),
// Reference data, not windowed, like the immunization history below.
gate.admits("SURGICAL_HISTORY")
? loadSurgicalHistory(userId)
: Promise.resolve(null),
// Reference data, not windowed — the immunization history is a lifetime
// document, so the whole live set rides when the leaf and module admit it.
gate.admits("IMMUNIZATIONS")
@@ -601,6 +607,7 @@ export async function collectDoctorReportData(
labResults,
illnessEpisodes,
visits,
surgicalHistory,
immunizations,
allergies,
familyHistory,
@@ -0,0 +1,150 @@
/**
* The procedure search and the body-site facets, pinned.
*
* The body site is ciphertext at rest, so the only place a search over it can
* run is here, after the decrypt. Three properties carry the feature:
*
* - every word of the query has to be found, in the site or the reason, so
* "left knee" finds a procedure filed as site "Knee", side LEFT;
* - accents and case do not matter, because a person types "Hufte" on a
* keyboard without umlauts and still means "Hüfte";
* - the side filter is exact, so asking for the right side never returns the
* left one.
*
* Mutation checks (each run, each seen red):
* - match ANY token instead of EVERY token → "needs every word" goes red;
* - drop the diacritic fold in `normalizeSearchText` → the umlaut case goes
* red;
* - key the facet on the raw text instead of the folded text → "folds case
* and spacing into one site" goes red with two facets.
*/
import { describe, expect, it } from "vitest";
import {
groupBodySites,
matchesProcedureQuery,
normalizeSearchText,
type ProcedureSearchItem,
} from "../procedures";
const terms = (laterality: ProcedureSearchItem["laterality"]) =>
laterality === "LEFT"
? ["left", "links"]
: laterality === "RIGHT"
? ["right", "rechts"]
: laterality === "BOTH"
? ["both", "beidseitig"]
: [];
function item(
bodySite: string | null,
laterality: ProcedureSearchItem["laterality"] = null,
reason: string | null = null,
): ProcedureSearchItem {
return { bodySite, laterality, reason };
}
describe("normalizeSearchText", () => {
it("folds case, accents and runs of whitespace", () => {
expect(normalizeSearchText(" Hüfte LINKS ")).toBe("hufte links");
});
});
describe("matchesProcedureQuery", () => {
it("matches everything when the query is empty", () => {
expect(matchesProcedureQuery(item(null), {}, terms)).toBe(true);
expect(matchesProcedureQuery(item("Knee"), { q: " " }, terms)).toBe(true);
});
it("finds a word in the body site", () => {
expect(matchesProcedureQuery(item("Left knee"), { q: "knee" }, terms)).toBe(
true,
);
});
it("finds a word in the reason when no site was typed", () => {
expect(
matchesProcedureQuery(
item(null, null, "Knee arthroscopy"),
{ q: "arthroscopy" },
terms,
),
).toBe(true);
});
it("finds the side word through the side field", () => {
expect(
matchesProcedureQuery(item("Knee", "LEFT"), { q: "left knee" }, terms),
).toBe(true);
expect(
matchesProcedureQuery(item("Knie", "LEFT"), { q: "Knie links" }, terms),
).toBe(true);
});
it("needs every word, not any word", () => {
expect(
matchesProcedureQuery(item("Knee", "RIGHT"), { q: "left knee" }, terms),
).toBe(false);
});
it("ignores accents on either side", () => {
expect(matchesProcedureQuery(item("Hüfte"), { q: "hufte" }, terms)).toBe(
true,
);
expect(matchesProcedureQuery(item("Hufte"), { q: "Hüfte" }, terms)).toBe(
true,
);
});
it("filters the side exactly", () => {
expect(
matchesProcedureQuery(
item("Knee", "LEFT"),
{ laterality: "RIGHT" },
terms,
),
).toBe(false);
expect(
matchesProcedureQuery(
item("Knee", "RIGHT"),
{ laterality: "RIGHT" },
terms,
),
).toBe(true);
expect(
matchesProcedureQuery(item("Knee", null), { laterality: "RIGHT" }, terms),
).toBe(false);
});
it("does not match a procedure with nothing to search when a query is set", () => {
expect(matchesProcedureQuery(item(null), { q: "knee" }, terms)).toBe(false);
});
});
describe("groupBodySites", () => {
it("counts one facet per site and side", () => {
const facets = groupBodySites([
item("Knee", "LEFT"),
item("Knee", "LEFT"),
item("Knee", "RIGHT"),
item("Gallbladder"),
item(null),
]);
expect(facets).toEqual([
{ bodySite: "Knee", laterality: "LEFT", count: 2 },
{ bodySite: "Knee", laterality: "RIGHT", count: 1 },
{ bodySite: "Gallbladder", laterality: null, count: 1 },
]);
});
it("folds case and spacing into one site, keeping the first spelling", () => {
const facets = groupBodySites([
item("Left knee "),
item("left knee"),
item("LEFT KNEE"),
]);
expect(facets).toEqual([
{ bodySite: "Left knee", laterality: null, count: 3 },
]);
});
});
+32 -1
View File
@@ -17,6 +17,7 @@ import type {
Encounter,
EncounterKind,
EncounterStatus,
Laterality,
MeasurementReminder,
Practitioner,
} from "@/generated/prisma/client";
@@ -58,6 +59,13 @@ export interface EncounterDTO {
practitioner: PractitionerDTO | null;
reason: string | null;
outcome: string | null;
/**
* Where on the body, decrypted. Carried on every kind, not only PROCEDURE,
* so a visit switched away from PROCEDURE and back keeps what was typed.
*/
bodySite: string | null;
/** The side of the body site, or null when not stated. */
laterality: Laterality | null;
/**
* The server-computed instant the appointment reminder next fires, when this
* visit minted one and it has not fired yet. `null` for a visit that already
@@ -91,7 +99,7 @@ export interface EncounterDTO {
function decryptField(
value: Uint8Array | null,
field: "reason" | "outcome",
field: "reason" | "outcome" | "bodySite",
): string | null {
if (!value || value.byteLength === 0) return null;
try {
@@ -124,6 +132,8 @@ export function toEncounterDTO(
practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null,
reason: decryptField(row.reasonEncrypted, "reason"),
outcome: decryptField(row.outcomeEncrypted, "outcome"),
bodySite: decryptField(row.bodySiteEncrypted, "bodySite"),
laterality: row.laterality,
reminderNextDueAt: row.reminder?.nextDueAt?.toISOString() ?? null,
...(links ? { links } : {}),
...(skipped ? { skipped } : {}),
@@ -145,3 +155,24 @@ export interface EncounterListDTO {
upcoming: EncounterDTO[];
past: EncounterDTO[];
}
/** One body site and side the procedure history holds, with how often. */
export interface BodySiteFacetDTO {
/** The first spelling seen; the facet itself is keyed case-insensitively. */
bodySite: string;
laterality: Laterality | null;
count: number;
}
/**
* The procedure history response.
*
* `procedures` is the filtered list, newest first. `bodySites` and `total` are
* computed over the whole history, before the filter, so the filter choices a
* client offers never shrink to the one it just picked.
*/
export interface ProcedureListDTO {
procedures: EncounterDTO[];
bodySites: BodySiteFacetDTO[];
total: number;
}
+21 -1
View File
@@ -11,7 +11,7 @@
* attached is read off a lock screen. A raw enum constant there is the schema
* leaking into the product.
*/
import type { EncounterKind } from "@/generated/prisma/client";
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
@@ -41,6 +41,8 @@ export function encounterKindLabel(
return t("encounters.kind.hospital");
case "THERAPY":
return t("encounters.kind.therapy");
case "PROCEDURE":
return t("encounters.kind.procedure");
default:
return t("encounters.kind.other");
}
@@ -73,7 +75,25 @@ export function encounterKindLabelKey(kind: EncounterKind): string {
return "encounters.kind.hospital";
case "THERAPY":
return "encounters.kind.therapy";
case "PROCEDURE":
return "encounters.kind.procedure";
default:
return "encounters.kind.other";
}
}
/**
* The side of a body site as a key, for the same translator-holding callers:
* the doctor-report PDF and the clinician share view name it in the reading
* clinician's language.
*/
export function lateralityLabelKey(laterality: Laterality): string {
switch (laterality) {
case "LEFT":
return "encounters.laterality.left";
case "RIGHT":
return "encounters.laterality.right";
default:
return "encounters.laterality.both";
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* The procedure and surgery history: every visit filed as PROCEDURE that
* happened, newest first, searchable by body site.
*
* The body site is ciphertext at rest (`Encounter.bodySiteEncrypted`), so the
* search cannot run in SQL. It runs here, after the decrypt, over the account's
* own procedures only. That set is a person's surgical history: tens of rows,
* not thousands, and a decrypt pass per search costs less than a plaintext
* search column would cost in exposure. The read is still bounded
* (`MAX_PROCEDURES`), so an account that somehow holds more cannot turn one
* search into an unbounded decrypt loop.
*
* Only DONE visits count. "What surgeries have you had" is asked about things
* that happened: a booked operation sits in the visits list's upcoming half,
* and a cancelled one or a no-show did not happen at all.
*
* The search and the facet grouping are pure functions, exported for the unit
* test, and the route is a thin wrapper over `loadProcedureHistory`.
*/
import type { Laterality, Prisma } from "@/generated/prisma/client";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import type { ShareDomain } from "@/lib/sharing/scope";
import {
toEncounterDTO,
type BodySiteFacetDTO,
type ProcedureListDTO,
} from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
loadEncounterLinksForMany,
} from "@/lib/encounters/service";
import type { ProcedureListQuery } from "@/lib/validations/encounters";
/** Upper bound on the procedures one read decrypts. */
export const MAX_PROCEDURES = 500;
/** What the search reads off one procedure, already decrypted. */
export interface ProcedureSearchItem {
bodySite: string | null;
laterality: Laterality | null;
reason: string | null;
}
/** Lower-case, accents folded, whitespace collapsed. */
export function normalizeSearchText(value: string): string {
return value
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
/**
* Whether one procedure matches the query.
*
* Every word of `q` has to appear somewhere in the site, the side or the
* reason. The side is searchable through its words (`lateralityTerms`), so
* "left knee" finds a procedure whose site says "Knee" and whose side is LEFT:
* the person reads those as one phrase and should not have to know they are
* stored apart. `laterality`, when given, is an exact filter on the field.
*/
export function matchesProcedureQuery(
item: ProcedureSearchItem,
query: ProcedureListQuery,
lateralityTerms: (laterality: Laterality | null) => string[],
): boolean {
if (query.laterality && item.laterality !== query.laterality) return false;
const words = normalizeSearchText(query.q ?? "")
.split(" ")
.filter(Boolean);
if (words.length === 0) return true;
const haystack = normalizeSearchText(
[item.bodySite, item.reason, ...lateralityTerms(item.laterality)]
.filter(Boolean)
.join(" "),
);
return words.every((word) => haystack.includes(word));
}
/**
* One facet per body site and side, most frequent first.
*
* Keyed on the folded text, so "Left knee" and "left knee" are one site; the
* first spelling seen is the one shown. Procedures with no site contribute no
* facet: there is nothing to filter them by.
*/
export function groupBodySites(
items: readonly ProcedureSearchItem[],
): BodySiteFacetDTO[] {
const facets = new Map<string, BodySiteFacetDTO>();
for (const item of items) {
const shown = item.bodySite?.replace(/\s+/g, " ").trim();
if (!shown) continue;
const key = `${normalizeSearchText(shown)}|${item.laterality ?? ""}`;
const facet = facets.get(key);
if (facet) facet.count += 1;
else
facets.set(key, {
bodySite: shown,
laterality: item.laterality,
count: 1,
});
}
// A stable sort, so equal counts keep the order they were first met in —
// newest procedure first, because that is the order the rows arrive in.
return [...facets.values()].sort((a, b) => b.count - a.count);
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* The words a side can be searched by: the English word, which the API and
* the MCP tool speak, and the record owner's own word, which the person types.
*/
export function lateralityTermsFor(
locale: string | null | undefined,
): (laterality: Laterality | null) => string[] {
const { t } = getServerTranslator(resolveLocale(locale));
const en = getServerTranslator("en").t;
const key = (laterality: Laterality) => {
switch (laterality) {
case "LEFT":
return "encounters.laterality.left";
case "RIGHT":
return "encounters.laterality.right";
default:
return "encounters.laterality.both";
}
};
return (laterality) =>
laterality ? [en(key(laterality)), t(key(laterality))] : [];
}
/**
* Read, decrypt, facet and filter the account's procedure history.
*
* The facets and the total are computed before the filter; the list after it.
* Links ride each row for the same reason they ride the visits list: the edit
* sheet seeds its pickers from the row it was handed.
*/
export async function loadProcedureHistory(
tx: Prisma.TransactionClient,
userId: string,
query: ProcedureListQuery,
options: {
locale: string | null;
visible: (domain: ShareDomain) => boolean;
now?: Date;
},
): Promise<ProcedureListDTO> {
const rows = await tx.encounter.findMany({
where: {
userId,
deletedAt: null,
kind: "PROCEDURE",
status: "DONE",
occurredAt: { lte: options.now ?? new Date() },
},
orderBy: { occurredAt: "desc" },
take: MAX_PROCEDURES,
include: ENCOUNTER_INCLUDE,
});
const decrypted = rows.map((row) => ({ row, dto: toEncounterDTO(row) }));
const terms = lateralityTermsFor(options.locale);
const matching = decrypted.filter(({ dto }) =>
matchesProcedureQuery(dto, query, terms),
);
const links = await loadEncounterLinksForMany(
tx,
userId,
matching.map(({ row }) => row.id),
options.visible,
);
return {
procedures: matching.map(({ row, dto }) => ({
...dto,
links: links.get(row.id),
})),
bodySites: groupBodySites(decrypted.map(({ dto }) => dto)),
total: decrypted.length,
};
}
+25 -2
View File
@@ -35,7 +35,11 @@
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
import type {
EncounterKind,
EncounterStatus,
Laterality,
} from "@/generated/prisma/client";
import {
recordUnknownKeys,
@@ -74,6 +78,9 @@ export interface EncounterBackupEntry {
practitionerId: string | null;
reasonEncrypted: string | null;
outcomeEncrypted: string | null;
/** The procedure's body site, ciphertext like the two above (v1.39.1). */
bodySiteEncrypted: string | null;
laterality: Laterality | null;
/**
* The appointment's reminder row, which restores in the same run since
* v1.37.20. The restore remaps it against the restored reminders and drops
@@ -141,6 +148,8 @@ const ENCOUNTER_BACKUP_SELECT = {
practitionerId: true,
reasonEncrypted: true,
outcomeEncrypted: true,
bodySiteEncrypted: true,
laterality: true,
reminderId: true,
createdAt: true,
updatedAt: true,
@@ -244,6 +253,8 @@ export async function buildVisitsBackupSection(
practitionerId: row.practitionerId,
reasonEncrypted: encodeCiphertext(row.reasonEncrypted),
outcomeEncrypted: encodeCiphertext(row.outcomeEncrypted),
bodySiteEncrypted: encodeCiphertext(row.bodySiteEncrypted),
laterality: row.laterality,
reminderId: row.reminderId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -336,7 +347,12 @@ export type RestoredEncounter = Pick<
OptionalNullable<
Pick<
EncounterBackupEntry,
"practitionerId" | "reasonEncrypted" | "outcomeEncrypted" | "reminderId"
| "practitionerId"
| "reasonEncrypted"
| "outcomeEncrypted"
| "bodySiteEncrypted"
| "laterality"
| "reminderId"
>
> & { deletedAt?: string | null };
@@ -457,6 +473,13 @@ export async function restoreVisitsData(
entry.outcomeEncrypted == null
? null
: decodeCiphertext(entry.outcomeEncrypted),
// Absent in a file written before v1.39.1: the visit comes back
// with no site, which is what it had.
bodySiteEncrypted:
entry.bodySiteEncrypted == null
? null
: decodeCiphertext(entry.bodySiteEncrypted),
laterality: entry.laterality ?? null,
reminderId,
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
+7
View File
@@ -277,6 +277,13 @@ export function buildFhirDocumentBundle(
// both carry the doses; the Bundle does not, and this comment is where a
// reader who notices should land.
//
// The surgical history (v1.39.1) rides the same decision. A procedure is a
// visit of kind PROCEDURE, and FHIR's `Procedure` resource would map it
// cleanly (`code.text` from the reason, `bodySite.text` from the site), but
// emitting one without its REST route and capability entry is the same
// half-shipped state. The PDF and the clinician view carry the section; the
// Bundle does not.
//
// held an episode (the aggregator gates `data.illnessEpisodes`). Each
// episode emits a patient-reported Condition (generic SNOMED root, label on
// `code.text`) plus a bounding Encounter that references it.
+61
View File
@@ -989,6 +989,67 @@ describe("get_visits", () => {
mode: "insensitive",
});
});
// v1.39.1 — the procedure history. A surgery fifteen years ago is still the
// answer to "what surgeries have I had", so `kind: PROCEDURE` without a
// `months` argument reads the whole record rather than the default year.
//
// Mutation checks (each run, each seen red):
// - keep the 12-month default for PROCEDURE → "reads the whole record"
// goes red with an `occurredAt` floor;
// - drop the fence around `bodySite` → the fence assertion goes red.
it("narrows to one kind", async () => {
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
await tool("get_visits").run(CTX, { kind: "SPECIALIST" });
const arg = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as {
where: Record<string, unknown>;
};
expect(arg.where.kind).toBe("SPECIALIST");
});
it("reads the whole record for procedures when no window is named", async () => {
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
await tool("get_visits").run(CTX, { kind: "PROCEDURE" });
const arg = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as {
where: { kind?: string; occurredAt?: { gte?: Date; lte?: Date } };
};
expect(arg.where.kind).toBe("PROCEDURE");
expect(arg.where.occurredAt?.gte).toBeUndefined();
expect(arg.where.occurredAt?.lte).toBeInstanceOf(Date);
});
it("keeps a named window for procedures", async () => {
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
await tool("get_visits").run(CTX, { kind: "PROCEDURE", months: 6 });
const arg = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as {
where: { occurredAt?: { gte?: Date } };
};
expect(arg.where.occurredAt?.gte).toBeInstanceOf(Date);
});
it("carries the body site fenced and the side as a constant", async () => {
vi.mocked(decryptFromBytes).mockImplementation((buf: Uint8Array) =>
Buffer.from(buf).toString("utf8"),
);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([
visitRow({
kind: "PROCEDURE",
bodySiteEncrypted: Buffer.from("knee", "utf8"),
laterality: "LEFT",
}),
] as never);
vi.mocked(listTargetsBySource).mockResolvedValue(new Map());
const result = (await tool("get_visits").run(CTX, {
kind: "PROCEDURE",
})) as {
windowMonths: number | null;
visits: Array<Record<string, unknown>>;
};
expect(result.windowMonths).toBeNull();
expect(result.visits[0].bodySite).toContain("<<<USER_TEXT_START>>>");
expect(result.visits[0].bodySite).toContain("knee");
expect(result.visits[0].laterality).toBe("LEFT");
});
});
describe("search — cursor pagination", () => {
+26 -6
View File
@@ -70,6 +70,7 @@ import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { listTargetsBySource } from "@/lib/links";
import { encounterKindEnum } from "@/lib/validations/encounters";
import type { McpAuthContext } from "./auth";
/**
@@ -929,7 +930,9 @@ const getPreventiveCareOutput: z.ZodRawShape = {
*/
const getVisitsOutput: z.ZodRawShape = {
present: z.boolean(),
windowMonths: z.number().optional(),
// Null when the read covered the whole record: a procedure history asked
// for without a window (v1.39.1).
windowMonths: z.number().nullable().optional(),
visits: z
.array(
z.object({
@@ -940,6 +943,8 @@ const getVisitsOutput: z.ZodRawShape = {
specialty: z.string().nullable(),
reason: z.string().nullable(),
outcome: z.string().nullable(),
bodySite: z.string().nullable(),
laterality: z.enum(["LEFT", "RIGHT", "BOTH"]).nullable(),
conditions: z.array(z.string()),
}),
)
@@ -1898,7 +1903,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "get_visits",
title: "Get past doctor visits",
description:
"Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none.",
"Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none.",
inputShape: {
months: z
.number()
@@ -1917,21 +1922,32 @@ export const MCP_TOOLS: McpToolDefinition[] = [
.describe(
"Optional case-insensitive name substring to narrow to one practitioner (e.g. a surname read back from an earlier result). Omit for every practitioner.",
),
kind: encounterKindEnum
.optional()
.describe(
"Optional visit kind to narrow to. PROCEDURE lists procedures and surgeries, over the whole record unless months is given.",
),
},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getVisitsOutput,
async run(ctx, args) {
const kindArg = encounterKindEnum.safeParse(args.kind);
const kind = kindArg.success ? kindArg.data : undefined;
// A procedure history is lifetime reference data; every other read keeps
// the default year. A window the caller names always wins.
const months =
typeof args.months === "number"
? args.months
: DEFAULT_VISITS_WINDOW_MONTHS;
: kind === "PROCEDURE"
? null
: DEFAULT_VISITS_WINDOW_MONTHS;
const practitioner =
typeof args.practitioner === "string" && args.practitioner.trim()
? args.practitioner.trim()
: undefined;
const now = new Date();
const cutoff = new Date(now);
cutoff.setMonth(cutoff.getMonth() - months);
const cutoff = months === null ? null : new Date(now);
cutoff?.setMonth(cutoff.getMonth() - (months ?? 0));
const rows = await prisma.encounter.findMany({
// `userId` is the resolved session's, never an argument — a visit
@@ -1939,7 +1955,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [
where: {
userId: ctx.userId,
deletedAt: null,
occurredAt: { gte: cutoff, lte: now },
occurredAt: { ...(cutoff ? { gte: cutoff } : {}), lte: now },
...(kind ? { kind } : {}),
...(practitioner
? {
practitioner: {
@@ -1979,6 +1996,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
const visits = rows.map((r) => {
const reason = decryptVisitText(r.reasonEncrypted);
const outcome = decryptVisitText(r.outcomeEncrypted);
const bodySite = decryptVisitText(r.bodySiteEncrypted);
return {
occurredAt: r.occurredAt.toISOString(),
status: r.status,
@@ -1995,6 +2013,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [
// too — scrubbed of any forged marker before it enters the payload.
reason: reason !== null ? fenceUserText(reason) : null,
outcome: outcome !== null ? fenceUserText(outcome) : null,
bodySite: bodySite !== null ? fenceUserText(bodySite) : null,
laterality: r.laterality,
conditions: (conditionsBySource.get(r.id) ?? []).map((c) =>
scrubFenceMarkers(c.label),
),
+58 -3
View File
@@ -25,6 +25,8 @@ import {
encounterLinkSchema,
encounterStatusEnum,
encounterKindEnum,
lateralityEnum,
procedureListQuerySchema,
} from "@/lib/validations/encounters";
import {
practitionerCreateSchema,
@@ -49,13 +51,13 @@ import {
const createEncounterRequest = encounterCreateSchema.meta({
id: "CreateEncounterRequest",
description:
"File a visit, or book one. `occurredAt` is the only required field — a visit saves with a date and nothing else. A future instant with `status: PLANNED` books an appointment and mints exactly one reminder for it. `reason` and `outcome` are encrypted at rest. The three id arrays pre-link documents, lab results and condition episodes; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save.",
"File a visit, or book one. `occurredAt` is the only required field — a visit saves with a date and nothing else. A future instant with `status: PLANNED` books an appointment and mints exactly one reminder for it. `reason`, `outcome` and `bodySite` are encrypted at rest. `kind: PROCEDURE` marks a procedure or surgery; `bodySite` (free text) and `laterality` (LEFT / RIGHT / BOTH, null when not stated) say where, and are accepted on any kind. The three id arrays pre-link documents, lab results and condition episodes; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save.",
});
const updateEncounterRequest = encounterUpdateSchema.meta({
id: "UpdateEncounterRequest",
description:
"Partial edit of a visit; an omitted key leaves the column untouched, and a body naming nothing is a 422. Moving `occurredAt` re-anchors the appointment reminder the visit already owns and never mints a second. Moving `status` to DONE closes the checkup named by `reminderId`, on the transition only. CANCELLED and NO_SHOW stop the reminder without deleting the row. A present id array replaces that link family, empty array included.",
"Partial edit of a visit; an omitted key leaves the column untouched, and a body naming nothing is a 422. Moving `occurredAt` re-anchors the appointment reminder the visit already owns and never mints a second. Moving `status` to DONE closes the checkup named by `reminderId`, on the transition only. CANCELLED and NO_SHOW stop the reminder without deleting the row. A present id array replaces that link family, empty array included. Switching an existing visit to `kind: PROCEDURE` is an ordinary edit of this kind; nothing has to be entered again.",
});
const listEncountersQuery = encounterListQuerySchema.meta({
@@ -76,6 +78,12 @@ const encounterLinkRequest = encounterLinkSchema.meta({
"Targets to file against, or unfile from, a visit. Idempotent in both directions and capped at 100 ids. Ids naming nothing the caller owns come back in `unknown` rather than failing the request.",
});
const listProceduresQuery = procedureListQuerySchema.meta({
id: "ListProceduresQuery",
description:
"Filters for the procedure history. `q` must match every word, case- and accent-insensitively, somewhere in the body site, the side (English or the record owner's language) or the reason. `laterality` filters the side exactly. Both run server-side after the decrypt, because the body site is encrypted at rest.",
});
const createPractitionerRequest = practitionerCreateSchema.meta({
id: "CreatePractitionerRequest",
description:
@@ -163,6 +171,8 @@ const encounter = z
practitioner: practitioner.nullable(),
reason: z.string().nullable(),
outcome: z.string().nullable(),
bodySite: z.string().nullable(),
laterality: lateralityEnum.nullable(),
reminderNextDueAt: z.string().nullable(),
links: encounterLinks.optional(),
skipped: skipReport.optional(),
@@ -172,7 +182,7 @@ const encounter = z
.meta({
id: "Encounter",
description:
"One contact with the healthcare system: a visit that happened, or one that is booked. `practitioner` is resolved rather than an id. `reason` and `outcome` are the decrypted free text (null on a key-rotation gap). `reminderNextDueAt` is the server-computed instant a booked appointment next nudges, and is null for a visit that already happened or whose one-shot reminder is spent.",
"One contact with the healthcare system: a visit that happened, or one that is booked. `practitioner` is resolved rather than an id. `reason`, `outcome` and `bodySite` are the decrypted free text (null on a key-rotation gap). `kind: PROCEDURE` marks a procedure or surgery, and `bodySite` with `laterality` say where; both are carried on every kind so switching a visit's kind away and back loses nothing. `reminderNextDueAt` is the server-computed instant a booked appointment next nudges, and is null for a visit that already happened or whose one-shot reminder is spent.",
});
const encounterList = z
@@ -186,6 +196,30 @@ const encounterList = z
"Visits split server-side rather than client-side. `upcoming` reads soonest-first and `past` reads newest-first — opposite directions, resolved once here so the ordering lives in one place.",
});
const bodySiteFacet = z
.object({
bodySite: z.string(),
laterality: lateralityEnum.nullable(),
count: z.number().int(),
})
.meta({
id: "BodySiteFacet",
description:
"One body site and side in the procedure history, with how many procedures carry it. Keyed case- and space-insensitively; `bodySite` is the first spelling seen.",
});
const procedureList = z
.object({
procedures: z.array(encounter),
bodySites: z.array(bodySiteFacet),
total: z.number().int(),
})
.meta({
id: "ProcedureList",
description:
"The procedure history. `procedures` is the filtered list, newest first. `bodySites` and `total` are computed over the whole history before the filter, so the filter choices never shrink to the one just picked.",
});
const suggestionCandidate = z
.object({
id: z.string(),
@@ -278,6 +312,27 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/encounters/procedures": {
get: {
tags: ["Records"],
summary: "List procedures and surgeries",
description:
"The caller's procedure and surgery history: every live visit of kind PROCEDURE with status DONE, newest first, bounded at 500. Planned, cancelled and missed procedures are not in it; a booked one stays in the visit list's `upcoming` half. Read at the same section and level as the visit list.",
requestParams: { query: listProceduresQuery },
responses: {
...recordRefusal(),
"200": {
description: "The procedure history.",
content: {
"application/json": {
schema: dataEnvelope(procedureList, "ListProceduresEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/encounters/suggest": {
get: {
tags: ["Records"],
+8
View File
@@ -46,6 +46,14 @@ export const encounterKeys = {
episodeId ?? null,
] as const,
encounter: (id: string) => ["encounters", "detail", id] as const,
/**
* The procedure history for one filter. Under the `["encounters"]` root, so
* every visit write (switching a kind, editing a site) evicts it with the
* list. The filter is part of the key because it is part of the question:
* the search runs server-side over the decrypted site.
*/
encounterProcedures: (q: string, laterality: string | null) =>
["encounters", "procedures", q, laterality] as const,
/**
* The "which visit does this belong to" verdict for one anchor date. The
* anchor is part of the key because it IS the question: a document dated the
@@ -52,8 +52,8 @@ function resolves(key: string): boolean {
describe("report selection catalogue", () => {
it("carries every measurement type and every structured leaf", () => {
expect(MEASUREMENT_LEAF_IDS).toHaveLength(77);
expect(STRUCTURED_LEAF_IDS).toHaveLength(17);
expect(ALL_LEAF_IDS).toHaveLength(94);
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
expect(ALL_LEAF_IDS).toHaveLength(95);
});
it("resolves a label for every leaf", () => {
+5 -1
View File
@@ -48,6 +48,7 @@ export type StructuredLeafId =
| "ALLERGIES"
| "ILLNESS_EPISODES"
| "VISITS"
| "SURGICAL_HISTORY"
| "IMMUNIZATIONS"
| "FAMILY_HISTORY"
| "MOOD"
@@ -204,6 +205,7 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
ALLERGIES: "history",
ILLNESS_EPISODES: "history",
VISITS: "history",
SURGICAL_HISTORY: "history",
IMMUNIZATIONS: "history",
FAMILY_HISTORY: "sensitive",
MOOD: "sensitive",
@@ -211,7 +213,7 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
ANAMNESIS: "sensitive",
};
/** i18n label keys for the 17 structured leaves. */
/** i18n label keys for the 18 structured leaves. */
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
EMERGENCY: "reportSelection.leafEmergency",
@@ -225,6 +227,7 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
ALLERGIES: "reportSelection.leafAllergies",
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
VISITS: "reportSelection.leafVisits",
SURGICAL_HISTORY: "reportSelection.leafSurgicalHistory",
IMMUNIZATIONS: "reportSelection.leafImmunizations",
FAMILY_HISTORY: "reportSelection.leafFamilyHistory",
MOOD: "reportSelection.leafMood",
@@ -323,6 +326,7 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
// no module of its own — it is core, like the checkups page it lives on —
// and the whole report is already gated by `doctorReport`. Reading the
// absence as an oversight is the mistake this comment exists to prevent.
// SURGICAL_HISTORY is absent for the same reason: a procedure is a visit.
};
/** Every measurement leaf id, in enum order. */
+3 -1
View File
@@ -16,7 +16,8 @@ import { type ReportLeafId } from "./catalogue";
/**
* Identity and insurer (the cover page a practice files the document under),
* the three classic vitals, weight and BMI, the glucose series and its panel,
* labs, the drug list and adherence, allergies and past illness episodes.
* labs, the drug list and adherence, allergies, past illness episodes, the
* surgical history and the immunization record.
*
* Deliberately not in it: everything a wearable produces, gait and mobility,
* environment exposure, the per-dose administration ledger (a clinician asks
@@ -39,6 +40,7 @@ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
"MEDICATION_COMPLIANCE",
"ALLERGIES",
"ILLNESS_EPISODES",
"SURGICAL_HISTORY",
"IMMUNIZATIONS",
];
+5
View File
@@ -48,6 +48,7 @@ import {
InjectionSite,
IntakeAttributionSource,
IntakeSource,
Laterality,
MeasurementAggregationProvenance,
MeasurementReminderEventKind,
MeasurementSource,
@@ -1088,6 +1089,10 @@ const encounterBackupSchema = z
practitionerId: z.string().nullable().optional(),
reasonEncrypted: base64BytesSchema.nullable().optional(),
outcomeEncrypted: base64BytesSchema.nullable().optional(),
// v1.39.1 — the procedure's body site and side. Optional so a file written
// before they existed still parses; the visit restores with neither.
bodySiteEncrypted: base64BytesSchema.nullable().optional(),
laterality: z.enum(Laterality).nullable().optional(),
// Remapped against the restored reminders (they travel since v1.37.20);
// dropped to NULL, with the drop named, only when the file lacks the row.
reminderId: z.string().nullable().optional(),
+33
View File
@@ -61,10 +61,22 @@ export const encounterKindEnum = z.enum([
"HOSPITAL",
"THERAPY",
"OTHER",
"PROCEDURE",
]);
/** The side a procedure's body site is on. Absent or null: not stated. */
export const lateralityEnum = z.enum(["LEFT", "RIGHT", "BOTH"]);
export type EncounterStatusInput = z.infer<typeof encounterStatusEnum>;
export type EncounterKindInput = z.infer<typeof encounterKindEnum>;
export type LateralityInput = z.infer<typeof lateralityEnum>;
/**
* Where on the body, in the person's own words. Becomes `bodySiteEncrypted`.
* Bounded like a label rather than like prose: "left knee, medial meniscus" is
* the long end of what belongs here, and the reason field holds the rest.
*/
const bodySite = z.string().max(200).nullable().optional();
const id = z.string().min(1).max(40);
@@ -88,6 +100,8 @@ export const encounterCreateSchema = z
reason: z.string().max(2000).nullable().optional(),
/** Becomes `outcomeEncrypted`. */
outcome: z.string().max(4000).nullable().optional(),
bodySite,
laterality: lateralityEnum.nullable().optional(),
/** The checkup this visit closes, when it was filed from a due one. */
reminderId: id.nullable().optional(),
documentIds: linkIds,
@@ -113,6 +127,8 @@ export const encounterUpdateSchema = z
practitionerId: id.nullable().optional(),
reason: z.string().max(2000).nullable().optional(),
outcome: z.string().max(4000).nullable().optional(),
bodySite,
laterality: lateralityEnum.nullable().optional(),
reminderId: id.nullable().optional(),
documentIds: linkIds,
labResultIds: linkIds,
@@ -173,6 +189,23 @@ export const encounterSuggestQuerySchema = z
export type EncounterSuggestQuery = z.infer<typeof encounterSuggestQuerySchema>;
/**
* The procedure history query.
*
* `q` is matched after the decrypt, over the body site, the side and the
* reason, every word required (see `src/lib/encounters/procedures.ts`). It is
* named `q` and not `bodySite` because it also searches the reason: a knee
* operation recorded before the site field existed says "knee" only there.
*/
export const procedureListQuerySchema = z
.object({
q: z.string().max(200).optional(),
laterality: lateralityEnum.optional(),
})
.strict();
export type ProcedureListQuery = z.infer<typeof procedureListQuerySchema>;
/* ── links ────────────────────────────────────────────────────────── */
export const encounterLinkTargetEnum = z.enum([
@@ -0,0 +1,372 @@
/**
* The procedure history against real Postgres, through the real routes.
*
* What only a real database can answer:
*
* - the new enum value and the two new columns exist and round-trip, which a
* mocked client would accept whether or not migration 0349 ran;
* - the body site is ciphertext on disk, not the words the person typed;
* - switching a visit that already exists to PROCEDURE keeps everything it
* carried, so nothing has to be entered again;
* - the history reads only DONE procedures of the caller, and the body-site
* search runs over the decrypted text;
* - the backup carries the site and the side, and a restore brings them
* back readable.
*
* Mutation checks (each run, each seen red):
* - drop `bodySiteEncrypted` from `ENCOUNTER_BACKUP_SELECT` → the round trip
* goes red with a null site;
* - drop `laterality` from the restore's `createMany` → the round trip goes
* red with a null side;
* - write `entry.bodySite` without `encryptToBytes` (as bytes of the plain
* string) → "stores the site as ciphertext" goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import {
GET as listEncounters,
POST as createEncounter,
} from "@/app/api/encounters/route";
import { PATCH as patchEncounter } from "@/app/api/encounters/[id]/route";
import { GET as listProcedures } from "@/app/api/encounters/procedures/route";
import { encryptToBytes, decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import {
buildVisitsBackupSection,
restoreVisitsData,
} from "@/lib/export/visits-backup";
import { backupPayloadSchema } from "@/lib/validations/backup";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const OWNER_ID = "procedures-owner";
const STRANGER_ID = "procedures-stranger";
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
async function seedSession() {
const prisma = getPrismaClient();
for (const id of [OWNER_ID, STRANGER_ID]) {
await prisma.user.create({
data: {
id,
username: id,
email: `${id}@example.test`,
timezone: "Europe/Berlin",
locale: "en",
},
});
}
const session = await prisma.session.create({
data: {
userId: OWNER_ID,
expiresAt: daysFromNow(7),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
}
function post(body: unknown): Promise<Response> {
return createEncounter(
new Request("http://localhost/api/encounters", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
);
}
function patch(id: string, body: unknown): Promise<Response> {
return patchEncounter(
new Request(`http://localhost/api/encounters/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
{ params: Promise.resolve({ id }) } as never,
);
}
function procedures(query = ""): Promise<Response> {
return listProcedures(
new Request(
`http://localhost/api/encounters/procedures${query ? `?${query}` : ""}`,
) as never,
);
}
async function json<T>(res: Response): Promise<T> {
return ((await res.json()) as { data: T }).data;
}
interface Row {
id: string;
kind: string;
reason: string | null;
bodySite: string | null;
laterality: string | null;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedSession();
});
describe("filing a procedure", () => {
it("stores the site as ciphertext and reads it back decrypted", async () => {
const res = await post({
occurredAt: daysFromNow(-400).toISOString(),
kind: "PROCEDURE",
reason: "Meniscus repair",
bodySite: " Knee ",
laterality: "LEFT",
});
expect(res.status).toBe(201);
const created = await json<Row>(res);
expect(created.kind).toBe("PROCEDURE");
expect(created.bodySite).toBe("Knee");
expect(created.laterality).toBe("LEFT");
const stored = await getPrismaClient().encounter.findUniqueOrThrow({
where: { id: created.id },
});
expect(stored.bodySiteEncrypted).not.toBeNull();
expect(
Buffer.from(stored.bodySiteEncrypted!).toString("utf8"),
).not.toContain("Knee");
expect(decryptFromBytes(stored.bodySiteEncrypted!)).toBe("Knee");
expect(stored.laterality).toBe("LEFT");
});
it("refuses a side that is not one", async () => {
const res = await post({
occurredAt: daysFromNow(-10).toISOString(),
kind: "PROCEDURE",
laterality: "UP",
});
expect(res.status).toBe(422);
});
});
describe("switching a visit already on file", () => {
it("keeps the date, the reason and the outcome, and lists it as a procedure", async () => {
const prisma = getPrismaClient();
const existing = await prisma.encounter.create({
data: {
userId: OWNER_ID,
occurredAt: daysFromNow(-2000),
status: "DONE",
kind: "HOSPITAL",
reasonEncrypted: encryptToBytes("Appendectomy"),
outcomeEncrypted: encryptToBytes("Home after three days"),
},
});
const before = await json<{ procedures: Row[] }>(await procedures());
expect(before.procedures).toHaveLength(0);
const res = await patch(existing.id, {
kind: "PROCEDURE",
bodySite: "Abdomen",
});
expect(res.status).toBe(200);
const updated = await json<Row & { outcome: string | null }>(res);
expect(updated.kind).toBe("PROCEDURE");
expect(updated.reason).toBe("Appendectomy");
expect(updated.outcome).toBe("Home after three days");
expect(updated.bodySite).toBe("Abdomen");
expect(updated.laterality).toBeNull();
const after = await json<{ procedures: Row[] }>(await procedures());
expect(after.procedures.map((p) => p.id)).toEqual([existing.id]);
});
it("clears the site with an explicit null", async () => {
const created = await json<Row>(
await post({
occurredAt: daysFromNow(-30).toISOString(),
kind: "PROCEDURE",
bodySite: "Wrist",
laterality: "RIGHT",
}),
);
const cleared = await json<Row>(
await patch(created.id, { bodySite: null, laterality: null }),
);
expect(cleared.bodySite).toBeNull();
expect(cleared.laterality).toBeNull();
});
});
describe("the procedure history", () => {
async function seedHistory() {
const prisma = getPrismaClient();
const make = (
userId: string,
days: number,
over: {
kind?: "PROCEDURE" | "HOSPITAL";
status?: "DONE" | "PLANNED" | "CANCELLED";
site?: string;
side?: "LEFT" | "RIGHT";
reason?: string;
},
) =>
prisma.encounter.create({
data: {
userId,
occurredAt: daysFromNow(days),
status: over.status ?? "DONE",
kind: over.kind ?? "PROCEDURE",
reasonEncrypted: over.reason ? encryptToBytes(over.reason) : null,
bodySiteEncrypted: over.site ? encryptToBytes(over.site) : null,
laterality: over.side ?? null,
},
});
return {
leftKnee: await make(OWNER_ID, -3000, {
site: "Knee",
side: "LEFT",
reason: "Arthroscopy",
}),
rightKnee: await make(OWNER_ID, -1000, { site: "knee", side: "RIGHT" }),
gallbladder: await make(OWNER_ID, -500, {
site: "Gallbladder",
reason: "Cholecystectomy",
}),
hospital: await make(OWNER_ID, -200, {
kind: "HOSPITAL",
site: "Knee",
side: "LEFT",
}),
cancelled: await make(OWNER_ID, -100, { status: "CANCELLED" }),
planned: await make(OWNER_ID, 30, { status: "PLANNED", site: "Knee" }),
strangers: await make(STRANGER_ID, -50, { site: "Knee", side: "LEFT" }),
};
}
it("lists the caller's procedures that happened, newest first", async () => {
const seeded = await seedHistory();
const body = await json<{
procedures: Row[];
total: number;
bodySites: Array<{
bodySite: string;
laterality: string | null;
count: number;
}>;
}>(await procedures());
expect(body.procedures.map((p) => p.id)).toEqual([
seeded.gallbladder.id,
seeded.rightKnee.id,
seeded.leftKnee.id,
]);
expect(body.total).toBe(3);
expect(body.bodySites).toEqual([
{ bodySite: "Gallbladder", laterality: null, count: 1 },
{ bodySite: "knee", laterality: "RIGHT", count: 1 },
{ bodySite: "Knee", laterality: "LEFT", count: 1 },
]);
});
it("answers 'what was done on the left knee'", async () => {
const seeded = await seedHistory();
const body = await json<{ procedures: Row[]; total: number }>(
await procedures("q=left%20knee"),
);
expect(body.procedures.map((p) => p.id)).toEqual([seeded.leftKnee.id]);
expect(body.total).toBe(3);
});
it("finds a procedure by its reason as well as its site", async () => {
const seeded = await seedHistory();
const body = await json<{ procedures: Row[] }>(
await procedures("q=cholecyst"),
);
expect(body.procedures.map((p) => p.id)).toEqual([seeded.gallbladder.id]);
});
it("keeps a booked procedure in the visit list's upcoming half", async () => {
const seeded = await seedHistory();
const list = await json<{ upcoming: Row[] }>(
await listEncounters(
new Request("http://localhost/api/encounters") as never,
),
);
expect(list.upcoming.map((row) => row.id)).toEqual([seeded.planned.id]);
});
});
describe("the backup", () => {
it("carries the site and the side, and a restore reads them back", async () => {
const prisma = getPrismaClient();
const created = await json<Row>(
await post({
occurredAt: daysFromNow(-400).toISOString(),
kind: "PROCEDURE",
reason: "Hip replacement",
bodySite: "Hüfte",
laterality: "BOTH",
}),
);
const section = await buildVisitsBackupSection(prisma, OWNER_ID);
const entry = section.encounters.find((row) => row.id === created.id);
expect(entry?.kind).toBe("PROCEDURE");
expect(entry?.laterality).toBe("BOTH");
expect(entry?.bodySiteEncrypted).toEqual(expect.any(String));
// Through the file schema, as a restore reads it: a field the schema does
// not know would be dropped here and come back as null.
const parsed = backupPayloadSchema.parse({
exportedAt: new Date().toISOString(),
userId: OWNER_ID,
...JSON.parse(JSON.stringify(section)),
});
await prisma.$transaction((tx) =>
restoreVisitsData(tx, OWNER_ID, parsed, []),
);
const restored = await prisma.encounter.findUniqueOrThrow({
where: { id: created.id },
});
expect(restored.kind).toBe("PROCEDURE");
expect(restored.laterality).toBe("BOTH");
expect(decryptFromBytes(restored.bodySiteEncrypted!)).toBe("Hüfte");
const history = await json<{ procedures: Row[] }>(
await procedures("q=hufte"),
);
expect(history.procedures.map((p) => p.bodySite)).toEqual(["Hüfte"]);
});
});