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

+69
View File
@@ -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);
});
@@ -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:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...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<string, unknown> = {}) {
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<string, unknown> {
const call = vi.mocked(prisma.illnessEpisode.update).mock.calls.at(-1);
return (call?.[0] as { data: Record<string, unknown> }).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<string, unknown>;
}) => 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<string, unknown>;
};
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<string, unknown>;
};
expect(call.data.bodySiteEncrypted).toBeNull();
expect(call.data.laterality).toBeNull();
});
});
+18 -1
View File
@@ -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"] : []),
],
}),
},
});
+4
View File
@@ -127,6 +127,10 @@ async function postEpisode(request: NextRequest): Promise<Response> {
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,
},
});