Keep an appointment reminder inside the visit that owns it

Review of the visit work found the appointment arm leaking in both
directions: rows the visit routes could strand, and a reminder surface
that would act on one as though it were a checkup.

An edit could orphan a live reminder. Pointing a still-booked visit at
the checkup it closed walked the foreign key off the visit's own
reminder, and the next reschedule then minted a second one, because the
re-anchor path asked the key what the visit owned instead of asking the
engine. An orphan fires: the cron reads the reminder table, not the
visit list, so it pushed for an appointment nobody could see, edit or
cancel, and deleting the visit did not reach it. The edit arm now
refuses the contradiction the create arm already refused, ownership is
resolved through the relation, deletion retires the row it owns rather
than the row it points at, and a sweep at the end of every visit write
retires anything left armed that no visit claims -- so the state is
unreachable rather than merely unlikely.

The by-id reminder routes never excluded appointments, and that was the
larger hole. Marking one done committed the satisfy and THEN failed on
the mapper, so every appointment push a person acknowledged answered 500
after the write had landed. An edit could give a spent one-shot a
cadence and turn it into a permanent nudge that appears in no list, or
switch a cancelled one back on. The delete is a hard delete, so somebody
holding only the measurements domain could destroy a row belonging to a
visit in the health background. All five refuse in the lookup now -- an
appointment is not addressable there at all -- and the chat buttons that
mark a reminder done or postpone it refuse it the same way. Nor is an
appointment satisfied by a lab result: the auto-satisfy matcher used to
resolve any free-text reminder from any panel, which would have marked
next month's cardiology appointment attended.

A visit is a seam between four domains, and names were crossing it. The
values never did, but the title of a discharge letter is itself the
sensitive part, and a grant that opened only the health background was
never consent for it. Link labels and dates now follow the target's own
domain; the link is still listed, so a delegate learns the visit
produced a lab result without learning which analyte. Closing a checkup
follows the same rule: it re-anchors a preventive-care cadence, which is
a measurements write, so a health-background delegate files the visit
and the closure is reported as skipped rather than performed in silence.

Three smaller repairs. A checkup now closes at the visit's own instant
rather than at the moment it was typed in, which anchors a backdated
entry correctly and makes correcting a mis-filed visit the no-op it
should always have been. A window ending before today no longer widens
back out to today. And the fallback in a push notification was the raw
enum constant, so a lock screen could read a schema value; it reads the
record owner's own language now, in all six.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 11:24:08 +02:00
1 parent d24dfd89a9
commit 339e815527
29 files changed
+1618 -92

No files matched your search

+45 -3
View File
@@ -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:
+13
View File
@@ -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"
}
}
}
+13
View File
@@ -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"
}
}
}
+13
View File
@@ -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"
}
}
}
+13
View File
@@ -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"
}
}
}
+13
View File
@@ -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"
}
}
}
+13
View File
@@ -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"
}
}
}
@@ -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);
+9 -2
View File
@@ -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 });
}
+10 -5
View File
@@ -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,
+80 -36
View File
@@ -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);
});
}
+50 -9
View File
@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
);
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<Response> {
meta: { status: created.status, kind: created.kind },
});
return apiSuccess(toEncounterDTO(created), 201);
return apiSuccess(
toEncounterDTO(created, undefined, summarizeRestoreSkips(skips)),
201,
);
}
@@ -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);
@@ -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);
@@ -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);
+3
View File
@@ -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");
}
@@ -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);
});
});
+4 -3
View File
@@ -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<
+33 -4
View File
@@ -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(),
};
+47
View File
@@ -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");
}
}
+193 -11
View File
@@ -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<string> {
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<LinkTargetKind, ShareDomain> = {
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<EncounterLinksDTO> {
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<void> {
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<string | null> {
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<number> {
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<void> {
const reminderId = await findOwnAppointmentReminderId(
tx,
userId,
encounterId,
);
if (!reminderId) return;
await deleteAppointmentReminder(tx, reminderId, at);
}
+10 -1
View File
@@ -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 {
@@ -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);
});
});
+15 -8
View File
@@ -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`.
*/
+17
View File
@@ -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<Date | null> {
// 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) {
+21 -2
View File
@@ -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(),
})
+24 -2
View File
@@ -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) {
@@ -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();
@@ -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<Links> {
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<ReturnType<typeof seedRecord>>;
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();
});
});