diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7e7c2d163..06661dca7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -16369,8 +16369,9 @@ paths: tags: - Records summary: Soft-delete a practitioner - description: Stamps `deletedAt`. Every visit that named this entry survives with no practice attached, because the - reference nulls rather than cascades. Idempotent. Audits as `practitioner.contact.delete`. + description: Stamps `deletedAt`. The entry leaves the address book and the pickers; every live visit and vaccination + that named it keeps showing it, and the tombstone is kept while one does. Idempotent. Audits as + `practitioner.contact.delete`. parameters: - in: path name: id diff --git a/src/app/api/practitioners/[id]/route.ts b/src/app/api/practitioners/[id]/route.ts index 3377dd7b2..0d1f8819d 100644 --- a/src/app/api/practitioners/[id]/route.ts +++ b/src/app/api/practitioners/[id]/route.ts @@ -7,9 +7,10 @@ * read by id and then checked against the resolved user, so a foreign id and a * missing id give the same 404 and probing learns nothing. * - * Deleting a practitioner keeps every visit that named it. The foreign key is - * SetNull, so the visits survive with no practice attached rather than - * disappearing with the address-book entry. + * Deleting a practitioner takes it out of the address book and the pickers, + * not out of the history: every live visit and dose that named it keeps + * showing it, and the nightly purge keeps the tombstone for as long as one + * does (`soft-delete-retention.ts`). */ import { NextRequest } from "next/server"; diff --git a/src/lib/encounters/__tests__/appointment-reminder.test.ts b/src/lib/encounters/__tests__/appointment-reminder.test.ts index 6c3d881d2..47eada90e 100644 --- a/src/lib/encounters/__tests__/appointment-reminder.test.ts +++ b/src/lib/encounters/__tests__/appointment-reminder.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it } from "vitest"; import { appointmentNextDueAfterFiring, appointmentNotifyHour, + appointmentReminderAddress, } from "@/lib/encounters/appointment-reminder"; const TZ = "Europe/Berlin"; @@ -84,3 +85,34 @@ describe("the hour the nudge fires", () => { expect(appointmentNotifyHour(APPOINTMENT, "UTC")).toBe(7); }); }); + +describe("the address an appointment reminder names", () => { + const reminder = (practitionerUserId: string) => ({ + userId: "u1", + origin: "ENCOUNTER", + location: null, + encounters: [ + { + userId: "u1", + practitioner: { + userId: practitionerUserId, + location: "Hauptstraße 1", + locationEncrypted: null, + }, + }, + ], + }); + + it("is the practice of the visit's practitioner, deleted from the address book or not", () => { + // The select carries no `deletedAt`: the booked visit still shows the + // practitioner, so the reminder names the same practice. + expect(appointmentReminderAddress(reminder("u1"))).toBe("Hauptstraße 1"); + }); + + it("counter-check: another account's practitioner names nothing", () => { + expect(appointmentReminderAddress(reminder("u2"))).toBeNull(); + expect( + appointmentReminderAddress({ ...reminder("u1"), encounters: [] }), + ).toBeNull(); + }); +}); diff --git a/src/lib/encounters/appointment-reminder.ts b/src/lib/encounters/appointment-reminder.ts index 2565edcea..0cae8c541 100644 --- a/src/lib/encounters/appointment-reminder.ts +++ b/src/lib/encounters/appointment-reminder.ts @@ -199,7 +199,6 @@ export const APPOINTMENT_REMINDER_ADDRESS_INCLUDE = { practitioner: { select: { userId: true, - deletedAt: true, location: true, locationEncrypted: true, }, @@ -212,7 +211,9 @@ export const APPOINTMENT_REMINDER_ADDRESS_INCLUDE = { /** * 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. + * here. Null when the visit names no practitioner of the same account. A + * practitioner deleted from the address book is still the one the booked + * visit is with, and the visit still shows it, so the reminder names it too. */ export function appointmentReminderAddress(reminder: { userId: string; @@ -222,7 +223,6 @@ export function appointmentReminderAddress(reminder: { userId: string; practitioner: { userId: string; - deletedAt: Date | null; location: string | null; locationEncrypted: Uint8Array | null; } | null; @@ -235,8 +235,7 @@ export function appointmentReminderAddress(reminder: { !visit || !practitioner || visit.userId !== reminder.userId || - practitioner.userId !== reminder.userId || - practitioner.deletedAt !== null + practitioner.userId !== reminder.userId ) { return null; } diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts index 2957bb947..2baa603e7 100644 --- a/src/lib/export/visits-backup.ts +++ b/src/lib/export/visits-backup.ts @@ -222,7 +222,19 @@ export async function buildVisitsBackupSection( conditionLinkRows, ] = await Promise.all([ prisma.practitioner.findMany({ - where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + // A portable file leaves the address book's tombstones out, except one + // a live visit or dose still names: the record shows it, so the file + // carries it (with its `deletedAt`) instead of dropping the reference. + where: disasterRecovery + ? { userId } + : { + userId, + OR: [ + { deletedAt: null }, + { encounters: { some: { deletedAt: null } } }, + { vaccinations: { some: { deletedAt: null } } }, + ], + }, orderBy: { name: "asc" }, select: PRACTITIONER_BACKUP_SELECT, }), @@ -290,7 +302,9 @@ export async function buildVisitsBackupSection( }), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), - ...(disasterRecovery + // A portable file names a tombstone only when a live visit holds it, + // and says so, so the restore keeps it out of the address book. + ...(disasterRecovery || row.deletedAt ? { deletedAt: row.deletedAt?.toISOString() ?? null } : {}), })), diff --git a/src/lib/mcp/__tests__/module-gate-sweep.test.ts b/src/lib/mcp/__tests__/module-gate-sweep.test.ts index 20d0bfdf6..88e88aa78 100644 --- a/src/lib/mcp/__tests__/module-gate-sweep.test.ts +++ b/src/lib/mcp/__tests__/module-gate-sweep.test.ts @@ -190,11 +190,13 @@ describe("get_visits", () => { expect(result.visits[0].conditions).toEqual(["Knee pain"]); }); - it("does not name a deleted practitioner", async () => { + it("names the practitioner a live visit was with, even once deleted from the address book", async () => { + // The web record and the doctor report show it; the agent reads the same + // record (the address-book entry is kept while a live visit names it). const result = (await tool("get_visits").run(CTX, {})) as { visits: Array<{ practitioner: string | null; specialty: string | null }>; }; - expect(result.visits[0].practitioner).toBeNull(); - expect(result.visits[0].specialty).toBeNull(); + expect(result.visits[0].practitioner).toBe("Dr. Gone"); + expect(result.visits[0].specialty).toBe("Cardiology"); }); }); diff --git a/src/lib/mcp/__tests__/record-search.test.ts b/src/lib/mcp/__tests__/record-search.test.ts index 7a7f5e9e1..67f3a9e34 100644 --- a/src/lib/mcp/__tests__/record-search.test.ts +++ b/src/lib/mcp/__tests__/record-search.test.ts @@ -797,16 +797,26 @@ describe("short query words", () => { }); describe("practitioners and planned visits", () => { - it("does not match or show a deleted practitioner's name", async () => { + it("finds and names the practitioner a live visit was with, even once deleted from the address book", async () => { + // Deleting the entry takes it out of the address book, not out of the + // visit: the web record shows it, and so does the agent's read. store.encounter[0].practitioner = { name: "Dr. Weber", specialty: "Orthopädie", - deletedAt: new Date(), }; + expect((await search(ME, "weber")).results.map((r) => r.id)).toEqual([ + "visit:v-knee", + ]); + const hit = await fetchId(ME, "visit:v-knee"); + expect(hit.metadata.practitioner).toBe("Dr. Weber"); + expect(hit.text).toContain("Weber"); + }); + + it("counter-check: a visit with no practitioner names none", async () => { + store.encounter[0].practitioner = null; expect((await search(ME, "weber")).results).toHaveLength(0); const hit = await fetchId(ME, "visit:v-knee"); expect(hit.metadata.practitioner).toBeNull(); - expect(hit.text).not.toContain("Weber"); }); it("lists past visits, not future planned ones, for an empty query", async () => { diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts index 79cca5ebd..6edfa7102 100644 --- a/src/lib/mcp/record-search.ts +++ b/src/lib/mcp/record-search.ts @@ -322,27 +322,26 @@ function visitTitle(args: { } /** - * The practitioner columns a record read selects. `deletedAt` rides along so - * a practitioner the person removed contributes nothing: the visit keeps its - * scalar id, but the name is no longer something the record says. + * The practitioner columns a record read selects. A live visit or dose names + * the practitioner it was with even after the address-book entry is deleted, + * as the web record and the doctor report do: deleting the entry takes it out + * of the address book and the pickers, not out of the history. The purge keeps + * the entry for as long as a live record names it (`soft-delete-retention.ts`). */ const PRACTITIONER_SELECT = { - select: { name: true, specialty: true, deletedAt: true }, + select: { name: true, specialty: true }, } as const; type PractitionerRow = { name: string; specialty: string | null; - deletedAt: Date | null; } | null; -/** The practitioner as the record may still show it, or null. */ -export function livePractitioner( +/** The practitioner a live visit or dose names, or null. */ +export function visitPractitioner( row: PractitionerRow | undefined, ): { name: string; specialty: string | null } | null { - return row && row.deletedAt === null - ? { name: row.name, specialty: row.specialty } - : null; + return row ? { name: row.name, specialty: row.specialty } : null; } async function visitCandidates( @@ -368,7 +367,7 @@ async function visitCandidates( withinCap(rows, read), read, (row): SearchCandidate => { - const practitioner = livePractitioner(row.practitioner); + const practitioner = visitPractitioner(row.practitioner); const reason = decryptText(row.reasonEncrypted); const outcome = decryptText(row.outcomeEncrypted); const bodySite = decryptText(row.bodySiteEncrypted); @@ -728,7 +727,7 @@ async function vaccinationCandidates( withinCap(rows, read), read, (row): SearchCandidate => { - const practitioner = livePractitioner(row.practitioner); + const practitioner = visitPractitioner(row.practitioner); const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug }); const own = ownVaccine(row); const catalogEn = vaccineCatalogName(row.antigenSlug, "en"); @@ -1098,7 +1097,7 @@ async function fetchVisit( const bodySite = decryptText(row.bodySiteEncrypted); const day = userDayKey(row.occurredAt, owner.timezone); const kindLabel = encounterKindLabel(row.kind, "en"); - const live = livePractitioner(row.practitioner); + const live = visitPractitioner(row.practitioner); const practitioner = live?.name ?? null; const specialty = live?.specialty ?? null; @@ -1378,7 +1377,7 @@ async function fetchVaccination( const own = ownVaccine(row); const name = row.vaccineName ?? catalog ?? own?.name ?? row.antigenSlug ?? "Vaccination"; - const practitioner = livePractitioner(row.practitioner); + const practitioner = visitPractitioner(row.practitioner); const day = dateOnly(row.occurredAt); const dose = row.doseNumber !== null diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 476d8a3ed..4eb2b3b17 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -2236,19 +2236,15 @@ export const MCP_TOOLS: McpToolDefinition[] = [ orderBy: [{ occurredAt: "asc" }, { id: "asc" }], take, include: { - practitioner: { - select: { name: true, specialty: true, deletedAt: true }, - }, + practitioner: { select: { name: true, specialty: true } }, }, }), ); const appointments = appointmentRows.map((r) => { const reason = decryptVisitText(r.reasonEncrypted); - // A practitioner the person deleted is no longer named. - const practitioner = - r.practitioner && r.practitioner.deletedAt === null - ? r.practitioner - : null; + // A live visit names its practitioner even after the address-book + // entry is deleted, as the web record does. + const practitioner = r.practitioner; return { occurredAt: r.occurredAt.toISOString(), practitioner: practitioner @@ -2578,7 +2574,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ take, include: { practitioner: { - select: { name: true, specialty: true, deletedAt: true }, + select: { name: true, specialty: true }, }, }, }), @@ -2613,11 +2609,9 @@ export const MCP_TOOLS: McpToolDefinition[] = [ const reason = decryptVisitText(r.reasonEncrypted); const outcome = decryptVisitText(r.outcomeEncrypted); const bodySite = decryptVisitText(r.bodySiteEncrypted); - // A practitioner the person deleted is no longer named. - const seenBy = - r.practitioner && r.practitioner.deletedAt === null - ? r.practitioner - : null; + // A live visit names its practitioner even after the address-book + // entry is deleted, as the web record does. + const seenBy = r.practitioner; return { occurredAt: r.occurredAt.toISOString(), status: r.status, diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 36cd0c7d4..68136f0b2 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -705,7 +705,7 @@ export const encounterPaths: NonNullable = { tags: ["Records"], summary: "Soft-delete a practitioner", description: - "Stamps `deletedAt`. Every visit that named this entry survives with no practice attached, because the reference nulls rather than cascades. Idempotent. Audits as `practitioner.contact.delete`.", + "Stamps `deletedAt`. The entry leaves the address book and the pickers; every live visit and vaccination that named it keeps showing it, and the tombstone is kept while one does. Idempotent. Audits as `practitioner.contact.delete`.", requestParams: idPath, responses: { ...recordRefusal(), diff --git a/tests/integration/practitioner-contact-encryption.test.ts b/tests/integration/practitioner-contact-encryption.test.ts index d98363add..82df5cb39 100644 --- a/tests/integration/practitioner-contact-encryption.test.ts +++ b/tests/integration/practitioner-contact-encryption.test.ts @@ -322,4 +322,64 @@ describe("practitioner phone and address at rest", () => { ]); } }); + + it("carries a deleted practitioner a live visit names in a portable file, and no other tombstone", async () => { + const prisma = getPrismaClient(); + const gone = new Date("2026-09-01T00:00:00.000Z"); + for (const id of ["pr-live", "pr-named", "pr-orphan"]) { + await prisma.practitioner.create({ + data: { + id, + userId: OWNER_ID, + name: id, + deletedAt: id === "pr-live" ? null : gone, + }, + }); + } + await prisma.encounter.create({ + data: { + id: "visit-named", + userId: OWNER_ID, + occurredAt: new Date("2026-08-01T08:00:00.000Z"), + practitionerId: "pr-named", + }, + }); + + const portable = await buildVisitsBackupSection(prisma, OWNER_ID, { + purpose: "portable-export", + }); + + expect( + portable.practitioners + .map((p) => ({ id: p.id, deleted: Boolean(p.deletedAt) })) + .sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual([ + { id: "pr-live", deleted: false }, + { id: "pr-named", deleted: true }, + ]); + expect(portable.encounters[0].practitionerId).toBe("pr-named"); + + // The restore brings the named entry back as a tombstone: on the visit, + // not in the address book. + await prisma.$transaction(async (tx) => { + await restoreVisitsData( + tx, + OWNER_ID, + { + practitioners: portable.practitioners, + encounters: portable.encounters, + encounterDocumentLinks: [], + encounterLabLinks: [], + encounterConditionLinks: [], + }, + [], + ); + }); + const visit = await prisma.encounter.findFirstOrThrow({ + where: { userId: OWNER_ID }, + select: { practitioner: { select: { id: true, deletedAt: true } } }, + }); + expect(visit.practitioner?.id).toBe("pr-named"); + expect(visit.practitioner?.deletedAt).not.toBeNull(); + }); });