diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e452d37df..3fead6c78 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -34495,6 +34495,8 @@ components: - type: "null" links: $ref: "#/components/schemas/EncounterLinks" + skipped: + $ref: "#/components/schemas/EncounterSkipReport" createdAt: type: string updatedAt: @@ -34587,19 +34589,59 @@ components: id: type: string label: - type: string + anyOf: + - type: string + - type: "null" date: anyOf: - type: string - type: "null" + redacted: + type: boolean required: - id - label - date + - redacted additionalProperties: false - description: One thing filed against a visit, resolved. The label and date are computed server-side from the target + description: "One thing filed against a visit, resolved. The label and date are computed server-side from the target itself — a document's title and filing date, a lab result's panel and analyte and draw date, a condition's label - and onset — so the client never re-derives them and the two cannot drift. + and onset — so the client never re-derives them and the two cannot drift. Both go null together, with `redacted: + true`, when the caller's grant does not cover the target's own domain: a visit lives in the health background + but points at the vault, the labs and the illness journal, and a grant that opened only the first was never + consent for the names in the other three. The link itself is still listed — the caller may know the visit + produced a lab result — and `redacted` is what tells a withheld name apart from a target that simply has no + date." + EncounterSkipReport: + type: object + properties: + catalogueKeys: + type: array + items: + type: object + properties: + catalogue: + type: string + key: + type: string + links: + type: number + required: + - catalogue + - key + - links + additionalProperties: false + links: + type: number + required: + - catalogueKeys + - links + additionalProperties: false + description: "What the write could not do, named. `links: 0` with an empty list is the ordinary answer. Anything else is + a side effect the caller's grant did not reach — today only `checkupClosure`, the closing of a preventive-care + checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still + saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will + not look at it again." CreateEncounterEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index fa01dd9b0..b7b0667fd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6740,6 +6740,7 @@ "restoreSkippedMoodFactor": "Stimmungsfaktor", "restoreSkippedMoodTag": "Stimmungs-Tag", "restoreSkippedVisitReference": "Besuchsverknüpfung", + "restoreSkippedCheckupClosure": "Vorsorge-Abschluss", "docsLink": "Dokumentation" }, "danger-zone": { @@ -9928,5 +9929,17 @@ "anamnesis": { "relativeNote": "Ein Eintrag zur Familienanamnese beschreibt die Angehörigen dieser Person. Trage ihn nur ein, wenn sie das hier festgehalten haben möchte." } + }, + "encounters": { + "kind": { + "routine": "Routinetermin", + "acute": "Akuttermin", + "specialist": "Facharzttermin", + "preventive": "Vorsorgetermin", + "emergency": "Notfall", + "hospital": "Krankenhaus", + "therapy": "Therapietermin", + "other": "Arzttermin" + } } } diff --git a/messages/en.json b/messages/en.json index 8fbdeebae..3e88f213b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6740,6 +6740,7 @@ "restoreSkippedMoodFactor": "Mood factor", "restoreSkippedMoodTag": "Mood tag", "restoreSkippedVisitReference": "Visit reference", + "restoreSkippedCheckupClosure": "Checkup closure", "docsLink": "Documentation" }, "danger-zone": { @@ -9928,5 +9929,17 @@ "anamnesis": { "relativeNote": "A family history entry describes this person's relatives. Add it only when they would want it recorded here." } + }, + "encounters": { + "kind": { + "routine": "Routine visit", + "acute": "Acute visit", + "specialist": "Specialist visit", + "preventive": "Preventive check", + "emergency": "Emergency", + "hospital": "Hospital", + "therapy": "Therapy session", + "other": "Appointment" + } } } diff --git a/messages/es.json b/messages/es.json index b1a9b53fc..4307d5674 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6740,6 +6740,7 @@ "restoreSkippedMoodFactor": "Factor de ánimo", "restoreSkippedMoodTag": "Etiqueta de ánimo", "restoreSkippedVisitReference": "Referencia de visita", + "restoreSkippedCheckupClosure": "Cierre de revisión", "docsLink": "Documentación" }, "danger-zone": { @@ -9928,5 +9929,17 @@ "anamnesis": { "relativeNote": "Una entrada de antecedentes familiares describe a los familiares de esta persona. Añádela solo si querría que quedara registrada aquí." } + }, + "encounters": { + "kind": { + "routine": "Visita de rutina", + "acute": "Visita por urgencia", + "specialist": "Visita al especialista", + "preventive": "Revisión preventiva", + "emergency": "Urgencia", + "hospital": "Hospital", + "therapy": "Sesión de terapia", + "other": "Cita médica" + } } } diff --git a/messages/fr.json b/messages/fr.json index 858728904..49213e5d1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6740,6 +6740,7 @@ "restoreSkippedMoodFactor": "Facteur d'humeur", "restoreSkippedMoodTag": "Étiquette d'humeur", "restoreSkippedVisitReference": "Référence de visite", + "restoreSkippedCheckupClosure": "Clôture d'un examen", "docsLink": "Documentation" }, "danger-zone": { @@ -9928,5 +9929,17 @@ "anamnesis": { "relativeNote": "Un antécédent familial décrit les proches de cette personne. Ne l'ajoutez que si elle voudrait le voir consigné ici." } + }, + "encounters": { + "kind": { + "routine": "Consultation de routine", + "acute": "Consultation urgente", + "specialist": "Consultation spécialisée", + "preventive": "Examen de prévention", + "emergency": "Urgence", + "hospital": "Hôpital", + "therapy": "Séance de thérapie", + "other": "Rendez-vous médical" + } } } diff --git a/messages/it.json b/messages/it.json index d1fa199ee..18914e095 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6740,6 +6740,7 @@ "restoreSkippedMoodFactor": "Fattore dell'umore", "restoreSkippedMoodTag": "Etichetta dell'umore", "restoreSkippedVisitReference": "Riferimento alla visita", + "restoreSkippedCheckupClosure": "Chiusura del controllo", "docsLink": "Documentazione" }, "danger-zone": { @@ -9928,5 +9929,17 @@ "anamnesis": { "relativeNote": "Una voce di anamnesi familiare descrive i parenti di questa persona. Aggiungila solo se vorrebbe vederla registrata qui." } + }, + "encounters": { + "kind": { + "routine": "Visita di routine", + "acute": "Visita urgente", + "specialist": "Visita specialistica", + "preventive": "Controllo preventivo", + "emergency": "Emergenza", + "hospital": "Ospedale", + "therapy": "Seduta di terapia", + "other": "Appuntamento medico" + } } } diff --git a/messages/pl.json b/messages/pl.json index f53f7c27a..c1208562d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6740,6 +6740,7 @@ "restoreSkippedMoodFactor": "Czynnik nastroju", "restoreSkippedMoodTag": "Tag nastroju", "restoreSkippedVisitReference": "Odwołanie wizyty", + "restoreSkippedCheckupClosure": "Zamknięcie badania", "docsLink": "Dokumentacja" }, "danger-zone": { @@ -9928,5 +9929,17 @@ "anamnesis": { "relativeNote": "Wpis w wywiadzie rodzinnym opisuje bliskich tej osoby. Dodaj go tylko wtedy, gdy chciałaby, żeby został tu zapisany." } + }, + "encounters": { + "kind": { + "routine": "Wizyta kontrolna", + "acute": "Wizyta pilna", + "specialist": "Wizyta u specjalisty", + "preventive": "Badanie profilaktyczne", + "emergency": "Nagły przypadek", + "hospital": "Szpital", + "therapy": "Sesja terapeutyczna", + "other": "Wizyta lekarska" + } } } diff --git a/src/__tests__/encounter-reminder-exclusion.test.ts b/src/__tests__/encounter-reminder-exclusion.test.ts index 790e5bd78..884523d2e 100644 --- a/src/__tests__/encounter-reminder-exclusion.test.ts +++ b/src/__tests__/encounter-reminder-exclusion.test.ts @@ -101,6 +101,82 @@ function reminderRow( }; } +/** + * The BY-ID family: the published measurement-reminder surface. + * + * The list reads above answer "what preventive care is due". These answer + * "this reminder", by id, and they were the larger hole. An appointment row is + * not addressable here at all — it belongs to a visit, is managed through the + * visit routes, and is classified under a different sharing domain — so each + * of these treats one as not found rather than filtering it out of a list. + * + * What reaching them actually cost, before the fix: + * + * - `complete` and `satisfy` committed the mutation and THEN threw in the + * DTO mapper, answering 500 after the write had already landed. `complete` + * is the documented flow behind the iOS notification's Done button, so + * every appointment push a person acknowledged ended in an error. + * - `PATCH` could give a one-shot a cadence, turning a spent appointment + * into an eternal re-nag that appears in no list, or flip `enabled` back + * on for a cancelled one. + * - `DELETE` hard-deletes, so a delegate holding only the measurements + * domain could destroy a reminder belonging to a visit in the profile + * domain — a cross-domain reach the sharing model refuses everywhere else. + * + * The count per file is part of the expectation: three of these live in one + * module, and a matcher that only asked "does this file mention the exclusion" + * would stay green after two of the three lost it. + */ +const BY_ID_SITES: ReadonlyArray<{ + file: string; + sites: number; + surface: string; +}> = [ + { + file: "src/app/api/measurement-reminders/[id]/route.ts", + sites: 3, + surface: "the by-id read, edit and delete verbs", + }, + { + file: "src/app/api/measurement-reminders/[id]/complete/route.ts", + sites: 1, + surface: + "the explicit complete action behind the notification's Done button", + }, + { + file: "src/app/api/measurement-reminders/[id]/satisfy/route.ts", + sites: 1, + surface: "the manual satisfy action", + }, + { + file: "src/lib/telegram-webhook-handlers.ts", + sites: 2, + surface: "the chat buttons that mark a reminder done or postpone it", + }, +]; + +describe("an appointment reminder is not addressable by id", () => { + it("names a by-id set that is not empty (no vacuous pass)", () => { + expect(BY_ID_SITES.length).toBe(4); + expect(BY_ID_SITES.reduce((total, entry) => total + entry.sites, 0)).toBe( + 7, + ); + }); + + for (const { file, sites, surface } of BY_ID_SITES) { + it(`refuses an ENCOUNTER row on ${surface}`, () => { + const matches = read(file).match(EXCLUSION) ?? []; + expect( + matches.length, + `${file} owns ${sites} by-id lookup(s) for ${surface}; ` + + `${matches.length} of them exclude ENCOUNTER-origin rows. An ` + + "appointment reminder reaching this family commits a write it should " + + "have refused, or 500s after committing one.", + ).toBe(sites); + }); + } +}); + 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); diff --git a/src/app/api/encounters/[id]/links/route.ts b/src/app/api/encounters/[id]/links/route.ts index a567e657d..076ec4547 100644 --- a/src/app/api/encounters/[id]/links/route.ts +++ b/src/app/api/encounters/[id]/links/route.ts @@ -27,7 +27,10 @@ import { } from "@/lib/api-response"; import { encounterLinkSchema } from "@/lib/validations/encounters"; import { linkTargets, unlinkTargets } from "@/lib/links"; -import { loadEncounterLinks } from "@/lib/encounters/service"; +import { + actingDomainVisibility, + loadEncounterLinks, +} from "@/lib/encounters/service"; type RouteParams = { params: Promise<{ id: string }> }; @@ -78,7 +81,11 @@ async function handle( }, }); - const links = await loadEncounterLinks(prisma, user.id, id); + // Bare `requireAuth()` resolves the caller's OWN record — this route is + // not delegable — so there is no narrowed grant to honour and every + // domain is the caller's own. + const visible = await actingDomainVisibility(prisma, null); + const links = await loadEncounterLinks(prisma, user.id, id, visible); return apiSuccess({ links, unknown: result.unknownTargetIds }); } diff --git a/src/app/api/encounters/[id]/restore/route.ts b/src/app/api/encounters/[id]/restore/route.ts index 0a2d1dcb3..5da6b3242 100644 --- a/src/app/api/encounters/[id]/restore/route.ts +++ b/src/app/api/encounters/[id]/restore/route.ts @@ -17,11 +17,13 @@ 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 { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, + actingDomainVisibility, loadEncounterLinks, - resolveTimezone, + resolveOwnerNotificationContext, shouldHaveReminder, } from "@/lib/encounters/service"; import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder"; @@ -30,7 +32,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("manage", "profile"); + const { user, grantId } = await requireRecordAuth("manage", "profile"); const { id } = await params; const existing = await prisma.encounter.findUnique({ @@ -42,7 +44,9 @@ export const POST = apiHandler( } if (existing.deletedAt !== null) { - const timezone = await resolveTimezone(user.id); + const { timezone, locale } = await resolveOwnerNotificationContext( + user.id, + ); const now = new Date(); await prisma.$transaction(async (tx) => { await tx.encounter.update({ @@ -66,7 +70,7 @@ export const POST = apiHandler( occurredAt: existing.occurredAt, practitionerName: existing.practitioner?.name ?? null, practitionerLocation: existing.practitioner?.location ?? null, - kindLabel: existing.kind, + kindLabel: encounterKindLabel(existing.kind, locale), }, timezone, ); @@ -79,7 +83,8 @@ export const POST = apiHandler( where: { id }, include: ENCOUNTER_INCLUDE, }); - const links = await loadEncounterLinks(prisma, user.id, id); + const visible = await actingDomainVisibility(prisma, grantId); + const links = await loadEncounterLinks(prisma, user.id, id, visible); await auditLog("encounter.visit.restore", { userId: user.id, diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index cf7696591..24840de07 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -30,25 +30,35 @@ import { } from "@/lib/api-response"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encounterUpdateSchema } from "@/lib/validations/encounters"; +import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, + actingDomainVisibility, applyEncounterLinks, closeCheckupForVisit, + findOwnAppointmentReminderId, loadEncounterLinks, resolveClosableReminder, resolveOwnedPractitioner, - resolveTimezone, + resolveOwnerNotificationContext, + retireAppointmentReminderForVisit, + retireOrphanedAppointmentReminders, shouldHaveReminder, syncAppointmentReminder, } from "@/lib/encounters/service"; +import { + recordUnknownKeys, + summarizeRestoreSkips, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; 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 { user, grantId } = await requireRecordAuth("read", "profile"); const { id } = await params; const row = await prisma.encounter.findUnique({ @@ -59,7 +69,8 @@ export const GET = apiHandler( return apiError("Visit not found", 404); } - const links = await loadEncounterLinks(prisma, user.id, id); + const visible = await actingDomainVisibility(prisma, grantId); + const links = await loadEncounterLinks(prisma, user.id, id, visible); annotate({ action: { @@ -75,7 +86,7 @@ export const GET = apiHandler( export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("manage", "profile"); + const { user, grantId } = await requireRecordAuth("manage", "profile"); const { id } = await params; const existing = await prisma.encounter.findUnique({ where: { id } }); @@ -100,7 +111,7 @@ export const PATCH = apiHandler( } const entry = parsed.data; - const timezone = await resolveTimezone(user.id); + const { timezone, locale } = await resolveOwnerNotificationContext(user.id); const now = new Date(); // The state the visit will be in once this edit lands. Both the reminder @@ -112,7 +123,10 @@ export const PATCH = apiHandler( : existing.occurredAt; const nextStatus = entry.status ?? existing.status; + const skips: RestoreSkipLog = []; + const outcome = await prisma.$transaction(async (tx) => { + const visible = await actingDomainVisibility(tx, grantId); let practitionerId = existing.practitionerId; let practitionerName: string | null = null; let practitionerLocation: string | null = null; @@ -161,22 +175,24 @@ export const PATCH = apiHandler( : { 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; + // The appointment reminder this visit OWNS, resolved through the engine + // table rather than by reading the visit's own foreign key. Reading the + // key answers a different question — what the visit points at — and the + // two stop agreeing the moment the key is re-pointed at a checkup. + const ownAppointmentReminderId = await findOwnAppointmentReminderId( + tx, + user.id, + id, + ); let reminderId = existing.reminderId; if (shouldHaveReminder(nextStatus, nextOccurredAt, now)) { + // The same contradiction the create arm refuses, and it has to be + // refused here too: a body that books a visit AND names the checkup it + // closed would walk the foreign key off the visit's own reminder, + // leaving it live, armed and unreachable. + if (entry.reminderId) return "contradictory-reminder" as const; reminderId = await syncAppointmentReminder( tx, { @@ -184,7 +200,7 @@ export const PATCH = apiHandler( occurredAt: nextOccurredAt, practitionerName, practitionerLocation, - kindLabel: entry.kind ?? existing.kind, + kindLabel: encounterKindLabel(entry.kind ?? existing.kind, locale), status: nextStatus, existingReminderId: ownAppointmentReminderId, }, @@ -200,7 +216,7 @@ export const PATCH = apiHandler( occurredAt: nextOccurredAt, practitionerName, practitionerLocation, - kindLabel: entry.kind ?? existing.kind, + kindLabel: encounterKindLabel(entry.kind ?? existing.kind, locale), status: nextStatus, existingReminderId: ownAppointmentReminderId, }, @@ -246,12 +262,44 @@ export const PATCH = apiHandler( nextStatus === "DONE" && existing.status !== "DONE"; if (closesCheckupNow && reminderId) { const checkup = await resolveClosableReminder(tx, user.id, reminderId); - if (checkup) await closeCheckupForVisit(tx, checkup, timezone, now); + if (checkup) { + // Same domain fence as the create arm: closing a checkup is a + // measurements write, and a health-background delegate may edit the + // visit without being able to perform it. + if (visible("measurements")) { + await closeCheckupForVisit( + tx, + checkup, + timezone, + nextOccurredAt, + now, + ); + } else { + recordUnknownKeys( + skips, + "checkupClosure", + [checkup.id], + [checkup.id], + ); + } + } } + // Nothing this account owns may be left armed for a visit that does not + // point at it. Runs after the write so the row just minted, whose key is + // set one statement earlier, is never mistaken for a stray. + await retireOrphanedAppointmentReminders(tx, user.id, now); + return { updated }; }); + if (outcome === "contradictory-reminder") { + return apiError( + "A booked visit cannot also close a checkup — it has not happened yet", + 422, + { errorCode: "encounter.reminder-conflict" }, + ); + } if (outcome === "unknown-practitioner") { return apiError("Practitioner not found", 404, { errorCode: "encounter.practitioner-not-found", @@ -267,7 +315,8 @@ export const PATCH = apiHandler( where: { id }, include: ENCOUNTER_INCLUDE, }); - const links = await loadEncounterLinks(prisma, user.id, id); + const visible = await actingDomainVisibility(prisma, grantId); + const links = await loadEncounterLinks(prisma, user.id, id, visible); await auditLog("encounter.visit.update", { userId: user.id, @@ -302,7 +351,7 @@ export const PATCH = apiHandler( meta: { status: row.status }, }); - return apiSuccess(toEncounterDTO(row, links)); + return apiSuccess(toEncounterDTO(row, links, summarizeRestoreSkips(skips))); }, ); @@ -320,21 +369,16 @@ export const DELETE = apiHandler( if (existing.deletedAt === null) { const at = new Date(); await prisma.$transaction(async (tx) => { + // Only the visit's OWN appointment reminder goes with it, and it is + // found by the relation rather than by the foreign key: a visit whose + // key points at a checkup still owns its appointment row, and reading + // the key would leave that row armed for a visit that no longer exists. + // Retired BEFORE the tombstone, while the relation still resolves. + await retireAppointmentReminderForVisit(tx, user.id, id, at); 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 }, - }); - } - } + // A checkup the visit closed belongs to the person's preventive-care + // list and outlives it; the sweep below only reaps appointment rows. + await retireOrphanedAppointmentReminders(tx, user.id, at); }); } diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 2157a70a2..ab5067df5 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -30,17 +30,24 @@ import { encounterCreateSchema, encounterListQuerySchema, } from "@/lib/validations/encounters"; +import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, + actingDomainVisibility, applyEncounterLinks, closeCheckupForVisit, resolveClosableReminder, resolveOwnedPractitioner, - resolveTimezone, + resolveOwnerNotificationContext, shouldHaveReminder, syncAppointmentReminder, } from "@/lib/encounters/service"; +import { + recordUnknownKeys, + summarizeRestoreSkips, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "profile"); @@ -78,19 +85,33 @@ export const GET = apiHandler(async (request: NextRequest) => { : {}), }; + // The past arm's own upper bound is `now`, and the caller may have asked for + // an earlier one. Both have to hold, so they COMBINE to the tighter of the + // two rather than one overwriting the other — spreading the caller's + // `occurredAt` and then restating `lte` silently widened every window that + // ended before today back out to "everything up to now". + const requestedTo = query.to ? new Date(query.to) : null; + const pastCeiling = + requestedTo && requestedTo.getTime() < now.getTime() ? requestedTo : now; + // 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({ + // `gt` and `gte` are different keys, so the caller's lower bound and this + // arm's floor both survive the spread; only the `lte` pair collided. where: { ...where, occurredAt: { ...where.occurredAt, gt: now } }, orderBy: { occurredAt: "asc" }, take, include: ENCOUNTER_INCLUDE, }), prisma.encounter.findMany({ - where: { ...where, occurredAt: { ...where.occurredAt, lte: now } }, + where: { + ...where, + occurredAt: { ...where.occurredAt, lte: pastCeiling }, + }, orderBy: { occurredAt: "desc" }, take, include: ENCOUNTER_INCLUDE, @@ -113,7 +134,7 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter)); async function postEncounter(request: NextRequest): Promise { - const { user } = await requireRecordAuth("write", "profile"); + const { user, grantId } = await requireRecordAuth("write", "profile"); const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, @@ -133,10 +154,13 @@ async function postEncounter(request: NextRequest): Promise { const entry = parsed.data; const occurredAt = new Date(entry.occurredAt); - const timezone = await resolveTimezone(user.id); + const { timezone, locale } = await resolveOwnerNotificationContext(user.id); const now = new Date(); + const skips: RestoreSkipLog = []; + const created = await prisma.$transaction(async (tx) => { + const visible = await actingDomainVisibility(tx, grantId); // 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. @@ -183,7 +207,7 @@ async function postEncounter(request: NextRequest): Promise { occurredAt, practitionerName: practitioner?.name ?? null, practitionerLocation: practitioner?.location ?? null, - kindLabel: entry.kind, + kindLabel: encounterKindLabel(entry.kind, locale), status: entry.status, existingReminderId: null, }, @@ -198,10 +222,24 @@ async function postEncounter(request: NextRequest): Promise { ); 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. + // Filing a visit as done against a due checkup closes it — but closing + // one re-anchors a preventive-care cadence, which is a write in the + // measurements domain. A delegate holding only the health background may + // file the visit and may not perform that side effect, so the visit + // saves and the closure is reported as skipped rather than done in + // silence: somebody who believes a checkup was closed will not look at + // it again. if (entry.status === "DONE") { - await closeCheckupForVisit(tx, checkup, timezone, now); + if (visible("measurements")) { + await closeCheckupForVisit(tx, checkup, timezone, occurredAt, now); + } else { + recordUnknownKeys( + skips, + "checkupClosure", + [checkup.id], + [checkup.id], + ); + } } } @@ -251,5 +289,8 @@ async function postEncounter(request: NextRequest): Promise { meta: { status: created.status, kind: created.kind }, }); - return apiSuccess(toEncounterDTO(created), 201); + return apiSuccess( + toEncounterDTO(created, undefined, summarizeRestoreSkips(skips)), + 201, + ); } diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts index 2a4225f52..ce3a8d1fb 100644 --- a/src/app/api/measurement-reminders/[id]/complete/route.ts +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -41,8 +41,13 @@ export const POST = apiHandler( const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Filtering in the lookup rather than after it is what keeps a write + // from committing before the refusal. const existing = await prisma.measurementReminder.findFirst({ - where: { id, deletedAt: null }, + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, }); if (!existing || existing.userId !== user.id) { return apiError("Measurement reminder not found", 404); diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index b68acd296..a8785b2ae 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -48,8 +48,13 @@ export const GET = apiHandler( const { user } = await requireRecordAuth("read", "measurements"); const { id } = await params; + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Filtering in the lookup rather than after it is what keeps a write + // from committing before the refusal. const reminder = await prisma.measurementReminder.findFirst({ - where: { id, deletedAt: null }, + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, }); if (!reminder || reminder.userId !== user.id) { return apiError("Measurement reminder not found", 404); @@ -71,8 +76,13 @@ export const PATCH = apiHandler( const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Filtering in the lookup rather than after it is what keeps a write + // from committing before the refusal. const existing = await prisma.measurementReminder.findFirst({ - where: { id, deletedAt: null }, + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, }); if (!existing || existing.userId !== user.id) { return apiError("Measurement reminder not found", 404); @@ -234,8 +244,15 @@ export const DELETE = apiHandler( const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; - const existing = await prisma.measurementReminder.findUnique({ - where: { id }, + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Filtering in the lookup rather than after it is what keeps a write + // from committing before the refusal. + // `findFirst` rather than `findUnique`: the origin is not part of any + // unique key, so the predicate cannot ride a unique lookup. + const existing = await prisma.measurementReminder.findFirst({ + where: { id, origin: { not: "ENCOUNTER" } }, }); if (!existing || existing.userId !== user.id) { return apiError("Measurement reminder not found", 404); diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts index f877d3b87..dc24728fb 100644 --- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -27,8 +27,13 @@ export const POST = apiHandler( const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Filtering in the lookup rather than after it is what keeps a write + // from committing before the refusal. const existing = await prisma.measurementReminder.findFirst({ - where: { id, deletedAt: null }, + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, }); if (!existing || existing.userId !== user.id) { return apiError("Measurement reminder not found", 404); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index e13d8d443..b3b92484d 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -201,6 +201,9 @@ function catalogueLabel( if (catalogue === "visitReference") { return t("admin.section.backups.restoreSkippedVisitReference"); } + if (catalogue === "checkupClosure") { + return t("admin.section.backups.restoreSkippedCheckupClosure"); + } return t("admin.section.backups.restoreSkippedMoodFactor"); } diff --git a/src/lib/encounters/__tests__/appointment-reminder.test.ts b/src/lib/encounters/__tests__/appointment-reminder.test.ts new file mode 100644 index 000000000..6c3d881d2 --- /dev/null +++ b/src/lib/encounters/__tests__/appointment-reminder.test.ts @@ -0,0 +1,86 @@ +/** + * The never-nag property, proven rather than asserted in prose. + * + * A booked visit's reminder is a one-shot: both cadence columns are NULL, which + * the schema documents as "anchored on `anchorDate`". The claim that follows — + * that it can fire once and never again — is a claim about the SAME function + * the cron calls to advance a fired reminder. If that function returned an + * instant here, the row would re-arm every tick and nag forever about an + * appointment that already happened, and nothing else in the system would + * notice: the row is excluded from every list a person can see. + * + * The module said this test existed. It did not. That is the reason it does + * now: a comment claiming a proof is worse than no comment, because it stops + * the next reader looking. + */ +import { describe, expect, it } from "vitest"; + +import { + appointmentNextDueAfterFiring, + appointmentNotifyHour, +} from "@/lib/encounters/appointment-reminder"; + +const TZ = "Europe/Berlin"; +const APPOINTMENT = new Date("2026-09-14T07:40:00.000Z"); + +/** The row shape a minted appointment reminder actually carries. */ +const oneShot = { + intervalDays: null, + rrule: null, + anchorDate: APPOINTMENT, + notifyHour: 9, + createdAt: new Date("2026-08-01T00:00:00.000Z"), +}; + +describe("a fired appointment reminder can never re-arm", () => { + it("has no next occurrence once its instant has passed", () => { + const afterItFired = new Date("2026-09-14T08:00:00.000Z"); + + expect(appointmentNextDueAfterFiring(oneShot, TZ, afterItFired)).toBeNull(); + }); + + it("still has none a year later", () => { + // The failure this rules out is a cadence quietly inferred from the anchor, + // which would look correct on the day and nag every year after it. + expect( + appointmentNextDueAfterFiring( + oneShot, + TZ, + new Date("2027-10-01T00:00:00.000Z"), + ), + ).toBeNull(); + }); + + it("is not vacuous: a cadenced reminder DOES get a next occurrence", () => { + // Without this, "returns null" would also pass for a function that returns + // null unconditionally, which is the shape of green this repository keeps + // being bitten by. + const cadenced = { ...oneShot, intervalDays: 30 }; + + expect( + appointmentNextDueAfterFiring( + cadenced, + TZ, + new Date("2026-09-14T08:00:00.000Z"), + ), + ).not.toBeNull(); + }); +}); + +describe("the hour the nudge fires", () => { + it("is the appointment's own local hour, not the server's", () => { + // 07:40 UTC is 09:40 in Berlin, so the nudge belongs at 9. + expect(appointmentNotifyHour(APPOINTMENT, TZ)).toBe(9); + }); + + it("reads midnight as hour zero rather than twenty-four", () => { + // Some locales format midnight as "24"; the column is 0–23. + expect( + appointmentNotifyHour(new Date("2026-09-14T22:00:00.000Z"), TZ), + ).toBe(0); + }); + + it("follows the record's zone, so the same instant differs by zone", () => { + expect(appointmentNotifyHour(APPOINTMENT, "UTC")).toBe(7); + }); +}); diff --git a/src/lib/encounters/appointment-reminder.ts b/src/lib/encounters/appointment-reminder.ts index 79a1c4cf9..4386b55e4 100644 --- a/src/lib/encounters/appointment-reminder.ts +++ b/src/lib/encounters/appointment-reminder.ts @@ -159,9 +159,10 @@ export async function deleteAppointmentReminder( /** * 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. + * Exported for `__tests__/appointment-reminder.test.ts`, which 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 to advance a + * reminder it has just fired. */ export function appointmentNextDueAfterFiring( reminder: Pick< diff --git a/src/lib/encounters/dto.ts b/src/lib/encounters/dto.ts index 608de0763..9361d2591 100644 --- a/src/lib/encounters/dto.ts +++ b/src/lib/encounters/dto.ts @@ -20,17 +20,33 @@ import type { MeasurementReminder, Practitioner, } from "@/generated/prisma/client"; -import type { LinkedTarget } from "@/lib/links"; +import type { RestoreSkipSummary } from "@/lib/export/restore-skips"; import { toPractitionerDTO, type PractitionerDTO, } from "@/lib/practitioners/dto"; +/** + * One thing a visit produced, as the caller is allowed to see it. + * + * `label` and `date` go null together when the caller's grant does not cover + * the target's own domain — a health-background delegate may know the visit + * produced a lab result without being told which analyte. `redacted` says + * which of those nulls is a withholding and which is simply a target with no + * date, so a client never has to guess. + */ +export interface EncounterLinkDTO { + id: string; + label: string | null; + date: string | null; + redacted: boolean; +} + /** The three link families a visit can carry, each already resolved. */ export interface EncounterLinksDTO { - documents: LinkedTarget[]; - labResults: LinkedTarget[]; - conditions: LinkedTarget[]; + documents: EncounterLinkDTO[]; + labResults: EncounterLinkDTO[]; + conditions: EncounterLinkDTO[]; } export interface EncounterDTO { @@ -50,6 +66,17 @@ export interface EncounterDTO { reminderNextDueAt: string | null; /** Present on the detail response; omitted from the list. */ links?: EncounterLinksDTO; + /** + * What the write could not do, named. + * + * Present on create and edit. `links: 0` is the ordinary answer and says so. + * Anything else is a side effect the caller's grant did not reach — today + * only the closing of a checkup, which is a measurements-domain write that a + * health-background delegate cannot perform. Reported rather than skipped in + * silence, because a person who believes a checkup was closed will not look + * at it again. + */ + skipped?: RestoreSkipSummary; createdAt: string; updatedAt: string; } @@ -79,6 +106,7 @@ export type EncounterWithRelations = Encounter & { export function toEncounterDTO( row: EncounterWithRelations, links?: EncounterLinksDTO, + skipped?: RestoreSkipSummary, ): EncounterDTO { return { id: row.id, @@ -90,6 +118,7 @@ export function toEncounterDTO( outcome: decryptField(row.outcomeEncrypted, "outcome"), reminderNextDueAt: row.reminder?.nextDueAt?.toISOString() ?? null, ...(links ? { links } : {}), + ...(skipped ? { skipped } : {}), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; diff --git a/src/lib/encounters/kind-label.ts b/src/lib/encounters/kind-label.ts new file mode 100644 index 000000000..ed31a78d2 --- /dev/null +++ b/src/lib/encounters/kind-label.ts @@ -0,0 +1,47 @@ +/** + * The human name of a visit kind, in the record owner's language. + * + * Spelled out with literal `t()` calls rather than an interpolated key, for the + * same reason the restore-skip catalogue labels are: an interpolated key is + * invisible to `i18n-call-site-coverage`, so a renamed or missing kind would + * ship as raw dot notation instead of failing CI. + * + * This is not decoration. The value becomes a `MeasurementReminder.label`, and + * that label IS the push body — so the fallback for a visit with no practice + * attached is read off a lock screen. A raw enum constant there is the schema + * leaking into the product. + */ +import type { EncounterKind } from "@/generated/prisma/client"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +export function encounterKindLabel( + kind: EncounterKind, + locale: string | null | undefined, +): string { + const { t } = getServerTranslator(resolveLocale(locale)); + switch (kind) { + case "ROUTINE": + return t("encounters.kind.routine"); + case "ACUTE": + return t("encounters.kind.acute"); + case "SPECIALIST": + return t("encounters.kind.specialist"); + case "PREVENTIVE": + return t("encounters.kind.preventive"); + case "EMERGENCY": + return t("encounters.kind.emergency"); + case "HOSPITAL": + return t("encounters.kind.hospital"); + case "THERAPY": + return t("encounters.kind.therapy"); + default: + return t("encounters.kind.other"); + } +} diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 60c06b6bd..b1078ff8f 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -13,6 +13,8 @@ import { type SatisfiableReminder, } from "@/lib/measurement-reminders/satisfy"; import { listTargets, replaceTargets, type LinkTargetKind } from "@/lib/links"; +import { grantCoversDomain } from "@/lib/sharing/grants"; +import type { ShareDomain } from "@/lib/sharing/scope"; import type { EncounterLinksDTO } from "@/lib/encounters/dto"; import { deleteAppointmentReminder, @@ -32,25 +34,106 @@ export async function resolveTimezone(userId: string): Promise { return row?.timezone || DEFAULT_TIMEZONE; } +/** + * The record owner's zone AND language, read together. + * + * Both are properties of the RECORD rather than of whoever is acting on it: a + * delegate booking an appointment in somebody else's record produces a nudge + * that rings on the owner's phone, at the owner's hour, in the owner's + * language. Reading the actor's locale here would localise a stranger's + * notification into the helper's tongue. + */ +export async function resolveOwnerNotificationContext( + userId: string, +): Promise<{ timezone: string; locale: string | null }> { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true, locale: true }, + }); + return { + timezone: row?.timezone || DEFAULT_TIMEZONE, + locale: row?.locale ?? null, + }; +} + /** 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. */ +/** + * Which sections of the record the caller is actually inside. + * + * Returns a predicate rather than a set so the owner path costs nothing: with + * no grant there is nothing to look up and every domain is open. A grant that + * vanished between the auth check and here answers "no" to everything, which + * is the fail-closed direction. + */ +export async function actingDomainVisibility( + tx: Prisma.TransactionClient, + grantId: string | null, +): Promise<(domain: ShareDomain) => boolean> { + if (grantId === null) return () => true; + const grant = await tx.accountGrant.findUnique({ + where: { id: grantId }, + select: { scopeJson: true }, + }); + if (!grant) return () => false; + return (domain) => grantCoversDomain(grant, domain); +} + +/** Which section of the record each link family's TARGET belongs to. */ +const TARGET_DOMAIN: Record = { + document: "documents", + labResult: "labs", + conditionEpisode: "illness", +}; + +/** + * The three link families, resolved for one visit and redacted per family. + * + * A visit is a seam between four domains: it lives in the health background + * and points at the vault, the lab results and the illness journal. A grant + * that opened only the health background was never consent for what is on the + * other side of that seam — and while the VALUES never crossed it (the list + * carries a label and a date, not a document body or a result), the names are + * themselves the sensitive part. "Oncology discharge letter" and "Ferritin, + * 30 June" say most of what the person was worried about. + * + * So the name and the date are withheld per family, and the LINK is not: the + * caller may know the visit produced a lab result, because the visit is theirs + * to read. `label: null` rather than an invented placeholder, so an absence + * reads as an absence; the array an entry sits in already carries the kind, so + * a client renders "a linked lab result" from its own bundle without the + * server shipping prose down the wire. + * + * Owners and entire-record delegates see everything, so the ordinary case is + * untouched. + */ export async function loadEncounterLinks( tx: Prisma.TransactionClient, userId: string, encounterId: string, + visible: (domain: ShareDomain) => boolean, ): Promise { - const read = (targetKind: LinkTargetKind) => - listTargets(tx, { + const read = async (targetKind: LinkTargetKind) => { + const rows = await listTargets(tx, { userId, sourceKind: "encounter", sourceId: encounterId, targetKind, }); + if (visible(TARGET_DOMAIN[targetKind])) { + return rows.map((row) => ({ ...row, redacted: false })); + } + return rows.map((row) => ({ + id: row.id, + label: null, + date: null, + redacted: true, + })); + }; const [documents, labResults, conditions] = await Promise.all([ read("document"), read("labResult"), @@ -207,26 +290,125 @@ export async function resolveClosableReminder( * 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. + * and the explicit user action all route through. There is no second + * mechanism; the only decision here is WHICH INSTANT to satisfy at. + * + * It is the visit's own instant, bounded by now — not `now`. The plan said + * `now`, and two things it did not anticipate say otherwise: + * + * - **Anchoring.** A yearly panel drawn thirty days ago is next due 335 days + * from today, not 365. Satisfying at `now` moves every backdated entry's + * next due date forward by however long the person took to record it, so + * the more honest they are about the date, the more wrong the cadence gets. + * - **Re-transitions.** Correcting a mis-filed visit — DONE, then NO_SHOW, + * then DONE again — is a transition each way, so the status gate alone + * does not stop a second satisfy. At `now` the second one is strictly + * later and advances the cadence again; at the visit instant it is not + * strictly after the first, and the primitive's forward-only guard makes + * it the no-op it should always have been. + * + * Bounded by `now` because a visit can be booked into the future: a + * PLANNED-to-DONE correction on tomorrow's appointment must not stamp a + * satisfy the engine would read as ahead of its own clock. + * + * This also matches how the rest of the engine is fed — the cron and the + * ingest worker pass the EVENT instant, not the moment they happened to run. */ export async function closeCheckupForVisit( tx: Prisma.TransactionClient, reminder: SatisfiableReminder, timezone: string, + occurredAt: Date, now: Date, ): Promise { - await satisfyReminder(tx as unknown as PrismaClient, reminder, timezone, now); + const satisfiedAt = occurredAt.getTime() < now.getTime() ? occurredAt : now; + await satisfyReminder( + tx as unknown as PrismaClient, + reminder, + timezone, + satisfiedAt, + ); } -/** Soft-delete a visit's reminder alongside the visit. */ -export async function retireAppointmentReminder( +/** + * The live appointment reminder this visit owns, found by the relation rather + * than by reading the visit's foreign key. + * + * The distinction is the whole of the orphan defect. Reading `reminderId` and + * checking its origin answers "what does this visit point at", which stops + * being the same question the moment the column is re-pointed at a checkup: + * the appointment row is still live, still armed, and now invisible to the + * code that owns it, so the next reschedule mints a second one. Asking the + * engine table which of ITS rows this visit owns cannot drift that way, and it + * filters the origin and the tombstone in SQL instead of in a branch. + */ +export async function findOwnAppointmentReminderId( tx: Prisma.TransactionClient, - reminderId: string | null, + userId: string, + encounterId: string, +): Promise { + const row = await tx.measurementReminder.findFirst({ + where: { + userId, + origin: "ENCOUNTER", + deletedAt: null, + encounters: { some: { id: encounterId } }, + }, + select: { id: true }, + }); + return row?.id ?? null; +} + +/** + * Retire every live appointment reminder this account owns that no visit + * points at. + * + * A reminder nobody points at still fires — the cron reads the engine table, + * not the visit list — so an orphan pushes notifications for an appointment + * the person can no longer see, edit or cancel. The refusal in the edit route + * stops the one sequence that produced them; this makes the state itself + * unreachable, so a future path that finds a new way to strand one cannot + * leave it armed. + * + * A soft-deleted VISIT still points at its reminder, so this never reaps a + * deleted visit's row — that retirement belongs to the delete route, which + * knows it is deliberate. + */ +export async function retireOrphanedAppointmentReminders( + tx: Prisma.TransactionClient, + userId: string, + at: Date, +): Promise { + const result = await tx.measurementReminder.updateMany({ + where: { + userId, + origin: "ENCOUNTER", + deletedAt: null, + encounters: { none: {} }, + }, + data: { deletedAt: at, enabled: false, nextDueAt: null }, + }); + return result.count; +} + +/** + * Retire the appointment reminder a visit owns, alongside the visit. + * + * Takes the visit rather than a reminder id, and resolves the row by the + * relation: deleting a visit must reach the reminder it owns even if its + * foreign key is pointing somewhere else. + */ +export async function retireAppointmentReminderForVisit( + tx: Prisma.TransactionClient, + userId: string, + encounterId: string, at: Date, ): Promise { + const reminderId = await findOwnAppointmentReminderId( + tx, + userId, + encounterId, + ); if (!reminderId) return; await deleteAppointmentReminder(tx, reminderId, at); } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 022be17b9..b13148f07 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -39,13 +39,22 @@ * back — a reminder, which is on the coverage-pending register and never * travels, or a document, lab result or condition episode whose id a portable * export does not carry. + * + * The sixth, `checkupClosure`, is not about a restore at all, and it borrows + * this shape deliberately rather than growing a second reporting mechanism + * beside it. The situation is the same one: something a write was asked to do + * could not be done, the record itself survives, and the person is told which + * one rather than left to assume it worked. It is filed when a delegate files + * a visit against a preventive-care checkup their grant does not reach — the + * visit saves, the checkup stays due, and the response says so. */ export type SkippedCatalogue = | "cycleSymptom" | "illnessSymptom" | "moodFactor" | "moodTag" - | "visitReference"; + | "visitReference" + | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/measurement-reminders/__tests__/resolve.test.ts b/src/lib/measurement-reminders/__tests__/resolve.test.ts index c6f2b1553..13c665ef7 100644 --- a/src/lib/measurement-reminders/__tests__/resolve.test.ts +++ b/src/lib/measurement-reminders/__tests__/resolve.test.ts @@ -112,4 +112,43 @@ describe("findSatisfyingEvent", () => { }); expect(at).toBeNull(); }); + + it("never auto-satisfies a booked visit, whatever landed", async () => { + // An appointment reminder is free-text by construction (there is nothing a + // person could measure that means "you went"), and the free-text arm above + // resolves from ANY lab result. Left alone, a routine blood panel would + // mark next month's cardiology appointment as attended and silence it. + // + // Going is the only thing that satisfies a visit, and the visit routes are + // where that is recorded, so this matcher must refuse the row outright + // rather than look for evidence it cannot have. + const takenAt = new Date("2026-07-04T09:00:00Z"); + const prisma = makePrisma({ lab: { takenAt } }); + + const at = await findSatisfyingEvent(prisma as never, "u1", { + ...base, + measurementType: null, + origin: "ENCOUNTER", + }); + + expect(at).toBeNull(); + // Refused before the query, not filtered after it: an appointment is not a + // question this matcher should be asking the database. + expect(prisma.labResult.findFirst).not.toHaveBeenCalled(); + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + }); + + it("still auto-satisfies an ordinary free-text checkup", async () => { + // The counterpart, so the refusal above cannot be "never resolve anything". + const takenAt = new Date("2026-07-04T09:00:00Z"); + const prisma = makePrisma({ lab: { takenAt } }); + + const at = await findSatisfyingEvent(prisma as never, "u1", { + ...base, + measurementType: null, + origin: "VORSORGE", + }); + + expect(at).toEqual(takenAt); + }); }); diff --git a/src/lib/measurement-reminders/dto.ts b/src/lib/measurement-reminders/dto.ts index 96c868fce..0890cfd8f 100644 --- a/src/lib/measurement-reminders/dto.ts +++ b/src/lib/measurement-reminders/dto.ts @@ -26,11 +26,11 @@ export interface MeasurementReminderDtoShape { * a Coach cadence suggestion). The UI labels the source. * * Deliberately two members, while `ReminderOrigin` in the schema has three. - * The third, `ENCOUNTER`, belongs to a booked visit and never reaches a - * Vorsorge surface: every query that feeds this mapper excludes it, and the + * The third, `ENCOUNTER`, belongs to a booked visit and does not appear on + * this surface: every lookup that feeds this mapper excludes it, and the * published enum stays closed at these two so the reminder contract does not * move. Widening this union is the wrong repair for a type error here — the - * right one is finding the read site that lost its exclusion. + * right one is finding the lookup that lost its exclusion. */ origin: "VORSORGE" | "COACH"; notifyHour: number; @@ -44,11 +44,18 @@ export interface MeasurementReminderDtoShape { /** * The invariant this mapper enforces: an `ENCOUNTER`-origin row is a booked - * visit's one-shot reminder and belongs to no Vorsorge surface. Four read - * sites exclude it — the reminder list route, the daily digest, the insights - * preventive-care block and the MCP preventive-care tool. Reaching here means - * one of them lost its filter, and the honest outcome is a loud failure rather - * than an appointment relabelled as a checkup. + * visit's one-shot reminder and is not part of the measurement-reminder + * surface at all. Nine lookups keep it out — four list reads (the reminder + * list route, the daily digest, the insights preventive-care block and the MCP + * preventive-care tool) and five by-id lookups (read, edit, delete, complete + * and satisfy), which answer 404 rather than filtering, because an appointment + * is not addressable by that family. + * + * Reaching here means one of the nine lost its filter. The failure is loud on + * purpose, but note what it cannot undo: on `complete` and `satisfy` the throw + * used to land AFTER the write, so the caller got a 500 for a mutation that + * had already happened. Those two now refuse in the lookup, which is why the + * exclusion belongs in the query and not in a check after it. * * Proven by `src/__tests__/encounter-reminder-exclusion.test.ts`. */ diff --git a/src/lib/measurement-reminders/resolve.ts b/src/lib/measurement-reminders/resolve.ts index cc04cc7f3..8af993000 100644 --- a/src/lib/measurement-reminders/resolve.ts +++ b/src/lib/measurement-reminders/resolve.ts @@ -27,6 +27,12 @@ export interface ResolvableReminder { anchorDate: Date | null; lastSatisfiedAt: Date | null; createdAt: Date; + /** + * Optional so the many callers that construct this shape by hand keep + * compiling; absent means "an ordinary reminder", which is the safe reading. + * Only `ENCOUNTER` changes the answer. + */ + origin?: import("@/generated/prisma/client").ReminderOrigin; } /** @@ -50,6 +56,17 @@ export async function findSatisfyingEvent( userId: string, reminder: ResolvableReminder, ): Promise { + // An appointment is not satisfied by a measurement, and refusing it here is + // not a filter — it is the absence of a question worth asking. + // + // An appointment reminder is free-text by construction: there is no reading + // that means "you went". The free-text arm below resolves from ANY lab + // result, so without this a routine blood panel would mark next month's + // cardiology appointment as attended and silence its nudge. Going is the only + // thing that satisfies a visit, and that is recorded by moving the visit to + // DONE through the encounter routes. + if (reminder.origin === "ENCOUNTER") return null; + const floor = satisfactionFloor(reminder); if (reminder.measurementType !== null) { diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 45e1a5196..15a860b41 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -101,13 +101,14 @@ const practitioner = z const linkedTarget = z .object({ id: z.string(), - label: z.string(), + label: z.string().nullable(), date: z.string().nullable(), + redacted: z.boolean(), }) .meta({ id: "EncounterLinkedTarget", description: - "One thing filed against a visit, resolved. The label and date are computed server-side from the target itself — a document's title and filing date, a lab result's panel and analyte and draw date, a condition's label and onset — so the client never re-derives them and the two cannot drift.", + "One thing filed against a visit, resolved. The label and date are computed server-side from the target itself — a document's title and filing date, a lab result's panel and analyte and draw date, a condition's label and onset — so the client never re-derives them and the two cannot drift. Both go null together, with `redacted: true`, when the caller's grant does not cover the target's own domain: a visit lives in the health background but points at the vault, the labs and the illness journal, and a grant that opened only the first was never consent for the names in the other three. The link itself is still listed — the caller may know the visit produced a lab result — and `redacted` is what tells a withheld name apart from a target that simply has no date.", }); const encounterLinks = z @@ -122,6 +123,23 @@ const encounterLinks = z "The three link families of one visit, each ordered by when it was filed. Present on the detail response, absent from the list.", }); +const skipReport = z + .object({ + catalogueKeys: z.array( + z.object({ + catalogue: z.string(), + key: z.string(), + links: z.number(), + }), + ), + links: z.number(), + }) + .meta({ + id: "EncounterSkipReport", + description: + "What the write could not do, named. `links: 0` with an empty list is the ordinary answer. Anything else is a side effect the caller's grant did not reach — today only `checkupClosure`, the closing of a preventive-care checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will not look at it again.", + }); + const encounter = z .object({ id: z.string(), @@ -133,6 +151,7 @@ const encounter = z outcome: z.string().nullable(), reminderNextDueAt: z.string().nullable(), links: encounterLinks.optional(), + skipped: skipReport.optional(), createdAt: z.string(), updatedAt: z.string(), }) diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 245c002e6..822b20460 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -823,8 +823,19 @@ export async function handleCallback(update: TelegramUpdate) { // v1.19.0 — mark a Vorsorge/measurement reminder done; satisfy the // cadence via the shared primitive. const reminderId = data.slice("measure_done:".length).trim(); + // An appointment reminder is not addressable here, the same refusal the + // by-id HTTP family makes: it belongs to a visit, is managed through the + // visit routes, and sits in a different sharing domain. The lookup answers + // "not found" rather than acting on it — satisfying one would mark an + // appointment attended from a chat button, and postponing one would move a + // date the visit record still believes it owns. const reminder = await prisma.measurementReminder.findFirst({ - where: { id: reminderId, userId: user.id, deletedAt: null }, + where: { + id: reminderId, + userId: user.id, + deletedAt: null, + origin: { not: "ENCOUNTER" }, + }, select: { id: true, // v1.19.2 — the target metric; drives the optional numeric capture. @@ -912,8 +923,19 @@ export async function handleCallback(update: TelegramUpdate) { ); return; } + // An appointment reminder is not addressable here, the same refusal the + // by-id HTTP family makes: it belongs to a visit, is managed through the + // visit routes, and sits in a different sharing domain. The lookup answers + // "not found" rather than acting on it — satisfying one would mark an + // appointment attended from a chat button, and postponing one would move a + // date the visit record still believes it owns. const reminder = await prisma.measurementReminder.findFirst({ - where: { id: reminderId, userId: user.id, deletedAt: null }, + where: { + id: reminderId, + userId: user.id, + deletedAt: null, + origin: { not: "ENCOUNTER" }, + }, select: { id: true }, }); if (!reminder) { diff --git a/tests/integration/encounter-appointment-reminder.test.ts b/tests/integration/encounter-appointment-reminder.test.ts index 31bece0fa..1cf677656 100644 --- a/tests/integration/encounter-appointment-reminder.test.ts +++ b/tests/integration/encounter-appointment-reminder.test.ts @@ -153,6 +153,50 @@ describe("booking a visit", () => { }); }); +describe("what the notification will say", () => { + it("labels a practice-less appointment in the person's own language", async () => { + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { locale: "de" }, + }); + + await post({ + occurredAt: daysFromNow(10).toISOString(), + status: "PLANNED", + kind: "SPECIALIST", + }); + + const reminder = await prisma.measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID, origin: "ENCOUNTER" }, + }); + // The label IS the push body. A raw enum constant reaching a lock screen + // is not a fallback, it is a leak of the schema into the product. + expect(reminder.label).not.toBe("SPECIALIST"); + expect(reminder.label).not.toMatch(/^[A-Z_]+$/); + expect(reminder.label).toBe("Facharzttermin"); + }); + + it("prefers the practice name when there is one", async () => { + const prisma = getPrismaClient(); + const practitioner = await prisma.practitioner.create({ + data: { userId: OWNER_ID, name: "Praxis Nord" }, + }); + + await post({ + occurredAt: daysFromNow(10).toISOString(), + status: "PLANNED", + kind: "SPECIALIST", + practitionerId: practitioner.id, + }); + + const reminder = await prisma.measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID, origin: "ENCOUNTER" }, + }); + expect(reminder.label).toBe("Praxis Nord"); + }); +}); + describe("rescheduling", () => { it("moves the same row and never mints a second", async () => { const prisma = getPrismaClient(); @@ -228,6 +272,144 @@ describe("rescheduling", () => { }); }); +/** + * The orphan class, replayed as the sequence that produced it. + * + * A reminder nobody points at still fires: the cron reads the engine table, not + * the visit list. So an ENCOUNTER-origin row that no encounter owns pushes + * notifications for an appointment the person can no longer see, edit or + * cancel, and deleting the visit does not reach it. Every assertion below + * counts LIVE ENCOUNTER rows rather than checking that some particular row + * moved, because the defect was an extra row rather than a wrong one. + */ +async function liveEncounterReminders() { + return getPrismaClient().measurementReminder.findMany({ + where: { userId: OWNER_ID, origin: "ENCOUNTER", deletedAt: null }, + }); +} + +describe("an appointment reminder can never be orphaned", () => { + 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("refuses to point a still-booked visit at a checkup", async () => { + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + const checkup = await seedCheckup(); + + const res = await patch(created.id as string, { reminderId: checkup.id }); + + // The same contradiction the create arm already refuses: a visit that has + // not happened cannot have closed anything. + expect(res.status).toBe(422); + expect(await liveEncounterReminders()).toHaveLength(1); + }); + + it("keeps exactly one live reminder across the sequence that produced the orphan", async () => { + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + const checkup = await seedCheckup(); + const first = await liveEncounterReminders(); + expect(first).toHaveLength(1); + + // 1. Re-point at a checkup — refused while the visit is still booked. + await patch(created.id as string, { reminderId: checkup.id }); + expect(await liveEncounterReminders()).toHaveLength(1); + + // 2. Move the date. Before the fix this minted a SECOND reminder, because + // the re-anchor path looked only at the visit's foreign key and that key + // had been walked off its own row by step 1. + const moved = daysFromNow(21); + expect( + (await patch(created.id as string, { occurredAt: moved.toISOString() })) + .status, + ).toBe(200); + + const after = await liveEncounterReminders(); + expect(after).toHaveLength(1); + expect(after[0]!.nextDueAt?.toISOString()).toBe(moved.toISOString()); + }); + + it("leaves nothing live behind when the visit is deleted", async () => { + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + const checkup = await seedCheckup(); + await patch(created.id as string, { reminderId: checkup.id }); + await patch(created.id as string, { + occurredAt: daysFromNow(21).toISOString(), + }); + + await deleteEncounter( + new Request(`http://localhost/api/encounters/${created.id}`, { + method: "DELETE", + }) as never, + { params: Promise.resolve({ id: created.id as string }) } as never, + ); + + // Nothing may still be armed for a visit that no longer exists. + expect(await liveEncounterReminders()).toEqual([]); + // The person's own checkup is untouched — it was never this visit's to retire. + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }) + ).deletedAt, + ).toBeNull(); + }); + + it("switching a booked visit to done leaves no live appointment reminder", async () => { + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + + await patch(created.id as string, { + status: "DONE", + occurredAt: daysFromNow(-1).toISOString(), + }); + + // The row may survive as history, but it must not still be armed. + const live = await liveEncounterReminders(); + for (const row of live) { + expect(row.enabled).toBe(false); + expect(row.nextDueAt).toBeNull(); + } + }); +}); + describe("closing a checkup by filing a visit", () => { async function seedCheckup() { return getPrismaClient().measurementReminder.create({ @@ -291,6 +473,66 @@ describe("closing a checkup by filing a visit", () => { ); }); + it("anchors the checkup on the visit date, not on when it was typed in", async () => { + const prisma = getPrismaClient(); + const checkup = await seedCheckup(); + const visitAt = daysFromNow(-30); + + await post({ + occurredAt: visitAt.toISOString(), + status: "DONE", + reminderId: checkup.id, + }); + + const after = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + // A yearly panel drawn thirty days ago is next due 335 days from now, not + // 365. Satisfying at "now" would quietly move every backdated entry's next + // due date forward by however long the person took to record it. + expect(after.lastSatisfiedAt?.toISOString()).toBe(visitAt.toISOString()); + const daysOut = + (after.nextDueAt!.getTime() - Date.now()) / (24 * 60 * 60 * 1000); + expect(daysOut).toBeGreaterThan(330); + expect(daysOut).toBeLessThan(340); + }); + + it("does not advance the checkup again when a visit re-enters DONE", 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 }, + }); + + // Correcting a mis-filed visit is an ordinary thing to do, and the round + // trip is a transition each way — so the status gate alone does not stop + // the second satisfy. Satisfying at the VISIT instant does: it is not + // strictly after the last one, and the primitive is forward-only. + await patch(created.id as string, { status: "NO_SHOW" }); + expect((await patch(created.id as string, { status: "DONE" })).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({ @@ -322,6 +564,158 @@ describe("closing a checkup by filing a visit", () => { }); }); +describe("the list window", () => { + it("honours an upper bound that is earlier than now", async () => { + // The past arm bounds itself at `now`, and the user's own `to` used to be + // overwritten by that bound rather than combined with it, so every + // narrower window silently widened to "everything up to today". + await post({ occurredAt: daysFromNow(-2).toISOString(), status: "DONE" }); + + const { GET: listVisits } = await import("@/app/api/encounters/route"); + const res = await listVisits( + new Request( + `http://localhost/api/encounters?to=${daysFromNow(-30).toISOString()}`, + ) as never, + ); + expect(res.status).toBe(200); + const listed = (await res.json()) as { + data: { upcoming: unknown[]; past: unknown[] }; + }; + expect(listed.data.past).toEqual([]); + expect(listed.data.upcoming).toEqual([]); + }); + + it("still returns a visit inside the window", async () => { + // The counterpart, so the fix cannot be "return nothing". + await post({ occurredAt: daysFromNow(-2).toISOString(), status: "DONE" }); + + const { GET: listVisits } = await import("@/app/api/encounters/route"); + const res = await listVisits( + new Request( + `http://localhost/api/encounters?from=${daysFromNow(-5).toISOString()}&to=${daysFromNow(-1).toISOString()}`, + ) as never, + ); + const listed = (await res.json()) as { data: { past: unknown[] } }; + expect(listed.data.past).toHaveLength(1); + }); +}); + +describe("an appointment is not addressable through the reminder API", () => { + async function bookedReminder() { + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }); + return getPrismaClient().measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID, origin: "ENCOUNTER" }, + }); + } + + it("404s the read, and does not leak the appointment", async () => { + const reminder = await bookedReminder(); + const { GET } = await import("@/app/api/measurement-reminders/[id]/route"); + const res = await GET( + new Request( + `http://localhost/api/measurement-reminders/${reminder.id}`, + ) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + expect(res.status).toBe(404); + }); + + it("404s complete WITHOUT committing the satisfy", async () => { + // The shape that mattered most: this is the endpoint behind the Done + // button on an appointment push. It used to write, then 500 on the mapper. + const reminder = await bookedReminder(); + const { POST: complete } = + await import("@/app/api/measurement-reminders/[id]/complete/route"); + const res = await complete( + new Request( + `http://localhost/api/measurement-reminders/${reminder.id}/complete`, + { method: "POST" }, + ) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + + expect(res.status).toBe(404); + const after = await getPrismaClient().measurementReminder.findUniqueOrThrow( + { where: { id: reminder.id } }, + ); + expect(after.lastSatisfiedAt).toBeNull(); + expect(after.nextDueAt?.toISOString()).toBe( + reminder.nextDueAt?.toISOString(), + ); + }); + + it("404s satisfy WITHOUT committing it", async () => { + const reminder = await bookedReminder(); + const { POST: satisfy } = + await import("@/app/api/measurement-reminders/[id]/satisfy/route"); + const res = await satisfy( + new Request( + `http://localhost/api/measurement-reminders/${reminder.id}/satisfy`, + { method: "POST" }, + ) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + + expect(res.status).toBe(404); + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: reminder.id }, + }) + ).lastSatisfiedAt, + ).toBeNull(); + }); + + it("404s the edit, so a one-shot cannot be given a cadence", async () => { + const reminder = await bookedReminder(); + const { PATCH: editReminder } = + await import("@/app/api/measurement-reminders/[id]/route"); + const res = await editReminder( + new Request(`http://localhost/api/measurement-reminders/${reminder.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ intervalDays: 7 }), + }) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + + expect(res.status).toBe(404); + // A cadence here would turn a spent one-shot into a permanent re-nag that + // shows up in no list the person can reach. + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: reminder.id }, + }) + ).intervalDays, + ).toBeNull(); + }); + + it("404s the delete, so the row survives for the visit that owns it", async () => { + const reminder = await bookedReminder(); + const { DELETE: destroy } = + await import("@/app/api/measurement-reminders/[id]/route"); + const res = await destroy( + new Request(`http://localhost/api/measurement-reminders/${reminder.id}`, { + method: "DELETE", + }) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + + expect(res.status).toBe(404); + // This verb hard-deletes. A delegate holding only the measurements domain + // reaching it would destroy a row belonging to the profile domain. + expect( + await getPrismaClient().measurementReminder.count({ + where: { id: reminder.id }, + }), + ).toBe(1); + }); +}); + describe("the Vorsorge list", () => { it("does not show a booked appointment as a checkup", async () => { const prisma = getPrismaClient(); diff --git a/tests/integration/encounter-scoped-delegate.test.ts b/tests/integration/encounter-scoped-delegate.test.ts new file mode 100644 index 000000000..524155ccb --- /dev/null +++ b/tests/integration/encounter-scoped-delegate.test.ts @@ -0,0 +1,351 @@ +/** + * What a NARROWLY scoped delegate may learn, and may do, through a visit. + * + * Visits sit in the health-background domain and link out to three others: the + * document vault, the lab results and the illness journal. That makes a visit a + * seam between domains, and a seam is where a scoped grant leaks. + * + * Two properties, both about somebody holding `profile` and nothing else: + * + * 1. **Names do not flow through the seam.** The values never did — the link + * list carries an id, a label and a date, not the document body or the + * analyte's result. But "Ferritin, 30 June" and "Oncology discharge + * letter" are themselves the sensitive part, and a grant that opened only + * the health background was never consent for them. A delegate without + * the target's domain gets the shape of the link and not its name. + * 2. **Closing a checkup needs the checkup's domain.** Satisfying one + * re-anchors a preventive-care cadence, which is a measurements-domain + * write. A profile-only delegate filing a visit still files it; the + * closure is skipped and SAID, rather than performed silently or refused + * with the whole request. + * + * The owner is checked in every case too, because a fix that reads "hide it + * from everyone" would satisfy half of these and break the product. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } 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 = "scoped-visit-owner"; +const DELEGATE_ID = "scoped-visit-delegate"; + +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: daysFromNow(1) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** Sign in as the delegate and switch into the owner's record. */ +async function switchInto( + access: "READ" | "WRITE" | "MANAGE", + scope: ShareDomain[] | null, +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access, + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await signIn(DELEGATE_ID); + await switchSessionTo(session.id, OWNER_ID); +} + +async function seedRecord() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `scoped-visit-${name}`, + email: `scoped-visit-${name}@example.test`, + timezone: "Europe/Berlin", + }, + }); + } + + const document = await prisma.inboundDocument.create({ + data: { + userId: OWNER_ID, + kind: "LAB_RESULT", + title: "Oncology discharge letter", + mimeType: "application/pdf", + byteSize: 10, + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + documentDate: daysFromNow(-3), + }, + }); + const labResult = await prisma.labResult.create({ + data: { + userId: OWNER_ID, + analyte: "Ferritin", + panel: "Iron panel", + value: 91, + unit: "ng/mL", + takenAt: daysFromNow(-3), + }, + }); + const episode = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Hashimoto thyroiditis", + type: "AUTOIMMUNE", + onsetAt: daysFromNow(-200), + }, + }); + const encounter = await prisma.encounter.create({ + data: { + userId: OWNER_ID, + occurredAt: daysFromNow(-2), + status: "DONE", + kind: "SPECIALIST", + }, + }); + await prisma.encounterDocumentLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + documentId: document.id, + }, + }); + await prisma.encounterLabLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + labResultId: labResult.id, + }, + }); + await prisma.encounterConditionLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + episodeId: episode.id, + }, + }); + return { document, labResult, episode, encounter }; +} + +type Link = { + id: string; + label: string | null; + date: string | null; + redacted: boolean; +}; + +type Links = { + documents: Link[]; + labResults: Link[]; + conditions: Link[]; +}; + +async function readLinks(encounterId: string): Promise { + const { GET } = await import("@/app/api/encounters/[id]/route"); + const res = await GET( + new Request(`http://localhost/api/encounters/${encounterId}`) as never, + { params: Promise.resolve({ id: encounterId }) } as never, + ); + expect(res.status).toBe(200); + const parsed = (await res.json()) as { data: { links: Links } }; + return parsed.data.links; +} + +let seeded: Awaited>; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + seeded = await seedRecord(); +}); + +describe("link labels follow the target's own domain", () => { + it("gives the owner every name", async () => { + await signIn(OWNER_ID); + const links = await readLinks(seeded.encounter.id); + + expect(links.documents[0]!.label).toBe("Oncology discharge letter"); + expect(links.labResults[0]!.label).toBe("Iron panel · Ferritin"); + expect(links.conditions[0]!.label).toBe("Hashimoto thyroiditis"); + expect(links.documents[0]!.redacted).toBe(false); + }); + + it("gives an entire-record delegate every name", async () => { + await switchInto("READ", null); + const links = await readLinks(seeded.encounter.id); + + // The ordinary sharing case must not change. + expect(links.documents[0]!.label).toBe("Oncology discharge letter"); + expect(links.labResults[0]!.label).toBe("Iron panel · Ferritin"); + expect(links.conditions[0]!.label).toBe("Hashimoto thyroiditis"); + }); + + it("withholds all three names from a profile-only delegate", async () => { + await switchInto("READ", ["profile"]); + const links = await readLinks(seeded.encounter.id); + + // The links still exist — the visit produced three things and saying so is + // within the grant. What they are called is not. + expect(links.documents).toHaveLength(1); + expect(links.labResults).toHaveLength(1); + expect(links.conditions).toHaveLength(1); + + // Null rather than an invented placeholder: the server is not telling + // this caller what the thing is called, and inventing a name here would + // make an absence look like a value. The array the entry sits in is the + // kind, so the client still renders "a linked lab result" from its own + // bundle without the server shipping English down the wire. + for (const link of [ + links.documents[0]!, + links.labResults[0]!, + links.conditions[0]!, + ]) { + expect(link.label).toBeNull(); + expect(link.redacted).toBe(true); + // The date is the other half of the identification and goes with the name. + expect(link.date).toBeNull(); + // The id stays: it is already the caller's own link row. + expect(link.id).toBeTruthy(); + } + }); + + it("reveals only the domain the delegate actually holds", async () => { + await switchInto("READ", ["profile", "labs"]); + const links = await readLinks(seeded.encounter.id); + + // The point of doing this per target rather than all-or-nothing. + expect(links.labResults[0]!.label).toBe("Iron panel · Ferritin"); + expect(links.labResults[0]!.redacted).toBe(false); + expect(links.documents[0]!.label).toBeNull(); + expect(links.conditions[0]!.label).toBeNull(); + }); +}); + +describe("closing a checkup needs the checkup's own domain", () => { + 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), + }, + }); + } + + async function fileVisit(reminderId: string) { + const { POST } = await import("@/app/api/encounters/route"); + return POST( + new Request("http://localhost/api/encounters", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + occurredAt: daysFromNow(-1).toISOString(), + status: "DONE", + reminderId, + }), + }) as never, + ); + } + + it("closes it for the owner", async () => { + const checkup = await seedCheckup(); + await signIn(OWNER_ID); + + expect((await fileVisit(checkup.id)).status).toBe(201); + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }) + ).lastSatisfiedAt, + ).not.toBeNull(); + }); + + it("files the visit but skips the closure for a profile-only delegate", async () => { + const checkup = await seedCheckup(); + await switchInto("WRITE", ["profile"]); + + const res = await fileVisit(checkup.id); + // The visit is theirs to file; refusing the whole request over a side + // effect they cannot perform would lose the record for no gain. + expect(res.status).toBe(201); + const parsed = (await res.json()) as { + data: { + skipped: { links: number; catalogueKeys: Array<{ key: string }> }; + }; + }; + + // Skipped, and SAID. Silence here is the failure mode: the person would + // believe a checkup was closed that is still due. + expect(parsed.data.skipped.links).toBe(1); + expect(parsed.data.skipped.catalogueKeys[0]!.key).toBe(checkup.id); + + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }) + ).lastSatisfiedAt, + ).toBeNull(); + expect(await getPrismaClient().encounter.count()).toBe(2); + }); + + it("closes it for a delegate who holds measurements too", async () => { + const checkup = await seedCheckup(); + await switchInto("WRITE", ["profile", "measurements"]); + + const res = await fileVisit(checkup.id); + expect(res.status).toBe(201); + const parsed = (await res.json()) as { + data: { skipped: { links: number } }; + }; + expect(parsed.data.skipped.links).toBe(0); + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }) + ).lastSatisfiedAt, + ).not.toBeNull(); + }); +});