diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 190b9430e..0e51a7b66 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 5670f4dd4..9fc11d6e0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 692477404..259255a98 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index afafe8e32..04ba8089b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index dbc7b9952..e1595882d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 622062c21..d4cc831e0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index a4be37e2d..b5ce01f70 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "병원과 의사", diff --git a/messages/pl.json b/messages/pl.json index 82df9291c..1694e5c05 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0349_encounter_procedure_body_site/migration.sql b/prisma/migrations/0349_encounter_procedure_body_site/migration.sql new file mode 100644 index 000000000..46b07b332 --- /dev/null +++ b/prisma/migrations/0349_encounter_procedure_body_site/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70acf8e51..622c4061a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 616af8582..58a23e4f4 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -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", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 2a4c03615..1d6ac52ea 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -943,6 +943,10 @@ const DELEGABLE_ROUTES: Record = { 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 = { * 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` — diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index 930e04f4f..e651b6e12 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -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, }, }), diff --git a/src/app/api/encounters/procedures/__tests__/route.test.ts b/src/app/api/encounters/procedures/__tests__/route.test.ts new file mode 100644 index 000000000..749e564cb --- /dev/null +++ b/src/app/api/encounters/procedures/__tests__/route.test.ts @@ -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; + +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(); + }); +}); diff --git a/src/app/api/encounters/procedures/route.ts b/src/app/api/encounters/procedures/route.ts new file mode 100644 index 000000000..cb6fd9a43 --- /dev/null +++ b/src/app/api/encounters/procedures/route.ts @@ -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); +}); diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index bdc74237c..1641c12b2 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -216,6 +216,10 @@ async function postEncounter(request: NextRequest): Promise { 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 }, }); diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index e36a95c13..aa64690a1 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -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() { {t("checkups.tabVisits")} + + {t("checkups.tabProcedures")} + @@ -78,6 +87,9 @@ export default function CheckupsPageClient() { for a person who never opens it. */} + + + ); diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index d7fa8c1d7..1dd578381 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -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", diff --git a/src/components/encounters/__tests__/encounter-form.test.tsx b/src/components/encounters/__tests__/encounter-form.test.tsx index c7011f645..8a7859bf1 100644 --- a/src/components/encounters/__tests__/encounter-form.test.tsx +++ b/src/components/encounters/__tests__/encounter-form.test.tsx @@ -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[0]) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + + {}} + isEdit + /> + + , + ); + } + + 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"); + }); +}); diff --git a/src/components/encounters/__tests__/procedures-section.test.tsx b/src/components/encounters/__tests__/procedures-section.test.tsx new file mode 100644 index 000000000..c9e0dc4b5 --- /dev/null +++ b/src/components/encounters/__tests__/procedures-section.test.tsx @@ -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( + "@/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 & { 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( + + + + + , + ); +} + +describe("", () => { + 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"); + }); +}); diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index 07bb5b908..19f3ab62b 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -60,6 +60,8 @@ function visit(over: Partial & { 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", diff --git a/src/components/encounters/encounter-form.tsx b/src/components/encounters/encounter-form.tsx index d46a5ec63..7b1a46be0 100644 --- a/src/components/encounters/encounter-form.tsx +++ b/src/components/encounters/encounter-form.tsx @@ -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 (
+ {showBodySite ? ( +
+ + patch({ bodySite: event.target.value })} + /> + + + + patch({ + laterality: + (event.target.value as EncounterDraft["laterality"]) || + null, + }) + } + > + + {LATERALITIES.map((side) => ( + + ))} + + +
+ ) : null} + string; +type Translate = (key: string, values?: Record) => 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), + }); +} diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index abac9c3fb..2f2a3a21a 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -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(null); diff --git a/src/components/encounters/procedures-section.tsx b/src/components/encounters/procedures-section.tsx new file mode 100644 index 000000000..5442340ca --- /dev/null +++ b/src/components/encounters/procedures-section.tsx @@ -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(null); + const q = useDebouncedValue(query.trim(), 250); + const list = useProcedures(enabled, { q, laterality: side }); + + const [editing, setEditing] = useState(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 ? ( + + ) : null; + + return ( +
+ + + {list.isError ? ( + void list.refetch()} + /> + ) : list.isPending ? ( +
+ {Array.from({ length: 3 }, (_, index) => ( + + ))} +
+ ) : total === 0 ? ( + } + title={t("encounters.procedures.emptyTitle")} + description={t("encounters.procedures.emptyDescription")} + action={addButton ?? undefined} + ctaSize="lg" + /> + ) : ( +
+
+ +
+ + { + setQuery(event.target.value); + // Typing starts a new question; a side picked from a chip + // belonged to the old one. + setSide(null); + }} + /> +
+
+ + {bodySites.length > 0 ? ( +
+ + {bodySites.map((facet) => { + const picked = isPicked(facet); + return ( + + ); + })} +
+ ) : null} +
+ +

+ {t("encounters.procedures.count", { + shown: String(procedures.length), + total: String(total), + })} +

+ + {procedures.length === 0 ? ( + } + title={t("encounters.procedures.noMatchTitle")} + action={ + + } + /> + ) : ( +
+ {procedures.map((encounter) => ( + + ))} +
+ )} +
+ )} + + {/* 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. */} + { + if (!open) setEditing(null); + }} + encounter={editing === "new" ? null : editing} + kind="PROCEDURE" + /> +
+ ); +} diff --git a/src/components/encounters/visit-card.tsx b/src/components/encounters/visit-card.tsx index 0baaf512e..80a553c42 100644 --- a/src/components/encounters/visit-card.tsx +++ b/src/components/encounters/visit-card.tsx @@ -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 ( ) : null} + {site ? ( +

+ {site} +

+ ) : null} + {isNoteworthy(encounter.status as EncounterStatus) || counts.length ? (
diff --git a/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx b/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx index 476fbbbfa..2f555dcc5 100644 --- a/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx +++ b/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx @@ -44,6 +44,8 @@ function visit(over: Partial & { id: string }): EncounterDTO { practitioner: null, reason: null, outcome: null, + bodySite: null, + laterality: null, reminderNextDueAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/src/hooks/use-encounters.ts b/src/hooks/use-encounters.ts index df26b6dfb..45cb0baa3 100644 --- a/src/hooks/use-encounters.ts +++ b/src/hooks/use-encounters.ts @@ -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( + qs ? `${BASE}/procedures?${qs}` : `${BASE}/procedures`, + ); + }, + placeholderData: keepPreviousData, + enabled, + }); +} + /** * "Does this belong to a visit?" for one anchor date. * diff --git a/src/lib/ai/coach/__tests__/visits-snapshot.test.ts b/src/lib/ai/coach/__tests__/visits-snapshot.test.ts index f410e722f..5941ac680 100644 --- a/src/lib/ai/coach/__tests__/visits-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/visits-snapshot.test.ts @@ -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"); diff --git a/src/lib/ai/coach/visits-snapshot.ts b/src/lib/ai/coach/visits-snapshot.ts index 70286d387..a4b524659 100644 --- a/src/lib/ai/coach/visits-snapshot.ts +++ b/src/lib/ai/coach/visits-snapshot.ts @@ -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 } : {}) } + : {}), }; } diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index c9ce41df9..2c0c5440c 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -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) ───── diff --git a/src/lib/encounters/__tests__/procedures.test.ts b/src/lib/encounters/__tests__/procedures.test.ts new file mode 100644 index 000000000..e98704415 --- /dev/null +++ b/src/lib/encounters/__tests__/procedures.test.ts @@ -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 }, + ]); + }); +}); diff --git a/src/lib/encounters/dto.ts b/src/lib/encounters/dto.ts index 41bd509c8..d2fed17d2 100644 --- a/src/lib/encounters/dto.ts +++ b/src/lib/encounters/dto.ts @@ -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; +} diff --git a/src/lib/encounters/kind-label.ts b/src/lib/encounters/kind-label.ts index 3e664ad08..b9cda6f0b 100644 --- a/src/lib/encounters/kind-label.ts +++ b/src/lib/encounters/kind-label.ts @@ -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"; + } +} diff --git a/src/lib/encounters/procedures.ts b/src/lib/encounters/procedures.ts new file mode 100644 index 000000000..fae3bd5c4 --- /dev/null +++ b/src/lib/encounters/procedures.ts @@ -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(); + 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 { + 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, + }; +} diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts index b34dde4ba..99f7c8d4b 100644 --- a/src/lib/export/visits-backup.ts +++ b/src/lib/export/visits-backup.ts @@ -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), diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 30c134d92..8408e0101 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -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; + }; + 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>; + }; + expect(result.windowMonths).toBeNull(); + expect(result.visits[0].bodySite).toContain("<<>>"); + expect(result.visits[0].bodySite).toContain("knee"); + expect(result.visits[0].laterality).toBe("LEFT"); + }); }); describe("search — cursor pagination", () => { diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index a6b6449b8..f3ba1815f 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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), ), diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index b5df36a31..b74a908ce 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -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 = { }, }, }, + "/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"], diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index be23db66a..cd774bf01 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -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 diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index c2c9fab6e..aa2990466 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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(), diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 009e11165..9955204dd 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -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; export type EncounterKindInput = z.infer; +export type LateralityInput = z.infer; + +/** + * 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; +/** + * 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; + /* ── links ────────────────────────────────────────────────────────── */ export const encounterLinkTargetEnum = z.enum([ diff --git a/tests/integration/encounter-procedures.test.ts b/tests/integration/encounter-procedures.test.ts new file mode 100644 index 000000000..31198c652 --- /dev/null +++ b/tests/integration/encounter-procedures.test.ts @@ -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 { + 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 { + 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 { + return listProcedures( + new Request( + `http://localhost/api/encounters/procedures${query ? `?${query}` : ""}`, + ) as never, + ); +} + +async function json(res: Response): Promise { + 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(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(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( + await post({ + occurredAt: daysFromNow(-30).toISOString(), + kind: "PROCEDURE", + bodySite: "Wrist", + laterality: "RIGHT", + }), + ); + const cleared = await json( + 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( + 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"]); + }); +});