feat(visits): record procedures and surgeries with a body site

A visit can be filed as a procedure or surgery (new EncounterKind
PROCEDURE) and carry a body site in the person's own words, encrypted at
rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit
switches to the new kind by an ordinary edit, keeping its date, reason,
outcome and links.

- migration 0349: enum value, laterality type, two nullable columns
- GET /api/encounters/procedures: the DONE procedures, newest first,
  searched after the decrypt over site, side and reason, with body-site
  choices and a total computed over the whole history
- create and edit accept bodySite and laterality on any kind
- /checkups gains a Procedures tab with a body-site filter; the visit
  form offers the site for a procedure and keeps it in view on any
  visit that carries one
- backup and restore carry both fields; the body site is registered as
  ciphertext and rotates with the key
- MCP get_visits narrows by kind and reads the whole record for
  procedures when no window is named; the Coach visit block names a
  procedure's site
- i18n in all seven locales, OpenAPI regenerated

Refs #1025
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 08:11:37 +02:00
1 parent ee23409b77
commit ad0d50fa28
43 files changed
+2486 -61

No files matched your search

+8
View File
@@ -170,6 +170,12 @@ export const PATCH = apiHandler(
? encryptToBytes(entry.outcome)
: null;
}
if (entry.bodySite !== undefined) {
data.bodySiteEncrypted = entry.bodySite?.trim()
? encryptToBytes(entry.bodySite.trim())
: null;
}
if (entry.laterality !== undefined) data.laterality = entry.laterality;
if (entry.practitionerId !== undefined) {
data.practitioner = practitionerId
? { connect: { id: practitionerId } }
@@ -331,12 +337,14 @@ export const PATCH = apiHandler(
occurredAt: existing.occurredAt,
status: existing.status,
kind: existing.kind,
laterality: existing.laterality,
practitionerId: existing.practitionerId,
},
after: {
occurredAt: outcome.updated.occurredAt,
status: outcome.updated.status,
kind: outcome.updated.kind,
laterality: outcome.updated.laterality,
practitionerId: outcome.updated.practitionerId,
},
}),
@@ -0,0 +1,179 @@
/**
* `GET /api/encounters/procedures` — the procedure history, searched after the
* decrypt.
*
* The body site is ciphertext in the fixture as it is in the database, so a
* route that searched the stored bytes instead of the decrypted text would
* find nothing and go red here. The fake honours nothing about the `where`, so
* the test asserts the `where` it was handed directly: a procedure history
* that forgot the kind or the status filter would otherwise pass against a
* fixture of procedures only.
*
* Mutation checks (each run, each seen red):
* - drop `kind: "PROCEDURE"` from the read → "reads only procedures that
* happened" goes red;
* - compute the facets over the filtered list → "offers every body site
* whatever the filter" goes red with one facet;
* - search `reasonEncrypted` bytes instead of the DTO → the knee search
* returns nothing and goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const mocks = vi.hoisted(() => ({
encounterFindMany: vi.fn(),
userFindUnique: vi.fn(),
requireRecordAuth: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireRecordAuth: mocks.requireRecordAuth,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => undefined,
}));
vi.mock("@/lib/db", () => ({
prisma: {
encounter: { findMany: mocks.encounterFindMany },
user: { findUnique: mocks.userFindUnique },
},
}));
vi.mock("@/lib/sharing/acting-domains", () => ({
actingDomainVisibility: vi.fn(async () => () => true),
}));
vi.mock("@/lib/links", () => ({
listTargets: vi.fn(async () => []),
listTargetsBySource: vi.fn(async () => new Map()),
replaceTargets: vi.fn(),
}));
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { GET } from "../route";
const handler = GET as unknown as (req: NextRequest) => Promise<Response>;
function request(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/encounters/procedures${query ? `?${query}` : ""}`,
);
}
function row(
id: string,
day: number,
bodySite: string | null,
laterality: "LEFT" | "RIGHT" | "BOTH" | null,
reason: string | null,
) {
const at = new Date(Date.UTC(2020, 0, day));
return {
id,
userId: "user-1",
occurredAt: at,
status: "DONE",
kind: "PROCEDURE",
practitionerId: null,
practitioner: null,
reminder: null,
reminderId: null,
reasonEncrypted: reason ? encryptToBytes(reason) : null,
outcomeEncrypted: null,
bodySiteEncrypted: bodySite ? encryptToBytes(bodySite) : null,
laterality,
createdAt: at,
updatedAt: at,
deletedAt: null,
};
}
interface Body {
data: {
procedures: Array<{ id: string; bodySite: string | null }>;
bodySites: Array<{ bodySite: string; laterality: string | null }>;
total: number;
};
}
beforeEach(() => {
vi.clearAllMocks();
mocks.requireRecordAuth.mockResolvedValue({
user: { id: "user-1" },
actor: { id: "user-1" },
grantId: null,
});
mocks.userFindUnique.mockResolvedValue({
timezone: "Europe/Berlin",
locale: "de",
});
mocks.encounterFindMany.mockResolvedValue([
row("p3", 30, "Knie", "RIGHT", "Arthroskopie"),
row("p2", 20, "Gallenblase", null, "Cholezystektomie"),
row("p1", 10, "Knie", "LEFT", "Meniskus"),
]);
});
describe("GET /api/encounters/procedures", () => {
it("reads the health background at READ", async () => {
await handler(request());
expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "profile");
});
it("reads only procedures that happened, for the caller", async () => {
await handler(request());
const args = mocks.encounterFindMany.mock.calls[0][0];
expect(args.where).toMatchObject({
userId: "user-1",
deletedAt: null,
kind: "PROCEDURE",
status: "DONE",
});
expect(args.orderBy).toEqual({ occurredAt: "desc" });
expect(args.take).toBeGreaterThan(0);
});
it("returns the whole history, newest first, with the decrypted site", async () => {
const res = await handler(request());
expect(res.status).toBe(200);
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p3", "p2", "p1"]);
expect(body.data.procedures[0].bodySite).toBe("Knie");
expect(body.data.total).toBe(3);
});
it("searches the decrypted site together with the side, in the owner's language", async () => {
const res = await handler(request("q=knie%20links"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p1"]);
});
it("finds the English side word as well", async () => {
const res = await handler(request("q=left"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p1"]);
});
it("filters the side exactly", async () => {
const res = await handler(request("laterality=RIGHT"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p3"]);
});
it("offers every body site whatever the filter", async () => {
const res = await handler(request("q=galle"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p2"]);
expect(body.data.bodySites).toHaveLength(3);
expect(body.data.total).toBe(3);
});
it("refuses an unknown side with 422", async () => {
const res = await handler(request("laterality=UP"));
expect(res.status).toBe(422);
expect(mocks.encounterFindMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,60 @@
/**
* `GET /api/encounters/procedures` — the procedure and surgery history.
*
* Every visit filed as PROCEDURE that happened, newest first, with the body
* sites it holds as filter choices. `q` searches the body site, the side and
* the reason; `laterality` filters the side exactly. Both run after the
* decrypt, because the body site is ciphertext at rest; the reasoning is
* written at `Encounter.bodySiteEncrypted` and in
* `src/lib/encounters/procedures.ts`.
*
* Same section and level as the visit list (`("read", "profile")`): a
* procedure is a visit, and a grant that reads the visits reads this.
* `userId` is narrowed from auth and fed to the Prisma `where`.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { procedureListQuerySchema } from "@/lib/validations/encounters";
import { resolveOwnerNotificationContext } from "@/lib/encounters/service";
import { loadProcedureHistory } from "@/lib/encounters/procedures";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
export const GET = apiHandler(async (request: NextRequest) => {
const { user, grantId } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = procedureListQuerySchema.safeParse({
q: params.get("q") ?? undefined,
laterality: params.get("laterality") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
// The side words are the record owner's, like every other label a visit
// resolves: a delegate searching somebody's history types in that person's
// record, and the English word is matched as well either way.
const { locale } = await resolveOwnerNotificationContext(user.id);
const visible = await actingDomainVisibility(prisma, grantId);
const body = await loadProcedureHistory(prisma, user.id, parsed.data, {
locale,
visible,
});
annotate({
action: { name: "encounter.procedure.list", entity_type: "encounter" },
meta: {
total: body.total,
matched: body.procedures.length,
filtered: Boolean(parsed.data.q?.trim() || parsed.data.laterality),
},
});
return apiSuccess(body);
});
+4
View File
@@ -216,6 +216,10 @@ async function postEncounter(request: NextRequest): Promise<Response> {
practitionerId: practitioner?.id ?? null,
reasonEncrypted: entry.reason ? encryptToBytes(entry.reason) : null,
outcomeEncrypted: entry.outcome ? encryptToBytes(entry.outcome) : null,
bodySiteEncrypted: entry.bodySite?.trim()
? encryptToBytes(entry.bodySite.trim())
: null,
laterality: entry.laterality ?? null,
},
select: { id: true },
});