mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
ec2c067e60
commit
a275a5bb65
11 files changed
+159
-47
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user