fix(records): show a deleted practitioner on the live visits that name it

The web record and the doctor report named the practitioner of a live
visit after the address-book entry was deleted; the MCP read, the search
and the appointment reminder did not. All of them now name it, the entry
stays out of the address book and the pickers, and a portable backup
carries it as a tombstone instead of dropping the visit's reference.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 16:01:13 +02:00
1 parent ec2c067e60
commit a275a5bb65
11 files changed
+159 -47

No files matched your search

+3 -2
View File
@@ -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
+4 -3
View File
@@ -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";
@@ -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();
});
});
+4 -5
View File
@@ -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;
}
+16 -2
View File
@@ -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 }
: {}),
})),
@@ -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");
});
});
+13 -3
View File
@@ -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 () => {
+13 -14
View File
@@ -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
+8 -14
View File
@@ -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,
+1 -1
View File
@@ -705,7 +705,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["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`.",
"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(),
@@ -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();
});
});