import type { SaveTransport } from '../utils/saveOperation'; import { ReactNode, useState, useCallback, createContext, useContext, useEffect, useRef } from 'react'; import { Address, Attachment, Bundle, CodeableConcept, Coding, ContactPoint, Encounter, Extension, FhirResource, Identifier, Patient } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { IValueSet } from './ValueSetService.tsx'; import { useHttpService, HttpError } from './HttpService.tsx'; import { readRegistrationList, readRegistrationReceipt, type RegistrationList, type RegistrationReceipt } from './registrationReceipt'; import { v4 as uuidv4 } from 'uuid'; import { useAuthServiceContext } from './AuthService.tsx'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { asRecordChanged, ifMatch } from '../utils/recordChanged'; import { markSearchStarted, overlayHeldPage, startSearch, writtenSince } from '../utils/writeThroughHold'; import { patientSearchFallbacks, patientSearchParams } from '../utils/patientSearchTerm'; import { enrolledOf, tribalAffiliationExtension } from '../utils/tribalAffiliation'; const API_URL = environment.fhirIdentityApi; // - receptionist pre-create patient dedup. `fields` echoes the RECEPTIONIST's OWN // submitted values with a matched/differs verdict - confirm-don't-reveal, never the matched // patient's data. `candidateToken` is opaque: pass it through untouched, never parse/log/display it. export type MatchFieldVerdict = | { submitted: true; value: string; matches: boolean } | { submitted: false; hidden: true }; export interface MatchCandidate { candidateToken: string; ruleHits: string[]; /** Account binding is shown only with corroborated matching evidence. */ sameAccount?: boolean; inThisPractice?: boolean; patientId?: string; fields: Record<'firstname' | 'lastname' | 'email' | 'phone' | 'birthDate' | 'postalCode', MatchFieldVerdict>; } export interface PatientMatchInput { firstname: string; lastname: string; email: string; phone: string; birthDate: string; postalCode?: string; ssn?: string; } export interface PatientMatchAdvice { advisory: 'complete' | 'incomplete' | 'rateLimited' | 'unavailable'; candidates: MatchCandidate[]; displayLimited: boolean; truncated?: boolean; } const matchObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const matchText = (value: unknown): value is string => typeof value === 'string' && value.length > 0; const matchId = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9.-]{1,64}$/.test(value); const readMatchAdvice = (value: unknown, submitted: PatientMatchInput): PatientMatchAdvice => { if (!matchObject(value) || typeof value.advisory !== 'string' || !['complete', 'incomplete', 'rateLimited', 'unavailable'].includes(value.advisory) || !Array.isArray(value.candidates) || value.candidates.length > 3 || typeof value.displayLimited !== 'boolean' || ('truncated' in value && typeof value.truncated !== 'boolean')) throw new Error('The matching result could not be confirmed.'); const fieldNames = ['firstname', 'lastname', 'email', 'phone', 'birthDate', 'postalCode', 'ssn'] as const; const candidates = value.candidates.map((candidate: unknown) => { if (!matchObject(candidate) || !matchText(candidate.candidateToken) || !Array.isArray(candidate.ruleHits) || candidate.ruleHits.length === 0 || !candidate.ruleHits.every(rule => typeof rule === 'string' && ['phone', 'dob-name', 'name-address', 'ssn'].includes(rule)) || !matchObject(candidate.fields) || ('sameAccount' in candidate && typeof candidate.sameAccount !== 'boolean') || ('inThisPractice' in candidate && typeof candidate.inThisPractice !== 'boolean') || ('patientId' in candidate && (candidate.inThisPractice !== true || !matchId(candidate.patientId)))) throw new Error('The matching result could not be confirmed.'); for (const name of fieldNames) { const field = candidate.fields[name]; const raw = submitted[name]?.trim() ?? ''; const expected = name === 'ssn' ? ((/^\d{9}$/.test(raw.replace(/\D/g, '')) && !/^(\d)\1{8}$/.test(raw.replace(/\D/g, ''))) ? `•••-••-${raw.replace(/\D/g, '').slice(-4)}` : '') : raw; if (!matchObject(field) || (expected ? field.submitted !== true || field.value !== expected || typeof field.matches !== 'boolean' : field.submitted !== false || field.hidden !== true || 'value' in field)) throw new Error('The matching result could not be confirmed.'); } return candidate as unknown as MatchCandidate; }); return { advisory: value.advisory as PatientMatchAdvice['advisory'], candidates, displayLimited: value.displayLimited, ...(value.truncated === true ? { truncated: true } : {}) }; }; const readLinkedPatient = (value: unknown): {patientId: string; linked: true; alreadyAffiliated?: boolean} => { if (!matchObject(value) || !matchId(value.patientId) || value.linked !== true || ('alreadyAffiliated' in value && typeof value.alreadyAffiliated !== 'boolean')) throw new Error('The adoption answer could not be confirmed.'); return {patientId: value.patientId, linked: true, ...(typeof value.alreadyAffiliated === 'boolean' ? {alreadyAffiliated: value.alreadyAffiliated} : {})}; }; // - what `searchPatients` returns. Deliberately an INTERSECTION rather than a wrapper // object: it still IS a Bundle, so every existing call site keeps working untouched and // only the surface that needs to explain itself reads the extra field. `excludedInactiveCount` is // how many matches were dropped for `active: false` - non-zero means the search DID match records, // we just hid them, which must never be rendered as "no matching patients found". // // // INACTIVE IS NOT MERGED, and conflating them put a FALSE SENTENCE in front of staff. The search // told a receptionist a hidden record "is inactive because it was merged into another account. Search // for that account instead" - asserted from `active === false` alone. `Patient One`, the most-used // fixture on this board, is inactive with NO link whatsoever (deactivated 2026-05-11 by // client-writable `active`), so staff were directed to find an account that does not exist. // // It cost more than a confusing message: the false claim is WHY this was diagnosed as damage from a // dedup smoke and carded as such. A UI that states a cause it cannot know sends the next person after // the wrong bug. Same class as - do not name a state after a conclusion you cannot draw. // // So the two are counted separately. `excludedMergedCount` is a SUBSET of `excludedInactiveCount`: // every merged record is also inactive, and only a `replaced-by` link makes "merged" true. export type PatientSearchBundle = Bundle & { excludedInactiveCount: number; excludedMergedCount: number; }; /** * Was this record retired INTO another one? * * FHIR `Patient.link.type === 'replaced-by'` is the only value that means it. `seealso` and `refer` * are cross-references between records that are both still live, so treating any link as a merge * would reintroduce the same false claim through a different door. */ const wasMergedAway = (patient?: Patient): boolean => (patient?.link ?? []).some((l) => l.type === 'replaced-by'); export const usePatientService = () => { const { get, post, put, writeWithHeaders } = useHttpService(); const { currentUser, userRole, USER_ROLES, selectedRelatedPatient, actingAs, setActingAs } = useAuthServiceContext(); const { transformToIdentifier, formatDate, transformToHumanName, transformToCodeableConcept, transformToAddress, transformToTelecom, transformFromHumanName, transformFromIdentifier, transformFromAddress, transformFromTelecom, transformFromCodeableConcept } = useFhirServiceContext(); const extensionUrls = { race: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-race', ethnicity: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity', tribalAffiliation: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation', birthsex: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex', sex: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-sex', interpreterRequired: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed', genderIdentity: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-genderIdentity' }; // // (BETA BLOCKER) - the subject of a clinical write must be structurally impossible to get // wrong. Spencer ruled option C on 2026-08-12: **the surface declares which patient it is for, and // the setter enforces it.** // // WHY THIS IS NOT A DISPLAY BUG. `patientState` lives here, app-wide, and **31 modal files read it // as the SUBJECT of a create**: `transformToFhirCondition(data, patientState,...)` stamps // `subject: Patient/{id}` straight from it. So a wrong value does not merely render the wrong chart - // it attaches a NEW CLINICAL RECORD TO THE WRONG PERSON. That is the inverse of //: not hiding real data, but attributing it. // // WHY THE GUARD IS AT THE SETTER AND NOT AT THE 31 WRITES. A write that reads an ambient value can // always drift; a write whose ambient value CANNOT BE WRONG cannot. There are 16 setter call sites // and 31 write sites, so the door is both the smaller surface and the only one that closes the class // rather than an instance. fixed one route by which this went stale; this makes staleness // unrepresentable. // // AND WHY NOT ROUTE-BINDING, which is the obvious answer: a PATIENT viewing their own chart, a // RELATED PERSON, and a caregiver ACTING-AS all take their subject from the SESSION, not from a route // id (the three effects below, ). Binding to the route would break every proxy path. An // expectation works for both because the surface - route or session - declares it. // // THE EXPECTATION IS A REF, NOT STATE, AND THAT IS THE WHOLE CORRECTNESS ARGUMENT. Every real // caller declares and then assigns inside ONE execution - `PatientPage`'s `initPatient` effect and the // act-as effect below both do `declareExpectedPatient(x)`... `await`... `setPatientState(x)` through // function references captured from the render their effect ran in. Held as state, the declaration // only reaches the setter on the NEXT render, so that captured setter still enforced the PREVIOUS // expectation and refused the switch it had just been told about. Measured in a browser 2026-08-13: // it fired on 5 of 5 ordinary patient switches, each time naming the previously viewed patient. // A ref is written synchronously, so the declaration binds the assignment that follows it. // Do not convert this back to `useState` to "make it reactive" - nothing renders off it (that is // asserted by a test), and reactivity is precisely what broke it. const expectedPatientIdRef = useRef(undefined); const [patientState, rawSetPatientState] = useState(); const [tempPatientState, setTempPatientState] = useState(); const [encounterState, setEncounterState] = useState(); const [patientBundle, setPatientBundle] = useState | null>(null); const [patientList, setPatientList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const patientBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); /** * - THE DOOR. Every assignment of the app-wide patient goes through here. * * IT FAILS CLOSED, and the direction matters: a patient that disagrees with the declared * expectation clears the context to `undefined` rather than keeping the previous one. An empty chart * is a visible, recoverable failure; the PREVIOUS patient is an invisible one that a clinician will * act on - and, through the 31 create paths, write to. * Exactly the shape `ClinicianOverviewPage:181` already used by hand; this makes it the rule. * * NO EXPECTATION DECLARED THE ASSIGNMENT IS ALLOWED. That is deliberate and it is the one soft * edge in this design: surfaces adopt `declareExpectedPatient` incrementally, and a hard refusal here * would blank every surface that has not adopted it yet - turning a latent attribution bug into an * immediate outage across the whole chart. The guard binds where it has been declared; the migration * is what widens it. Do not "tighten" this to refuse undeclared assignments without first * confirming every surface declares - that is a product-wide breakage, not a hardening. */ const setPatientState = useCallback>>( (action) => { rawSetPatientState((previous) => { const next = typeof action === 'function' ? (action as (p: Patient | undefined) => Patient | undefined)(previous) : action; // Read through the ref, never a closed-over value: this callback is captured by effects // that go on to declare a NEW expectation before assigning, and a captured copy of the // old one is what refused every legitimate switch. const expectedPatientId = expectedPatientIdRef.current; if (next && expectedPatientId && next.id !== expectedPatientId) { console.error( `Refused to set patient context to ${next.id}: this surface declared it is for ` + `${expectedPatientId}. Clearing rather than showing the wrong patient.`, ); return undefined; } return next; }); }, // Deliberately EMPTY, and it is load-bearing rather than tidy: a stable setter cannot carry a // stale expectation into a later call, so this closes the defect class rather than this instance. [], ); /** * How a surface says which patient it is for. * * Route-driven surfaces pass the id from the URL; session-driven ones (patient self, related person, * act-as) pass the id they resolved from the session. **Both are declarations of intent, which is why * this works where route-binding would not.** * * Passing `undefined` withdraws the expectation - correct when a surface unmounts or genuinely has * no single subject (a patient LIST), and the reason this is not simply derived from the router. * * WHO CALLS THIS, AND - MORE IMPORTANTLY - WHO MUST NOT. There are two kinds of `setPatientState` * call in this codebase and only ONE of them may declare: * * - **SUBJECT CHANGE** - a patient-list row, the receptionist quick view, a freshly registered * patient, a chart route. The surface is asserting who it is now for, so it declares FIRST and * assigns second. All seven such sites do this today. * * - **UPDATE** - `onPatientUpdated` from the profile card, `handlePatientUpdated` on the * receptionist home, the `patient.updated` WebSocket broadcast, the routed profile page. * **These deliberately do NOT declare, and that is the protection, not an omission.** They ride * the expectation the opening surface already set, which is precisely what makes a save that * returns the WRONG PERSON impossible to accept. * * **Adding `declareExpectedPatient(updated.id)` at an update site reads like tidying up and * silently DEFEATS the guard** - the expectation would be re-derived from the very response it exists * to check. Pinned by *"an UPDATE that comes back as a different patient is refused"* in * `__tests__/patientContextGuard.test.tsx`, with the same-patient arm beside it so the rule cannot be * satisfied by simply refusing every edit. * * **This is enforced by a BEHAVIOUR test and by this note, not by a source guard.** A guard keyed on * handler names (`onPatientUpdated`, `handle*Updated`) would be exactly the needle-shaped instrument * that let seven bracketed statuses through - it would bind the names that exist today and * miss the next one. Stating the rule where the rule lives is the honest instrument here. */ const declareExpectedPatient = useCallback((patientId: string | undefined) => { expectedPatientIdRef.current = patientId; }, []); /** * - THE OTHER HALF OF A DECLARATION, and its absence is what made the guard cry wolf. * * Measured on the deployed build: switching from the Platform Manager workspace to the clinician * overview refused the clinician's own patient and CLEARED the context - because the surface before it * had declared a patient and **nothing ever took the declaration back**. The overview declares nothing * itself, so it inherited a stale expectation and every patient it opened disagreed with it. * * The soft edge above says *no expectation declared the assignment is allowed*. That was only ever * true for the FIRST surface. Without a release, an undeclared surface is not undeclared - **it is * carrying whatever the last one left behind**, so the permissive default silently became a refusal. * * COMPARE-AND-CLEAR, never a blind clear. A surface may only take back the expectation it still * owns: if something else declared after it, that newer declaration is the live one and unmounting * must not disturb it. A plain `declareExpectedPatient(undefined)` on unmount would trade this bug * for its mirror image - the guard falling open exactly when two surfaces overlap, which is the moment * it is most needed. */ const releaseExpectedPatient = useCallback((patientId: string | undefined) => { if (expectedPatientIdRef.current === patientId) { expectedPatientIdRef.current = undefined; } }, []); useEffect(() => { patientBundleRef.current = patientBundle; }, [patientBundle]); useEffect(() => { // Gated on !actingAs (, Option B): while the caller has switched into a proxied // subject, this self-assignment must NOT stomp the subject the act-as effect below is // displaying - including a stray setCurrentUser from a self-profile edit, which would // otherwise re-fire this effect and silently revert patientState back to self. if (userRole === USER_ROLES.PATIENT && currentUser && currentUser.fhir && !actingAs) { // - the SESSION is the surface here, and it declares itself. This is the case that // rules out route-binding: a patient viewing their own chart has no patient id in the URL, // so the expectation has to come from who they are. const self = currentUser.fhir as Patient; declareExpectedPatient(self.id); setPatientState(self); } }, [userRole, currentUser, actingAs, declareExpectedPatient]); useEffect(() => { // Gated on !actingAs too ( fast-follow A3c), for parity with the PATIENT self-effect // above - not reachable today since the switcher is Patient-only (a RelatedPerson session // can't also be acting-as), but the same rationale applies: this self-assignment must never // stomp the act-as subject the effect below is displaying. if (userRole === USER_ROLES.RELATED_PERSON && selectedRelatedPatient && !actingAs) { // - a related person switching between the people they care for is a legitimate // subject change, so the expectation MOVES with the selection rather than pinning them to // whoever was chosen first. declareExpectedPatient(selectedRelatedPatient.id); setPatientState(selectedRelatedPatient); } }, [userRole, selectedRelatedPatient, actingAs, declareExpectedPatient]); useEffect(() => { // Act-as (, Option B): switch the globally-displayed patient to the proxied subject. // This lives here (below AuthService + HttpService in the provider tree - see // App.tsx AppServiceProviders) specifically so the GET carries X-Acting-As // (useHttpService -> fetchWithAuth reads actingAsRef); AuthService itself sits ABOVE // HttpService and can't make this call. Gated on currentUser (not just actingAs) to avoid // the app-provider auth-gate /login reload loop (any authed fetch in an app-wrapping // provider must wait for a logged-in user). // // ONLY A 403 REVERTS. This used to read 'a 403/any throw means the grant is stale or // revoked' and act on it, which is how a guardian was returned to their own record by a // fetch that failed while the auth layer was refreshing. Reverting DOES re-run the // self-patient effect above and clear the persisted target (AuthService.tsx: // writePersistedActingAs) - which is exactly why it must only happen on a real refusal. if (!actingAs || !currentUser) { return; } let cancelled = false; (async () => { try { const subjectPatient = await get(`${API_URL}/Patient/${actingAs.subjectPatientId}`); if (!cancelled) { // - declared from the GRANT (`actingAs.subjectPatientId`), not from the // fetched resource. That is the whole value here: the expectation comes from what // the caregiver was authorized to view, so if the server ever returned a different // Patient than the grant names, the guard clears the context instead of quietly // showing - and writing to - someone the grant does not cover. declareExpectedPatient(actingAs.subjectPatientId); setPatientState(subjectPatient); } } catch (error) { // ONLY A DEFINITIVE REFUSAL ENDS THE SESSION, and this catch already told the two // apart well enough to decide whether to log before it treated them identically // anyway. That is the whole defect: this effect runs on mount, which is moments // after enter() reloads the page, so a fetch that fails because the auth layer is // still refreshing - measured as `TypeError: Failed to fetch` - took the same // branch as a 403 and returned a guardian to their own record. // // LEAVING THE SESSION STANDING REACHES NOTHING. The grant is enforced on the // server for every read and every write, so a view that outlives a failed fetch // cannot open a record. Reverting silently reaches everything, because the // guardian goes on believing they are in the child's context. const refused = error instanceof HttpError && error.status === 403; if (!refused) { console.error('Could not load the act-as subject Patient; leaving the session as it is:', error); return; } if (!cancelled) { // Goes through the funnel with no `announced` flag, so the notice is set and // the banner is replaced rather than removed. setActingAs(null); } } })(); return () => { cancelled = true; }; // setActingAs is now included below (stable, empty-dep useCallback in AuthService.tsx, safe // to list). `get` is deliberately NOT listed: it's memoized but transitively depends on // loggedInAs (via useHttpService's fetchWithAuth -> getValidToken -> refreshAccessToken), and // this exact class of provider-effect-depends-on-fetcher-identity has caused a refetch/reload // loop before (see reference-app-provider-auth-gate.md). Left out rather than risk that here. // eslint-disable-next-line react-hooks/exhaustive-deps }, [actingAs, currentUser, setActingAs]); const getGeneralPractitioner = useCallback((patient: FhirResource): string => { const gp = (patient as Patient)?.generalPractitioner?.[0]; return gp?.display || ''; }, []); // CONDITIONAL: written only over `basedOn`, the version the candidate was built on. A refusal is a // RecordChangedError (412 another write won; 428 this page is too old to name a version). See saveOverCurrent. // `transport`: the save this belongs to - checked before the request is sent and before its answer is held. const updatePatient = useCallback(async (patient: Patient, basedOn: string, transport?: SaveTransport): Promise => { try { return await put(`${API_URL}/Patient/${patient.id}`, patient, { headers: ifMatch(basedOn), ...transport }); } catch (error) { throw asRecordChanged(error); } }, [put]); // A patient list is a filtered search: a held patient is laid over the rows it lists and never added to it. const fetchPatients = useCallback(async (pageSize: number = 10, page?: string, searchTerm?: string): Promise> => { const started = startSearch(); const searched = await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); if (pageSize) { params.append('_count', pageSize + ''); } if (page) { params.append('page', page); } if (searchTerm) { params.append('name', searchTerm); } if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Patient?${params.toString()}`); }); return overlayHeldPage('Patient', markSearchStarted(searched, started)) as Bundle; }, [get]); // The patient list's search: the same name, phone or birth-date handle the desk's header search reads. // ONE PAGE, with the server's next link kept: the Paginator pages by that link itself. Following every // page here, 10 at a time, stopped at the 50-page backstop, so a practice past 500 patients could not // reach the rest and the list had no next page to offer. const fetchPatientsMatching = useCallback(async (pageSize: number = 10, page?: string, searchTerm?: string): Promise> => { const params = new URLSearchParams(); if (pageSize) params.append('_count', pageSize + ''); if (page) params.append('page', page); if (searchTerm?.trim()) { for (const [key, value] of patientSearchParams(searchTerm)) params.append(key, value); } const started = startSearch(); // SEARCH-BOUNDED: one page by design. The Paginator follows this bundle's link[next] itself. return overlayHeldPage('Patient', markSearchStarted(await get>(`${API_URL}/Patient?${params.toString()}`), started)) as Bundle; }, [get]); const searchPatients = useCallback(async (searchTerm: string): Promise => { const ask = (pairs: [string, string][]): Promise> => { const params = new URLSearchParams(); params.append('_count', '10'); for (const [key, value] of pairs) params.append(key, value); // SEARCH-BOUNDED: one page per reading, for the typeahead reason given below. return get>(`${API_URL}/Patient?${params.toString()}`); }; const params = new URLSearchParams(); params.append('_count', '10'); // One input, THREE handles - the three the desk's own control advertises. Which one a term is // lives in `utils/patientSearchTerm`, tested there, because the order of the tests is the whole // rule: a date is asked about first, since the phone test accepts any run of digits and // separators and used to swallow `1985-04-12` and search for the phone number 19850412. // // Phone matching still depends on the FHIR `phone` search param against the stored value - // phones are persisted as-typed, so reliability across formats is a server concern (see the // phone-normalization follow-up). for (const [key, value] of patientSearchParams(searchTerm)) params.append(key, value); // SEARCH-BOUNDED: `_count=10` is the product decision, not an oversight - this backs a typeahead, // and following every page would defeat the point of one. Do NOT wrap this in a paginator. // // THE HONEST RESIDUE, because it is the same shape as the defect this card is about: a // receptionist searching a common surname sees the first ten and no indication there are more. // The server's `total` on this bundle is the full match count, so "showing 10 of 34" is available // to whoever wants to render it - that is a UI decision across three call sites and belongs to // its own change, not to a pagination sweep. let bundle = await get>(`${API_URL}/Patient?${params.toString()}`); // A NAME THAT HOLDS A SPACE ("ZZ Desk", "Van Buren") is one stored part, so the word-per-param search // above misses it. Only when that search finds nobody, ask the other readings of the words and keep // each patient once, in the order the readings found them, up to the same ten. if (!bundle.entry?.length) { const fallbacks = patientSearchFallbacks(searchTerm); if (fallbacks.length) { const answers = await Promise.all(fallbacks.map(ask)); const byId = new Map['entry']>[number]>(); for (const entry of answers.flatMap((answer) => answer.entry ?? [])) { const id = entry.resource?.id; if (id && !byId.has(id)) byId.set(id, entry); } if (byId.size) bundle = { ...bundle, total: byId.size, entry: [...byId.values()].slice(0, 10) }; } } // Exclude INACTIVE records (active:false). Filter is `active !== false` (not `active === true`) // so patients with no `active` field - the common case - are still returned; only // explicitly-inactive records drop out. // //: this comment used to say "retired by a duplicate-account merge". That was the // assumption that produced the false on-screen claim - most inactive staging records were never // merged at all. Exclusion is keyed on `active`; MERGE is a separate, narrower fact. // // - the exclusion itself is right (you should book the surviving account, not the // merged-away one), but it used to happen SILENTLY, and the callers render a bare "No matching // patients found." So a receptionist searching a real patient whose record carries the flag was // told she does not exist - indistinguishable from a typo'd name. Report the count so the caller // can tell the two apart. Additive: this is still a Bundle, so the other call sites // (PatientSearch, ReceptionistPatientSearchDialog, InvoiceEditor) are unaffected. const entries = bundle.entry; if (!entries) { // A bundle with no `entry` key at all is HealthLake's no-match shape and a DIFFERENT input // from an empty array - don't invent the key, just report the honest zero. return { ...bundle, excludedInactiveCount: 0, excludedMergedCount: 0 }; } const kept = entries.filter((e) => e.resource?.active !== false); const excluded = entries.filter((e) => e.resource?.active === false); return { ...bundle, entry: kept, excludedInactiveCount: excluded.length, excludedMergedCount: excluded.filter((e) => wasMergedAway(e.resource)).length, }; }, [get]); const fetchPatientsWithPolling = useCallback(async (forceReload = false): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; if (!forceReload && !shouldReload && !writtenSince('Patient', lastFetchTimeRef.current) && patientBundleRef.current && cacheFresh) { return patientBundleRef.current; } let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: Patient[]; do { bundle = await fetchPatients(); entries = (bundle.entry ?? []).map(entry => entry.resource) as Patient[]; found = lastUpserted ? entries.some(p => p.id === lastUpserted) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0); setPatientBundle(bundle as Bundle); setPatientList(entries); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; return bundle; }, [fetchPatients, lastUpserted, shouldReload]); const fetchPatient = useCallback(async ( id: string, everything: boolean = false, page?: string, forceReload: boolean = false, // A read made for a save: admitted and cancelled with it. transport?: SaveTransport, ): Promise => { if (!everything && !forceReload) { const cachedPatient = patientList.find(patient => patient.id === id); if (cachedPatient) { return Promise.resolve(cachedPatient); } } let url = `${API_URL}/Patient/${id}`; if (everything) { url += '/$everything?_count=100'; if (page) { url += `&page=${page}`; } } return get(url, { ...transport }); }, [get, patientList]); // `transport`: the opening this request belongs to, when a caller has one; admitted, cancelled and practice-pinned by it. // A registration is an operation the caller names: a retry of the same id resumes it rather than creating another. const createPatient = useCallback(async (registrationId: string, patient: any, transport?: SaveTransport & { organizationId?: string }): Promise => { const { data, header } = await writeWithHeaders('POST', `${API_URL}/Patient`, { ...patient, registrationId }, { ...transport }); return readRegistrationReceipt(registrationId, data, header); }, [writeWithHeaders]); // Check or resume a registration from its id alone; the server answers from what it stored, never from this body. const resumeRegistration = useCallback(async (registrationId: string, transport?: SaveTransport & { organizationId?: string }): Promise => { const { data, header } = await writeWithHeaders('POST', `${API_URL}/Patient`, { registrationId, resume: true }, { ...transport }); return readRegistrationReceipt(registrationId, data, header); }, [writeWithHeaders]); // The caller's own registrations at this practice, completed and unfinished; a page says whether it is complete. const listRegistrations = useCallback(async (cursor?: string, transport?: SaveTransport & { organizationId?: string }): Promise => { const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''; return readRegistrationList(await get(`${API_URL}/Patient/registrations${query}`, { ...transport })); }, [get]); // - both routes live in templates/fhir/02-fhir-base.yaml, so they are on fhirBaseApi, // the SAME base createPatient uses. A wrong base here is a silent 404, not an error. const matchCandidates = useCallback(async (input: PatientMatchInput, transport?: SaveTransport & { organizationId?: string }): Promise => { const submitted = { ...input }; const answer = await post(`${API_URL}/Patient/match-candidates`, submitted, { ...transport }); return readMatchAdvice(answer, submitted); }, [post]); const linkExistingPatient = useCallback(async (input: PatientMatchInput & { candidateToken: string }, transport?: SaveTransport & { organizationId?: string }): Promise<{ patientId: string; linked: true; alreadyAffiliated?: boolean; }> => { const answer = await post(`${API_URL}/Patient/link-existing`, { ...input }, { ...transport }); return readLinkedPatient(answer); }, [post]); const transformToFhirPatient = useCallback((item: any, photo: Attachment | undefined): Patient => { const extensions: Extension[] = []; const { identifier, address, telecom, active, deceasedBoolean, multipleBirthBoolean, gender, genderIdentity, sex, interpreterRequired, birthsex, birthDate, name, communication, maritalStatus, ombRaceCategory, detailedRace, ombEthnicityCategory, detailedEthnicity, tribalAffiliation, isEnrolled, generalPractitioner, meta } = item; const usCorePatientProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'; const transformedIdentifier: Identifier[] = transformToIdentifier(identifier); const transformedAddress: Address[] = transformToAddress(address, 'patient'); const transformedTelecom: ContactPoint[] = transformToTelecom(telecom, 'patient'); if (sex) { extensions.push({ url: extensionUrls.sex, valueCode: sex }); } if (interpreterRequired) { extensions.push({ url: extensionUrls.interpreterRequired, valueCode: interpreterRequired }); } if (birthsex) { extensions.push({ url: extensionUrls.birthsex, valueCode: birthsex }); } if (genderIdentity && Object.keys(genderIdentity).length !== 0) { extensions.push({ url: extensionUrls.genderIdentity, valueCodeableConcept: { coding: [genderIdentity], text: genderIdentity.display } }); } if ((ombRaceCategory && ombRaceCategory.length !== 0) || (detailedRace && detailedRace.length !== 0)) { const childExtensions: Extension[] = []; if (ombRaceCategory && ombRaceCategory.length !== 0) { ombRaceCategory.forEach((race: Coding) => { childExtensions.push({ url: 'ombCategory', valueCoding: race }); }); } if (detailedRace && detailedRace.length !== 0) { detailedRace.forEach((race: Coding) => { childExtensions.push({ url: 'detailed', valueCoding: race }); }); } const text = [ ...((ombRaceCategory ?? []).map((r: Coding) => r.display)), ...((detailedRace ?? []).map((r: Coding) => r.display)) ].join(', '); if (text) { childExtensions.push({ url: 'text', valueString: text }); } if (childExtensions.length) { extensions.push({ url: extensionUrls.race, extension: childExtensions }); } } if (ombEthnicityCategory || (detailedEthnicity && detailedEthnicity.length !== 0)) { const childExtensions: Extension[] = []; if (ombEthnicityCategory) { childExtensions.push({ url: 'ombCategory', valueCoding: ombEthnicityCategory }); } if (detailedEthnicity && detailedEthnicity.length !== 0) { detailedEthnicity.forEach((ethnicity: Coding) => { childExtensions.push({ url: 'detailed', valueCoding: ethnicity }); }); } const text = [ ...(ombEthnicityCategory && [ombEthnicityCategory.display]), ...((detailedEthnicity ?? []).map((e: Coding) => e.display)) ].join(', '); if (text) { childExtensions.push({ url: 'text', valueString: text }); } if (childExtensions.length) { extensions.push({ url: extensionUrls.ethnicity, extension: childExtensions }); } } // Enrolment is written only when answered Yes or No; Not given leaves it out, never false. if (tribalAffiliation) { extensions.push(tribalAffiliationExtension(tribalAffiliation, isEnrolled)); } return { resourceType: 'Patient', active: active, deceasedBoolean: deceasedBoolean, multipleBirthBoolean: multipleBirthBoolean, meta: { ...meta, profile: [usCorePatientProfile] }, generalPractitioner: generalPractitioner, name: transformToHumanName(name, 'patient'), ...(communication.length !== 0 && { communication: communication.map((type: IValueSet) => ({ id: uuidv4(), preferred: false, language: transformToCodeableConcept(type) })), }), ...(gender && { gender: gender }), ...(birthDate && { birthDate: formatDate(birthDate) }), ...(maritalStatus && { maritalStatus: transformToCodeableConcept(maritalStatus) as CodeableConcept }), ...(photo && { photo: [photo] }), ...(transformedIdentifier.length !== 0 && { identifier: transformedIdentifier }), ...(transformedAddress.length !== 0 && { address: transformedAddress }), ...(transformedTelecom.length !== 0 && { telecom: transformedTelecom }), ...(extensions.length !== 0 && { extension: extensions }) }; }, [extensionUrls, transformToIdentifier, formatDate, transformToHumanName, transformToCodeableConcept, transformToAddress, transformToTelecom]); const transformFromFhirPatient = useCallback((patient: Patient): { photo: Attachment | undefined, formData: any } => { const extensions: Extension[] = patient.extension ?? []; const usCoreRaceExtension = extensions.find((ext: Extension) => ext.url === extensionUrls.race); const usCoreEthnicityExtension = extensions.find((ext: Extension) => ext.url === extensionUrls.ethnicity); const usCoreTribalAffiliationExtension = extensions.find((ext: Extension) => ext.url === extensionUrls.tribalAffiliation); const ombRaceCategory = usCoreRaceExtension?.extension ?.filter((ext: Extension) => ext.url === 'ombCategory') .map(ext => ext.valueCoding) ?? []; const detailedRace = usCoreRaceExtension?.extension ?.filter((ext: Extension) => ext.url === 'detailed') .map(ext => ext.valueCoding) ?? []; const ombEthnicityCategory = usCoreEthnicityExtension?.extension?.find((ext: Extension) => ext.url === 'ombCategory')?.valueCoding const detailedEthnicity = usCoreEthnicityExtension?.extension ?.filter((ext: Extension) => ext.url === 'detailed') .map(ext => ext.valueCoding) ?? []; const tribalAffiliation = usCoreTribalAffiliationExtension?.extension ?.find((ext: Extension) => ext.url === 'tribalAffiliation') ?.valueCodeableConcept?.coding?.[0] ?? null; // Absent is Not given (undefined), not No. const isEnrolled = enrolledOf(usCoreTribalAffiliationExtension); return { photo: patient.photo && patient.photo[0], formData: { meta: patient.meta, name: transformFromHumanName(patient), active: patient.active ?? true, deceasedBoolean: patient.deceasedBoolean ?? false, multipleBirthBoolean: patient.multipleBirthBoolean ?? false, gender: patient.gender ?? '', birthDate: patient.birthDate ?? '', identifier: transformFromIdentifier(patient.identifier ?? []), address: transformFromAddress(patient), telecom: transformFromTelecom(patient), communication: (patient.communication ?? []).map(communication => transformFromCodeableConcept(communication.language)), maritalStatus: transformFromCodeableConcept(patient.maritalStatus), genderIdentity: extensions.find((extension: Extension) => extension.url === extensionUrls.genderIdentity)?.valueCodeableConcept?.coding?.[0] ?? {}, sex: extensions.find((extension: Extension) => extension.url === extensionUrls.sex)?.valueCode ?? '', birthsex: extensions.find((extension: Extension) => extension.url === extensionUrls.birthsex)?.valueCode ?? '', interpreterRequired: extensions.find((extension: Extension) => extension.url === extensionUrls.interpreterRequired)?.valueCode ?? '', generalPractitioner: patient.generalPractitioner, ombRaceCategory, detailedRace, ombEthnicityCategory, detailedEthnicity, tribalAffiliation, isEnrolled } }; }, [extensionUrls, transformFromHumanName, transformFromIdentifier, transformFromAddress, transformFromTelecom, transformFromCodeableConcept]); return { extensionUrls, patientState, setPatientState, // - a surface declares which patient it is for; the setter above enforces it. declareExpectedPatient, // - and takes it back when it goes away. Prefer `useSurfaceExpectedPatient` in a // component: calling these two by hand is what left the declaration behind in the first place. releaseExpectedPatient, // The expectation itself is deliberately NOT exported. It lives in a ref, so a component // reading it during render would get a value that never triggers a re-render - a silent // wrong answer. Nothing consumed it; a test pins that it is not on the context at all. tempPatientState, setTempPatientState, encounterState, setEncounterState, patientBundle, patientList, setPatientList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, getGeneralPractitioner, updatePatient, fetchPatients, fetchPatientsMatching, fetchPatientsWithPolling, fetchPatient, createPatient, resumeRegistration, listRegistrations, matchCandidates, linkExistingPatient, searchPatients, transformToFhirPatient, transformFromFhirPatient }; }; const PatientServiceContext = createContext | undefined>(undefined); export const PatientServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const patientService = usePatientService(); return ( {children} ); }; export const usePatientServiceContext = () => { const context = useContext(PatientServiceContext); if (context === undefined) { throw new Error('usePatientServiceContext must be used within a PatientServiceProvider'); } return context; }; /** * - HOW A SURFACE SHOULD DECLARE ITS PATIENT. Use this rather than the two context functions. * * It exists because declaring and releasing are ONE decision that was being made in two places, and * the second half was simply never written: seven call sites declared, none released. Binding them into * one hook means a new surface cannot adopt half of the contract, which is the only reason the defect * lasted - every individual site looked complete. * * It remembers what THIS surface last declared, so the unmount release is a compare-and-clear against * that value. A surface that declares patient A, then patient B, releases only B - and if another surface * has since declared C, this one releases nothing at all. * * Not for the SESSION-driven declarations inside the provider (patient self, related person, act-as). * Those belong to the session rather than to a screen, and they must outlive any page that unmounts. */ export const useSurfaceExpectedPatient = () => { const { declareExpectedPatient, releaseExpectedPatient } = usePatientServiceContext(); const declaredRef = useRef(undefined); const declare = useCallback((patientId: string | undefined) => { declaredRef.current = patientId; declareExpectedPatient(patientId); }, [declareExpectedPatient]); // Reads through the ref inside the cleanup, never a captured value: the id changes over the // surface's life (a receptionist opens one patient after another) and a closed-over copy would // release the FIRST patient rather than the current one - clearing nothing, silently. useEffect(() => () => releaseExpectedPatient(declaredRef.current), [releaseExpectedPatient]); return declare; };