import type { Appointment, Bundle, Encounter } from 'fhir/r4'; // Only a finished visit counts as seen: one that has arrived or is under way has not completed the first // consultation. Mirrors SEEN_EVIDENCE_ENCOUNTER_STATUSES in the api's appointment-self-booking.ts - the // server is the boundary and this list only decides what the patient is OFFERED, but the two // disagreeing would show somebody a service the server then refuses. const SEEN_ENCOUNTER_STATUSES = new Set(['finished']); // HAS THIS PATIENT BEEN SEEN AT THIS PRACTICE? (Spencer, 2026-09-09: "New patient should be enforced // on 'seen' meaning they've actually had an encounter, not just appointments.") // // This replaces an appointment-history read that was asymmetric on purpose - a positive was conclusive // and a negative only meant "we cannot tell", so the flow asked the patient to say which they were. // The question is gone with it. An Encounter is the record of a visit, the server reads the same one to // decide the same thing, and a patient's own answer was never able to override a record we hold. // // The organization is filtered here rather than in the search because "new" is a property of the // patient-practice relationship: established at one practice is still new at the next. // // A migrating practice's long-standing patients have no Encounter here and so read as new. That is // Spencer's ruling and it is safe for one reason only: this gates the patient's OWN booking and never // care. The front desk has no gate at all, so the worst case is a phone call. export function resolvePatientTypeFromEncounters( bundle: Bundle | undefined, organizationId: string | undefined, ): 'existing' | 'new' { if (!organizationId) { return 'existing'; } const organizationReference = `Organization/${organizationId}`; const seenHere = (bundle?.entry ?? []).some((entry) => { const encounter = entry.resource; return ( encounter !== undefined && SEEN_ENCOUNTER_STATUSES.has(encounter.status) && encounter.serviceProvider?.reference === organizationReference ); }); return seenHere ? 'existing' : 'new'; } // A visit history that cannot be read proves no visit, so only the consult is offered, as the server // refuses the rest. export async function readPatientTypeFromEncounters( read: () => Promise | undefined>, organizationId: string | undefined, ): Promise<'existing' | 'new'> { try { return resolvePatientTypeFromEncounters(await read(), organizationId); } catch { return organizationId ? 'new' : 'existing'; } } // RETAINED FOR THE APPOINTMENT-SHAPED READ ONLY, and no longer consulted by the new-patient gate. // Kept because removing it would take its reasoning with it, and that reasoning is why the gate is // not keyed on appointments: this function cannot answer the question, and said so. // // DELIBERATELY ASYMMETRIC. A POSITIVE is conclusive: those fulfilled appointments really happened. A // NEGATIVE is NOT - it means "we cannot tell", for two reasons that live in the server handler (api // Appointment/get.ts:164-204): the patient-scoped search walks Slots with `_revinclude=Appointment:slot`, // so appointments with no Slot are invisible to it, and its `_count=100` caps SLOTS rather than // appointments, so older visits at a busy practice fall outside the window. export function resolvePatientTypeFromHistory( bundle: Bundle | undefined, ): 'existing' | 'unknown' { const seen = (bundle?.entry ?? []).some((entry) => entry.resource?.status === 'fulfilled'); return seen ? 'existing' : 'unknown'; }