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); + }); +});