From 3482581ce16c6c8efec3d97615960b9e2988a3b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 13:07:10 +0200 Subject: [PATCH] fix(encounters): name the practice address on an appointment nudge again Sealing the practitioner's address left the appointment arm reading the old readable column, which is now empty, so a booked visit's push lost the address on booking, rescheduling and restoring a deleted visit. The reminder no longer keeps its own copy of the address. The reminder tick resolves it from the owning visit's practitioner when it builds the push and decrypts it there, so an edit of the address also reaches a nudge that is already booked. MeasurementReminder.location stays NULL on appointment rows: booking writes none, a re-anchor clears one an earlier release wrote, and a backup neither carries nor restores one. A checkup's own free-text location is unchanged. --- prisma/schema.prisma | 11 +- src/app/api/encounters/[id]/restore/route.ts | 1 - src/app/api/encounters/[id]/route.ts | 5 - src/app/api/encounters/route.ts | 4 +- src/lib/encounters/appointment-reminder.ts | 76 +++++++++- src/lib/encounters/service.ts | 9 +- ...minders-backup-appointment-address.test.ts | 67 +++++++++ src/lib/export/reminders-backup.ts | 10 +- src/lib/jobs/measurement-reminder.ts | 12 +- .../encounter-appointment-reminder.test.ts | 136 ++++++++++++++++++ 10 files changed, 310 insertions(+), 21 deletions(-) create mode 100644 src/lib/export/__tests__/reminders-backup-appointment-address.test.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ff13f258..a79dee095 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5519,7 +5519,16 @@ model MeasurementReminder { /// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour. /// DST-safe windowing is applied at the cron via the existing TZ helpers. notifyHour Int @default(9) @map("notify_hour") - /// Free-text "wo": "Hausarzt", "Labor Dr. X". Optional. + /// Free-text "wo": "Hausarzt", "Labor Dr. X". Optional. A checkup's own + /// words, readable like its label. + /// + /// Always NULL on an ENCOUNTER row: an appointment's address is the + /// practitioner's, encrypted there (`Practitioner.locationEncrypted`), and + /// the reminder tick resolves it from the owning visit when it builds the + /// push (`appointmentReminderAddress`). Earlier releases copied it here in + /// the clear; `free-text-encryption-backfill` clears those copies, a + /// re-anchor clears the row's own, and the backup neither writes nor + /// restores one. location String? /// Server-computed canonical next-due instant (server-authoritative). nextDueAt DateTime? @map("next_due_at") diff --git a/src/app/api/encounters/[id]/restore/route.ts b/src/app/api/encounters/[id]/restore/route.ts index 73c5731fd..3fce66112 100644 --- a/src/app/api/encounters/[id]/restore/route.ts +++ b/src/app/api/encounters/[id]/restore/route.ts @@ -70,7 +70,6 @@ export const POST = apiHandler( userId: user.id, occurredAt: existing.occurredAt, practitionerName: existing.practitioner?.name ?? null, - practitionerLocation: existing.practitioner?.location ?? null, kindLabel: encounterKindLabel(existing.kind, locale), }, timezone, diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index e651b6e12..a83c937cf 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -130,7 +130,6 @@ export const PATCH = apiHandler( const visible = await actingDomainVisibility(tx, grantId); let practitionerId = existing.practitionerId; let practitionerName: string | null = null; - let practitionerLocation: string | null = null; if (entry.practitionerId !== undefined) { if (entry.practitionerId === null) { @@ -144,7 +143,6 @@ export const PATCH = apiHandler( if (!resolved) return "unknown-practitioner" as const; practitionerId = resolved.id; practitionerName = resolved.name; - practitionerLocation = resolved.location; } } else if (practitionerId) { const resolved = await resolveOwnedPractitioner( @@ -153,7 +151,6 @@ export const PATCH = apiHandler( practitionerId, ); practitionerName = resolved?.name ?? null; - practitionerLocation = resolved?.location ?? null; } const data: Prisma.EncounterUpdateInput = {}; @@ -206,7 +203,6 @@ export const PATCH = apiHandler( userId: user.id, occurredAt: nextOccurredAt, practitionerName, - practitionerLocation, kindLabel: encounterKindLabel(entry.kind ?? existing.kind, locale), status: nextStatus, existingReminderId: ownAppointmentReminderId, @@ -222,7 +218,6 @@ export const PATCH = apiHandler( userId: user.id, occurredAt: nextOccurredAt, practitionerName, - practitionerLocation, kindLabel: encounterKindLabel(entry.kind ?? existing.kind, locale), status: nextStatus, existingReminderId: ownAppointmentReminderId, diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 1641c12b2..87e72f062 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -195,8 +195,7 @@ async function postEncounter(request: NextRequest): Promise { // A practitioner the caller does not own is a refusal rather than a // silently dropped field: the person named a practice and would otherwise // see the visit save without it. - let practitioner: - { id: string; name: string; location: string | null } | undefined; + let practitioner: { id: string; name: string } | undefined; if (entry.practitionerId) { practitioner = await resolveOwnedPractitioner( tx, @@ -241,7 +240,6 @@ async function postEncounter(request: NextRequest): Promise { userId: user.id, occurredAt, practitionerName: practitioner?.name ?? null, - practitionerLocation: practitioner?.location ?? null, kindLabel: encounterKindLabel(entry.kind, locale), status: entry.status, existingReminderId: null, diff --git a/src/lib/encounters/appointment-reminder.ts b/src/lib/encounters/appointment-reminder.ts index 4386b55e4..2565edcea 100644 --- a/src/lib/encounters/appointment-reminder.ts +++ b/src/lib/encounters/appointment-reminder.ts @@ -28,8 +28,18 @@ * partial unique index enforces this: the COACH one is `WHERE origin = 'COACH'` * so ENCOUNTER rows do not inherit it, and a similar index here would break * anyone who books two appointments. + * + * ── The practice address is read, not copied ──────────────────────────────── + * + * The practitioner's address is encrypted at rest (`locationEncrypted`), so + * the reminder row carries no copy of it: `MeasurementReminder.location` stays + * NULL on every ENCOUNTER row, and a re-anchor clears a copy an earlier release + * wrote. The tick resolves the address from the visit's practitioner when it + * builds the push ({@link appointmentReminderAddress}), which also means an + * edit of the address reaches a nudge that is already booked. */ import type { Prisma } from "@/generated/prisma/client"; +import { readPractitionerContact } from "@/lib/practitioners/dto"; import { computeReminderNextDueAt, type ReminderScheduleInput, @@ -41,8 +51,6 @@ export interface AppointmentReminderInput { occurredAt: Date; /** Used as the label when present; the kind label is the fallback. */ practitionerName: string | null; - /** Copied onto the reminder's free-text `location`. */ - practitionerLocation: string | null; /** The fallback label when the visit names no practice. */ kindLabel: string; } @@ -90,7 +98,6 @@ export async function mintAppointmentReminder( anchorDate: input.occurredAt, origin: "ENCOUNTER", notifyHour: appointmentNotifyHour(input.occurredAt, timezone), - location: input.practitionerLocation, // Stamped directly: see the note at the top of this file on why the // cadence helper cannot produce a one-shot's first slot. nextDueAt: input.occurredAt, @@ -119,7 +126,8 @@ export async function reanchorAppointmentReminder( label: reminderLabel(input), anchorDate: input.occurredAt, notifyHour: appointmentNotifyHour(input.occurredAt, timezone), - location: input.practitionerLocation, + // No readable copy of the address; clears one an earlier release wrote. + location: null, nextDueAt: input.occurredAt, enabled: true, deletedAt: null, @@ -178,3 +186,63 @@ export function appointmentNextDueAfterFiring( now, ); } + +/** + * What the reminder tick reads beside an ENCOUNTER row to name the practice + * address: the practitioner of the live visit that owns it. + */ +export const APPOINTMENT_REMINDER_ADDRESS_INCLUDE = { + encounters: { + where: { deletedAt: null }, + select: { + userId: true, + practitioner: { + select: { + userId: true, + deletedAt: true, + location: true, + locationEncrypted: true, + }, + }, + }, + take: 1, + }, +} as const; + +/** + * The address a reminder's push names. A checkup's own free-text `location`; + * for an appointment, the owning visit's practitioner address, decrypted + * here. Null when the visit names no live practitioner of the same account. + */ +export function appointmentReminderAddress(reminder: { + userId: string; + origin: string; + location: string | null; + encounters?: ReadonlyArray<{ + userId: string; + practitioner: { + userId: string; + deletedAt: Date | null; + location: string | null; + locationEncrypted: Uint8Array | null; + } | null; + }>; +}): string | null { + if (reminder.origin !== "ENCOUNTER") return reminder.location; + const visit = reminder.encounters?.[0]; + const practitioner = visit?.practitioner; + if ( + !visit || + !practitioner || + visit.userId !== reminder.userId || + practitioner.userId !== reminder.userId || + practitioner.deletedAt !== null + ) { + return null; + } + return readPractitionerContact( + practitioner.locationEncrypted, + practitioner.location, + "location", + ); +} diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index cb324a443..7d65b544c 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -475,22 +475,25 @@ export async function retireAppointmentReminderForVisit( * * Returns `undefined` when the id names nothing this account owns, which the * routes turn into a 404 — the same refusal shape a foreign encounter id gets. + * + * The address is deliberately not read here: it is encrypted on the + * practitioner, and the appointment reminder resolves it when it fires + * rather than keeping a copy (see `appointment-reminder.ts`). */ export async function resolveOwnedPractitioner( tx: Prisma.TransactionClient, userId: string, practitionerId: string, -): Promise<{ id: string; name: string; location: string | null } | undefined> { +): Promise<{ id: string; name: string } | undefined> { const row = await tx.practitioner.findUnique({ where: { id: practitionerId }, select: { id: true, name: true, - location: true, userId: true, deletedAt: true, }, }); if (!row || row.userId !== userId || row.deletedAt !== null) return undefined; - return { id: row.id, name: row.name, location: row.location }; + return { id: row.id, name: row.name }; } diff --git a/src/lib/export/__tests__/reminders-backup-appointment-address.test.ts b/src/lib/export/__tests__/reminders-backup-appointment-address.test.ts new file mode 100644 index 000000000..63a60d1ef --- /dev/null +++ b/src/lib/export/__tests__/reminders-backup-appointment-address.test.ts @@ -0,0 +1,67 @@ +/** + * An appointment reminder carries no readable copy of the practice address: + * the address lives encrypted on the practitioner and is resolved when the + * reminder fires. A file written before that (or before the backfill cleared + * the copy) must not plant the copy back on restore, and a checkup's own + * free-text location still round-trips. + */ +import { describe, expect, it, vi } from "vitest"; + +import { restoreRemindersData } from "@/lib/export/reminders-backup"; + +function fakeTx() { + const created: Array> = []; + const tx = { + measurementReminderEvent: { + deleteMany: vi.fn(async () => ({ count: 0 })), + createMany: vi.fn(async () => ({ count: 0 })), + }, + measurementReminder: { + deleteMany: vi.fn(async () => ({ count: 0 })), + createMany: vi.fn(async (args: { data: Record[] }) => { + created.push(...args.data); + return { count: args.data.length }; + }), + }, + }; + return { tx, created }; +} + +const BASE = { + createdAt: "2026-09-01T08:00:00.000Z", + updatedAt: "2026-09-01T08:00:00.000Z", +}; + +describe("restoreRemindersData", () => { + it("drops the address copy on an appointment reminder and keeps a checkup's location", async () => { + const { tx, created } = fakeTx(); + await restoreRemindersData( + tx as never, + "owner", + { + measurementReminders: [ + { + ...BASE, + id: "appt", + label: "Praxis Nord", + origin: "ENCOUNTER", + location: "Hauptstr. 1, Berlin", + }, + { + ...BASE, + id: "checkup", + label: "Blood panel", + origin: "VORSORGE", + location: "Labor Mitte", + }, + ], + measurementReminderEvents: [], + }, + [], + ); + expect(created.map((row) => [row.id, row.location])).toEqual([ + ["appt", null], + ["checkup", "Labor Mitte"], + ]); + }); +}); diff --git a/src/lib/export/reminders-backup.ts b/src/lib/export/reminders-backup.ts index 5ad1a8b15..ed3991166 100644 --- a/src/lib/export/reminders-backup.ts +++ b/src/lib/export/reminders-backup.ts @@ -189,7 +189,9 @@ export async function buildRemindersBackupSection( endsOn: row.endsOn?.toISOString() ?? null, origin: row.origin, notifyHour: row.notifyHour, - location: row.location, + // An appointment's address lives encrypted on its practitioner; a + // readable copy the backfill has not cleared yet is not carried. + location: row.origin === "ENCOUNTER" ? null : row.location, nextDueAt: row.nextDueAt?.toISOString() ?? null, lastSatisfiedAt: row.lastSatisfiedAt?.toISOString() ?? null, enabled: row.enabled, @@ -327,7 +329,11 @@ export async function restoreRemindersData( // the schema default is what those rows were living as. origin: entry.origin ?? "VORSORGE", notifyHour: entry.notifyHour ?? 9, - location: entry.location ?? null, + // An appointment reminder resolves its address from the practitioner + // when it fires; a file from before that must not plant a readable + // copy of it back. + location: + entry.origin === "ENCOUNTER" ? null : (entry.location ?? null), // Verbatim, not recomputed: `nextDueAt` is server-authoritative and // the exported value IS what the server had resolved. Recomputing // here would move a due date the person had already been shown. diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 4e1e89082..320d73a70 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -60,6 +60,10 @@ import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders" import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { + APPOINTMENT_REMINDER_ADDRESS_INCLUDE, + appointmentReminderAddress, +} from "@/lib/encounters/appointment-reminder"; /** * v1.18.0 — map a reminder's `measurementType` to the toggleable module @@ -321,6 +325,7 @@ export async function runMeasurementReminderTick( locale: true, }, }, + ...APPOINTMENT_REMINDER_ADDRESS_INCLUDE, }, }); @@ -471,16 +476,19 @@ export async function runMeasurementReminderTick( continue; } + // An appointment's address lives encrypted on its practitioner and is + // resolved here; a checkup's is its own free-text column. + const address = appointmentReminderAddress(reminder); const { title, body } = buildMeasurementReminderPayload( await resolveJobLocale(reminder.user.locale), reminder.label, - reminder.location, + address, ); const renderForRecipient = (locale: Locale) => { const rendered = buildMeasurementReminderPayload( locale, reminder.label, - reminder.location, + address, ); return { title: rendered.title, message: rendered.body }; }; diff --git a/tests/integration/encounter-appointment-reminder.test.ts b/tests/integration/encounter-appointment-reminder.test.ts index 656c62859..2ae3e692f 100644 --- a/tests/integration/encounter-appointment-reminder.test.ts +++ b/tests/integration/encounter-appointment-reminder.test.ts @@ -17,7 +17,10 @@ import { PATCH as patchEncounter, DELETE as deleteEncounter, } from "@/app/api/encounters/[id]/route"; +import { POST as restoreEncounter } from "@/app/api/encounters/[id]/restore/route"; import { GET as listReminders } from "@/app/api/measurement-reminders/route"; +import { encryptNote } from "@/lib/crypto/note-cipher"; +import { runMeasurementReminderTick } from "@/lib/jobs/measurement-reminder"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -842,3 +845,136 @@ describe("the Vorsorge list", () => { expect(listed.data[0]!.label).toBe("Annual blood panel"); }); }); + +/** + * The practice address on the nudge. + * + * The address is encrypted on the practitioner, so the reminder carries no + * readable copy of it: the tick resolves it from the visit's practitioner when + * it builds the push. Every path that re-anchors the reminder (booking, + * rescheduling, restoring a deleted visit) must still end in a push that names + * the address, and none of them may write it onto the reminder row. + */ +describe("the practice address on the nudge", () => { + const ADDRESS = "Hauptstr. 1, Berlin"; + + async function seedPractitioner() { + return getPrismaClient().practitioner.create({ + data: { + userId: OWNER_ID, + name: "Praxis Nord", + locationEncrypted: encryptNote(ADDRESS), + }, + }); + } + + /** Run the tick at the appointment instant and return the pushed body. */ + async function pushedBodyAt(at: Date): Promise { + const messages: string[] = []; + await runMeasurementReminderTick(getPrismaClient() as never, at, { + dispatch: (async (args: { message: string }) => { + messages.push(args.message); + return { dispatched: true } as never; + }) as never, + }); + return messages[0]; + } + + async function encounterReminderLocation() { + const row = await getPrismaClient().measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID, origin: "ENCOUNTER" }, + }); + return row.location; + } + + it("names the encrypted address when booked, and stores no copy of it", async () => { + const practitioner = await seedPractitioner(); + const occurredAt = daysFromNow(10); + await post({ + occurredAt: occurredAt.toISOString(), + status: "PLANNED", + practitionerId: practitioner.id, + }); + + expect(await encounterReminderLocation()).toBeNull(); + expect(await pushedBodyAt(occurredAt)).toContain(ADDRESS); + }); + + it("keeps the address after a reschedule and clears an old readable copy", async () => { + const prisma = getPrismaClient(); + const practitioner = await seedPractitioner(); + const created = ( + await body( + await post({ + occurredAt: daysFromNow(10).toISOString(), + status: "PLANNED", + practitionerId: practitioner.id, + }), + ) + ).data; + // A row written by an earlier release still carries a readable copy. + await prisma.measurementReminder.updateMany({ + where: { userId: OWNER_ID, origin: "ENCOUNTER" }, + data: { location: "old readable copy" }, + }); + + const moved = daysFromNow(20); + const res = await patch(created.id as string, { + occurredAt: moved.toISOString(), + }); + expect(res.status).toBe(200); + + expect(await encounterReminderLocation()).toBeNull(); + expect(await pushedBodyAt(moved)).toContain(ADDRESS); + }); + + it("keeps the address when a deleted visit is restored", async () => { + const practitioner = await seedPractitioner(); + const occurredAt = daysFromNow(10); + const created = ( + await body( + await post({ + occurredAt: occurredAt.toISOString(), + status: "PLANNED", + practitionerId: practitioner.id, + }), + ) + ).data; + const id = created.id as string; + await deleteEncounter( + new Request(`http://localhost/api/encounters/${id}`, { + method: "DELETE", + }) as never, + { params: Promise.resolve({ id }) } as never, + ); + const res = await restoreEncounter( + new Request(`http://localhost/api/encounters/${id}/restore`, { + method: "POST", + }) as never, + { params: Promise.resolve({ id }) } as never, + ); + expect(res.status).toBe(200); + + expect(await encounterReminderLocation()).toBeNull(); + expect(await pushedBodyAt(occurredAt)).toContain(ADDRESS); + }); + + it("follows an edit of the practitioner's address", async () => { + const prisma = getPrismaClient(); + const practitioner = await seedPractitioner(); + const occurredAt = daysFromNow(10); + await post({ + occurredAt: occurredAt.toISOString(), + status: "PLANNED", + practitionerId: practitioner.id, + }); + await prisma.practitioner.update({ + where: { id: practitioner.id }, + data: { locationEncrypted: encryptNote("Am Markt 3") }, + }); + + const pushed = await pushedBodyAt(occurredAt); + expect(pushed).toContain("Am Markt 3"); + expect(pushed).not.toContain(ADDRESS); + }); +});