Files
HealthLog/src/lib/encounters/dto.ts
T
Marc-André Bombeck 339e815527 Keep an appointment reminder inside the visit that owns it
Review of the visit work found the appointment arm leaking in both
directions: rows the visit routes could strand, and a reminder surface
that would act on one as though it were a checkup.

An edit could orphan a live reminder. Pointing a still-booked visit at
the checkup it closed walked the foreign key off the visit's own
reminder, and the next reschedule then minted a second one, because the
re-anchor path asked the key what the visit owned instead of asking the
engine. An orphan fires: the cron reads the reminder table, not the
visit list, so it pushed for an appointment nobody could see, edit or
cancel, and deleting the visit did not reach it. The edit arm now
refuses the contradiction the create arm already refused, ownership is
resolved through the relation, deletion retires the row it owns rather
than the row it points at, and a sweep at the end of every visit write
retires anything left armed that no visit claims -- so the state is
unreachable rather than merely unlikely.

The by-id reminder routes never excluded appointments, and that was the
larger hole. Marking one done committed the satisfy and THEN failed on
the mapper, so every appointment push a person acknowledged answered 500
after the write had landed. An edit could give a spent one-shot a
cadence and turn it into a permanent nudge that appears in no list, or
switch a cancelled one back on. The delete is a hard delete, so somebody
holding only the measurements domain could destroy a row belonging to a
visit in the health background. All five refuse in the lookup now -- an
appointment is not addressable there at all -- and the chat buttons that
mark a reminder done or postpone it refuse it the same way. Nor is an
appointment satisfied by a lab result: the auto-satisfy matcher used to
resolve any free-text reminder from any panel, which would have marked
next month's cardiology appointment attended.

A visit is a seam between four domains, and names were crossing it. The
values never did, but the title of a discharge letter is itself the
sensitive part, and a grant that opened only the health background was
never consent for it. Link labels and dates now follow the target's own
domain; the link is still listed, so a delegate learns the visit
produced a lab result without learning which analyte. Closing a checkup
follows the same rule: it re-anchors a preventive-care cadence, which is
a measurements write, so a health-background delegate files the visit
and the closure is reported as skipped rather than performed in silence.

Three smaller repairs. A checkup now closes at the visit's own instant
rather than at the moment it was typed in, which anchors a backdated
entry correctly and makes correcting a mis-filed visit the no-op it
should always have been. A window ending before today no longer widens
back out to today. And the fallback in a push notification was the raw
enum constant, so a lock screen could read a schema value; it reads the
record owner's own language now, in all six.
2026-08-08 11:24:08 +02:00

139 lines
4.4 KiB
TypeScript

/**
* Encounter row → wire DTO.
*
* Everything is published resolved. The practitioner comes back as an object
* and never as a bare id, the links come back with a label and a date, and the
* appointment's next-due instant comes back computed. A client that had to
* re-derive any of them would drift from the server the first time one of the
* derivations changed on one side only.
*
* The two free-text columns are `Bytes` ciphertext and decrypt fail-soft: a
* key-rotation gap on one row reads as a missing reason, not as a 500 on the
* whole list.
*/
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { getEvent } from "@/lib/logging/context";
import type {
Encounter,
EncounterKind,
EncounterStatus,
MeasurementReminder,
Practitioner,
} from "@/generated/prisma/client";
import type { RestoreSkipSummary } from "@/lib/export/restore-skips";
import {
toPractitionerDTO,
type PractitionerDTO,
} from "@/lib/practitioners/dto";
/**
* One thing a visit produced, as the caller is allowed to see it.
*
* `label` and `date` go null together when the caller's grant does not cover
* the target's own domain — a health-background delegate may know the visit
* produced a lab result without being told which analyte. `redacted` says
* which of those nulls is a withholding and which is simply a target with no
* date, so a client never has to guess.
*/
export interface EncounterLinkDTO {
id: string;
label: string | null;
date: string | null;
redacted: boolean;
}
/** The three link families a visit can carry, each already resolved. */
export interface EncounterLinksDTO {
documents: EncounterLinkDTO[];
labResults: EncounterLinkDTO[];
conditions: EncounterLinkDTO[];
}
export interface EncounterDTO {
id: string;
occurredAt: string;
status: EncounterStatus;
kind: EncounterKind;
/** Resolved, never just an id. `null` when the visit names no practice. */
practitioner: PractitionerDTO | null;
reason: string | null;
outcome: string | null;
/**
* The server-computed instant the appointment reminder next fires, when this
* visit minted one and it has not fired yet. `null` for a visit that already
* happened, or once the one-shot reminder is spent.
*/
reminderNextDueAt: string | null;
/** Present on the detail response; omitted from the list. */
links?: EncounterLinksDTO;
/**
* What the write could not do, named.
*
* Present on create and edit. `links: 0` is the ordinary answer and says so.
* Anything else is a side effect the caller's grant did not reach — today
* only the closing of a checkup, which is a measurements-domain write that a
* health-background delegate cannot perform. Reported rather than skipped in
* silence, because a person who believes a checkup was closed will not look
* at it again.
*/
skipped?: RestoreSkipSummary;
createdAt: string;
updatedAt: string;
}
function decryptField(
value: Uint8Array | null,
field: "reason" | "outcome",
): string | null {
if (!value || value.byteLength === 0) return null;
try {
return decryptFromBytes(value);
} catch (err) {
getEvent()?.addWarning(
`encounter ${field} decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
export type EncounterWithRelations = Encounter & {
practitioner?: Practitioner | null;
reminder?: Pick<MeasurementReminder, "nextDueAt"> | null;
};
export function toEncounterDTO(
row: EncounterWithRelations,
links?: EncounterLinksDTO,
skipped?: RestoreSkipSummary,
): EncounterDTO {
return {
id: row.id,
occurredAt: row.occurredAt.toISOString(),
status: row.status,
kind: row.kind,
practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null,
reason: decryptField(row.reasonEncrypted, "reason"),
outcome: decryptField(row.outcomeEncrypted, "outcome"),
reminderNextDueAt: row.reminder?.nextDueAt?.toISOString() ?? null,
...(links ? { links } : {}),
...(skipped ? { skipped } : {}),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* The list response.
*
* Two arrays rather than one sorted list, resolved server-side: "what is
* coming up" and "what happened" are read in opposite directions, and making
* the client split and re-sort one array would put that decision in two
* places.
*/
export interface EncounterListDTO {
upcoming: EncounterDTO[];
past: EncounterDTO[];
}