mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
ee23409b77
commit
ad0d50fa28
43 files changed
+2486
-61
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user