diff --git a/messages/de.json b/messages/de.json index 5b0e56882..67d889688 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 01de7f3bb..77a28a788 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index ec784cde8..4fbb5a1e2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 9a0f9a1be..635960486 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 6620b9fa7..714993fc1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 82918dd3e..f8c2d28dd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/encounter-reminder-exclusion.test.ts b/src/__tests__/encounter-reminder-exclusion.test.ts new file mode 100644 index 000000000..790e5bd78 --- /dev/null +++ b/src/__tests__/encounter-reminder-exclusion.test.ts @@ -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/, + ); + }); +}); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index f51839ab9..9b35caac1 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -923,6 +923,22 @@ const DELEGABLE_ROUTES: Record = { 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 = { 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 = { "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 = { 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 = { * 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 }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 6575f2228..f18f282b5 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -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( diff --git a/src/app/api/encounters/[id]/links/route.ts b/src/app/api/encounters/[id]/links/route.ts new file mode 100644 index 000000000..a567e657d --- /dev/null +++ b/src/app/api/encounters/[id]/links/route.ts @@ -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 { + 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"), +); diff --git a/src/app/api/encounters/[id]/restore/route.ts b/src/app/api/encounters/[id]/restore/route.ts new file mode 100644 index 000000000..0a2d1dcb3 --- /dev/null +++ b/src/app/api/encounters/[id]/restore/route.ts @@ -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)); + }, +); diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts new file mode 100644 index 000000000..cf7696591 --- /dev/null +++ b/src/app/api/encounters/[id]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts new file mode 100644 index 000000000..2157a70a2 --- /dev/null +++ b/src/app/api/encounters/route.ts @@ -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 { + 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); +} diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 8b22c5721..7e4d0b2e8 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -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: [ diff --git a/src/app/api/practitioners/[id]/restore/route.ts b/src/app/api/practitioners/[id]/restore/route.ts new file mode 100644 index 000000000..8a974ec03 --- /dev/null +++ b/src/app/api/practitioners/[id]/restore/route.ts @@ -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)); + }, +); diff --git a/src/app/api/practitioners/[id]/route.ts b/src/app/api/practitioners/[id]/route.ts new file mode 100644 index 000000000..5d8de3fbb --- /dev/null +++ b/src/app/api/practitioners/[id]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts new file mode 100644 index 000000000..04a495b5a --- /dev/null +++ b/src/app/api/practitioners/route.ts @@ -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 { + 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); +} diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 7c60aa662..bb90bd9dd 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -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 }, diff --git a/src/lib/encounters/appointment-reminder.ts b/src/lib/encounters/appointment-reminder.ts new file mode 100644 index 000000000..79a1c4cf9 --- /dev/null +++ b/src/lib/encounters/appointment-reminder.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + ); +} diff --git a/src/lib/encounters/dto.ts b/src/lib/encounters/dto.ts new file mode 100644 index 000000000..608de0763 --- /dev/null +++ b/src/lib/encounters/dto.ts @@ -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 | 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[]; +} diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts new file mode 100644 index 000000000..60c06b6bd --- /dev/null +++ b/src/lib/encounters/service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index ba63d860c..062d5b5ef 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -271,10 +271,17 @@ export async function readPreventiveCareBlock( ): Promise { 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" }, diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 358b6ac1d..52d635481 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -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); diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index b0d9ee897..e8e886fa4 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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" } }, diff --git a/src/lib/practitioners/dto.ts b/src/lib/practitioners/dto.ts new file mode 100644 index 000000000..d20e35560 --- /dev/null +++ b/src/lib/practitioners/dto.ts @@ -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(), + }; +} diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 3d4b1f334..e64890544 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -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": diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts new file mode 100644 index 000000000..2217b53be --- /dev/null +++ b/src/lib/validations/encounters.ts @@ -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; +export type EncounterKindInput = z.infer; + +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; + +/** + * 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; + +/** + * 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; + +/* ── 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; diff --git a/src/lib/validations/practitioners.ts b/src/lib/validations/practitioners.ts new file mode 100644 index 000000000..3bfc283d1 --- /dev/null +++ b/src/lib/validations/practitioners.ts @@ -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; + +/** + * 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; + +/** 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; diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 5c8b566c9..929e2f76e 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -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", diff --git a/tests/integration/encounter-appointment-reminder.test.ts b/tests/integration/encounter-appointment-reminder.test.ts new file mode 100644 index 000000000..31bece0fa --- /dev/null +++ b/tests/integration/encounter-appointment-reminder.test.ts @@ -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 { + 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 { + 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 }; +} + +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"); + }); +});