From 14f0fc4d4fcaf2de8717b3bdab005472e161f997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 21:55:14 +0200 Subject: [PATCH] feat(body-sites): record a body site on conditions and add a body-site view A condition (illness episode) can carry where on the body it sits, in the person's own words and encrypted at rest, plus an optional side (LEFT, RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site. - migration 0354: two nullable columns on illness_episodes, reusing the laterality type from 0349 - create and edit accept bodySite and laterality; on PATCH an absent key keeps the stored value, so an edit from the shipped iPhone app, which never sends either field, leaves them untouched - GET /api/body-sites: the sites the record holds across visits and conditions, folded after the decrypt and bounded per kind; with site (and laterality) the visits and conditions there, one hop to their documents, lab results and visits. Read in the profile section; conditions only under an illness grant with the module on, and links into ungranted sections come back as unlabelled placeholders - the link service reads document and visit links from the condition's end of the two existing tables - /checkups gains a Body sites tab; the visit and condition forms share one body-site field pair that suggests sites already used; a condition's page links its site into the view - the doctor report, the clinician page and the MCP doctor-visit summary name the site on conditions; the Coach illness block carries it sanitised, and MCP get_illness_recovery returns it fenced - backup and restore carry both fields in both file kinds; the site is registered as ciphertext and rotates with the key - i18n in all seven locales, OpenAPI regenerated Refs #1025 --- docs/api/openapi.yaml | 351 +++++++++++-- messages/de.json | 30 +- messages/en.json | 30 +- messages/es.json | 30 +- messages/fr.json | 30 +- messages/it.json | 30 +- messages/ko.json | 30 +- messages/pl.json | 30 +- .../0354_condition_body_site/migration.sql | 17 + prisma/schema.prisma | 22 +- scripts/rotate-encryption-key.ts | 8 + src/__tests__/sharing-surface-guard.test.ts | 10 +- src/app/api/body-sites/route.ts | 69 +++ .../episodes/[id]/__tests__/body-site.test.ts | 226 ++++++++ src/app/api/illness/episodes/[id]/route.ts | 19 +- src/app/api/illness/episodes/route.ts | 4 + src/app/checkups/page-client.tsx | 34 +- .../__tests__/body-site-fields.test.tsx | 80 +++ .../__tests__/body-sites-section.test.tsx | 204 ++++++++ .../body-sites/body-site-fields.tsx | 99 ++++ .../body-sites/body-sites-section.tsx | 458 ++++++++++++++++ src/components/clinician/history-sections.tsx | 10 + src/components/encounters/encounter-form.tsx | 49 +- .../illness/illness-episode-detail.tsx | 30 +- src/components/illness/new-episode-sheet.tsx | 23 + src/components/illness/types.ts | 8 + src/hooks/use-body-sites.ts | 51 ++ .../__tests__/doctor-report-pdf-core.test.ts | 56 ++ .../illness-snapshot-body-site.test.ts | 83 +++ src/lib/ai/coach/illness-snapshot.ts | 67 ++- src/lib/ai/coach/system-prompt.ts | 5 +- .../body-sites/__tests__/body-sites.test.ts | 89 ++++ src/lib/body-sites/dto.ts | 76 +++ src/lib/body-sites/index.ts | 275 ++++++++++ src/lib/crypto/encrypted-columns.ts | 3 + .../clinical-records-notes-section.ts | 18 +- src/lib/doctor-report-types.ts | 7 + .../__tests__/condition-body-site.test.ts | 84 +++ src/lib/doctor-report/clinical-records.ts | 37 +- .../export/__tests__/records-backup.test.ts | 12 + src/lib/export/records-backup.ts | 9 + src/lib/export/restore-backup.ts | 11 + src/lib/illness/dto.ts | 18 +- src/lib/links/link-service.ts | 28 +- src/lib/mcp/__tests__/tools.test.ts | 47 ++ src/lib/mcp/doctor-visit-summary.ts | 8 + src/lib/mcp/tools.ts | 43 +- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/encounters.ts | 95 ++++ src/lib/openapi/routes/illness.ts | 9 +- src/lib/query-keys/encounters.ts | 11 + src/lib/query-keys/index.ts | 3 + src/lib/validations/backup.ts | 7 + src/lib/validations/body-sites.ts | 26 + src/lib/validations/illness.ts | 13 + tests/integration/backup-round-trip.test.ts | 21 + tests/integration/body-sites.test.ts | 490 ++++++++++++++++++ 57 files changed, 3501 insertions(+), 133 deletions(-) create mode 100644 prisma/migrations/0354_condition_body_site/migration.sql create mode 100644 src/app/api/body-sites/route.ts create mode 100644 src/app/api/illness/episodes/[id]/__tests__/body-site.test.ts create mode 100644 src/components/body-sites/__tests__/body-site-fields.test.tsx create mode 100644 src/components/body-sites/__tests__/body-sites-section.test.tsx create mode 100644 src/components/body-sites/body-site-fields.tsx create mode 100644 src/components/body-sites/body-sites-section.tsx create mode 100644 src/hooks/use-body-sites.ts create mode 100644 src/lib/ai/coach/__tests__/illness-snapshot-body-site.test.ts create mode 100644 src/lib/body-sites/__tests__/body-sites.test.ts create mode 100644 src/lib/body-sites/dto.ts create mode 100644 src/lib/body-sites/index.ts create mode 100644 src/lib/doctor-report/__tests__/condition-body-site.test.ts create mode 100644 src/lib/validations/body-sites.ts create mode 100644 tests/integration/body-sites.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8e942b3e6..9aa62b43c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14036,6 +14036,43 @@ paths: "403": *a9 "422": *a4 "429": *a5 + /api/body-sites: + get: + tags: + - Records + summary: List body sites, or what is filed at one + description: The body sites the record holds, across visits and conditions, and with `site` the visits and conditions + filed at that site with their linked documents, lab results and visits. The same list serves as suggestions for + a body-site field, so a site is written the same way twice. Body sites are encrypted at rest, so grouping and + matching run server-side after the decrypt, over at most 500 rows per kind. Read at the same section and level + as the visit list; conditions join only when the caller may read the illness section and the record has the + illness module on. + parameters: + - in: query + name: site + schema: + type: string + minLength: 1 + maxLength: 200 + - in: query + name: laterality + schema: + type: string + enum: + - LEFT + - RIGHT + - BOTH + responses: + "200": + description: The body sites, and the selection when one was named. + content: + application/json: + schema: + $ref: "#/components/schemas/ListBodySitesEnvelope" + "401": *a1 + "403": *a9 + "422": *a4 + "429": *a5 /api/encounters/suggest: get: tags: @@ -27522,12 +27559,27 @@ components: - type: string maxLength: 2000 - type: "null" + bodySite: + anyOf: + - type: string + maxLength: 200 + - type: "null" + laterality: + anyOf: + - type: string + enum: + - LEFT + - RIGHT + - BOTH + - type: "null" required: - label - type description: Open an illness/condition episode. `label` is the user-facing name; `type` + `lifecycle` classify it. `onsetAt` defaults to 'now' server-side when omitted. `parentConditionId` threads a FLARE/RECURRING bout under a - parent condition (must be an owned, live episode). The optional `note` is encrypted at rest. + parent condition (must be an owned, live episode). The optional `note` is encrypted at rest. `bodySite` (free + text, encrypted at rest) and `laterality` (LEFT / RIGHT / BOTH, null when not stated) say where on the body the + condition sits; both are optional on every type. UpdateIllnessEpisodeRequest: type: object properties: @@ -27573,9 +27625,24 @@ components: - type: string maxLength: 2000 - type: "null" + bodySite: + anyOf: + - type: string + maxLength: 200 + - type: "null" + laterality: + anyOf: + - type: string + enum: + - LEFT + - RIGHT + - BOTH + - type: "null" additionalProperties: false - description: Partial edit of an episode; an omitted key leaves the column untouched. A `null` `resolvedAt` re-opens a - resolved episode; a `null` `parentConditionId` detaches it from its parent. Rejects unknown keys. + description: "Partial edit of an episode; an omitted key leaves the column untouched. A `null` `resolvedAt` re-opens a + resolved episode; a `null` `parentConditionId` detaches it from its parent. `bodySite` and `laterality` follow + the same rule: an omitted key keeps the stored value, `null` (or a blank site) clears it, so a client that does + not know the fields never erases them. Rejects unknown keys." ResolveIllnessEpisodeRequest: type: object properties: @@ -30516,39 +30583,39 @@ components: auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large; - coach — coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, - coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, - coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, - coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, - coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — cycle.bulk.invalid, - cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, - cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, - cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, - cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; documents — - documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, - documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, - documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, - documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, - documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, - documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, - documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, - documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, - documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, - documents.inbound.restoreGone, documents.inbound.uploadBusy, documents.inbound.uploadTimeout, - documents.inbound.vaccinationNotFound; encounter — encounter.invalid, encounter.link.invalid, - encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment - — environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found; - export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; - feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, - health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, - illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, - illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest — - ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, - insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — - labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound, - labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, - labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, - labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + body-site — body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle, + coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, + coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, + coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — + coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — + cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, + cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, + cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — + cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; + documents — documents.chat.conversationNotFound, documents.chat.invalid, + documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, + documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, + documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound; encounter — encounter.invalid, + encounter.link.invalid, encounter.practitioner-not-found, encounter.reminder-conflict, + encounter.reminder-not-found; environment — environment.invalid, environment.no_home, + environment.range_too_large, environment.travel.not_found; export — export.selection.empty, + export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid; + health_score_config — health_score_config.invalid, health_score_config.too_narrow; illness — + illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, + illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — + import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, + insights.generate.outboundScreened, insights.generate.profileScopeChanged, + insights.generate.scopeChanged; labs — labs.biomarker.create.invalid, labs.biomarker.duplicate, + labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, + labs.create.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, + labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile — @@ -34650,6 +34717,24 @@ components: - type: string minLength: 1 - type: "null" + bodySite: + anyOf: + - type: string + maxLength: 200 + - type: "null" + bodySiteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + laterality: + anyOf: + - type: string + enum: + - LEFT + - RIGHT + - BOTH + - type: "null" deletedAt: anyOf: - type: string @@ -61086,6 +61171,183 @@ components: 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. + ListBodySitesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/BodySiteList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + BodySiteList: + type: object + properties: + sites: + type: array + items: + $ref: "#/components/schemas/BodySite" + selection: + type: object + properties: + bodySite: + type: string + laterality: + anyOf: + - type: string + enum: + - LEFT + - RIGHT + - BOTH + - type: "null" + visits: + type: array + items: + $ref: "#/components/schemas/Encounter" + conditions: + anyOf: + - type: array + items: + $ref: "#/components/schemas/BodySiteCondition" + - type: "null" + required: + - bodySite + - laterality + - visits + - conditions + additionalProperties: false + required: + - sites + additionalProperties: false + description: "The record's body sites, most records first, and with `site` the visits and conditions filed there, each + newest first with its links. `selection.conditions` is null (not empty) when the caller may not read conditions + in this record or the record has the illness module off. Links into a section the caller's grant does not cover + carry `redacted: true` and no label." + BodySite: + type: object + properties: + bodySite: + type: string + procedures: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + conditions: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + sides: + type: array + items: + $ref: "#/components/schemas/BodySiteSide" + required: + - bodySite + - procedures + - conditions + - sides + additionalProperties: false + description: One body site the record holds, across visits (procedures, mostly) and conditions. Keyed case-, accent- and + space-insensitively; `bodySite` is the first spelling met, newest record first. `conditions` is 0 when the + caller may not read conditions. + BodySiteSide: + type: object + properties: + laterality: + anyOf: + - type: string + enum: + - LEFT + - RIGHT + - BOTH + - type: "null" + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - laterality + - count + additionalProperties: false + description: "How many records sit on one side of a site. `laterality: null` counts the records whose side was not stated." + BodySiteCondition: + type: object + properties: + id: + type: string + label: + type: string + type: + type: string + enum: + - INFECTION + - ALLERGY + - INJURY + - MENTAL_HEALTH + - AUTOIMMUNE + - CHRONIC + - OTHER + lifecycle: + type: string + enum: + - ACUTE + - CHRONIC_ONGOING + - RECURRING + - FLARE + onsetAt: + type: string + resolvedAt: + anyOf: + - type: string + - type: "null" + bodySite: + anyOf: + - type: string + - type: "null" + laterality: + anyOf: + - type: string + enum: + - LEFT + - RIGHT + - BOTH + - type: "null" + links: + type: object + properties: + documents: + type: array + items: + $ref: "#/components/schemas/EncounterLinkedTarget" + visits: + type: array + items: + $ref: "#/components/schemas/EncounterLinkedTarget" + required: + - documents + - visits + additionalProperties: false + required: + - id + - label + - type + - lifecycle + - onsetAt + - resolvedAt + - bodySite + - laterality + - links + additionalProperties: false + description: "A condition at the picked site, with the documents filed against it and the visits it was linked to. No + note: that is read on the condition itself. A document link comes back with `redacted: true` and no label or + date when the caller's grant does not cover documents." SuggestEncounterEnvelope: type: object properties: @@ -62059,6 +62321,18 @@ components: anyOf: - type: string - type: "null" + bodySite: + anyOf: + - type: string + - type: "null" + laterality: + anyOf: + - type: string + enum: + - LEFT + - RIGHT + - BOTH + - type: "null" createdAt: type: string updatedAt: @@ -62072,12 +62346,15 @@ components: - resolvedAt - parentConditionId - note + - bodySite + - laterality - createdAt - updatedAt additionalProperties: false description: A stored illness/condition episode. `note` is the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `resolvedAt` is null while the episode is open. `parentConditionId` links a - FLARE/RECURRING bout to its parent condition. + FLARE/RECURRING bout to its parent condition. `bodySite` is the decrypted body site (null when not stated or on + a key-rotation gap) and `laterality` its side. CreateIllnessEpisodeEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index f80638722..e08797806 100644 --- a/messages/de.json +++ b/messages/de.json @@ -268,6 +268,7 @@ "illnessTitle": "Erkrankungen", "illnessOngoing": "Andauernd", "illnessColCondition": "Erkrankung", + "illnessColBodySite": "Körperstelle", "illnessColType": "Art", "illnessColLifecycle": "Verlauf", "illnessColOnset": "Beginn", @@ -9194,7 +9195,8 @@ "parentNone": "Nicht verknüpft", "resolved": "Beendet am", "resolvedHelp": "Leer lassen, solange die Episode noch andauert.", - "markOngoing": "Noch andauernd" + "markOngoing": "Noch andauernd", + "bodySiteHint": "Zum Beispiel Knie oder unterer Rücken." }, "sheet": { "title": "{date} erfassen", @@ -10928,10 +10930,11 @@ } }, "checkups": { - "viewSwitchLabel": "Vorsorge, Termine oder Eingriffe anzeigen", + "viewSwitchLabel": "Vorsorge, Termine, Eingriffe oder Körperstellen anzeigen", "tabVorsorge": "Vorsorge", "tabVisits": "Termine", - "tabProcedures": "Eingriffe" + "tabProcedures": "Eingriffe", + "tabBodySites": "Körperstellen" }, "practitioners": { "title": "Praxen und Ärzte", @@ -10972,5 +10975,26 @@ "removeNamed": "{name} entfernen", "removed": "Verknüpfung mit {name} gelöst" } + }, + "bodySites": { + "title": "Körperstellen", + "description": "Eingriffe und Erkrankungen nach der Stelle am Körper.", + "loadError": "Die Körperstellen konnten nicht geladen werden.", + "emptyTitle": "Noch keine Körperstelle erfasst", + "emptyDescription": "Gib bei einem Eingriff oder einer Erkrankung eine Körperstelle an, dann erscheint sie hier.", + "sitesLabel": "Körperstellen", + "sidesLabel": "Seite", + "allSides": "Alle Seiten", + "pickPrompt": "Wähle eine Stelle, um zu sehen, was dazu erfasst ist.", + "noMatchTitle": "Auf dieser Seite ist nichts erfasst", + "visitsTitle": "Eingriffe und Termine", + "conditionsTitle": "Erkrankungen", + "hiddenDocument": "Ein Dokument, das nicht mit dir geteilt ist", + "hiddenLabResult": "Ein Laborwert, der nicht mit dir geteilt ist", + "hiddenCondition": "Eine Erkrankung, die nicht mit dir geteilt ist", + "hiddenVisit": "Ein Termin, der nicht mit dir geteilt ist", + "linkedLabel": "Verknüpfte Einträge", + "conditionSince": "Seit {onset}", + "conditionDates": "{onset} bis {resolved}" } } diff --git a/messages/en.json b/messages/en.json index ec7b4cbdf..230d343f2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -304,6 +304,7 @@ "illnessTitle": "Conditions & illnesses", "illnessOngoing": "Ongoing", "illnessColCondition": "Condition", + "illnessColBodySite": "Body site", "illnessColType": "Type", "illnessColLifecycle": "Course", "illnessColOnset": "Onset", @@ -9194,7 +9195,8 @@ "parentNone": "Not linked", "resolved": "Ended on", "resolvedHelp": "Leave empty while the episode is still ongoing.", - "markOngoing": "Still ongoing" + "markOngoing": "Still ongoing", + "bodySiteHint": "For example knee or lower back." }, "sheet": { "title": "Log {date}", @@ -10928,10 +10930,11 @@ } }, "checkups": { - "viewSwitchLabel": "Show checkups, visits or procedures", + "viewSwitchLabel": "Show checkups, visits, procedures or body sites", "tabVorsorge": "Checkups", "tabVisits": "Visits", - "tabProcedures": "Procedures" + "tabProcedures": "Procedures", + "tabBodySites": "Body sites" }, "practitioners": { "title": "Practices and doctors", @@ -10972,5 +10975,26 @@ "removeNamed": "Remove {name}", "removed": "Unlinked {name}" } + }, + "bodySites": { + "title": "Body sites", + "description": "Procedures and conditions by where on the body they are.", + "loadError": "Body sites could not be loaded.", + "emptyTitle": "No body site recorded yet", + "emptyDescription": "Add a body site to a procedure or a condition and it appears here.", + "sitesLabel": "Body sites", + "sidesLabel": "Side", + "allSides": "All sides", + "pickPrompt": "Pick a site to see what is filed there.", + "noMatchTitle": "Nothing recorded on this side", + "visitsTitle": "Procedures and visits", + "conditionsTitle": "Conditions", + "hiddenDocument": "A document not shared with you", + "hiddenLabResult": "A lab result not shared with you", + "hiddenCondition": "A condition not shared with you", + "hiddenVisit": "A visit not shared with you", + "linkedLabel": "Linked records", + "conditionSince": "Since {onset}", + "conditionDates": "{onset} to {resolved}" } } diff --git a/messages/es.json b/messages/es.json index b64f58204..86a50e634 100644 --- a/messages/es.json +++ b/messages/es.json @@ -268,6 +268,7 @@ "illnessTitle": "Afecciones y enfermedades", "illnessOngoing": "En curso", "illnessColCondition": "Afección", + "illnessColBodySite": "Zona del cuerpo", "illnessColType": "Tipo", "illnessColLifecycle": "Curso", "illnessColOnset": "Inicio", @@ -9194,7 +9195,8 @@ "parentNone": "Sin vincular", "resolved": "Finalizó el", "resolvedHelp": "Déjalo vacío mientras el episodio siga en curso.", - "markOngoing": "Aún en curso" + "markOngoing": "Aún en curso", + "bodySiteHint": "Por ejemplo, rodilla o zona lumbar." }, "sheet": { "title": "Registrar {date}", @@ -10928,10 +10930,11 @@ } }, "checkups": { - "viewSwitchLabel": "Mostrar revisiones, citas o procedimientos", + "viewSwitchLabel": "Mostrar revisiones, citas, procedimientos o zonas del cuerpo", "tabVorsorge": "Revisiones", "tabVisits": "Citas", - "tabProcedures": "Procedimientos" + "tabProcedures": "Procedimientos", + "tabBodySites": "Zonas del cuerpo" }, "practitioners": { "title": "Consultas y médicos", @@ -10972,5 +10975,26 @@ "removeNamed": "Quitar {name}", "removed": "Se desvinculó {name}" } + }, + "bodySites": { + "title": "Zonas del cuerpo", + "description": "Procedimientos y enfermedades según la zona del cuerpo.", + "loadError": "No se pudieron cargar las zonas del cuerpo.", + "emptyTitle": "Aún no hay ninguna zona del cuerpo registrada", + "emptyDescription": "Añade una zona del cuerpo a un procedimiento o a una enfermedad y aparecerá aquí.", + "sitesLabel": "Zonas del cuerpo", + "sidesLabel": "Lado", + "allSides": "Todos los lados", + "pickPrompt": "Elige una zona para ver lo que hay registrado en ella.", + "noMatchTitle": "No hay nada registrado en este lado", + "visitsTitle": "Procedimientos y citas", + "conditionsTitle": "Enfermedades", + "hiddenDocument": "Un documento que no se ha compartido contigo", + "hiddenLabResult": "Un resultado de laboratorio que no se ha compartido contigo", + "hiddenCondition": "Una enfermedad que no se ha compartido contigo", + "hiddenVisit": "Una cita que no se ha compartido contigo", + "linkedLabel": "Registros vinculados", + "conditionSince": "Desde el {onset}", + "conditionDates": "Del {onset} al {resolved}" } } diff --git a/messages/fr.json b/messages/fr.json index a1884c959..da38152a6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -268,6 +268,7 @@ "illnessTitle": "Affections et maladies", "illnessOngoing": "En cours", "illnessColCondition": "Affection", + "illnessColBodySite": "Partie du corps", "illnessColType": "Type", "illnessColLifecycle": "Évolution", "illnessColOnset": "Début", @@ -9194,7 +9195,8 @@ "parentNone": "Non lié", "resolved": "Terminée le", "resolvedHelp": "Laissez vide tant que l'épisode est en cours.", - "markOngoing": "Toujours en cours" + "markOngoing": "Toujours en cours", + "bodySiteHint": "Par exemple genou ou bas du dos." }, "sheet": { "title": "Enregistrer {date}", @@ -10928,10 +10930,11 @@ } }, "checkups": { - "viewSwitchLabel": "Afficher les dépistages, les rendez-vous ou les interventions", + "viewSwitchLabel": "Afficher les dépistages, les rendez-vous, les interventions ou les parties du corps", "tabVorsorge": "Dépistages", "tabVisits": "Rendez-vous", - "tabProcedures": "Interventions" + "tabProcedures": "Interventions", + "tabBodySites": "Parties du corps" }, "practitioners": { "title": "Cabinets et médecins", @@ -10972,5 +10975,26 @@ "removeNamed": "Retirer {name}", "removed": "{name} dissocié" } + }, + "bodySites": { + "title": "Parties du corps", + "description": "Interventions et maladies selon la partie du corps concernée.", + "loadError": "Les parties du corps n’ont pas pu être chargées.", + "emptyTitle": "Aucune partie du corps enregistrée pour l’instant", + "emptyDescription": "Indiquez une partie du corps sur une intervention ou une maladie pour la retrouver ici.", + "sitesLabel": "Parties du corps", + "sidesLabel": "Côté", + "allSides": "Tous les côtés", + "pickPrompt": "Choisissez une partie du corps pour voir ce qui y est enregistré.", + "noMatchTitle": "Rien n’est enregistré de ce côté", + "visitsTitle": "Interventions et rendez-vous", + "conditionsTitle": "Maladies", + "hiddenDocument": "Un document qui ne vous est pas partagé", + "hiddenLabResult": "Un résultat d’analyse qui ne vous est pas partagé", + "hiddenCondition": "Une maladie qui ne vous est pas partagée", + "hiddenVisit": "Un rendez-vous qui ne vous est pas partagé", + "linkedLabel": "Éléments liés", + "conditionSince": "Depuis le {onset}", + "conditionDates": "Du {onset} au {resolved}" } } diff --git a/messages/it.json b/messages/it.json index 23b5b04d4..946342467 100644 --- a/messages/it.json +++ b/messages/it.json @@ -268,6 +268,7 @@ "illnessTitle": "Condizioni e malattie", "illnessOngoing": "In corso", "illnessColCondition": "Condizione", + "illnessColBodySite": "Parte del corpo", "illnessColType": "Tipo", "illnessColLifecycle": "Decorso", "illnessColOnset": "Inizio", @@ -9194,7 +9195,8 @@ "parentNone": "Non collegato", "resolved": "Conclusa il", "resolvedHelp": "Lascia vuoto finché l'episodio è ancora in corso.", - "markOngoing": "Ancora in corso" + "markOngoing": "Ancora in corso", + "bodySiteHint": "Ad esempio ginocchio o zona lombare." }, "sheet": { "title": "Registra {date}", @@ -10928,10 +10930,11 @@ } }, "checkups": { - "viewSwitchLabel": "Mostra controlli, appuntamenti o interventi", + "viewSwitchLabel": "Mostra controlli, appuntamenti, interventi o parti del corpo", "tabVorsorge": "Controlli", "tabVisits": "Appuntamenti", - "tabProcedures": "Interventi" + "tabProcedures": "Interventi", + "tabBodySites": "Parti del corpo" }, "practitioners": { "title": "Studi e medici", @@ -10972,5 +10975,26 @@ "removeNamed": "Rimuovi {name}", "removed": "Collegamento a {name} rimosso" } + }, + "bodySites": { + "title": "Parti del corpo", + "description": "Interventi e malattie in base alla parte del corpo.", + "loadError": "Non è stato possibile caricare le parti del corpo.", + "emptyTitle": "Nessuna parte del corpo registrata", + "emptyDescription": "Aggiungi una parte del corpo a un intervento o a una malattia e comparirà qui.", + "sitesLabel": "Parti del corpo", + "sidesLabel": "Lato", + "allSides": "Tutti i lati", + "pickPrompt": "Scegli una parte del corpo per vedere cosa vi è registrato.", + "noMatchTitle": "Nulla di registrato su questo lato", + "visitsTitle": "Interventi e appuntamenti", + "conditionsTitle": "Malattie", + "hiddenDocument": "Un documento non condiviso con te", + "hiddenLabResult": "Un risultato di laboratorio non condiviso con te", + "hiddenCondition": "Una malattia non condivisa con te", + "hiddenVisit": "Un appuntamento non condiviso con te", + "linkedLabel": "Voci collegate", + "conditionSince": "Dal {onset}", + "conditionDates": "Dal {onset} al {resolved}" } } diff --git a/messages/ko.json b/messages/ko.json index fb5db9cc2..2eca5e2c9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -304,6 +304,7 @@ "illnessTitle": "질환 및 아픈 날", "illnessOngoing": "진행 중", "illnessColCondition": "질환", + "illnessColBodySite": "신체 부위", "illnessColType": "유형", "illnessColLifecycle": "경과", "illnessColOnset": "발병", @@ -9194,7 +9195,8 @@ "parentNone": "연결 안 함", "resolved": "종료일", "resolvedHelp": "아직 진행 중이면 비워 두세요.", - "markOngoing": "아직 진행 중" + "markOngoing": "아직 진행 중", + "bodySiteHint": "예: 무릎, 허리" }, "sheet": { "title": "{date} 기록", @@ -10928,10 +10930,11 @@ } }, "checkups": { - "viewSwitchLabel": "건강검진, 진료 또는 시술 보기", + "viewSwitchLabel": "건강검진, 진료, 시술 또는 신체 부위 보기", "tabVorsorge": "건강검진", "tabVisits": "진료", - "tabProcedures": "시술·수술" + "tabProcedures": "시술·수술", + "tabBodySites": "신체 부위" }, "practitioners": { "title": "병원과 의사", @@ -10972,5 +10975,26 @@ "removeNamed": "{name} 제거", "removed": "{name} 연결 해제됨" } + }, + "bodySites": { + "title": "신체 부위", + "description": "시술과 질환을 신체 부위별로 봅니다.", + "loadError": "신체 부위를 불러오지 못했습니다.", + "emptyTitle": "아직 기록된 신체 부위가 없습니다", + "emptyDescription": "시술이나 질환에 신체 부위를 입력하면 여기에 표시됩니다.", + "sitesLabel": "신체 부위", + "sidesLabel": "방향", + "allSides": "전체", + "pickPrompt": "부위를 선택하면 그 부위에 기록된 내용을 볼 수 있습니다.", + "noMatchTitle": "이쪽에는 기록된 내용이 없습니다", + "visitsTitle": "시술 및 진료", + "conditionsTitle": "질환", + "hiddenDocument": "공유되지 않은 문서", + "hiddenLabResult": "공유되지 않은 검사 결과", + "hiddenCondition": "공유되지 않은 질환", + "hiddenVisit": "공유되지 않은 진료", + "linkedLabel": "연결된 기록", + "conditionSince": "{onset}부터", + "conditionDates": "{onset} ~ {resolved}" } } diff --git a/messages/pl.json b/messages/pl.json index cc0535130..046fbc99f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -268,6 +268,7 @@ "illnessTitle": "Schorzenia i choroby", "illnessOngoing": "Trwająca", "illnessColCondition": "Schorzenie", + "illnessColBodySite": "Miejsce na ciele", "illnessColType": "Typ", "illnessColLifecycle": "Przebieg", "illnessColOnset": "Początek", @@ -9194,7 +9195,8 @@ "parentNone": "Niepowiązane", "resolved": "Zakończono", "resolvedHelp": "Pozostaw puste, dopóki epizod nadal trwa.", - "markOngoing": "Nadal trwa" + "markOngoing": "Nadal trwa", + "bodySiteHint": "Na przykład kolano lub dolna część pleców." }, "sheet": { "title": "Zapisz {date}", @@ -10928,10 +10930,11 @@ } }, "checkups": { - "viewSwitchLabel": "Pokaż badania profilaktyczne, wizyty lub zabiegi", + "viewSwitchLabel": "Pokaż badania profilaktyczne, wizyty, zabiegi lub miejsca na ciele", "tabVorsorge": "Profilaktyka", "tabVisits": "Wizyty", - "tabProcedures": "Zabiegi" + "tabProcedures": "Zabiegi", + "tabBodySites": "Miejsca na ciele" }, "practitioners": { "title": "Gabinety i lekarze", @@ -10972,5 +10975,26 @@ "removeNamed": "Usuń {name}", "removed": "Odłączono: {name}" } + }, + "bodySites": { + "title": "Miejsca na ciele", + "description": "Zabiegi i choroby według miejsca na ciele.", + "loadError": "Nie udało się wczytać miejsc na ciele.", + "emptyTitle": "Nie zapisano jeszcze żadnego miejsca na ciele", + "emptyDescription": "Dodaj miejsce na ciele do zabiegu lub choroby, a pojawi się tutaj.", + "sitesLabel": "Miejsca na ciele", + "sidesLabel": "Strona", + "allSides": "Wszystkie strony", + "pickPrompt": "Wybierz miejsce, aby zobaczyć, co jest do niego zapisane.", + "noMatchTitle": "Po tej stronie nic nie zapisano", + "visitsTitle": "Zabiegi i wizyty", + "conditionsTitle": "Choroby", + "hiddenDocument": "Dokument, który nie jest Ci udostępniony", + "hiddenLabResult": "Wynik badania, który nie jest Ci udostępniony", + "hiddenCondition": "Choroba, która nie jest Ci udostępniona", + "hiddenVisit": "Wizyta, która nie jest Ci udostępniona", + "linkedLabel": "Powiązane wpisy", + "conditionSince": "Od {onset}", + "conditionDates": "Od {onset} do {resolved}" } } diff --git a/prisma/migrations/0354_condition_body_site/migration.sql b/prisma/migrations/0354_condition_body_site/migration.sql new file mode 100644 index 000000000..ce2e6dff9 --- /dev/null +++ b/prisma/migrations/0354_condition_body_site/migration.sql @@ -0,0 +1,17 @@ +-- Body site and side on conditions. +-- +-- A condition (illness episode) can now carry the body site it sits on, in the +-- person's own words and encrypted at rest, plus an optional side. It reuses +-- the `laterality` type migration 0349 created for procedures, so "left knee" +-- on a condition and "left knee" on a procedure are the same pair of values. +-- The reasoning for free text and for searching after the decrypt is written +-- at `Encounter.bodySiteEncrypted` and `IllnessEpisode.bodySiteEncrypted`. +-- +-- Purely additive: two nullable columns, no backfill, no row rewritten. An +-- existing condition keeps no site until the person adds one. +-- +-- Reversibility: both columns can be dropped. The `laterality` type stays, +-- since `encounters.laterality` still uses it. +ALTER TABLE "illness_episodes" + ADD COLUMN "body_site_encrypted" BYTEA, + ADD COLUMN "laterality" "laterality"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9cff57b4e..f0faf9c6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7478,6 +7478,24 @@ model IllnessEpisode { /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per /// convention). noteEncrypted Bytes? @map("note_encrypted") + /// Where on the body the condition sits, in the person's own words + /// ("knee", "lower back", "L4/L5"), v1.39.2. The same field, codec and + /// reasoning as `Encounter.bodySiteEncrypted`: free text because no picker + /// covers every site and a coded vocabulary the person cannot read is worse + /// than their own word, encrypted because it is health data, and searched + /// by decrypting the account's own rows server-side + /// (`src/lib/body-sites/`), never through a plaintext or hashed copy. + /// + /// Allowed on every type. An injury or a chronic joint condition is the + /// usual case, but a type edit must not lose what was typed. + /// + /// The shipped iOS app neither sends nor decodes it, and the PATCH treats + /// an absent key as "leave alone", so an edit from the phone keeps it. + bodySiteEncrypted Bytes? @map("body_site_encrypted") + /// Which side, when the site has one. Same enum and the same NULL meaning + /// as `Encounter.laterality`: not stated or not applicable. Plaintext, + /// because a side alone says nothing. + laterality Laterality? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. @@ -8350,8 +8368,8 @@ enum EncounterKind { @@map("encounter_kind") } -/// The side of the body a procedure's site is on. See -/// `Encounter.laterality`. +/// The side of the body a site is on. See `Encounter.laterality`; since +/// v1.39.2 also `IllnessEpisode.laterality`. enum Laterality { LEFT RIGHT diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 744436a92..a78a36f30 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -549,6 +549,14 @@ async function main() { prisma.illnessEpisode, ), ); + // v1.39.2 — a condition's body site. + results.push( + await rotateBytesColumn( + "IllnessEpisode", + "bodySiteEncrypted", + prisma.illnessEpisode, + ), + ); results.push( await rotateBytesColumn( "IllnessDayLog", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 1d6ac52ea..9a5e17cd0 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -947,6 +947,10 @@ const DELEGABLE_ROUTES: Record = { 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/body-sites/route.ts": { + domain: "profile", + why: "The body sites the record holds and, for a picked one, the procedures and conditions filed there. It starts from the visits, so it sits in their section and level. Conditions join only when the grant also covers illness and the record has the module on; otherwise the condition table is not read, and a procedure's links into a section the grant does not cover come back as unlabelled placeholders. Matching runs over the resolved user's own rows after the decrypt.", + }, "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.", @@ -1952,8 +1956,12 @@ const ACTOR_ROUTES: Record = { * * 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. + * + * v1.39.2 -- the body-site view adds one: a read on the record list in the + * visits' domain, with conditions joined only under an illness grant. + * 231 -> 232. */ -const FROZEN_ENTRY_COUNT = 231; +const FROZEN_ENTRY_COUNT = 232; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/body-sites/route.ts b/src/app/api/body-sites/route.ts new file mode 100644 index 000000000..2016d3c95 --- /dev/null +++ b/src/app/api/body-sites/route.ts @@ -0,0 +1,69 @@ +/** + * `GET /api/body-sites` — the body sites the record holds, across procedures + * and conditions, and with `?site=` (optionally `&laterality=`) everything filed + * at one of them (v1.39.2). + * + * Without `site` the answer is the list of sites, which serves two surfaces: + * the body-site view's choices, and the suggestions a body-site field offers + * while the person types, so "knee" is not written three ways. With `site` it + * adds the procedures and conditions at that site, each with its linked + * documents, lab results and visits. + * + * Same section and level as the visit list (`("read", "profile")`): the view + * starts from visits. Conditions ride only when the caller's grant covers the + * `illness` section AND the record has the illness module on; otherwise the + * condition table is not read, so neither a condition row nor a condition's + * site reaches the answer. Links into a section the grant does not cover come + * back as placeholders without a label. The reasoning is in + * `src/lib/body-sites/index.ts`. `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 { bodySiteQuerySchema } from "@/lib/validations/body-sites"; +import { loadBodySites } from "@/lib/body-sites"; +import { isIllnessEnabled } from "@/lib/illness/gate"; +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 = bodySiteQuerySchema.safeParse({ + site: params.get("site") ?? undefined, + laterality: params.get("laterality") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "body-site.invalid", + }); + } + + const visible = await actingDomainVisibility(prisma, grantId); + // The module follows the RECORD, like every illness route: a delegate who + // runs the module on their own account does not open it in somebody else's. + const conditionsReadable = + visible("illness") && (await isIllnessEnabled(user.id)); + + const body = await loadBodySites(prisma, user.id, parsed.data, { + visible, + conditionsReadable, + }); + + annotate({ + action: { name: "body-site.list", entity_type: "body_site" }, + meta: { + sites: body.sites.length, + selected: Boolean(body.selection), + visits: body.selection?.visits.length ?? 0, + conditions: body.selection?.conditions?.length ?? 0, + conditions_readable: conditionsReadable, + }, + }); + + return apiSuccess(body); +}); diff --git a/src/app/api/illness/episodes/[id]/__tests__/body-site.test.ts b/src/app/api/illness/episodes/[id]/__tests__/body-site.test.ts new file mode 100644 index 000000000..10698f21c --- /dev/null +++ b/src/app/api/illness/episodes/[id]/__tests__/body-site.test.ts @@ -0,0 +1,226 @@ +/** + * v1.39.2 — a condition's body site and side on the episode write routes. + * + * The contract that matters most is the one a client in the field relies on + * without knowing it: the shipped iPhone app edits conditions and has never + * heard of either field. Its PATCH body (built by `IllnessEpisodePatch` in the + * public iOS repository) always carries `resolvedAt`, `parentConditionId` and + * `note`, sometimes `label`, and never `bodySite` or `laterality`. That body + * must not touch either column, or every edit from the phone would erase what + * the web client stored. + * + * Mutation checks (each run, each seen red): + * - write `data.bodySiteEncrypted = null` unconditionally in the PATCH → + * "an edit from the shipped iPhone app" goes red; + * - drop the `encryptToBytes` call in the POST (store the bytes of the plain + * string) → "stores the site as ciphertext" goes red. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +vi.mock("@/lib/db", () => ({ + prisma: { + illnessEpisode: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/illness/gate", () => ({ requireIllnessEnabled: vi.fn() })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserHealthContext: vi.fn(), +})); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PATCH } from "../route"; +import { POST } from "../../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const PARAMS = { params: Promise.resolve({ id: "ep-1" }) }; + +function row(overrides: Record = {}) { + return { + id: "ep-1", + userId: "user-1", + label: "Meniscus tear", + type: "INJURY", + lifecycle: "ACUTE", + onsetAt: new Date("2026-06-01T00:00:00.000Z"), + resolvedAt: null, + parentConditionId: null, + noteEncrypted: null, + bodySiteEncrypted: encryptToBytes("Knee"), + laterality: "LEFT", + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), + deletedAt: null, + ...overrides, + }; +} + +function patchReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/illness/episodes/ep-1", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/illness/episodes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** The `data` the route handed to `illnessEpisode.update`. */ +function updateData(): Record { + const call = vi.mocked(prisma.illnessEpisode.update).mock.calls.at(-1); + return (call?.[0] as { data: Record }).data; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireIllnessEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue(row() as never); + vi.mocked(prisma.illnessEpisode.update).mockImplementation((async () => + row()) as never); +}); + +describe("PATCH /api/illness/episodes/{id} — body site", () => { + it("an edit from the shipped iPhone app leaves the site and side alone", async () => { + // The exact shape `IllnessEpisodePatch.encode(to:)` emits for a label + // change: optional scalars only when set, the three full-value keys always. + const res = await PATCH( + patchReq({ + label: "Meniscus tear, medial", + resolvedAt: null, + parentConditionId: null, + note: "Physio twice a week", + }), + PARAMS as never, + ); + expect(res.status).toBe(200); + const data = updateData(); + expect(data.label).toBe("Meniscus tear, medial"); + expect("bodySiteEncrypted" in data).toBe(false); + expect("laterality" in data).toBe(false); + + // And the answer still carries what was stored, so the phone's next read + // and the web's next read agree. + const body = await res.json(); + expect(body.data.bodySite).toBe("Knee"); + expect(body.data.laterality).toBe("LEFT"); + }); + + it("writes the site encrypted and the side as sent", async () => { + const res = await PATCH( + patchReq({ bodySite: " Lower back ", laterality: "BOTH" }), + PARAMS as never, + ); + expect(res.status).toBe(200); + const data = updateData(); + expect(decryptFromBytes(data.bodySiteEncrypted as Uint8Array)).toBe( + "Lower back", + ); + expect(data.laterality).toBe("BOTH"); + }); + + it("clears both on null, and a blank site clears the site", async () => { + await PATCH( + patchReq({ bodySite: null, laterality: null }), + PARAMS as never, + ); + expect(updateData()).toMatchObject({ + bodySiteEncrypted: null, + laterality: null, + }); + await PATCH(patchReq({ bodySite: " " }), PARAMS as never); + expect(updateData().bodySiteEncrypted).toBeNull(); + }); + + it("refuses a side outside the three values", async () => { + const res = await PATCH(patchReq({ laterality: "UP" }), PARAMS as never); + expect(res.status).toBe(422); + expect(prisma.illnessEpisode.update).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/illness/episodes — body site", () => { + beforeEach(() => { + vi.mocked(prisma.illnessEpisode.create).mockImplementation((async (args: { + data: Record; + }) => row({ ...args.data, id: "ep-new" })) as never); + }); + + it("stores the site as ciphertext and returns it readable", async () => { + const res = await POST( + postReq({ + label: "Tennis elbow", + type: "INJURY", + bodySite: "Elbow", + laterality: "RIGHT", + }), + ); + expect(res.status).toBe(201); + const call = vi.mocked(prisma.illnessEpisode.create).mock.calls[0][0] as { + data: Record; + }; + const stored = call.data.bodySiteEncrypted as Uint8Array; + expect(Buffer.from(stored).toString("utf8")).not.toContain("Elbow"); + expect(decryptFromBytes(stored)).toBe("Elbow"); + expect(call.data.laterality).toBe("RIGHT"); + const body = await res.json(); + expect(body.data).toMatchObject({ bodySite: "Elbow", laterality: "RIGHT" }); + }); + + it("a create from the shipped iPhone app stores no site", async () => { + // `IllnessEpisodeCreate` sends label + type and the set optionals only. + const res = await POST( + postReq({ label: "Cold", type: "INFECTION", lifecycle: "ACUTE" }), + ); + expect(res.status).toBe(201); + const call = vi.mocked(prisma.illnessEpisode.create).mock.calls[0][0] as { + data: Record; + }; + expect(call.data.bodySiteEncrypted).toBeNull(); + expect(call.data.laterality).toBeNull(); + }); +}); diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts index 220859e8d..f614398c5 100644 --- a/src/app/api/illness/episodes/[id]/route.ts +++ b/src/app/api/illness/episodes/[id]/route.ts @@ -79,6 +79,7 @@ export const PATCH = apiHandler( type: true, lifecycle: true, parentConditionId: true, + laterality: true, }, }); if ( @@ -154,6 +155,15 @@ export const PATCH = apiHandler( if (entry.note !== undefined) { data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null; } + // v1.39.2 — the body site and side. Written only when the key is in the + // body: the shipped iPhone app edits conditions and has never heard of + // either field, so an absent key must keep what the web client stored. + if (entry.bodySite !== undefined) { + data.bodySiteEncrypted = entry.bodySite?.trim() + ? encryptToBytes(entry.bodySite.trim()) + : null; + } + if (entry.laterality !== undefined) data.laterality = entry.laterality; const updated = await prisma.illnessEpisode.update({ where: { id }, data }); @@ -171,6 +181,7 @@ export const PATCH = apiHandler( onsetAt: existing.onsetAt, resolvedAt: existing.resolvedAt, parentConditionId: existing.parentConditionId, + laterality: existing.laterality, }, after: { label: updated.label, @@ -179,8 +190,14 @@ export const PATCH = apiHandler( onsetAt: updated.onsetAt, resolvedAt: updated.resolvedAt, parentConditionId: updated.parentConditionId, + laterality: updated.laterality, }, - redacted: entry.note !== undefined ? ["note"] : [], + // The site is the person's own words, like the note: named when it + // changed, never quoted. + redacted: [ + ...(entry.note !== undefined ? ["note"] : []), + ...(entry.bodySite !== undefined ? ["bodySite"] : []), + ], }), }, }); diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 2095d5246..15425425f 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -127,6 +127,10 @@ async function postEpisode(request: NextRequest): Promise { resolvedAt: entry.resolvedAt ? new Date(entry.resolvedAt) : null, parentConditionId: entry.parentConditionId ?? null, noteEncrypted: entry.note ? encryptToBytes(entry.note) : null, + bodySiteEncrypted: entry.bodySite?.trim() + ? encryptToBytes(entry.bodySite.trim()) + : null, + laterality: entry.laterality ?? null, }, }); diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index aa64690a1..77a2197cd 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -13,6 +13,8 @@ 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 { BodySitesSection } from "@/components/body-sites/body-sites-section"; +import type { BodySiteSide } from "@/hooks/use-body-sites"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; @@ -36,6 +38,11 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys"; * 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. * + * v1.39.2 — a fourth tab, the body-site view: procedures and conditions by + * where on the body they are, with what is linked to them. It starts from the + * visits (a procedure is one), so it sits here rather than under the illness + * journal, and its read hangs off the same `["encounters"]` root. + * * 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. @@ -47,8 +54,21 @@ export default function CheckupsPageClient() { const { t } = useTranslations(); // A link to one visit (`?visit=`, from a document's link chip) lands on // the visits tab, where the section opens it. - const linkedVisit = useSearchParams()?.get("visit"); - const [view, setView] = useState(linkedVisit ? "visits" : "vorsorge"); + const searchParams = useSearchParams(); + const linkedVisit = searchParams?.get("visit"); + // A link to one body site (`?site=&side=`, from a + // condition's page) lands on the body-site tab with that site picked. + const linkedSite = searchParams?.get("site")?.trim() || null; + const linkedSideParam = searchParams?.get("side"); + const linkedSide: BodySiteSide = + linkedSideParam === "LEFT" || + linkedSideParam === "RIGHT" || + linkedSideParam === "BOTH" + ? linkedSideParam + : null; + const [view, setView] = useState( + linkedVisit ? "visits" : linkedSite ? "body-sites" : "vorsorge", + ); const refresh = useCallback( () => @@ -78,6 +98,9 @@ export default function CheckupsPageClient() { {t("checkups.tabProcedures")} + + {t("checkups.tabBodySites")} + @@ -90,6 +113,13 @@ export default function CheckupsPageClient() { + + + ); diff --git a/src/components/body-sites/__tests__/body-site-fields.test.tsx b/src/components/body-sites/__tests__/body-site-fields.test.tsx new file mode 100644 index 000000000..ca4c1537b --- /dev/null +++ b/src/components/body-sites/__tests__/body-site-fields.test.tsx @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * The body-site field pair suggests the sites the record already holds, across + * procedures and conditions, as the server resolved them. + * + * - the suggestions are the server's list, in its order; + * - the site already typed is not suggested back to itself; + * - no list (a refused or failed read) is a plain field, not an empty list. + * + * Mutation check (run, seen red): drop the `list` attribute from the input → + * "offers the sites the record holds" goes red. + */ + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { ...actual, apiGet: () => new Promise(() => {}) }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { BodySiteListDTO } from "@/lib/body-sites/dto"; +import { BodySiteFields } from "../body-site-fields"; + +function render(data: BodySiteListDTO | null, typed = ""): string { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryOnMount: false } }, + }); + if (data) queryClient.setQueryData(queryKeys.bodySites(null, null), data); + return renderToStaticMarkup( + + + {}} + onLateralityChange={() => {}} + /> + + , + ); +} + +const site = (bodySite: string) => ({ + bodySite, + procedures: 1, + conditions: 0, + sides: [{ laterality: null, count: 1 }], +}); + +describe("", () => { + it("offers the sites the record holds, in the server's order", () => { + const html = render({ sites: [site("Knee"), site("Lower back")] }); + const listId = html.match(/ { + const html = render({ sites: [site("Knee"), site("Hip")] }, "knee"); + expect(html).not.toContain('