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
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 21:55:14 +02:00
1 parent af65f01fec
commit 14f0fc4d4f
57 files changed
+3501 -133

No files matched your search

+1
View File
@@ -71,6 +71,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"backup.section.missing",
"backup.upload.too_large",
],
"body-site": ["body-site.invalid"],
coach: [
"coach.budget.exceeded",
"coach.conversation.invalidTitle",
+95
View File
@@ -33,6 +33,11 @@ import {
practitionerUpdateSchema,
practitionerListQuerySchema,
} from "@/lib/validations/practitioners";
import { bodySiteQuerySchema } from "@/lib/validations/body-sites";
import {
illnessLifecycleEnum,
illnessTypeEnum,
} from "@/lib/validations/illness";
import {
dataEnvelope,
@@ -220,6 +225,75 @@ const procedureList = z
"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 bodySitesQuery = bodySiteQuerySchema.meta({
id: "BodySitesQuery",
description:
"Without `site`: only the list of sites. With `site`: also what is filed there. `site` is compared to the whole stored site, case- and accent-insensitively, after the decrypt; it names a site from the list rather than searching words. `laterality` narrows to one side and needs `site`; LEFT and RIGHT include records on BOTH sides, and a record whose side was never stated is left out once a side is picked.",
});
const bodySiteSide = z
.object({
laterality: lateralityEnum.nullable(),
count: z.number().int(),
})
.meta({
id: "BodySiteSide",
description:
"How many records sit on one side of a site. `laterality: null` counts the records whose side was not stated.",
});
const bodySite = z
.object({
bodySite: z.string(),
procedures: z.number().int(),
conditions: z.number().int(),
sides: z.array(bodySiteSide),
})
.meta({
id: "BodySite",
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.",
});
const bodySiteCondition = z
.object({
id: z.string(),
label: z.string(),
type: illnessTypeEnum,
lifecycle: illnessLifecycleEnum,
onsetAt: z.string(),
resolvedAt: z.string().nullable(),
bodySite: z.string().nullable(),
laterality: lateralityEnum.nullable(),
links: z.object({
documents: z.array(linkedTarget),
visits: z.array(linkedTarget),
}),
})
.meta({
id: "BodySiteCondition",
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.",
});
const bodySiteList = z
.object({
sites: z.array(bodySite),
selection: z
.object({
bodySite: z.string(),
laterality: lateralityEnum.nullable(),
visits: z.array(encounter),
conditions: z.array(bodySiteCondition).nullable(),
})
.optional(),
})
.meta({
id: "BodySiteList",
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.",
});
const suggestionCandidate = z
.object({
id: z.string(),
@@ -333,6 +407,27 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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.",
requestParams: { query: bodySitesQuery },
responses: {
...recordRefusal(),
"200": {
description: "The body sites, and the selection when one was named.",
content: {
"application/json": {
schema: dataEnvelope(bodySiteList, "ListBodySitesEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/encounters/suggest": {
get: {
tags: ["Records"],
+6 -3
View File
@@ -26,6 +26,7 @@ import {
illnessTypeEnum,
illnessLifecycleEnum,
} from "@/lib/validations/illness";
import { lateralityEnum } from "@/lib/validations/encounters";
import {
dataEnvelope,
@@ -42,13 +43,13 @@ import {
const createIllnessEpisodeRequest = illnessEpisodeCreateSchema.meta({
id: "CreateIllnessEpisodeRequest",
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.",
"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. `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.",
});
const updateIllnessEpisodeRequest = illnessEpisodeUpdateSchema.meta({
id: "UpdateIllnessEpisodeRequest",
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.",
"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.",
});
const resolveIllnessEpisodeRequest = illnessEpisodeResolveSchema.meta({
@@ -113,13 +114,15 @@ const illnessEpisode = z
resolvedAt: z.string().nullable(),
parentConditionId: z.string().nullable(),
note: z.string().nullable(),
bodySite: z.string().nullable(),
laterality: lateralityEnum.nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "IllnessEpisode",
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.",
"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. `bodySite` is the decrypted body site (null when not stated or on a key-rotation gap) and `laterality` its side.",
});
const illnessDayLog = z