Files
HealthLog/src/lib/encounters/service.ts
T
Marc-André Bombeck 7981c34cc7 Drop the two visit exports nothing consumes yet
The timezone resolver and the dependent-keys bundle shipped ahead of
their consumers, and the dead-code gate rightly went red on the trunk.
The surfaces that need them arrive with the next visit stage and bring
them back together with their callers.
2026-08-08 13:48:14 +02:00

432 lines
14 KiB
TypeScript

/**
* The work the encounter routes share: resolving a visit, wiring its links,
* and keeping its appointment reminder in step with its status.
*
* Lives beside the routes rather than inside one of them because create and
* edit both do most of it, and a copy in each is how the two drift.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
satisfyReminder,
type SatisfiableReminder,
} from "@/lib/measurement-reminders/satisfy";
import { listTargets, replaceTargets, type LinkTargetKind } from "@/lib/links";
import { grantCoversDomain } from "@/lib/sharing/grants";
import type { ShareDomain } from "@/lib/sharing/scope";
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
import {
deleteAppointmentReminder,
disableAppointmentReminder,
mintAppointmentReminder,
reanchorAppointmentReminder,
type AppointmentReminderInput,
} from "@/lib/encounters/appointment-reminder";
const DEFAULT_TIMEZONE = "Europe/Berlin";
/**
* The record owner's zone AND language, read together.
*
* Both are properties of the RECORD rather than of whoever is acting on it: a
* delegate booking an appointment in somebody else's record produces a nudge
* that rings on the owner's phone, at the owner's hour, in the owner's
* language. Reading the actor's locale here would localise a stranger's
* notification into the helper's tongue.
*/
export async function resolveOwnerNotificationContext(
userId: string,
): Promise<{ timezone: string; locale: string | null }> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true, locale: true },
});
return {
timezone: row?.timezone || DEFAULT_TIMEZONE,
locale: row?.locale ?? null,
};
}
/** The relations the detail and list responses resolve. */
export const ENCOUNTER_INCLUDE = {
practitioner: true,
reminder: { select: { nextDueAt: true } },
} as const satisfies Prisma.EncounterInclude;
/**
* Which sections of the record the caller is actually inside.
*
* Returns a predicate rather than a set so the owner path costs nothing: with
* no grant there is nothing to look up and every domain is open. A grant that
* vanished between the auth check and here answers "no" to everything, which
* is the fail-closed direction.
*/
export async function actingDomainVisibility(
tx: Prisma.TransactionClient,
grantId: string | null,
): Promise<(domain: ShareDomain) => boolean> {
if (grantId === null) return () => true;
const grant = await tx.accountGrant.findUnique({
where: { id: grantId },
select: { scopeJson: true },
});
if (!grant) return () => false;
return (domain) => grantCoversDomain(grant, domain);
}
/** Which section of the record each link family's TARGET belongs to. */
const TARGET_DOMAIN: Record<LinkTargetKind, ShareDomain> = {
document: "documents",
labResult: "labs",
conditionEpisode: "illness",
};
/**
* The three link families, resolved for one visit and redacted per family.
*
* A visit is a seam between four domains: it lives in the health background
* and points at the vault, the lab results and the illness journal. A grant
* that opened only the health background was never consent for what is on the
* other side of that seam — and while the VALUES never crossed it (the list
* carries a label and a date, not a document body or a result), the names are
* themselves the sensitive part. "Oncology discharge letter" and "Ferritin,
* 30 June" say most of what the person was worried about.
*
* So the name and the date are withheld per family, and the LINK is not: the
* caller may know the visit produced a lab result, because the visit is theirs
* to read. `label: null` rather than an invented placeholder, so an absence
* reads as an absence; the array an entry sits in already carries the kind, so
* a client renders "a linked lab result" from its own bundle without the
* server shipping prose down the wire.
*
* Owners and entire-record delegates see everything, so the ordinary case is
* untouched.
*/
export async function loadEncounterLinks(
tx: Prisma.TransactionClient,
userId: string,
encounterId: string,
visible: (domain: ShareDomain) => boolean,
): Promise<EncounterLinksDTO> {
const read = async (targetKind: LinkTargetKind) => {
const rows = await listTargets(tx, {
userId,
sourceKind: "encounter",
sourceId: encounterId,
targetKind,
});
if (visible(TARGET_DOMAIN[targetKind])) {
return rows.map((row) => ({ ...row, redacted: false }));
}
return rows.map((row) => ({
id: row.id,
label: null,
date: null,
redacted: true,
}));
};
const [documents, labResults, conditions] = await Promise.all([
read("document"),
read("labResult"),
read("conditionEpisode"),
]);
return { documents, labResults, conditions };
}
/**
* Apply whichever of the three link arrays the request actually named.
*
* An absent array leaves that family alone; a present one replaces it, empty
* array included. Unknown ids are dropped by the link service and never block
* the save — a visit is never refused because one document it pointed at has
* since been deleted.
*/
export async function applyEncounterLinks(
tx: Prisma.TransactionClient,
userId: string,
encounterId: string,
arrays: {
documentIds?: string[];
labResultIds?: string[];
episodeIds?: string[];
},
): Promise<void> {
const families: Array<[LinkTargetKind, string[] | undefined]> = [
["document", arrays.documentIds],
["labResult", arrays.labResultIds],
["conditionEpisode", arrays.episodeIds],
];
for (const [targetKind, targetIds] of families) {
if (targetIds === undefined) continue;
await replaceTargets(tx, {
userId,
sourceKind: "encounter",
sourceId: encounterId,
targetKind,
targetIds,
});
}
}
/**
* Whether a visit in this state, at this instant, should be nudging.
*
* Only a PLANNED visit in the future does. A visit that already happened needs
* no reminder, and a terminal one must not produce a nudge for an appointment
* that is not going to occur.
*/
export function shouldHaveReminder(
status: string,
occurredAt: Date,
now: Date,
): boolean {
return status === "PLANNED" && occurredAt.getTime() > now.getTime();
}
export interface ReminderSyncInput extends AppointmentReminderInput {
status: string;
/**
* The APPOINTMENT reminder this visit already owns, or null.
*
* Never a checkup the visit closes. `Encounter.reminderId` carries either
* one, and the two are kept apart by their origin: an appointment reminder
* is `ENCOUNTER`, a checkup is not. Confusing them would re-anchor a
* person's own Vorsorge cadence onto an appointment date, which is why the
* routes resolve the origin before calling this.
*/
existingReminderId: string | null;
}
/**
* Bring the visit's appointment reminder in line with the visit.
*
* Returns the id to store on `Encounter.reminderId` — the existing one, a
* freshly minted one, or the existing one left in place but switched off.
* Never mints a second row for a visit that already has one.
*/
export async function syncAppointmentReminder(
tx: Prisma.TransactionClient,
input: ReminderSyncInput,
timezone: string,
now: Date,
): Promise<string | null> {
const wanted = shouldHaveReminder(input.status, input.occurredAt, now);
if (!wanted) {
// The row stays — the history of a cancelled appointment is worth keeping —
// but it stops nudging.
if (input.existingReminderId) {
await disableAppointmentReminder(tx, input.existingReminderId);
}
return input.existingReminderId;
}
if (input.existingReminderId) {
await reanchorAppointmentReminder(
tx,
input.existingReminderId,
input,
timezone,
);
return input.existingReminderId;
}
return mintAppointmentReminder(tx, input, timezone);
}
/**
* The checkup a visit says it closes, re-narrowed to the caller.
*
* Refuses an `ENCOUNTER`-origin row outright. Those belong to a booked visit
* and are not a checkup anybody can close by filing one; accepting it here
* would let a client re-point one visit's appointment reminder at another
* visit and re-anchor it on the wrong date.
*
* Returns `undefined` when the id names nothing closeable.
*/
export async function resolveClosableReminder(
tx: Prisma.TransactionClient,
userId: string,
reminderId: string,
): Promise<SatisfiableReminder | undefined> {
const row = await tx.measurementReminder.findUnique({
where: { id: reminderId },
select: {
id: true,
userId: true,
origin: true,
deletedAt: true,
intervalDays: true,
rrule: true,
anchorDate: true,
notifyHour: true,
lastSatisfiedAt: true,
createdAt: true,
},
});
if (!row || row.userId !== userId || row.deletedAt !== null) return undefined;
if (row.origin === "ENCOUNTER") return undefined;
return {
id: row.id,
intervalDays: row.intervalDays,
rrule: row.rrule,
anchorDate: row.anchorDate,
notifyHour: row.notifyHour,
lastSatisfiedAt: row.lastSatisfiedAt,
createdAt: row.createdAt,
};
}
/**
* Close the checkup a completed visit was filed against.
*
* `satisfyReminder` is the one shared primitive the cron, the ingest worker
* and the explicit user action all route through. There is no second
* mechanism; the only decision here is WHICH INSTANT to satisfy at.
*
* It is the visit's own instant, bounded by now — not `now`. The plan said
* `now`, and two things it did not anticipate say otherwise:
*
* - **Anchoring.** A yearly panel drawn thirty days ago is next due 335 days
* from today, not 365. Satisfying at `now` moves every backdated entry's
* next due date forward by however long the person took to record it, so
* the more honest they are about the date, the more wrong the cadence gets.
* - **Re-transitions.** Correcting a mis-filed visit — DONE, then NO_SHOW,
* then DONE again — is a transition each way, so the status gate alone
* does not stop a second satisfy. At `now` the second one is strictly
* later and advances the cadence again; at the visit instant it is not
* strictly after the first, and the primitive's forward-only guard makes
* it the no-op it should always have been.
*
* Bounded by `now` because a visit can be booked into the future: a
* PLANNED-to-DONE correction on tomorrow's appointment must not stamp a
* satisfy the engine would read as ahead of its own clock.
*
* This also matches how the rest of the engine is fed — the cron and the
* ingest worker pass the EVENT instant, not the moment they happened to run.
*/
export async function closeCheckupForVisit(
tx: Prisma.TransactionClient,
reminder: SatisfiableReminder,
timezone: string,
occurredAt: Date,
now: Date,
): Promise<void> {
const satisfiedAt = occurredAt.getTime() < now.getTime() ? occurredAt : now;
await satisfyReminder(
tx as unknown as PrismaClient,
reminder,
timezone,
satisfiedAt,
);
}
/**
* The live appointment reminder this visit owns, found by the relation rather
* than by reading the visit's foreign key.
*
* The distinction is the whole of the orphan defect. Reading `reminderId` and
* checking its origin answers "what does this visit point at", which stops
* being the same question the moment the column is re-pointed at a checkup:
* the appointment row is still live, still armed, and now invisible to the
* code that owns it, so the next reschedule mints a second one. Asking the
* engine table which of ITS rows this visit owns cannot drift that way, and it
* filters the origin and the tombstone in SQL instead of in a branch.
*/
export async function findOwnAppointmentReminderId(
tx: Prisma.TransactionClient,
userId: string,
encounterId: string,
): Promise<string | null> {
const row = await tx.measurementReminder.findFirst({
where: {
userId,
origin: "ENCOUNTER",
deletedAt: null,
encounters: { some: { id: encounterId } },
},
select: { id: true },
});
return row?.id ?? null;
}
/**
* Retire every live appointment reminder this account owns that no visit
* points at.
*
* A reminder nobody points at still fires — the cron reads the engine table,
* not the visit list — so an orphan pushes notifications for an appointment
* the person can no longer see, edit or cancel. The refusal in the edit route
* stops the one sequence that produced them; this makes the state itself
* unreachable, so a future path that finds a new way to strand one cannot
* leave it armed.
*
* A soft-deleted VISIT still points at its reminder, so this never reaps a
* deleted visit's row — that retirement belongs to the delete route, which
* knows it is deliberate.
*/
export async function retireOrphanedAppointmentReminders(
tx: Prisma.TransactionClient,
userId: string,
at: Date,
): Promise<number> {
const result = await tx.measurementReminder.updateMany({
where: {
userId,
origin: "ENCOUNTER",
deletedAt: null,
encounters: { none: {} },
},
data: { deletedAt: at, enabled: false, nextDueAt: null },
});
return result.count;
}
/**
* Retire the appointment reminder a visit owns, alongside the visit.
*
* Takes the visit rather than a reminder id, and resolves the row by the
* relation: deleting a visit must reach the reminder it owns even if its
* foreign key is pointing somewhere else.
*/
export async function retireAppointmentReminderForVisit(
tx: Prisma.TransactionClient,
userId: string,
encounterId: string,
at: Date,
): Promise<void> {
const reminderId = await findOwnAppointmentReminderId(
tx,
userId,
encounterId,
);
if (!reminderId) return;
await deleteAppointmentReminder(tx, reminderId, at);
}
/**
* The practitioner a visit names, re-narrowed to the caller.
*
* 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.
*/
export async function resolveOwnedPractitioner(
tx: Prisma.TransactionClient,
userId: string,
practitionerId: string,
): Promise<{ id: string; name: string; location: string | null } | 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 };
}