mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Nine visit routes and five practitioner routes, all classified under the existing profile domain and each reviewed into the frozen sharing literal with the sentence that argues for it. An address book of a person's own doctors is record content, not account configuration: it names no credential, no integration and no notification channel, which is the fence that classification turns on. The eight sharing domains are unchanged. Link and unlink stay bare, matching document link and unlink, and the pair is revisited as one decision later. Nothing is a required field. A visit saves with a date and nothing else. Every id a body may carry is re-narrowed to the caller before anything is written, and an unknown link id never blocks a save. A booked visit mints exactly one reminder on the existing engine with the new origin, and the appointment half is the part worth reading twice. Both cadence columns stay null, which is the schema's one-shot, so the first due instant is stamped directly and the never-nag property is structural rather than guarded: after it fires, the cadence helper has no occurrence to return. Rescheduling re-anchors that row and never mints a second, which the tests assert by counting rows rather than by checking the date moved. Filing a visit as done closes the checkup it was filed against, on the transition rather than on every later edit. Writing the test first found that: satisfying is forward-only, so re-saving a completed visit was pushing the checkup's next due date out again as if the person had been a second time. An appointment must appear on no preventive-care surface, and that is four separate reads, one of them in the assistant tooling. Each carries the exclusion, a guard proves all four by source text with a non-zero match count, and the reminder mapper refuses such a row outright — so a read site that loses its filter fails loudly instead of showing an appointment as a checkup. The published origin enum is untouched.
110 lines
3.3 KiB
TypeScript
110 lines
3.3 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 { LinkedTarget } from "@/lib/links";
|
|
import {
|
|
toPractitionerDTO,
|
|
type PractitionerDTO,
|
|
} from "@/lib/practitioners/dto";
|
|
|
|
/** The three link families a visit can carry, each already resolved. */
|
|
export interface EncounterLinksDTO {
|
|
documents: LinkedTarget[];
|
|
labResults: LinkedTarget[];
|
|
conditions: LinkedTarget[];
|
|
}
|
|
|
|
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;
|
|
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,
|
|
): 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 } : {}),
|
|
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[];
|
|
}
|