Add the visit and address-book API, and the appointment arm

Nine visit routes and five practitioner routes, all classified under the
existing profile domain and each reviewed into the frozen sharing
literal with the sentence that argues for it. An address book of a
person's own doctors is record content, not account configuration: it
names no credential, no integration and no notification channel, which
is the fence that classification turns on. The eight sharing domains are
unchanged. Link and unlink stay bare, matching document link and unlink,
and the pair is revisited as one decision later.

Nothing is a required field. A visit saves with a date and nothing else.
Every id a body may carry is re-narrowed to the caller before anything
is written, and an unknown link id never blocks a save.

A booked visit mints exactly one reminder on the existing engine with
the new origin, and the appointment half is the part worth reading
twice. Both cadence columns stay null, which is the schema's one-shot,
so the first due instant is stamped directly and the never-nag property
is structural rather than guarded: after it fires, the cadence helper
has no occurrence to return. Rescheduling re-anchors that row and never
mints a second, which the tests assert by counting rows rather than by
checking the date moved.

Filing a visit as done closes the checkup it was filed against, on the
transition rather than on every later edit. Writing the test first found
that: satisfying is forward-only, so re-saving a completed visit was
pushing the checkup's next due date out again as if the person had been
a second time.

An appointment must appear on no preventive-care surface, and that is
four separate reads, one of them in the assistant tooling. Each carries
the exclusion, a guard proves all four by source text with a non-zero
match count, and the reminder mapper refuses such a row outright — so a
read site that loses its filter fails loudly instead of showing an
appointment as a checkup. The published origin enum is untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 07:51:29 +02:00
1 parent ba21c3a9bc
commit f158494def
30 files changed
+2743 -6

No files matched your search

+8
View File
@@ -9805,6 +9805,14 @@
"illnessEpisodeDelete": "{name} hat einen Krankheitseintrag gelöscht",
"illnessEpisodeResolve": "{name} hat eine Erkrankung als beendet markiert",
"illnessEpisodeRestore": "{name} hat einen Krankheitseintrag wiederhergestellt",
"encounterVisitCreate": "{name} hat einen Arztbesuch angelegt",
"encounterVisitUpdate": "{name} hat einen Arztbesuch bearbeitet",
"encounterVisitDelete": "{name} hat einen Arztbesuch gelöscht",
"encounterVisitRestore": "{name} hat einen Arztbesuch wiederhergestellt",
"practitionerContactCreate": "{name} hat eine Praxis zum Adressbuch hinzugefügt",
"practitionerContactUpdate": "{name} hat einen Adressbucheintrag bearbeitet",
"practitionerContactDelete": "{name} hat einen Adressbucheintrag gelöscht",
"practitionerContactRestore": "{name} hat einen Adressbucheintrag wiederhergestellt",
"illnessDayLogUpsert": "{name} hat einen Krankheitstag erfasst",
"medicationUpdate": "{name} hat ein Medikament geändert",
"medicationDelete": "{name} hat ein Medikament gelöscht",
+8
View File
@@ -9805,6 +9805,14 @@
"illnessEpisodeDelete": "{name} removed an illness entry",
"illnessEpisodeResolve": "{name} marked an illness as over",
"illnessEpisodeRestore": "{name} restored an illness entry",
"encounterVisitCreate": "{name} added a visit",
"encounterVisitUpdate": "{name} edited a visit",
"encounterVisitDelete": "{name} deleted a visit",
"encounterVisitRestore": "{name} restored a visit",
"practitionerContactCreate": "{name} added a practice to the address book",
"practitionerContactUpdate": "{name} edited an address book entry",
"practitionerContactDelete": "{name} deleted an address book entry",
"practitionerContactRestore": "{name} restored an address book entry",
"illnessDayLogUpsert": "{name} recorded an illness day",
"medicationUpdate": "{name} changed a medication",
"medicationDelete": "{name} removed a medication",
+8
View File
@@ -9805,6 +9805,14 @@
"illnessEpisodeDelete": "{name} eliminó una enfermedad",
"illnessEpisodeResolve": "{name} marcó una enfermedad como terminada",
"illnessEpisodeRestore": "{name} restauró una enfermedad",
"encounterVisitCreate": "{name} añadió una visita médica",
"encounterVisitUpdate": "{name} editó una visita médica",
"encounterVisitDelete": "{name} eliminó una visita médica",
"encounterVisitRestore": "{name} restauró una visita médica",
"practitionerContactCreate": "{name} añadió una consulta a la agenda",
"practitionerContactUpdate": "{name} editó una entrada de la agenda",
"practitionerContactDelete": "{name} eliminó una entrada de la agenda",
"practitionerContactRestore": "{name} restauró una entrada de la agenda",
"illnessDayLogUpsert": "{name} registró un día de enfermedad",
"medicationUpdate": "{name} cambió un medicamento",
"medicationDelete": "{name} eliminó un medicamento",
+8
View File
@@ -9805,6 +9805,14 @@
"illnessEpisodeDelete": "{name} a supprimé une maladie",
"illnessEpisodeResolve": "{name} a marqué une maladie comme terminée",
"illnessEpisodeRestore": "{name} a restauré une maladie",
"encounterVisitCreate": "{name} a ajouté une consultation",
"encounterVisitUpdate": "{name} a modifié une consultation",
"encounterVisitDelete": "{name} a supprimé une consultation",
"encounterVisitRestore": "{name} a restauré une consultation",
"practitionerContactCreate": "{name} a ajouté un cabinet au carnet d'adresses",
"practitionerContactUpdate": "{name} a modifié une entrée du carnet d'adresses",
"practitionerContactDelete": "{name} a supprimé une entrée du carnet d'adresses",
"practitionerContactRestore": "{name} a restauré une entrée du carnet d'adresses",
"illnessDayLogUpsert": "{name} a enregistré une journée de maladie",
"medicationUpdate": "{name} a modifié un médicament",
"medicationDelete": "{name} a supprimé un médicament",
+8
View File
@@ -9805,6 +9805,14 @@
"illnessEpisodeDelete": "{name} ha eliminato una malattia",
"illnessEpisodeResolve": "{name} ha segnato una malattia come conclusa",
"illnessEpisodeRestore": "{name} ha ripristinato una malattia",
"encounterVisitCreate": "{name} ha aggiunto una visita medica",
"encounterVisitUpdate": "{name} ha modificato una visita medica",
"encounterVisitDelete": "{name} ha eliminato una visita medica",
"encounterVisitRestore": "{name} ha ripristinato una visita medica",
"practitionerContactCreate": "{name} ha aggiunto uno studio alla rubrica",
"practitionerContactUpdate": "{name} ha modificato una voce della rubrica",
"practitionerContactDelete": "{name} ha eliminato una voce della rubrica",
"practitionerContactRestore": "{name} ha ripristinato una voce della rubrica",
"illnessDayLogUpsert": "{name} ha registrato una giornata di malattia",
"medicationUpdate": "{name} ha modificato un farmaco",
"medicationDelete": "{name} ha eliminato un farmaco",
+8
View File
@@ -9805,6 +9805,14 @@
"illnessEpisodeDelete": "{name} usunął wpis o chorobie",
"illnessEpisodeResolve": "{name} oznaczył chorobę jako zakończoną",
"illnessEpisodeRestore": "{name} przywrócił wpis o chorobie",
"encounterVisitCreate": "{name} dodał wizytę lekarską",
"encounterVisitUpdate": "{name} zmienił wizytę lekarską",
"encounterVisitDelete": "{name} usunął wizytę lekarską",
"encounterVisitRestore": "{name} przywrócił wizytę lekarską",
"practitionerContactCreate": "{name} dodał gabinet do książki adresowej",
"practitionerContactUpdate": "{name} zmienił wpis w książce adresowej",
"practitionerContactDelete": "{name} usunął wpis w książce adresowej",
"practitionerContactRestore": "{name} przywrócił wpis w książce adresowej",
"illnessDayLogUpsert": "{name} zapisał dzień choroby",
"medicationUpdate": "{name} zmienił lek",
"medicationDelete": "{name} usunął lek",
@@ -0,0 +1,173 @@
/**
* A booked visit's reminder appears on no preventive-care surface.
*
* A planned encounter mints one `MeasurementReminder` with `origin:
* ENCOUNTER`, because an appointment is a reminder with a date and a second
* engine would be a second cron, a second queue and a second set of
* preferences. The cost of that decision is this: every READ that means "the
* checkups this person keeps" now has to say so, or the checkup list fills up
* with appointments.
*
* The research that proposed the feature called this "a one-line `where`". It
* is four, and the fourth is in a different subsystem, which is exactly why it
* is worth a guard rather than a comment.
*
* Two halves, deliberately:
*
* 1. **By source text** — each of the four read sites contains an origin
* exclusion. Whitespace-tolerant, and it ASSERTS A NON-ZERO MATCH COUNT.
* A matcher that matches nothing is green for the wrong reason; the
* Bearer-scope guard in this directory stayed blind for months because
* its regex demanded a literal a two-line call never produced, and it
* passed by finding nothing rather than by finding everything.
* 2. **By behaviour** — the DTO mapper refuses an `ENCOUNTER` row. Source
* text cannot tell a filter that runs from one that is written, and the
* refusal is what turns a lost filter into a loud failure instead of an
* appointment relabelled as a checkup.
*
* What this file does NOT prove: that the four queries run. That is what the
* mapper's throw is for, and what an integration test against real rows would
* add. Written down so the limit is visible rather than assumed away.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import type { MeasurementReminder } from "@/generated/prisma/client";
const REPO_ROOT = resolve(__dirname, "../..");
function read(relativePath: string): string {
return readFileSync(resolve(REPO_ROOT, relativePath), "utf8");
}
/**
* Every read that answers "what preventive care is due", and what each one
* feeds. Named rather than discovered: a discovery sweep would have to guess
* which reminder reads are Vorsorge surfaces and which are the engine itself,
* and the engine's two reads must NOT carry the exclusion — firing the
* appointment and satisfying it is the whole point of the row.
*/
const VORSORGE_READ_SITES: ReadonlyArray<{ file: string; surface: string }> = [
{
file: "src/app/api/measurement-reminders/route.ts",
surface: "the reminder list the checkups page renders",
},
{
file: "src/lib/daily/load-digest.ts",
surface: "the daily digest's due list",
},
{
file: "src/lib/insights/feature-blocks.ts",
surface: "the insights preventive-care block",
},
{
file: "src/lib/mcp/tools.ts",
surface: "the MCP get_preventive_care tool",
},
];
/**
* `origin: { not: "ENCOUNTER" }`, tolerant of any formatting Prettier might
* choose — line breaks between the key and the object, single or double
* quotes, a trailing comma.
*/
const EXCLUSION = /origin\s*:\s*\{\s*not\s*:\s*["']ENCOUNTER["']\s*,?\s*\}/g;
function reminderRow(
origin: MeasurementReminder["origin"],
): MeasurementReminder {
const at = new Date("2026-07-01T09:00:00.000Z");
return {
id: "reminder-1",
userId: "user-1",
label: "Dr. Example",
measurementType: null,
intervalDays: null,
rrule: null,
anchorDate: at,
endsOn: null,
origin,
notifyHour: 9,
location: null,
nextDueAt: at,
lastSatisfiedAt: null,
enabled: true,
createdAt: at,
updatedAt: at,
deletedAt: null,
};
}
describe("appointment reminders are excluded from every Vorsorge read", () => {
it("names a read set that is not empty (no vacuous pass)", () => {
expect(VORSORGE_READ_SITES.length).toBe(4);
});
for (const { file, surface } of VORSORGE_READ_SITES) {
it(`excludes ENCOUNTER rows from ${surface}`, () => {
const source = read(file);
const matches = source.match(EXCLUSION) ?? [];
expect(
matches.length,
`${file} feeds ${surface} and no longer excludes ENCOUNTER-origin ` +
"rows, so a booked appointment will surface there as a checkup",
).toBeGreaterThan(0);
});
}
it("leaves the engine's own reads alone", () => {
// The cron fires these rows and the satisfy primitive resolves them. An
// exclusion in either would mean a booked appointment that never nudges,
// which is the opposite failure and just as silent.
for (const file of [
"src/lib/jobs/measurement-reminder.ts",
"src/lib/measurement-reminders/satisfy.ts",
]) {
const matches = read(file).match(EXCLUSION) ?? [];
expect(
matches.length,
`${file} is the engine, not a Vorsorge surface — excluding ENCOUNTER ` +
"rows here would stop appointments firing at all",
).toBe(0);
}
});
it("the Coach dedup read needs nothing, because it is already narrow", () => {
// It filters to `origin: "COACH"`, so an ENCOUNTER row cannot reach it.
// Asserted rather than assumed: if that narrowing ever widened, this file
// is where the consequence should surface.
expect(read("src/lib/ai/coach/suggest-gate.ts")).toMatch(
/origin\s*:\s*["']COACH["']/,
);
});
});
describe("the reminder DTO refuses an appointment row", () => {
it("maps a VORSORGE row normally", () => {
expect(toMeasurementReminderDto(reminderRow("VORSORGE")).origin).toBe(
"VORSORGE",
);
});
it("maps a COACH row normally", () => {
expect(toMeasurementReminderDto(reminderRow("COACH")).origin).toBe("COACH");
});
it("throws on an ENCOUNTER row rather than relabelling it", () => {
// The invariant: every query feeding this mapper excludes them, so one
// arriving means a read site lost its filter. Failing loudly is the only
// honest answer — the alternative is an appointment shown as a checkup.
expect(() => toMeasurementReminderDto(reminderRow("ENCOUNTER"))).toThrow(
/ENCOUNTER-origin reminder/,
);
});
it("names the guard file in the message, so the next reader finds the proof", () => {
expect(() => toMeasurementReminderDto(reminderRow("ENCOUNTER"))).toThrow(
/encounter-reminder-exclusion/,
);
});
});
+50 -2
View File
@@ -923,6 +923,22 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "measurements",
why: "One nutrient's daily series over the record, scoped by the resolved user id.",
},
"app/api/encounters/route.ts": {
domain: "profile",
why: "The record's visits — what happened at a practice and what is booked. Record content in the same sense as the allergies and family history already under this domain: it names no credential, no integration and no notification channel. Carries the honest cost of that classification, which is that a delegate granted `profile` for allergies also sees the visit list; the consent copy names visits for exactly this reason. The create arm is a delegable write.",
},
"app/api/encounters/[id]/route.ts": {
domain: "profile",
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
},
"app/api/practitioners/route.ts": {
domain: "profile",
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
},
"app/api/practitioners/[id]/route.ts": {
domain: "profile",
why: "One practitioner of the record, fetch-then-guard against the resolved user.",
},
"app/api/illness/episodes/route.ts": {
domain: "illness",
why: "The record's illness episodes. Module-gated on the record on both arms, so a delegate gets the surface only where the owner switched it on; the create arm is a delegable write.",
@@ -1108,6 +1124,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "illness",
why: "Reopening a deleted episode with its day logs intact. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/encounters/[id]/restore/route.ts": {
domain: "profile",
why: "Reopening a deleted visit with its links intact. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/practitioners/[id]/restore/route.ts": {
domain: "profile",
why: "Reopening a deleted address-book entry. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/mood-entries/bulk-delete/route.ts": {
domain: "mind",
why: "Tombstoning a selection. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
@@ -1347,6 +1371,10 @@ const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
"Adding an analyte to the record's catalogue. A name the record already tracks is the ordinary 409 from the same `(userId, name)` uniqueness the owner would hit themselves.",
"app/api/illness/episodes/route.ts":
"Opening an illness episode. The module gate runs against the RECORD before the write, so a delegate cannot create an episode inside a record whose owner switched the module off.",
"app/api/encounters/route.ts":
"Filing a visit, or booking one. Every id the body may carry — the practitioner, the checkup it closes, the three link arrays — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's document to another's visit. Booking mints the appointment reminder under the RECORD, which is correct: it is the owner's appointment and the owner's channels that should be nudged about it.",
"app/api/practitioners/route.ts":
"Adding a doctor or practice to the record's address book. Nothing is unique across accounts here by design, so a delegate adding a practice the owner already has produces a second row rather than a collision with somebody else's namespace.",
"app/api/medications/[id]/side-effects/route.ts":
"Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.",
"app/api/medications/route.ts":
@@ -1453,6 +1481,26 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: ["C4"],
why: "Editing and deleting an episode; the delete soft-deletes. C4 on the edit.",
},
"app/api/encounters/[id]/route.ts": {
domain: "profile",
conditions: ["C4"],
why: "Editing and deleting a visit; the delete soft-deletes and retires the visit's own appointment reminder with it, never a checkup the visit closed. C4 on the edit: the date, the status, the kind and the practitioner are filed before and after, and the encrypted reason and outcome deliberately are not.",
},
"app/api/encounters/[id]/restore/route.ts": {
domain: "profile",
conditions: [],
why: "Reopening a deleted visit with its links intact.",
},
"app/api/practitioners/[id]/route.ts": {
domain: "profile",
conditions: ["C4"],
why: "Correcting and removing one address-book entry; the delete soft-deletes and leaves every visit that named it, because the reference nulls rather than cascades. C4 on the edit, over the plaintext fields only.",
},
"app/api/practitioners/[id]/restore/route.ts": {
domain: "profile",
conditions: [],
why: "Reopening a deleted address-book entry. It does not re-attach the visits whose reference was nulled, because which visits meant this practice is not recoverable.",
},
"app/api/illness/episodes/[id]/resolve/route.ts": {
domain: "illness",
conditions: [],
@@ -1809,7 +1857,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* read-only profile summary makes the next explicit admission: 197 → 198.
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
*/
const FROZEN_ENTRY_COUNT = 200;
const FROZEN_ENTRY_COUNT = 212;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -2570,7 +2618,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(62);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(70);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(62);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(70);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
@@ -0,0 +1,93 @@
/**
* `POST /api/encounters/{id}/links` — file things against a visit.
* `DELETE /api/encounters/{id}/links` — unfile them.
*
* Deliberately bare `requireAuth()` rather than a delegable declaration, and
* the reason is consistency rather than oversight: document link and unlink are
* not delegable today — only the vault reads are — and an encounter link is the
* same verb one layer up. Making one delegable and not the other would mean two
* answers to the same question. The pair is revisited as one decision when the
* polymorphic-linking work lands.
*
* Both verbs are idempotent, both are capped, and neither ever blocks a save:
* an id that names nothing this account owns comes back in `unknown` rather
* than failing the request, because a visit must never be un-saveable because
* one document it pointed at has since been deleted.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { encounterLinkSchema } from "@/lib/validations/encounters";
import { linkTargets, unlinkTargets } from "@/lib/links";
import { loadEncounterLinks } from "@/lib/encounters/service";
type RouteParams = { params: Promise<{ id: string }> };
async function handle(
request: NextRequest,
{ params }: RouteParams,
verb: "link" | "unlink",
): Promise<Response> {
const { user } = await requireAuth();
const { id } = await params;
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = encounterLinkSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.link.invalid",
});
}
const apply = verb === "link" ? linkTargets : unlinkTargets;
const result = await apply(prisma, {
userId: user.id,
sourceKind: "encounter",
sourceId: id,
targetKind: parsed.data.targetKind,
targetIds: parsed.data.targetIds,
});
if (result.unknownSource) {
return apiError("Visit not found", 404);
}
annotate({
action: {
name: `encounter.link.${verb}`,
entity_type: "encounter",
entity_id: id,
},
meta: {
target_kind: parsed.data.targetKind,
changed: result.changed,
unknown: result.unknownTargetIds.length,
},
});
const links = await loadEncounterLinks(prisma, user.id, id);
return apiSuccess({ links, unknown: result.unknownTargetIds });
}
export const POST = apiHandler(
async (request: NextRequest, context: RouteParams) =>
handle(request, context, "link"),
);
export const DELETE = apiHandler(
async (request: NextRequest, context: RouteParams) =>
handle(request, context, "unlink"),
);
@@ -0,0 +1,100 @@
/**
* `POST /api/encounters/{id}/restore` — clear a visit's tombstone.
*
* MANAGE rather than WRITE: undoing a deletion is a management act when the
* manager is the one who holds delete.
*
* The link rows survived the soft delete — they cascade only on a hard one —
* so a restored visit comes back with its documents, labs and conditions still
* filed against it. Its appointment reminder comes back too, but only if the
* appointment is still in the future: restoring a visit whose date has passed
* must not produce a nudge for something that already did or did not happen.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { toEncounterDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
loadEncounterLinks,
resolveTimezone,
shouldHaveReminder,
} from "@/lib/encounters/service";
import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.encounter.findUnique({
where: { id },
include: { practitioner: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Visit not found", 404);
}
if (existing.deletedAt !== null) {
const timezone = await resolveTimezone(user.id);
const now = new Date();
await prisma.$transaction(async (tx) => {
await tx.encounter.update({
where: { id },
data: { deletedAt: null },
});
if (
existing.reminderId &&
shouldHaveReminder(existing.status, existing.occurredAt, now)
) {
const reminder = await tx.measurementReminder.findUnique({
where: { id: existing.reminderId },
select: { id: true, origin: true },
});
if (reminder?.origin === "ENCOUNTER") {
await reanchorAppointmentReminder(
tx,
reminder.id,
{
userId: user.id,
occurredAt: existing.occurredAt,
practitionerName: existing.practitioner?.name ?? null,
practitionerLocation: existing.practitioner?.location ?? null,
kindLabel: existing.kind,
},
timezone,
);
}
}
});
}
const row = await prisma.encounter.findUniqueOrThrow({
where: { id },
include: ENCOUNTER_INCLUDE,
});
const links = await loadEncounterLinks(prisma, user.id, id);
await auditLog("encounter.visit.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { encounterId: id, occurredAt: row.occurredAt.toISOString() },
});
annotate({
action: {
name: "encounter.visit.restore",
entity_type: "encounter",
entity_id: id,
},
});
return apiSuccess(toEncounterDTO(row, links));
},
);
+361
View File
@@ -0,0 +1,361 @@
/**
* `GET /api/encounters/{id}` — one owned, live visit with its links resolved.
* `PATCH /api/encounters/{id}` — edit it (partial; field-by-field).
* `DELETE /api/encounters/{id}` — soft-delete it, and retire its appointment
* reminder with it.
*
* Owner-scoped by fetch-then-guard. `userId` is narrowed from auth; the body
* never carries it.
*
* The status transitions are where the appointment arm lives. Moving a visit to
* DONE closes the checkup it was filed against — on the transition, so re-saving
* a completed visit does not push that checkup's next due date out again.
* Moving it to CANCELLED or NO_SHOW stops its reminder without deleting the
* row, so the history stays honest. Moving its date re-anchors the reminder it
* already owns and never mints a second.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encounterUpdateSchema } from "@/lib/validations/encounters";
import { toEncounterDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
applyEncounterLinks,
closeCheckupForVisit,
loadEncounterLinks,
resolveClosableReminder,
resolveOwnedPractitioner,
resolveTimezone,
shouldHaveReminder,
syncAppointmentReminder,
} from "@/lib/encounters/service";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read", "profile");
const { id } = await params;
const row = await prisma.encounter.findUnique({
where: { id },
include: ENCOUNTER_INCLUDE,
});
if (!row || row.userId !== user.id || row.deletedAt !== null) {
return apiError("Visit not found", 404);
}
const links = await loadEncounterLinks(prisma, user.id, id);
annotate({
action: {
name: "encounter.visit.read",
entity_type: "encounter",
entity_id: id,
},
});
return apiSuccess(toEncounterDTO(row, links));
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.encounter.findUnique({ where: { id } });
if (
!existing ||
existing.userId !== user.id ||
existing.deletedAt !== null
) {
return apiError("Visit not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
});
if (jsonError) return jsonError;
const parsed = encounterUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
const entry = parsed.data;
const timezone = await resolveTimezone(user.id);
const now = new Date();
// The state the visit will be in once this edit lands. Both the reminder
// decision and the checkup decision read from here rather than from the
// body, so an edit that moves only the date is judged against the status
// the visit already has.
const nextOccurredAt = entry.occurredAt
? new Date(entry.occurredAt)
: existing.occurredAt;
const nextStatus = entry.status ?? existing.status;
const outcome = await prisma.$transaction(async (tx) => {
let practitionerId = existing.practitionerId;
let practitionerName: string | null = null;
let practitionerLocation: string | null = null;
if (entry.practitionerId !== undefined) {
if (entry.practitionerId === null) {
practitionerId = null;
} else {
const resolved = await resolveOwnedPractitioner(
tx,
user.id,
entry.practitionerId,
);
if (!resolved) return "unknown-practitioner" as const;
practitionerId = resolved.id;
practitionerName = resolved.name;
practitionerLocation = resolved.location;
}
} else if (practitionerId) {
const resolved = await resolveOwnedPractitioner(
tx,
user.id,
practitionerId,
);
practitionerName = resolved?.name ?? null;
practitionerLocation = resolved?.location ?? null;
}
const data: Prisma.EncounterUpdateInput = {};
if (entry.occurredAt !== undefined) data.occurredAt = nextOccurredAt;
if (entry.status !== undefined) data.status = entry.status;
if (entry.kind !== undefined) data.kind = entry.kind;
if (entry.reason !== undefined) {
data.reasonEncrypted = entry.reason
? encryptToBytes(entry.reason)
: null;
}
if (entry.outcome !== undefined) {
data.outcomeEncrypted = entry.outcome
? encryptToBytes(entry.outcome)
: null;
}
if (entry.practitionerId !== undefined) {
data.practitioner = practitionerId
? { connect: { id: practitionerId } }
: { disconnect: true };
}
// Whether the visit's own appointment reminder — the ENCOUNTER-origin
// row this code minted — is the thing `reminderId` currently holds. A
// checkup the visit closes lives in the same column and must not be
// re-anchored onto the appointment's date.
const currentReminder = existing.reminderId
? await tx.measurementReminder.findUnique({
where: { id: existing.reminderId },
select: { id: true, origin: true },
})
: null;
const ownAppointmentReminderId =
currentReminder?.origin === "ENCOUNTER" ? currentReminder.id : null;
let reminderId = existing.reminderId;
if (shouldHaveReminder(nextStatus, nextOccurredAt, now)) {
reminderId = await syncAppointmentReminder(
tx,
{
userId: user.id,
occurredAt: nextOccurredAt,
practitionerName,
practitionerLocation,
kindLabel: entry.kind ?? existing.kind,
status: nextStatus,
existingReminderId: ownAppointmentReminderId,
},
timezone,
now,
);
} else if (ownAppointmentReminderId) {
// No longer a future appointment: the row stays, switched off.
reminderId = await syncAppointmentReminder(
tx,
{
userId: user.id,
occurredAt: nextOccurredAt,
practitionerName,
practitionerLocation,
kindLabel: entry.kind ?? existing.kind,
status: nextStatus,
existingReminderId: ownAppointmentReminderId,
},
timezone,
now,
);
}
// A body may re-point the visit at the checkup it closes.
if (entry.reminderId !== undefined) {
if (entry.reminderId === null) {
reminderId = ownAppointmentReminderId;
} else {
const checkup = await resolveClosableReminder(
tx,
user.id,
entry.reminderId,
);
if (!checkup) return "unknown-reminder" as const;
reminderId = checkup.id;
}
}
if (reminderId !== existing.reminderId) {
data.reminder = reminderId
? { connect: { id: reminderId } }
: { disconnect: true };
}
const updated = await tx.encounter.update({ where: { id }, data });
await applyEncounterLinks(tx, user.id, id, entry);
// Filing the visit as done closes the checkup it names — on the
// TRANSITION, not on every edit of a visit that is already done.
//
// `satisfyReminder` is forward-only, which means an older instant is a
// no-op and a NEWER one always advances. So re-saving a completed visit
// would push the checkup's next due date out again, as if the person had
// been a second time. Gating on the transition is what makes re-filing
// genuinely idempotent rather than merely non-erroring.
const closesCheckupNow =
nextStatus === "DONE" && existing.status !== "DONE";
if (closesCheckupNow && reminderId) {
const checkup = await resolveClosableReminder(tx, user.id, reminderId);
if (checkup) await closeCheckupForVisit(tx, checkup, timezone, now);
}
return { updated };
});
if (outcome === "unknown-practitioner") {
return apiError("Practitioner not found", 404, {
errorCode: "encounter.practitioner-not-found",
});
}
if (outcome === "unknown-reminder") {
return apiError("Checkup not found", 404, {
errorCode: "encounter.reminder-not-found",
});
}
const row = await prisma.encounter.findUniqueOrThrow({
where: { id },
include: ENCOUNTER_INCLUDE,
});
const links = await loadEncounterLinks(prisma, user.id, id);
await auditLog("encounter.visit.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
encounterId: id,
// The free text is deliberately absent: it is encrypted PHI and the
// audit table is not a second copy of the record.
...overwriteDetails({
before: {
occurredAt: existing.occurredAt,
status: existing.status,
kind: existing.kind,
practitionerId: existing.practitionerId,
},
after: {
occurredAt: outcome.updated.occurredAt,
status: outcome.updated.status,
kind: outcome.updated.kind,
practitionerId: outcome.updated.practitionerId,
},
}),
},
});
annotate({
action: {
name: "encounter.visit.update",
entity_type: "encounter",
entity_id: id,
},
meta: { status: row.status },
});
return apiSuccess(toEncounterDTO(row, links));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.encounter.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Visit not found", 404);
}
// Already tombstoned: deleting twice is a no-op that still succeeds.
if (existing.deletedAt === null) {
const at = new Date();
await prisma.$transaction(async (tx) => {
await tx.encounter.update({ where: { id }, data: { deletedAt: at } });
// Only the visit's OWN appointment reminder goes with it. A checkup the
// visit closed belongs to the person's Vorsorge list and outlives it.
if (existing.reminderId) {
const reminder = await tx.measurementReminder.findUnique({
where: { id: existing.reminderId },
select: { id: true, origin: true },
});
if (reminder?.origin === "ENCOUNTER") {
await tx.measurementReminder.update({
where: { id: reminder.id },
data: { deletedAt: at, enabled: false, nextDueAt: null },
});
}
}
});
}
await auditLog("encounter.visit.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
encounterId: id,
occurredAt: existing.occurredAt.toISOString(),
status: existing.status,
},
});
annotate({
action: {
name: "encounter.visit.delete",
entity_type: "encounter",
entity_id: id,
},
});
return apiSuccess({ deleted: true });
},
);
+255
View File
@@ -0,0 +1,255 @@
/**
* `GET /api/encounters` — the account's visits, split into upcoming and past.
* `POST /api/encounters` — file one, optionally pre-linked and optionally
* booked as a future appointment.
*
* No module gate: a visit is core. The reasoning is written at the `Encounter`
* model in `prisma/schema.prisma`, and the gate is relocated to the link
* pickers, which follow the modules that own what they point at.
*
* `userId` is narrowed from auth and fed to the Prisma `where`; it is never a
* body field. The `data` object is built field-by-field. Nothing is mandatory
* except the date: a visit saves with an instant and nothing else.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
encounterCreateSchema,
encounterListQuerySchema,
} from "@/lib/validations/encounters";
import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
applyEncounterLinks,
closeCheckupForVisit,
resolveClosableReminder,
resolveOwnedPractitioner,
resolveTimezone,
shouldHaveReminder,
syncAppointmentReminder,
} from "@/lib/encounters/service";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = encounterListQuerySchema.safeParse({
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
status: params.get("status") ?? undefined,
practitionerId: params.get("practitionerId") ?? undefined,
limit: params.get("limit") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
const query = parsed.data;
const take = query.limit ?? 100;
const now = new Date();
const where = {
userId: user.id,
deletedAt: null,
...(query.status ? { status: query.status } : {}),
...(query.practitionerId ? { practitionerId: query.practitionerId } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
};
// Two queries rather than one sorted list. Upcoming reads soonest-first and
// past reads newest-first, which is the opposite direction; resolving both
// here means the client renders two lists instead of splitting and
// re-sorting one, and the ordering lives in one place.
const [upcoming, past] = await Promise.all([
prisma.encounter.findMany({
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: { occurredAt: "asc" },
take,
include: ENCOUNTER_INCLUDE,
}),
prisma.encounter.findMany({
where: { ...where, occurredAt: { ...where.occurredAt, lte: now } },
orderBy: { occurredAt: "desc" },
take,
include: ENCOUNTER_INCLUDE,
}),
]);
annotate({
action: { name: "encounter.visit.list", entity_type: "encounter" },
meta: { upcoming: upcoming.length, past: past.length },
});
const body: EncounterListDTO = {
upcoming: upcoming.map((row) => toEncounterDTO(row)),
past: past.map((row) => toEncounterDTO(row)),
};
return apiSuccess(body);
});
// A double-tap on "save" must not file the same visit twice.
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter));
async function postEncounter(request: NextRequest): Promise<Response> {
const { user } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
});
if (jsonError) return jsonError;
const parsed = encounterCreateSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "encounter.visit.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
const entry = parsed.data;
const occurredAt = new Date(entry.occurredAt);
const timezone = await resolveTimezone(user.id);
const now = new Date();
const created = await prisma.$transaction(async (tx) => {
// A practitioner the caller does not own is a refusal rather than a
// silently dropped field: the person named a practice and would otherwise
// see the visit save without it.
let practitioner:
{ id: string; name: string; location: string | null } | undefined;
if (entry.practitionerId) {
practitioner = await resolveOwnedPractitioner(
tx,
user.id,
entry.practitionerId,
);
if (!practitioner) return null;
}
// Field-by-field — never spread the parsed object whole.
const row = await tx.encounter.create({
data: {
userId: user.id,
occurredAt,
status: entry.status,
kind: entry.kind,
practitionerId: practitioner?.id ?? null,
reasonEncrypted: entry.reason ? encryptToBytes(entry.reason) : null,
outcomeEncrypted: entry.outcome ? encryptToBytes(entry.outcome) : null,
},
select: { id: true },
});
await applyEncounterLinks(tx, user.id, row.id, entry);
// `Encounter.reminderId` carries one of two things, never both, and which
// one follows from the visit's own state. A booked appointment owns a
// reminder this code mints; a completed visit may instead close a checkup
// the person already had. A body claiming both is contradictory — a visit
// that has not happened cannot have closed anything — and is refused.
let reminderId: string | null = null;
if (shouldHaveReminder(entry.status, occurredAt, now)) {
if (entry.reminderId) return "contradictory-reminder" as const;
reminderId = await syncAppointmentReminder(
tx,
{
userId: user.id,
occurredAt,
practitionerName: practitioner?.name ?? null,
practitionerLocation: practitioner?.location ?? null,
kindLabel: entry.kind,
status: entry.status,
existingReminderId: null,
},
timezone,
now,
);
} else if (entry.reminderId) {
const checkup = await resolveClosableReminder(
tx,
user.id,
entry.reminderId,
);
if (!checkup) return "unknown-reminder" as const;
reminderId = checkup.id;
// Filing a visit as done against a due checkup closes it. Forward-only
// and idempotent, so re-filing the same visit changes nothing.
if (entry.status === "DONE") {
await closeCheckupForVisit(tx, checkup, timezone, now);
}
}
if (reminderId) {
await tx.encounter.update({
where: { id: row.id },
data: { reminderId },
});
}
return tx.encounter.findUniqueOrThrow({
where: { id: row.id },
include: ENCOUNTER_INCLUDE,
});
});
if (created === null) {
return apiError("Practitioner not found", 404, {
errorCode: "encounter.practitioner-not-found",
});
}
if (created === "unknown-reminder") {
return apiError("Checkup not found", 404, {
errorCode: "encounter.reminder-not-found",
});
}
if (created === "contradictory-reminder") {
return apiError(
"A planned visit cannot also close a checkup — it has not happened yet",
422,
{ errorCode: "encounter.reminder-conflict" },
);
}
await auditLog("encounter.visit.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { encounterId: created.id, status: created.status },
});
annotate({
action: {
name: "encounter.visit.create",
entity_type: "encounter",
entity_id: created.id,
},
meta: { status: created.status, kind: created.kind },
});
return apiSuccess(toEncounterDTO(created), 201);
}
+7 -1
View File
@@ -39,7 +39,13 @@ export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "measurements");
const reminders = await prisma.measurementReminder.findMany({
where: { userId: user.id, deletedAt: null },
// A booked visit's one-shot reminder rides this same engine with
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
// Vorsorge surface, or the list fills with appointments. Four read
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
// proves every one of them, and the DTO mapper refuses such a row
// outright so a site that lost its filter fails loudly.
where: { userId: user.id, deletedAt: null, origin: { not: "ENCOUNTER" } },
// Most-urgent first; a null next-due (uncomputable / disabled) sinks
// to the end so the actionable items float to the top.
orderBy: [
@@ -0,0 +1,58 @@
/**
* `POST /api/practitioners/{id}/restore` — clear a practitioner's tombstone.
*
* MANAGE rather than WRITE: undoing a deletion is a management act when the
* manager is the one who holds delete.
*
* Restoring the address-book entry does NOT re-attach it to the visits that
* named it. Those references were nulled when the row was deleted, and
* inventing them back would be guessing at which visits meant this practice.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { toPractitionerDTO } from "@/lib/practitioners/dto";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.practitioner.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Practitioner not found", 404);
}
// Restoring a live row is a no-op that still succeeds, so a retry after a
// dropped response does not answer 404 for work that already happened.
const restored =
existing.deletedAt === null
? existing
: await prisma.practitioner.update({
where: { id },
data: { deletedAt: null },
});
await auditLog("practitioner.contact.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { practitionerId: id, name: restored.name },
});
annotate({
action: {
name: "practitioner.contact.restore",
entity_type: "practitioner",
entity_id: id,
},
});
return apiSuccess(toPractitionerDTO(restored));
},
);
+174
View File
@@ -0,0 +1,174 @@
/**
* `GET /api/practitioners/{id}` — one owned, live practitioner.
* `PATCH /api/practitioners/{id}` — edit it (partial; field-by-field).
* `DELETE /api/practitioners/{id}` — soft-delete it (idempotent).
*
* Owner-scoped by fetch-then-guard, the episode-detail pattern: the row is
* read by id and then checked against the resolved user, so a foreign id and a
* missing id give the same 404 and probing learns nothing.
*
* Deleting a practitioner keeps every visit that named it. The foreign key is
* SetNull, so the visits survive with no practice attached rather than
* disappearing with the address-book entry.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { practitionerUpdateSchema } from "@/lib/validations/practitioners";
import { toPractitionerDTO } from "@/lib/practitioners/dto";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read", "profile");
const { id } = await params;
const row = await prisma.practitioner.findUnique({ where: { id } });
if (!row || row.userId !== user.id || row.deletedAt !== null) {
return apiError("Practitioner not found", 404);
}
annotate({
action: {
name: "practitioner.contact.read",
entity_type: "practitioner",
entity_id: id,
},
});
return apiSuccess(toPractitionerDTO(row));
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.practitioner.findUnique({ where: { id } });
if (
!existing ||
existing.userId !== user.id ||
existing.deletedAt !== null
) {
return apiError("Practitioner not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = practitionerUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "practitioner.invalid",
});
}
const entry = parsed.data;
// Field-by-field, and only the keys the body actually named: an absent key
// leaves the column alone, an explicit null clears it.
const data: Prisma.PractitionerUpdateInput = {};
if (entry.name !== undefined) data.name = entry.name;
if (entry.specialty !== undefined) data.specialty = entry.specialty;
if (entry.practice !== undefined) data.practice = entry.practice;
if (entry.location !== undefined) data.location = entry.location;
if (entry.phone !== undefined) data.phone = entry.phone;
if (entry.note !== undefined) {
data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null;
}
const updated = await prisma.practitioner.update({ where: { id }, data });
await auditLog("practitioner.contact.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
practitionerId: id,
// A manager may edit somebody else's address book, so the owner's
// activity feed has to say what changed rather than only that
// something did. The note is deliberately absent: it is encrypted PHI
// and the audit table is not a second copy of the record.
...overwriteDetails({
before: {
name: existing.name,
specialty: existing.specialty,
practice: existing.practice,
location: existing.location,
phone: existing.phone,
},
after: {
name: updated.name,
specialty: updated.specialty,
practice: updated.practice,
location: updated.location,
phone: updated.phone,
},
}),
},
});
annotate({
action: {
name: "practitioner.contact.update",
entity_type: "practitioner",
entity_id: id,
},
});
return apiSuccess(toPractitionerDTO(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.practitioner.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Practitioner not found", 404);
}
// Already tombstoned: deleting twice is a no-op that still succeeds.
if (existing.deletedAt === null) {
await prisma.practitioner.update({
where: { id },
data: { deletedAt: new Date() },
});
}
await auditLog("practitioner.contact.delete", {
userId: user.id,
ipAddress: getClientIp(request),
// Soft delete: the row and its name survive, so the id is enough to
// reconstruct what went missing from the list.
details: { practitionerId: id, name: existing.name },
});
annotate({
action: {
name: "practitioner.contact.delete",
entity_type: "practitioner",
entity_id: id,
},
});
return apiSuccess({ deleted: true });
},
);
+123
View File
@@ -0,0 +1,123 @@
/**
* `GET /api/practitioners` — the account's own address book of doctors and
* practices, name-ordered and searchable.
* `POST /api/practitioners` — add one.
*
* No module gate: an address book is core, like the checkups page the visits
* that reference it live on. The reasoning is written at the `Practitioner`
* model in `prisma/schema.prisma`.
*
* `userId` is narrowed from auth and fed to the Prisma `where`; it is never a
* body field. The `data` object is built field-by-field, never spread from the
* parsed body. `note` is encrypted at rest; `name` stays plaintext so the
* picker can search and sort it in SQL.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
practitionerCreateSchema,
practitionerListQuerySchema,
} from "@/lib/validations/practitioners";
import { toPractitionerDTO } from "@/lib/practitioners/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = practitionerListQuerySchema.safeParse({
q: params.get("q") ?? undefined,
limit: params.get("limit") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "practitioner.invalid",
});
}
const rows = await prisma.practitioner.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.q
? { name: { contains: parsed.data.q, mode: "insensitive" as const } }
: {}),
},
orderBy: { name: "asc" },
take: parsed.data.limit ?? 100,
});
annotate({
action: { name: "practitioner.contact.list", entity_type: "practitioner" },
meta: { count: rows.length },
});
return apiSuccess(rows.map(toPractitionerDTO));
});
// A double-tap on "save" must not mint a second copy of the same practice.
export const POST = apiHandler(
withIdempotency<[NextRequest]>(postPractitioner),
);
async function postPractitioner(request: NextRequest): Promise<Response> {
const { user } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = practitionerCreateSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "practitioner.contact.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "practitioner.invalid",
});
}
const entry = parsed.data;
// Field-by-field — never spread the parsed object whole.
const created = await prisma.practitioner.create({
data: {
userId: user.id,
name: entry.name,
specialty: entry.specialty ?? null,
practice: entry.practice ?? null,
location: entry.location ?? null,
phone: entry.phone ?? null,
noteEncrypted: entry.note ? encryptToBytes(entry.note) : null,
},
});
await auditLog("practitioner.contact.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { practitionerId: created.id },
});
annotate({
action: {
name: "practitioner.contact.create",
entity_type: "practitioner",
entity_id: created.id,
},
});
return apiSuccess(toPractitionerDTO(created), 201);
}
+7
View File
@@ -364,10 +364,17 @@ export async function loadDailyDigest(
orderBy: { integration: "asc" },
}),
prisma.measurementReminder.findMany({
// A booked visit's one-shot reminder rides this same engine with
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
// Vorsorge surface, or the list fills with appointments. Four read
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
// proves every one of them, and the DTO mapper refuses such a row
// outright so a site that lost its filter fails loudly.
where: {
userId: user.id,
enabled: true,
deletedAt: null,
origin: { not: "ENCOUNTER" },
nextDueAt: { not: null, lte: now },
},
select: { label: true },
+179
View File
@@ -0,0 +1,179 @@
/**
* A booked visit's reminder — one row on the EXISTING engine, not a second one.
*
* A planned encounter mints exactly one `MeasurementReminder` with
* `origin = ENCOUNTER`. No new queue, no new cron, no new notification event
* type, no new preference key: the enum's own docblock states the rule, and an
* appointment is a reminder with a date like every other reminder with a date.
*
* ── Why the row is shaped the way it is ─────────────────────────────────────
*
* `measurementType` is NULL, which is the engine's free-text arm: there is no
* measurement that could satisfy "you have an appointment", so it resolves only
* on an explicit satisfy. `intervalDays` and `rrule` are both NULL, which the
* schema documents as a one-shot anchored on `anchorDate`.
*
* That last part carries a consequence worth stating rather than discovering:
* `computeReminderNextDueAt` returns NULL when there is no cadence, so a
* one-shot's first `nextDueAt` cannot come from it and is stamped here
* directly. The same fact is what makes the never-nag property STRUCTURAL —
* after the cron fires the row it calls that function to advance the slot, gets
* NULL, and the reminder can never fire again. There is no guard enforcing
* this and there should not be one; it is a property of having no cadence.
*
* ── One row per visit, ever ─────────────────────────────────────────────────
*
* Rescheduling re-anchors the existing row. It never mints a second, which is
* why `Encounter.reminderId` is a single column and not a list. Deliberately NO
* partial unique index enforces this: the COACH one is `WHERE origin = 'COACH'`
* so ENCOUNTER rows do not inherit it, and a similar index here would break
* anyone who books two appointments.
*/
import type { Prisma } from "@/generated/prisma/client";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
/** What the reminder needs to know about the visit that owns it. */
export interface AppointmentReminderInput {
userId: string;
occurredAt: Date;
/** Used as the label when present; the kind label is the fallback. */
practitionerName: string | null;
/** Copied onto the reminder's free-text `location`. */
practitionerLocation: string | null;
/** The fallback label when the visit names no practice. */
kindLabel: string;
}
/** The local hour the nudge fires, read off the appointment in the user's zone. */
export function appointmentNotifyHour(
occurredAt: Date,
timezone: string,
): number {
const hour = new Intl.DateTimeFormat("en-GB", {
timeZone: timezone,
hour: "2-digit",
hour12: false,
}).format(occurredAt);
const parsed = Number.parseInt(hour, 10);
// `24` is a legal formatter output for midnight in some locales; the column
// is 0–23 and midnight is 0.
if (!Number.isFinite(parsed)) return 9;
return parsed % 24;
}
function reminderLabel(input: AppointmentReminderInput): string {
return input.practitionerName?.trim() || input.kindLabel;
}
/**
* Mint the one reminder a planned visit owns, inside the caller's transaction.
*
* Returns the row's id for `Encounter.reminderId`.
*/
export async function mintAppointmentReminder(
tx: Prisma.TransactionClient,
input: AppointmentReminderInput,
timezone: string,
): Promise<string> {
const created = await tx.measurementReminder.create({
data: {
userId: input.userId,
label: reminderLabel(input),
// The free-text arm: nothing a person could measure satisfies this.
measurementType: null,
// Both NULL — a one-shot anchored on `anchorDate`.
intervalDays: null,
rrule: null,
anchorDate: input.occurredAt,
origin: "ENCOUNTER",
notifyHour: appointmentNotifyHour(input.occurredAt, timezone),
location: input.practitionerLocation,
// Stamped directly: see the note at the top of this file on why the
// cadence helper cannot produce a one-shot's first slot.
nextDueAt: input.occurredAt,
},
select: { id: true },
});
return created.id;
}
/**
* Move an existing appointment reminder to a new instant.
*
* Re-anchors and re-stamps the same row rather than minting a second, and
* re-enables it: a visit that was cancelled and then rebooked should nudge
* again, which is the whole reason the person moved the date.
*/
export async function reanchorAppointmentReminder(
tx: Prisma.TransactionClient,
reminderId: string,
input: AppointmentReminderInput,
timezone: string,
): Promise<void> {
await tx.measurementReminder.update({
where: { id: reminderId },
data: {
label: reminderLabel(input),
anchorDate: input.occurredAt,
notifyHour: appointmentNotifyHour(input.occurredAt, timezone),
location: input.practitionerLocation,
nextDueAt: input.occurredAt,
enabled: true,
deletedAt: null,
},
});
}
/**
* Stop an appointment reminder without deleting it.
*
* A cancelled or missed appointment keeps its row so the history stays honest,
* but must never nudge again. `enabled: false` is the engine's own master
* switch and is distinct from the tombstone.
*/
export async function disableAppointmentReminder(
tx: Prisma.TransactionClient,
reminderId: string,
): Promise<void> {
await tx.measurementReminder.update({
where: { id: reminderId },
data: { enabled: false, nextDueAt: null },
});
}
/** Soft-delete the reminder alongside the visit that owns it. */
export async function deleteAppointmentReminder(
tx: Prisma.TransactionClient,
reminderId: string,
at: Date,
): Promise<void> {
await tx.measurementReminder.update({
where: { id: reminderId },
data: { deletedAt: at, enabled: false, nextDueAt: null },
});
}
/**
* What a one-shot appointment reminder's next slot becomes once it has fired.
*
* Exported for the test that proves the never-nag property rather than
* asserting it in prose: the answer must be NULL, and it must come from the
* same function the cron uses.
*/
export function appointmentNextDueAfterFiring(
reminder: Pick<
ReminderScheduleInput,
"intervalDays" | "rrule" | "anchorDate" | "notifyHour" | "createdAt"
>,
timezone: string,
now: Date,
): Date | null {
return computeReminderNextDueAt(
{ ...reminder, lastSatisfiedAt: null },
timezone,
now,
);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Encounter row → wire DTO.
*
* Everything is published resolved. The practitioner comes back as an object
* and never as a bare id, the links come back with a label and a date, and the
* appointment's next-due instant comes back computed. A client that had to
* re-derive any of them would drift from the server the first time one of the
* derivations changed on one side only.
*
* The two free-text columns are `Bytes` ciphertext and decrypt fail-soft: a
* key-rotation gap on one row reads as a missing reason, not as a 500 on the
* whole list.
*/
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { getEvent } from "@/lib/logging/context";
import type {
Encounter,
EncounterKind,
EncounterStatus,
MeasurementReminder,
Practitioner,
} from "@/generated/prisma/client";
import type { LinkedTarget } from "@/lib/links";
import {
toPractitionerDTO,
type PractitionerDTO,
} from "@/lib/practitioners/dto";
/** The three link families a visit can carry, each already resolved. */
export interface EncounterLinksDTO {
documents: LinkedTarget[];
labResults: LinkedTarget[];
conditions: LinkedTarget[];
}
export interface EncounterDTO {
id: string;
occurredAt: string;
status: EncounterStatus;
kind: EncounterKind;
/** Resolved, never just an id. `null` when the visit names no practice. */
practitioner: PractitionerDTO | null;
reason: string | null;
outcome: string | null;
/**
* The server-computed instant the appointment reminder next fires, when this
* visit minted one and it has not fired yet. `null` for a visit that already
* happened, or once the one-shot reminder is spent.
*/
reminderNextDueAt: string | null;
/** Present on the detail response; omitted from the list. */
links?: EncounterLinksDTO;
createdAt: string;
updatedAt: string;
}
function decryptField(
value: Uint8Array | null,
field: "reason" | "outcome",
): string | null {
if (!value || value.byteLength === 0) return null;
try {
return decryptFromBytes(value);
} catch (err) {
getEvent()?.addWarning(
`encounter ${field} decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
export type EncounterWithRelations = Encounter & {
practitioner?: Practitioner | null;
reminder?: Pick<MeasurementReminder, "nextDueAt"> | null;
};
export function toEncounterDTO(
row: EncounterWithRelations,
links?: EncounterLinksDTO,
): EncounterDTO {
return {
id: row.id,
occurredAt: row.occurredAt.toISOString(),
status: row.status,
kind: row.kind,
practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null,
reason: decryptField(row.reasonEncrypted, "reason"),
outcome: decryptField(row.outcomeEncrypted, "outcome"),
reminderNextDueAt: row.reminder?.nextDueAt?.toISOString() ?? null,
...(links ? { links } : {}),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* The list response.
*
* Two arrays rather than one sorted list, resolved server-side: "what is
* coming up" and "what happened" are read in opposite directions, and making
* the client split and re-sort one array would put that decision in two
* places.
*/
export interface EncounterListDTO {
upcoming: EncounterDTO[];
past: EncounterDTO[];
}
+257
View File
@@ -0,0 +1,257 @@
/**
* The work the encounter routes share: resolving a visit, wiring its links,
* and keeping its appointment reminder in step with its status.
*
* Lives beside the routes rather than inside one of them because create and
* edit both do most of it, and a copy in each is how the two drift.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
satisfyReminder,
type SatisfiableReminder,
} from "@/lib/measurement-reminders/satisfy";
import { listTargets, replaceTargets, type LinkTargetKind } from "@/lib/links";
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
import {
deleteAppointmentReminder,
disableAppointmentReminder,
mintAppointmentReminder,
reanchorAppointmentReminder,
type AppointmentReminderInput,
} from "@/lib/encounters/appointment-reminder";
const DEFAULT_TIMEZONE = "Europe/Berlin";
export async function resolveTimezone(userId: string): Promise<string> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
return row?.timezone || DEFAULT_TIMEZONE;
}
/** The relations the detail and list responses resolve. */
export const ENCOUNTER_INCLUDE = {
practitioner: true,
reminder: { select: { nextDueAt: true } },
} as const satisfies Prisma.EncounterInclude;
/** The three link families, resolved for one visit. */
export async function loadEncounterLinks(
tx: Prisma.TransactionClient,
userId: string,
encounterId: string,
): Promise<EncounterLinksDTO> {
const read = (targetKind: LinkTargetKind) =>
listTargets(tx, {
userId,
sourceKind: "encounter",
sourceId: encounterId,
targetKind,
});
const [documents, labResults, conditions] = await Promise.all([
read("document"),
read("labResult"),
read("conditionEpisode"),
]);
return { documents, labResults, conditions };
}
/**
* Apply whichever of the three link arrays the request actually named.
*
* An absent array leaves that family alone; a present one replaces it, empty
* array included. Unknown ids are dropped by the link service and never block
* the save — a visit is never refused because one document it pointed at has
* since been deleted.
*/
export async function applyEncounterLinks(
tx: Prisma.TransactionClient,
userId: string,
encounterId: string,
arrays: {
documentIds?: string[];
labResultIds?: string[];
episodeIds?: string[];
},
): Promise<void> {
const families: Array<[LinkTargetKind, string[] | undefined]> = [
["document", arrays.documentIds],
["labResult", arrays.labResultIds],
["conditionEpisode", arrays.episodeIds],
];
for (const [targetKind, targetIds] of families) {
if (targetIds === undefined) continue;
await replaceTargets(tx, {
userId,
sourceKind: "encounter",
sourceId: encounterId,
targetKind,
targetIds,
});
}
}
/**
* Whether a visit in this state, at this instant, should be nudging.
*
* Only a PLANNED visit in the future does. A visit that already happened needs
* no reminder, and a terminal one must not produce a nudge for an appointment
* that is not going to occur.
*/
export function shouldHaveReminder(
status: string,
occurredAt: Date,
now: Date,
): boolean {
return status === "PLANNED" && occurredAt.getTime() > now.getTime();
}
export interface ReminderSyncInput extends AppointmentReminderInput {
status: string;
/**
* The APPOINTMENT reminder this visit already owns, or null.
*
* Never a checkup the visit closes. `Encounter.reminderId` carries either
* one, and the two are kept apart by their origin: an appointment reminder
* is `ENCOUNTER`, a checkup is not. Confusing them would re-anchor a
* person's own Vorsorge cadence onto an appointment date, which is why the
* routes resolve the origin before calling this.
*/
existingReminderId: string | null;
}
/**
* Bring the visit's appointment reminder in line with the visit.
*
* Returns the id to store on `Encounter.reminderId` — the existing one, a
* freshly minted one, or the existing one left in place but switched off.
* Never mints a second row for a visit that already has one.
*/
export async function syncAppointmentReminder(
tx: Prisma.TransactionClient,
input: ReminderSyncInput,
timezone: string,
now: Date,
): Promise<string | null> {
const wanted = shouldHaveReminder(input.status, input.occurredAt, now);
if (!wanted) {
// The row stays — the history of a cancelled appointment is worth keeping —
// but it stops nudging.
if (input.existingReminderId) {
await disableAppointmentReminder(tx, input.existingReminderId);
}
return input.existingReminderId;
}
if (input.existingReminderId) {
await reanchorAppointmentReminder(
tx,
input.existingReminderId,
input,
timezone,
);
return input.existingReminderId;
}
return mintAppointmentReminder(tx, input, timezone);
}
/**
* The checkup a visit says it closes, re-narrowed to the caller.
*
* Refuses an `ENCOUNTER`-origin row outright. Those belong to a booked visit
* and are not a checkup anybody can close by filing one; accepting it here
* would let a client re-point one visit's appointment reminder at another
* visit and re-anchor it on the wrong date.
*
* Returns `undefined` when the id names nothing closeable.
*/
export async function resolveClosableReminder(
tx: Prisma.TransactionClient,
userId: string,
reminderId: string,
): Promise<SatisfiableReminder | undefined> {
const row = await tx.measurementReminder.findUnique({
where: { id: reminderId },
select: {
id: true,
userId: true,
origin: true,
deletedAt: true,
intervalDays: true,
rrule: true,
anchorDate: true,
notifyHour: true,
lastSatisfiedAt: true,
createdAt: true,
},
});
if (!row || row.userId !== userId || row.deletedAt !== null) return undefined;
if (row.origin === "ENCOUNTER") return undefined;
return {
id: row.id,
intervalDays: row.intervalDays,
rrule: row.rrule,
anchorDate: row.anchorDate,
notifyHour: row.notifyHour,
lastSatisfiedAt: row.lastSatisfiedAt,
createdAt: row.createdAt,
};
}
/**
* Close the checkup a completed visit was filed against.
*
* `satisfyReminder` is the one shared primitive the cron, the ingest worker
* and the explicit user action all route through, and it is forward-only and
* idempotent — so filing the same visit as DONE twice is a no-op that still
* succeeds. That is the whole of "the appointment closes the checkup"; there
* is no second mechanism.
*/
export async function closeCheckupForVisit(
tx: Prisma.TransactionClient,
reminder: SatisfiableReminder,
timezone: string,
now: Date,
): Promise<void> {
await satisfyReminder(tx as unknown as PrismaClient, reminder, timezone, now);
}
/** Soft-delete a visit's reminder alongside the visit. */
export async function retireAppointmentReminder(
tx: Prisma.TransactionClient,
reminderId: string | null,
at: Date,
): Promise<void> {
if (!reminderId) return;
await deleteAppointmentReminder(tx, reminderId, at);
}
/**
* The practitioner a visit names, re-narrowed to the caller.
*
* Returns `undefined` when the id names nothing this account owns, which the
* routes turn into a 404 — the same refusal shape a foreign encounter id gets.
*/
export async function resolveOwnedPractitioner(
tx: Prisma.TransactionClient,
userId: string,
practitionerId: string,
): Promise<{ id: string; name: string; location: string | null } | undefined> {
const row = await tx.practitioner.findUnique({
where: { id: practitionerId },
select: {
id: true,
name: true,
location: true,
userId: true,
deletedAt: true,
},
});
if (!row || row.userId !== userId || row.deletedAt !== null) return undefined;
return { id: row.id, name: row.name, location: row.location };
}
+7
View File
@@ -271,10 +271,17 @@ export async function readPreventiveCareBlock(
): Promise<AggregatedFeatures["preventiveCare"] | undefined> {
const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY);
const rows = await prisma.measurementReminder.findMany({
// A booked visit's one-shot reminder rides this same engine with
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
// Vorsorge surface, or the list fills with appointments. Four read
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
// proves every one of them, and the DTO mapper refuses such a row
// outright so a site that lost its filter fails loudly.
where: {
userId,
deletedAt: null,
enabled: true,
origin: { not: "ENCOUNTER" },
nextDueAt: { not: null, lte: horizon },
},
orderBy: { nextDueAt: "asc" },
+8 -1
View File
@@ -667,7 +667,14 @@ describe("get_preventive_care", () => {
};
expect(prisma.measurementReminder.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null, enabled: true },
where: {
userId: "user-1",
deletedAt: null,
enabled: true,
// A booked visit's reminder rides the same engine and is not a
// checkup; the tool must not offer one as preventive care.
origin: { not: "ENCOUNTER" },
},
}),
);
expect(result.present).toBe(true);
+12 -1
View File
@@ -1552,7 +1552,18 @@ export const MCP_TOOLS: McpToolDefinition[] = [
outputShape: getPreventiveCareOutput,
async run(ctx) {
const reminders = await prisma.measurementReminder.findMany({
where: { userId: ctx.userId, deletedAt: null, enabled: true },
// A booked visit's one-shot reminder rides this same engine with
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
// Vorsorge surface, or the list fills with appointments. Four read
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
// proves every one of them, and the DTO mapper refuses such a row
// outright so a site that lost its filter fails loudly.
where: {
userId: ctx.userId,
deletedAt: null,
enabled: true,
origin: { not: "ENCOUNTER" },
},
// Most-urgent first; a null next-due (uncomputable) sinks to the end.
orderBy: [
{ nextDueAt: { sort: "asc", nulls: "last" } },
+54
View File
@@ -0,0 +1,54 @@
/**
* Practitioner row → wire DTO.
*
* Server-authoritative: the client renders these values, it never rebuilds
* them. The free-text note lives in `noteEncrypted` (Bytes) and is decrypted
* fail-soft on read, so a key-rotation gap on one row reads as a missing note
* rather than 500-ing the address book.
*/
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { getEvent } from "@/lib/logging/context";
import type { Practitioner } from "@/generated/prisma/client";
export interface PractitionerDTO {
id: string;
name: string;
specialty: string | null;
practice: string | null;
location: string | null;
phone: string | null;
note: string | null;
createdAt: string;
updatedAt: string;
}
/** Decrypt a Bytes note fail-soft (null on missing / undecryptable). */
export function decryptPractitionerNote(
noteEncrypted: Uint8Array | null,
): string | null {
if (!noteEncrypted || noteEncrypted.byteLength === 0) return null;
try {
return decryptFromBytes(noteEncrypted);
} catch (err) {
getEvent()?.addWarning(
`practitioner note decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
export function toPractitionerDTO(row: Practitioner): PractitionerDTO {
return {
id: row.id,
name: row.name,
specialty: row.specialty,
practice: row.practice,
location: row.location,
phone: row.phone,
note: decryptPractitionerNote(row.noteEncrypted),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
+25
View File
@@ -72,6 +72,12 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.familyHistoryCreate", { name });
case "illness.episode.create":
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
case "encounter.visit.create":
return t("recordSharing.activityVerb.encounterVisitCreate", { name });
case "practitioner.contact.create":
return t("recordSharing.activityVerb.practitionerContactCreate", {
name,
});
case "biomarker.create":
return t("recordSharing.activityVerb.biomarkerCreate", { name });
case "customMetricEntry.create":
@@ -175,6 +181,25 @@ export function recordActivityVerbLine(
case "illness.day-log.upsert":
return t("recordSharing.activityVerb.illnessDayLogUpsert", { name });
case "encounter.visit.update":
return t("recordSharing.activityVerb.encounterVisitUpdate", { name });
case "encounter.visit.delete":
return t("recordSharing.activityVerb.encounterVisitDelete", { name });
case "encounter.visit.restore":
return t("recordSharing.activityVerb.encounterVisitRestore", { name });
case "practitioner.contact.update":
return t("recordSharing.activityVerb.practitionerContactUpdate", {
name,
});
case "practitioner.contact.delete":
return t("recordSharing.activityVerb.practitionerContactDelete", {
name,
});
case "practitioner.contact.restore":
return t("recordSharing.activityVerb.practitionerContactRestore", {
name,
});
case "medication.update":
return t("recordSharing.activityVerb.medicationUpdate", { name });
case "medication.delete":
+166
View File
@@ -0,0 +1,166 @@
/**
* Visit (encounter) request/response validation.
*
* Source of truth for the `/api/encounters*` wire contract; the OpenAPI
* registry reuses these schemas so the spec stays single-source. `userId` is
* NEVER a body field — it is narrowed from the session or Bearer in every
* route and fed to the Prisma `where`.
*
* Nothing here is mandatory except the date. A visit saves with an instant and
* nothing else: no practitioner, no kind, no reason, no link. That is a
* deliberate product constraint and not an oversight — a required field on a
* form somebody fills after a doctor's appointment is the bureaucracy this
* whole feature exists to avoid.
*
* The one exception to "no future instants" that the rest of the record keeps:
* `occurredAt` may be in the future, because a booked appointment IS a visit
* with a future date. It is bounded on both sides regardless, so a typo cannot
* park a row a century out.
*/
import { z } from "zod/v4";
/** Ten years out. Long enough for any real appointment, short enough that a
* mistyped year lands as a 422 rather than as a row nobody sees again. */
const MAX_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000;
/**
* An instant a visit can plausibly carry.
*
* Deliberately NOT `isPlausibleEntryInstant`, which refuses anything beyond a
* five-minute clock skew. That bound is right for a measurement, which can only
* describe something that already happened, and wrong here.
*/
const visitInstant = z.iso.datetime({ offset: true }).refine(
(value) => {
const at = new Date(value).getTime();
if (Number.isNaN(at)) return false;
if (at < Date.UTC(1900, 0, 1)) return false;
return at <= Date.now() + MAX_FUTURE_MS;
},
{
message:
"must be a plausible visit instant (not before 1900, not more than ten years ahead)",
},
);
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
export const encounterStatusEnum = z.enum([
"PLANNED",
"DONE",
"CANCELLED",
"NO_SHOW",
]);
export const encounterKindEnum = z.enum([
"ROUTINE",
"ACUTE",
"SPECIALIST",
"PREVENTIVE",
"EMERGENCY",
"HOSPITAL",
"THERAPY",
"OTHER",
]);
export type EncounterStatusInput = z.infer<typeof encounterStatusEnum>;
export type EncounterKindInput = z.infer<typeof encounterKindEnum>;
const id = z.string().min(1).max(40);
/**
* The three optional pre-link arrays.
*
* Bounded at the link service's own cap so an over-long list is a 422 with a
* field path rather than a thrown error from two layers down.
*/
const linkIds = z.array(id).max(100).optional();
/* ── encounter CRUD ───────────────────────────────────────────────── */
export const encounterCreateSchema = z
.object({
occurredAt: visitInstant,
status: encounterStatusEnum.optional().default("DONE"),
kind: encounterKindEnum.optional().default("OTHER"),
practitionerId: id.nullable().optional(),
/** Becomes `reasonEncrypted`. */
reason: z.string().max(2000).nullable().optional(),
/** Becomes `outcomeEncrypted`. */
outcome: z.string().max(4000).nullable().optional(),
/** The checkup this visit closes, when it was filed from a due one. */
reminderId: id.nullable().optional(),
documentIds: linkIds,
labResultIds: linkIds,
episodeIds: linkIds,
})
.strict();
export type EncounterCreate = z.infer<typeof encounterCreateSchema>;
/**
* Edit a visit. Every field optional; an omitted field is left untouched.
*
* A body naming nothing is a 422 rather than a silent success: a PATCH that
* changed nothing and answered 200 is indistinguishable from one that worked,
* which is how a client bug survives a release.
*/
export const encounterUpdateSchema = z
.object({
occurredAt: visitInstant.optional(),
status: encounterStatusEnum.optional(),
kind: encounterKindEnum.optional(),
practitionerId: id.nullable().optional(),
reason: z.string().max(2000).nullable().optional(),
outcome: z.string().max(4000).nullable().optional(),
reminderId: id.nullable().optional(),
documentIds: linkIds,
labResultIds: linkIds,
episodeIds: linkIds,
})
.strict()
.refine((body) => Object.keys(body).length > 0, {
message: "name at least one field to change",
});
export type EncounterUpdate = z.infer<typeof encounterUpdateSchema>;
/**
* List query.
*
* Windowed and bounded. The response splits past from upcoming server-side, so
* there is no `order` parameter: the client renders two lists rather than
* sorting one.
*/
export const encounterListQuerySchema = z
.object({
from: z.iso.datetime({ offset: true }).optional(),
to: z.iso.datetime({ offset: true }).optional(),
status: encounterStatusEnum.optional(),
practitionerId: id.optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
})
.strict()
.refine((q) => !(q.from && q.to) || q.from <= q.to, {
path: ["to"],
message: "to must be on or after from",
});
export type EncounterListQuery = z.infer<typeof encounterListQuerySchema>;
/* ── links ────────────────────────────────────────────────────────── */
export const encounterLinkTargetEnum = z.enum([
"document",
"labResult",
"conditionEpisode",
]);
export const encounterLinkSchema = z
.object({
targetKind: encounterLinkTargetEnum,
targetIds: z.array(id).min(1).max(100),
})
.strict();
export type EncounterLinkInput = z.infer<typeof encounterLinkSchema>;
+56
View File
@@ -0,0 +1,56 @@
/**
* Practitioner (address-book) request/response validation.
*
* Source of truth for the `/api/practitioners*` wire contract; the OpenAPI
* registry reuses these schemas. `userId` is NEVER a body field.
*
* `name` is the only required field, and it is plaintext rather than encrypted
* so the picker can search and sort it in SQL — the same tradeoff the document
* title makes, stated there too. `note` is the encrypted one.
*/
import { z } from "zod/v4";
export const practitionerCreateSchema = z
.object({
name: z.string().min(1).max(160),
/** Free text, not an enum: practice signage does not fit a closed list. */
specialty: z.string().max(120).nullable().optional(),
practice: z.string().max(160).nullable().optional(),
location: z.string().max(300).nullable().optional(),
phone: z.string().max(60).nullable().optional(),
/** Becomes `noteEncrypted`. */
note: z.string().max(2000).nullable().optional(),
})
.strict();
export type PractitionerCreate = z.infer<typeof practitionerCreateSchema>;
/**
* Edit a practitioner. A body naming nothing is a 422, for the same reason as
* the visit edit: a no-op that answers 200 hides a client defect.
*/
export const practitionerUpdateSchema = z
.object({
name: z.string().min(1).max(160).optional(),
specialty: z.string().max(120).nullable().optional(),
practice: z.string().max(160).nullable().optional(),
location: z.string().max(300).nullable().optional(),
phone: z.string().max(60).nullable().optional(),
note: z.string().max(2000).nullable().optional(),
})
.strict()
.refine((body) => Object.keys(body).length > 0, {
message: "name at least one field to change",
});
export type PractitionerUpdate = z.infer<typeof practitionerUpdateSchema>;
/** List query — a substring search over the plaintext name, bounded. */
export const practitionerListQuerySchema = z
.object({
q: z.string().min(1).max(160).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
})
.strict();
export type PractitionerListQuery = z.infer<typeof practitionerListQuerySchema>;
+64
View File
@@ -120,6 +120,22 @@ export const ADMITTED_MUTATING_HANDLERS = [
"write",
"create",
),
handler(
"app/api/encounters/route.ts",
"/api/encounters",
"POST",
"profile",
"write",
"create",
),
handler(
"app/api/practitioners/route.ts",
"/api/practitioners",
"POST",
"profile",
"write",
"create",
),
handler(
"app/api/medications/[id]/side-effects/route.ts",
"/api/medications/[id]/side-effects",
@@ -336,6 +352,54 @@ export const ADMITTED_MUTATING_HANDLERS = [
"manage",
"restore",
),
handler(
"app/api/encounters/[id]/route.ts",
"/api/encounters/[id]",
"PATCH",
"profile",
"manage",
"update",
),
handler(
"app/api/encounters/[id]/route.ts",
"/api/encounters/[id]",
"DELETE",
"profile",
"manage",
"delete",
),
handler(
"app/api/encounters/[id]/restore/route.ts",
"/api/encounters/[id]/restore",
"POST",
"profile",
"manage",
"restore",
),
handler(
"app/api/practitioners/[id]/route.ts",
"/api/practitioners/[id]",
"PATCH",
"profile",
"manage",
"update",
),
handler(
"app/api/practitioners/[id]/route.ts",
"/api/practitioners/[id]",
"DELETE",
"profile",
"manage",
"delete",
),
handler(
"app/api/practitioners/[id]/restore/route.ts",
"/api/practitioners/[id]/restore",
"POST",
"profile",
"manage",
"restore",
),
handler(
"app/api/illness/episodes/[id]/day-logs/route.ts",
"/api/illness/episodes/[id]/day-logs",
@@ -0,0 +1,356 @@
/**
* The appointment arm against real Postgres, through the real routes.
*
* Every property here is about ROWS — how many exist, which instant they carry,
* whether a second call wrote anything — and none of them can be answered by a
* mocked client. The one that matters most is a count: a reschedule must leave
* the reminder count at one, and a service that minted a second on every edit
* would pass any assertion phrased as "the reminder moved".
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { POST as createEncounter } from "@/app/api/encounters/route";
import {
PATCH as patchEncounter,
DELETE as deleteEncounter,
} from "@/app/api/encounters/[id]/route";
import { GET as listReminders } from "@/app/api/measurement-reminders/route";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const OWNER_ID = "appointment-owner";
/** Far enough out that the appointment is still in the future mid-run. */
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
async function seedSession() {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: OWNER_ID,
username: "appointment-owner",
email: "appointment-owner@example.test",
timezone: "Europe/Berlin",
},
});
const session = await prisma.session.create({
data: {
userId: OWNER_ID,
expiresAt: daysFromNow(7),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
}
function post(body: unknown): Promise<Response> {
return createEncounter(
new Request("http://localhost/api/encounters", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
);
}
function patch(id: string, body: unknown): Promise<Response> {
return patchEncounter(
new Request(`http://localhost/api/encounters/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
{ params: Promise.resolve({ id }) } as never,
);
}
async function body(res: Response) {
return (await res.json()) as { data: Record<string, unknown> };
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedSession();
});
describe("booking a visit", () => {
it("mints exactly one reminder, anchored on the appointment", async () => {
const prisma = getPrismaClient();
const occurredAt = daysFromNow(14);
const res = await post({
occurredAt: occurredAt.toISOString(),
status: "PLANNED",
kind: "SPECIALIST",
});
expect(res.status).toBe(201);
const reminders = await prisma.measurementReminder.findMany({
where: { userId: OWNER_ID },
});
expect(reminders).toHaveLength(1);
expect(reminders[0]!.origin).toBe("ENCOUNTER");
// Both cadence columns NULL is the schema's one-shot: it fires once and
// the engine can never compute a second occurrence for it.
expect(reminders[0]!.intervalDays).toBeNull();
expect(reminders[0]!.rrule).toBeNull();
expect(reminders[0]!.nextDueAt?.toISOString()).toBe(
occurredAt.toISOString(),
);
const created = (await body(res)).data;
expect(created.reminderNextDueAt).toBe(occurredAt.toISOString());
});
it("does not mint one for a visit that already happened", async () => {
const prisma = getPrismaClient();
const res = await post({
occurredAt: daysFromNow(-3).toISOString(),
status: "DONE",
});
expect(res.status).toBe(201);
expect(await prisma.measurementReminder.count()).toBe(0);
});
it("saves with a date and nothing else", async () => {
// The product constraint, asserted rather than described: no practitioner,
// no kind, no reason, no link, and the visit still files.
const res = await post({ occurredAt: daysFromNow(-1).toISOString() });
expect(res.status).toBe(201);
const created = (await body(res)).data;
expect(created.practitioner).toBeNull();
expect(created.reason).toBeNull();
expect(created.status).toBe("DONE");
});
});
describe("rescheduling", () => {
it("moves the same row and never mints a second", async () => {
const prisma = getPrismaClient();
const created = (
await body(
await post({
occurredAt: daysFromNow(14).toISOString(),
status: "PLANNED",
}),
)
).data;
const moved = daysFromNow(21);
const res = await patch(created.id as string, {
occurredAt: moved.toISOString(),
});
expect(res.status).toBe(200);
const reminders = await prisma.measurementReminder.findMany({
where: { userId: OWNER_ID },
});
// The count is the assertion. "The reminder moved" would also be true of a
// service that left the old row behind and minted a fresh one.
expect(reminders).toHaveLength(1);
expect(reminders[0]!.nextDueAt?.toISOString()).toBe(moved.toISOString());
});
it("stops nudging once the visit is cancelled, without deleting the row", async () => {
const prisma = getPrismaClient();
const created = (
await body(
await post({
occurredAt: daysFromNow(14).toISOString(),
status: "PLANNED",
}),
)
).data;
await patch(created.id as string, { status: "CANCELLED" });
const reminders = await prisma.measurementReminder.findMany({
where: { userId: OWNER_ID },
});
expect(reminders).toHaveLength(1);
expect(reminders[0]!.enabled).toBe(false);
expect(reminders[0]!.nextDueAt).toBeNull();
});
it("retires the reminder when the visit is deleted", async () => {
const prisma = getPrismaClient();
const created = (
await body(
await post({
occurredAt: daysFromNow(14).toISOString(),
status: "PLANNED",
}),
)
).data;
const res = await deleteEncounter(
new Request(`http://localhost/api/encounters/${created.id}`, {
method: "DELETE",
}) as never,
{ params: Promise.resolve({ id: created.id as string }) } as never,
);
expect(res.status).toBe(200);
const reminder = await prisma.measurementReminder.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect(reminder.deletedAt).not.toBeNull();
expect(reminder.nextDueAt).toBeNull();
});
});
describe("closing a checkup by filing a visit", () => {
async function seedCheckup() {
return getPrismaClient().measurementReminder.create({
data: {
userId: OWNER_ID,
label: "Annual blood panel",
intervalDays: 365,
anchorDate: daysFromNow(-370),
notifyHour: 9,
nextDueAt: daysFromNow(-5),
},
});
}
it("advances the checkup when the visit is filed as done", async () => {
const prisma = getPrismaClient();
const checkup = await seedCheckup();
const res = await post({
occurredAt: daysFromNow(-1).toISOString(),
status: "DONE",
reminderId: checkup.id,
});
expect(res.status).toBe(201);
const after = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: checkup.id },
});
expect(after.lastSatisfiedAt).not.toBeNull();
expect(after.nextDueAt!.getTime()).toBeGreaterThan(Date.now());
});
it("is idempotent — filing it done twice changes nothing and still succeeds", async () => {
const prisma = getPrismaClient();
const checkup = await seedCheckup();
const created = (
await body(
await post({
occurredAt: daysFromNow(-1).toISOString(),
status: "DONE",
reminderId: checkup.id,
}),
)
).data;
const first = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: checkup.id },
});
const res = await patch(created.id as string, { status: "DONE" });
expect(res.status).toBe(200);
const second = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: checkup.id },
});
expect(second.lastSatisfiedAt?.toISOString()).toBe(
first.lastSatisfiedAt?.toISOString(),
);
expect(second.nextDueAt?.toISOString()).toBe(
first.nextDueAt?.toISOString(),
);
});
it("refuses a planned visit that also claims to close a checkup", async () => {
const checkup = await seedCheckup();
const res = await post({
occurredAt: daysFromNow(14).toISOString(),
status: "PLANNED",
reminderId: checkup.id,
});
// A visit that has not happened cannot have closed anything.
expect(res.status).toBe(422);
});
it("refuses an appointment reminder as the checkup to close", async () => {
const prisma = getPrismaClient();
await post({
occurredAt: daysFromNow(14).toISOString(),
status: "PLANNED",
});
const appointment = await prisma.measurementReminder.findFirstOrThrow({
where: { userId: OWNER_ID, origin: "ENCOUNTER" },
});
const res = await post({
occurredAt: daysFromNow(-1).toISOString(),
status: "DONE",
reminderId: appointment.id,
});
// Accepting it would let one visit re-point another visit's reminder.
expect(res.status).toBe(404);
});
});
describe("the Vorsorge list", () => {
it("does not show a booked appointment as a checkup", async () => {
const prisma = getPrismaClient();
await prisma.measurementReminder.create({
data: {
userId: OWNER_ID,
label: "Annual blood panel",
intervalDays: 365,
notifyHour: 9,
nextDueAt: daysFromNow(30),
},
});
await post({
occurredAt: daysFromNow(14).toISOString(),
status: "PLANNED",
});
// Two rows exist on the engine; one of them is an appointment.
expect(await prisma.measurementReminder.count()).toBe(2);
const res = await listReminders();
expect(res.status).toBe(200);
const listed = (await res.json()) as {
data: Array<{ label: string; origin: string }>;
};
// Exactly the checkup. Had the exclusion been missing, the mapper would
// have thrown and this would be a 500 rather than a wrong list — which is
// the point of the refusal.
expect(listed.data).toHaveLength(1);
expect(listed.data[0]!.label).toBe("Annual blood panel");
});
});