mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(visits): record procedures and surgeries with a body site
A visit can be filed as a procedure or surgery (new EncounterKind PROCEDURE) and carry a body site in the person's own words, encrypted at rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit switches to the new kind by an ordinary edit, keeping its date, reason, outcome and links. - migration 0349: enum value, laterality type, two nullable columns - GET /api/encounters/procedures: the DONE procedures, newest first, searched after the decrypt over site, side and reason, with body-site choices and a total computed over the whole history - create and edit accept bodySite and laterality on any kind - /checkups gains a Procedures tab with a body-site filter; the visit form offers the site for a procedure and keeps it in view on any visit that carries one - backup and restore carry both fields; the body site is registered as ciphertext and rotates with the key - MCP get_visits narrows by kind and reads the whole record for procedures when no window is named; the Coach visit block names a procedure's site - i18n in all seven locales, OpenAPI regenerated Refs #1025
This commit is contained in:
1 parent
ee23409b77
commit
ad0d50fa28
43 files changed
+2486
-61
No files matched your search
+169
-9
@@ -13978,6 +13978,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:
|
||||
@@ -21362,7 +21395,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 94
|
||||
maxItems: 95
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -21421,7 +21454,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 94
|
||||
maxItems: 95
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -24037,7 +24070,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 94
|
||||
maxItems: 95
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -26830,6 +26863,7 @@ components:
|
||||
- HOSPITAL
|
||||
- THERAPY
|
||||
- OTHER
|
||||
- PROCEDURE
|
||||
practitionerId:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -26846,6 +26880,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
|
||||
@@ -26878,8 +26925,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:
|
||||
@@ -26905,6 +26954,7 @@ components:
|
||||
- HOSPITAL
|
||||
- THERAPY
|
||||
- OTHER
|
||||
- PROCEDURE
|
||||
practitionerId:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -26921,6 +26971,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
|
||||
@@ -26949,10 +27012,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:
|
||||
@@ -35702,6 +35767,7 @@ components:
|
||||
- HOSPITAL
|
||||
- THERAPY
|
||||
- OTHER
|
||||
- PROCEDURE
|
||||
practitionerId:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -35716,6 +35782,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
|
||||
@@ -60378,6 +60457,7 @@ components:
|
||||
- HOSPITAL
|
||||
- THERAPY
|
||||
- OTHER
|
||||
- PROCEDURE
|
||||
practitioner:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/Practitioner"
|
||||
@@ -60390,6 +60470,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
|
||||
@@ -60410,14 +60502,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:
|
||||
@@ -60562,6 +60658,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:
|
||||
@@ -60642,6 +60801,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",
|
||||
@@ -10395,6 +10400,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)"
|
||||
},
|
||||
@@ -10779,8 +10785,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",
|
||||
@@ -10822,6 +10836,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",
|
||||
@@ -10841,12 +10858,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",
|
||||
@@ -10395,6 +10400,7 @@
|
||||
"scopeCount": "{count} entries from {groups} areas",
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visits",
|
||||
"leafSurgicalHistory": "Surgical history",
|
||||
"leafImmunizations": "Immunizations",
|
||||
"leafEmergency": "Emergency information (blood type, contacts, directives)"
|
||||
},
|
||||
@@ -10779,8 +10785,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",
|
||||
@@ -10822,6 +10836,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",
|
||||
@@ -10841,12 +10858,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",
|
||||
@@ -10395,6 +10400,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)"
|
||||
},
|
||||
@@ -10779,8 +10785,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",
|
||||
@@ -10822,6 +10836,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",
|
||||
@@ -10841,12 +10858,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",
|
||||
@@ -10395,6 +10400,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)"
|
||||
},
|
||||
@@ -10779,8 +10785,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",
|
||||
@@ -10822,6 +10836,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",
|
||||
@@ -10841,12 +10858,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",
|
||||
@@ -10395,6 +10400,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)"
|
||||
},
|
||||
@@ -10779,8 +10785,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",
|
||||
@@ -10822,6 +10836,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",
|
||||
@@ -10841,12 +10858,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": "백신",
|
||||
@@ -10395,6 +10400,7 @@
|
||||
"scopeCount": "{groups}개 영역에서 {count}개 항목",
|
||||
"scopeSensitive": "{names} 포함",
|
||||
"leafVisits": "진료",
|
||||
"leafSurgicalHistory": "수술 이력",
|
||||
"leafImmunizations": "예방접종",
|
||||
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)"
|
||||
},
|
||||
@@ -10779,8 +10785,16 @@
|
||||
"emergency": "응급",
|
||||
"hospital": "병원",
|
||||
"therapy": "치료 세션",
|
||||
"other": "예약"
|
||||
"other": "예약",
|
||||
"procedure": "시술 또는 수술"
|
||||
},
|
||||
"laterality": {
|
||||
"left": "왼쪽",
|
||||
"right": "오른쪽",
|
||||
"both": "양쪽",
|
||||
"none": "지정 안 함"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"sectionTitle": "진료",
|
||||
"sectionDescription": "병원 방문과 잡아 둔 예약을 한곳에서 봐요.",
|
||||
"add": "진료",
|
||||
@@ -10822,6 +10836,9 @@
|
||||
"reason": "사유",
|
||||
"outcome": "결과",
|
||||
"outcomeReveal": "결과 추가",
|
||||
"bodySite": "신체 부위",
|
||||
"bodySiteHint": "예: 무릎, 담낭",
|
||||
"laterality": "방향",
|
||||
"linkDocuments": "문서",
|
||||
"linkLabResults": "검사 결과",
|
||||
"linkConditions": "질환",
|
||||
@@ -10841,12 +10858,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",
|
||||
@@ -10395,6 +10400,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)"
|
||||
},
|
||||
@@ -10779,8 +10785,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",
|
||||
@@ -10822,6 +10836,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",
|
||||
@@ -10841,12 +10858,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
@@ -8119,12 +8119,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.
|
||||
@@ -8138,27 +8138,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)
|
||||
@@ -8189,6 +8211,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
|
||||
@@ -8198,10 +8230,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,178 @@
|
||||
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("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 {
|
||||
@@ -70,6 +74,8 @@ export function VisitCard({
|
||||
const heading =
|
||||
encounter.practitioner?.name ??
|
||||
encounterKindText(t, encounter.kind as EncounterKind);
|
||||
// Where on the body, with the side — what the person wrote, so foreground.
|
||||
const site = bodySiteText(t, encounter.bodySite, encounter.laterality);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -103,6 +109,15 @@ export function VisitCard({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{site ? (
|
||||
<p
|
||||
className="text-foreground line-clamp-1 text-sm"
|
||||
data-slot="visit-card-body-site"
|
||||
>
|
||||
{site}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isNoteworthy(encounter.status as EncounterStatus) ||
|
||||
counts.length ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) ─────
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
@@ -929,7 +930,9 @@ const getPreventiveCareOutput: z.ZodRawShape = {
|
||||
*/
|
||||
const getVisitsOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
windowMonths: z.number().optional(),
|
||||
// Null when the read covered the whole record: a procedure history asked
|
||||
// for without a window (v1.39.1).
|
||||
windowMonths: z.number().nullable().optional(),
|
||||
visits: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -940,6 +943,8 @@ const getVisitsOutput: z.ZodRawShape = {
|
||||
specialty: z.string().nullable(),
|
||||
reason: z.string().nullable(),
|
||||
outcome: z.string().nullable(),
|
||||
bodySite: z.string().nullable(),
|
||||
laterality: z.enum(["LEFT", "RIGHT", "BOTH"]).nullable(),
|
||||
conditions: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
@@ -1898,7 +1903,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
name: "get_visits",
|
||||
title: "Get past doctor visits",
|
||||
description:
|
||||
"Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none.",
|
||||
"Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none.",
|
||||
inputShape: {
|
||||
months: z
|
||||
.number()
|
||||
@@ -1917,21 +1922,32 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
.describe(
|
||||
"Optional case-insensitive name substring to narrow to one practitioner (e.g. a surname read back from an earlier result). Omit for every practitioner.",
|
||||
),
|
||||
kind: encounterKindEnum
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional visit kind to narrow to. PROCEDURE lists procedures and surgeries, over the whole record unless months is given.",
|
||||
),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: getVisitsOutput,
|
||||
async run(ctx, args) {
|
||||
const kindArg = encounterKindEnum.safeParse(args.kind);
|
||||
const kind = kindArg.success ? kindArg.data : undefined;
|
||||
// A procedure history is lifetime reference data; every other read keeps
|
||||
// the default year. A window the caller names always wins.
|
||||
const months =
|
||||
typeof args.months === "number"
|
||||
? args.months
|
||||
: DEFAULT_VISITS_WINDOW_MONTHS;
|
||||
: kind === "PROCEDURE"
|
||||
? null
|
||||
: DEFAULT_VISITS_WINDOW_MONTHS;
|
||||
const practitioner =
|
||||
typeof args.practitioner === "string" && args.practitioner.trim()
|
||||
? args.practitioner.trim()
|
||||
: undefined;
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now);
|
||||
cutoff.setMonth(cutoff.getMonth() - months);
|
||||
const cutoff = months === null ? null : new Date(now);
|
||||
cutoff?.setMonth(cutoff.getMonth() - (months ?? 0));
|
||||
|
||||
const rows = await prisma.encounter.findMany({
|
||||
// `userId` is the resolved session's, never an argument — a visit
|
||||
@@ -1939,7 +1955,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
where: {
|
||||
userId: ctx.userId,
|
||||
deletedAt: null,
|
||||
occurredAt: { gte: cutoff, lte: now },
|
||||
occurredAt: { ...(cutoff ? { gte: cutoff } : {}), lte: now },
|
||||
...(kind ? { kind } : {}),
|
||||
...(practitioner
|
||||
? {
|
||||
practitioner: {
|
||||
@@ -1979,6 +1996,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
const visits = rows.map((r) => {
|
||||
const reason = decryptVisitText(r.reasonEncrypted);
|
||||
const outcome = decryptVisitText(r.outcomeEncrypted);
|
||||
const bodySite = decryptVisitText(r.bodySiteEncrypted);
|
||||
return {
|
||||
occurredAt: r.occurredAt.toISOString(),
|
||||
status: r.status,
|
||||
@@ -1995,6 +2013,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
// too — scrubbed of any forged marker before it enters the payload.
|
||||
reason: reason !== null ? fenceUserText(reason) : null,
|
||||
outcome: outcome !== null ? fenceUserText(outcome) : null,
|
||||
bodySite: bodySite !== null ? fenceUserText(bodySite) : null,
|
||||
laterality: r.laterality,
|
||||
conditions: (conditionsBySource.get(r.id) ?? []).map((c) =>
|
||||
scrubFenceMarkers(c.label),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
InjectionSite,
|
||||
IntakeAttributionSource,
|
||||
IntakeSource,
|
||||
Laterality,
|
||||
MeasurementAggregationProvenance,
|
||||
MeasurementReminderEventKind,
|
||||
MeasurementSource,
|
||||
@@ -1088,6 +1089,10 @@ const encounterBackupSchema = z
|
||||
practitionerId: z.string().nullable().optional(),
|
||||
reasonEncrypted: base64BytesSchema.nullable().optional(),
|
||||
outcomeEncrypted: base64BytesSchema.nullable().optional(),
|
||||
// v1.39.1 — the procedure's body site and side. Optional so a file written
|
||||
// before they existed still parses; the visit restores with neither.
|
||||
bodySiteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
laterality: z.enum(Laterality).nullable().optional(),
|
||||
// Remapped against the restored reminders (they travel since v1.37.20);
|
||||
// dropped to NULL, with the drop named, only when the file lacks the row.
|
||||
reminderId: z.string().nullable().optional(),
|
||||
|
||||
@@ -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