mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge remote-tracking branch 'origin/release/v1.39.1' into feat/medication-record-only
This commit is contained in:
commit
94ec9ab932
59 files changed
+2959
-79
No files matched your search
+169
-9
@@ -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
|
||||
@@ -24186,7 +24219,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 94
|
||||
maxItems: 95
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -26979,6 +27012,7 @@ components:
|
||||
- HOSPITAL
|
||||
- THERAPY
|
||||
- OTHER
|
||||
- PROCEDURE
|
||||
practitionerId:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -26995,6 +27029,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
|
||||
@@ -27027,8 +27074,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:
|
||||
@@ -27054,6 +27103,7 @@ components:
|
||||
- HOSPITAL
|
||||
- THERAPY
|
||||
- OTHER
|
||||
- PROCEDURE
|
||||
practitionerId:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -27070,6 +27120,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
|
||||
@@ -27098,10 +27161,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:
|
||||
@@ -35860,6 +35925,7 @@ components:
|
||||
- HOSPITAL
|
||||
- THERAPY
|
||||
- OTHER
|
||||
- PROCEDURE
|
||||
practitionerId:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -35874,6 +35940,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
|
||||
@@ -60732,6 +60811,7 @@ components:
|
||||
- HOSPITAL
|
||||
- THERAPY
|
||||
- OTHER
|
||||
- PROCEDURE
|
||||
practitioner:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/Practitioner"
|
||||
@@ -60744,6 +60824,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
|
||||
@@ -60764,14 +60856,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:
|
||||
@@ -60916,6 +61012,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:
|
||||
@@ -60996,6 +61155,7 @@ components:
|
||||
- HOSPITAL
|
||||
- THERAPY
|
||||
- OTHER
|
||||
- PROCEDURE
|
||||
practitionerName:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
+36
-3
@@ -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",
|
||||
@@ -10438,6 +10443,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)"
|
||||
},
|
||||
@@ -10822,8 +10828,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",
|
||||
@@ -10865,6 +10879,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",
|
||||
@@ -10884,12 +10901,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
@@ -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",
|
||||
@@ -10438,6 +10443,7 @@
|
||||
"scopeCount": "{count} entries from {groups} areas",
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visits",
|
||||
"leafSurgicalHistory": "Surgical history",
|
||||
"leafImmunizations": "Immunizations",
|
||||
"leafEmergency": "Emergency information (blood type, contacts, directives)"
|
||||
},
|
||||
@@ -10822,8 +10828,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",
|
||||
@@ -10865,6 +10879,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",
|
||||
@@ -10884,12 +10901,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
@@ -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",
|
||||
@@ -10438,6 +10443,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)"
|
||||
},
|
||||
@@ -10822,8 +10828,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",
|
||||
@@ -10865,6 +10879,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",
|
||||
@@ -10884,12 +10901,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
@@ -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",
|
||||
@@ -10438,6 +10443,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)"
|
||||
},
|
||||
@@ -10822,8 +10828,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",
|
||||
@@ -10865,6 +10879,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",
|
||||
@@ -10884,12 +10901,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
@@ -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",
|
||||
@@ -10438,6 +10443,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)"
|
||||
},
|
||||
@@ -10822,8 +10828,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",
|
||||
@@ -10865,6 +10879,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",
|
||||
@@ -10884,12 +10901,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
@@ -329,6 +329,11 @@
|
||||
"visitsColReason": "사유",
|
||||
"visitsColOutcome": "결과",
|
||||
"visitsColConditions": "질환",
|
||||
"surgicalHistoryTitle": "수술 이력",
|
||||
"surgicalHistoryColDate": "날짜",
|
||||
"surgicalHistoryColProcedure": "시술",
|
||||
"surgicalHistoryColBodySite": "신체 부위",
|
||||
"surgicalHistoryColOutcome": "결과",
|
||||
"immunizationsTitle": "예방접종",
|
||||
"immunizationsColDate": "날짜",
|
||||
"immunizationsColVaccine": "백신",
|
||||
@@ -10438,6 +10443,7 @@
|
||||
"scopeCount": "{groups}개 영역에서 {count}개 항목",
|
||||
"scopeSensitive": "{names} 포함",
|
||||
"leafVisits": "진료",
|
||||
"leafSurgicalHistory": "수술 이력",
|
||||
"leafImmunizations": "예방접종",
|
||||
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)"
|
||||
},
|
||||
@@ -10822,8 +10828,16 @@
|
||||
"emergency": "응급",
|
||||
"hospital": "병원",
|
||||
"therapy": "치료 세션",
|
||||
"other": "예약"
|
||||
"other": "예약",
|
||||
"procedure": "시술 또는 수술"
|
||||
},
|
||||
"laterality": {
|
||||
"left": "왼쪽",
|
||||
"right": "오른쪽",
|
||||
"both": "양쪽",
|
||||
"none": "지정 안 함"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"sectionTitle": "진료",
|
||||
"sectionDescription": "병원 방문과 잡아 둔 예약을 한곳에서 봐요.",
|
||||
"add": "진료",
|
||||
@@ -10865,6 +10879,9 @@
|
||||
"reason": "사유",
|
||||
"outcome": "결과",
|
||||
"outcomeReveal": "결과 추가",
|
||||
"bodySite": "신체 부위",
|
||||
"bodySiteHint": "예: 무릎, 담낭",
|
||||
"laterality": "방향",
|
||||
"linkDocuments": "문서",
|
||||
"linkLabResults": "검사 결과",
|
||||
"linkConditions": "질환",
|
||||
@@ -10884,12 +10901,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
@@ -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",
|
||||
@@ -10438,6 +10443,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)"
|
||||
},
|
||||
@@ -10822,8 +10828,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",
|
||||
@@ -10865,6 +10879,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",
|
||||
@@ -10884,12 +10901,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
@@ -8227,12 +8227,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.
|
||||
@@ -8246,27 +8246,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)
|
||||
@@ -8297,6 +8319,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
|
||||
@@ -8306,10 +8338,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
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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` —
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 } : {}) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
loadIllnessEpisodes,
|
||||
loadImmunizations,
|
||||
loadVisits,
|
||||
loadSurgicalHistory,
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
|
||||
@@ -510,6 +511,7 @@ export async function collectDoctorReportData(
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
visits,
|
||||
surgicalHistory,
|
||||
immunizations,
|
||||
allergies,
|
||||
familyHistory,
|
||||
@@ -528,6 +530,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")
|
||||
@@ -603,6 +609,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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
|
||||
@@ -931,7 +932,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({
|
||||
@@ -942,6 +945,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()),
|
||||
}),
|
||||
)
|
||||
@@ -1903,7 +1908,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()
|
||||
@@ -1922,21 +1927,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
|
||||
@@ -1944,7 +1960,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: {
|
||||
@@ -1984,6 +2001,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,
|
||||
@@ -2000,6 +2018,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),
|
||||
),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
InjectionSite,
|
||||
IntakeAttributionSource,
|
||||
IntakeSource,
|
||||
Laterality,
|
||||
MeasurementAggregationProvenance,
|
||||
MeasurementReminderEventKind,
|
||||
MeasurementSource,
|
||||
@@ -1091,6 +1092,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(),
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user