mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
This commit is contained in:
1 parent
9892088620
commit
3482581ce1
10 files changed
+310
-21
No files matched your search
+10
-1
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -195,8 +195,7 @@ async function postEncounter(request: NextRequest): Promise<Response> {
|
||||
// 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<Response> {
|
||||
userId: user.id,
|
||||
occurredAt,
|
||||
practitionerName: practitioner?.name ?? null,
|
||||
practitionerLocation: practitioner?.location ?? null,
|
||||
kindLabel: encounterKindLabel(entry.kind, locale),
|
||||
status: entry.status,
|
||||
existingReminderId: null,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
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<string, unknown>[] }) => {
|
||||
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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user