mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Two defects behind one omission. The card counts what a visit produced, so a list that dropped the links made every card read zero for a visit with three documents. And the edit sheet seeds its pickers from the row it was handed: an edit opened from a linkless list row would save a replace-set of three empty arrays and silently unfile everything the visit had collected. The links now ride the list, resolved in three grouped queries for the whole page through the batched arm of the same service the detail uses, with the same per-family redaction from the same predicate. The single-visit read hook had no caller and is gone with it — the list is where the surfaces read from. Both defects get a test that would have caught them: the card counts assert against a populated links object, the draft asserts the arrays survive a round trip through draftToBody, and the integration file asserts the list response itself carries every family.
148 lines
4.9 KiB
TypeScript
148 lines
4.9 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;
|
|
/**
|
|
* The three link families, resolved.
|
|
*
|
|
* Present on the list as well as the detail. Omitting them from the list
|
|
* would break two things at once: the card counts what a visit produced, and
|
|
* the edit sheet seeds its pickers from the row it was handed — an edit
|
|
* opened from a linkless row would save three empty arrays and unfile
|
|
* everything. The list pays three grouped queries for the whole page.
|
|
*/
|
|
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. Each row carries its links, for the reasons stated at
|
|
* `EncounterDTO.links`.
|
|
*/
|
|
export interface EncounterListDTO {
|
|
upcoming: EncounterDTO[];
|
|
past: EncounterDTO[];
|
|
}
|