import { useState, useCallback, useEffect, useRef, createContext, useContext, ReactNode } from 'react'; import type { CustomSelectOption } from "../components/ui/custom-select-control"; import { Address, Attachment, Bundle, CodeableConcept, ContactPoint, Identifier, Practitioner, PractitionerRole } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { searchPageSize } from '../utils/pageSizeOverride'; import { isRetryableFailure } from '../utils/fetchFailureMessage'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { markSearchStarted, overlayHeldPage, startSearch, writtenSince } from '../utils/writeThroughHold'; import { v4 as uuidv4 } from 'uuid'; import { splitSystemOwnedIdentifiers } from '../utils/accountHomeIdentifier'; import { STAFF_WORKSPACES } from '../config/staffRoles.ts'; import type { StaffProfileCompleteness } from '../utils/staffProfileCompleteness.ts'; /** The colleague list, for the signed-in user and practice named by `key`. */ export interface ColleagueDirectory { key: string; status: 'loading' | 'ready' | 'failed' | 'denied'; list: PractitionerWithRoles[]; } /** * - what the caller has on their OWN home gear. Values, because the audience is the person * themselves. The org-facing twin is `StaffProfileCompleteness`, which is booleans and nothing else. */ /** * - what the server is willing to say about an NPI that already exists. * * THE ABSENCES ARE THE CONTRACT. There is no organization here and there never will be: the lookup is * cross-org, but telling one practice where a practitioner ELSE works is a sideways disclosure the product * exists to prevent. `alreadyInYourOrganization` is the only org fact, and it is safe precisely because an * admin can already see their own staff list. */ export interface PractitionerNpiMatch { practitionerId: string; name: string; /** The account that signs in with the typed email, not merely a contact address on the record. */ signsInWithThisEmail?: boolean; alreadyInYourOrganization: boolean; } export interface PractitionerNpiMatchResponse { matches: PractitionerNpiMatch[]; } export interface StaffPersonalProfile { /** false no home gear yet; the two fields are then ABSENT, which is not the same as empty. */ hasHomeProfile: boolean; personalPhone?: string; personalEmail?: string; } export interface StaffPersonalProfileInput { /** Omitted = leave alone. Empty string = clear it. */ personalPhone?: string; personalEmail?: string; } export interface PractitionerWithRoles { practitioner: Practitioner; roles: PractitionerRole[]; /** * - server-derived BOOLEANS about what this person has supplied. Never a value. * * Optional because it is emitted for STAFF callers only: a patient reading the provider directory * gets the projection and no flags at all. `scope` says how much was evaluated - the list resolves * only what is free off the Practitioner, the by-id read resolves the home gear too. Read it through * `utils/staffProfileCompleteness`, which encodes both rules; a bare `!hasPersonalPhone` here is the * `??`-fallback bug this codebase has already shipped four times. */ profileCompleteness?: StaffProfileCompleteness; } // Each staff row wraps a practitioner with its roles. A held practitioner replaces the body of the row that lists it, // roles kept, and a deleted one leaves; a row is never added, since a write does not say whose staff it joins. const overlayHeldStaff = (bundle: Bundle, started: number): Bundle => { const rows = (bundle.entry ?? []).flatMap((entry) => (entry.resource ? [entry.resource] : [])); const practitioners = markSearchStarted({ ...bundle, entry: rows.map((row) => ({ resource: row.practitioner })) } as Bundle, started); const overlaid = overlayHeldPage('Practitioner', practitioners); if (overlaid === practitioners) return bundle; const shown = new Map((overlaid.entry ?? []).map((entry) => [entry.resource?.id, entry.resource as Practitioner])); const entry = (bundle.entry ?? []) .filter((row) => row.resource && shown.has(row.resource.practitioner.id)) .map((row) => ({ ...row, resource: { ...row.resource!, practitioner: shown.get(row.resource!.practitioner.id)! } })); return { ...bundle, entry, ...(overlaid.total === undefined ? {} : { total: overlaid.total }) }; }; type CreatePractitionerUserInput = { email: string; firstname: string; lastname: string; role: string; prefix?: string[]; suffix?: string[]; telecom?: ContactPoint[]; // - sent together, stored APART by the server, and that separation is the feature. // `npi` lands on the Practitioner (a professional identifier, publicly listed in NPPES). // `personalMobile` lands on the account's own home Patient and is never written to the // Practitioner, never returned to a patient, and never shown to org admins. npi?: string; personalMobile?: string; }; type CreatePractitionerInput = { user: CreatePractitionerUserInput; practitionerRole: PractitionerRole; }; type CreatePractitionerResponse = { practitioner: Practitioner; practitionerRole: PractitionerRole; }; const API_URL = environment.fhirIdentityApi; export const usePractitionerService = () => { const { npiSystem, identifierTypeSystem, transformToIdentifier, transformFromIdentifier, transformToCodeableConcept, transformFromCodeableConcept, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, transformToAddress, transformFromAddress, transformToTelecom, transformFromTelecom, transformToHumanName, transformFromHumanName, formatDate, unpackBundle } = useFhirServiceContext(); const { get, post, put, delete: del } = useHttpService(); const [practitionerState, setPractitionerState] = useState() const [practitionersList, setPractitionersList] = useState([]); const [colleagues, setColleagues] = useState(); const colleaguesKey = useRef(undefined); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [practitionersBundle, setPractitionersBundle] = useState | null>(null); const practitionersBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); // The read already on its way. Two screens mounting together before the first answer lands would // otherwise each start the whole paged read; they share this one instead. const inFlightRef = useRef> | null>(null); // Which read is newest. A forced reload starts beside an older read, and whichever answers last // would otherwise win: an older answer landing late put the pre-write roles back for five minutes. // Only the newest read writes; an older one's failure defers to the newest rather than surfacing. const generationRef = useRef(0); const latestReadRef = useRef> | null>(null); const userGroups: CustomSelectOption[] = [ { name: '', key: '' }, ...STAFF_WORKSPACES.map((workspace) => ({ name: workspace.name, key: workspace.key, })), ]; useEffect(() => { practitionersBundleRef.current = practitionersBundle; }, [practitionersBundle]); /** * The practice's staff, every page of it. * * - THIS FOLLOWS `next`, AND THAT IS WHAT KEEPS THE SERVER'S FIX FROM BECOMING A WORSE * DEFECT. `GET /R4/Practitioner` used to discard `_count` and return the whole staff table however * big it got; it pages properly now. But this caller asks for 100 and read only what came back - so * at 300 clinicians it would show 100 and say nothing, which is exactly the SILENT TRUNCATION the * fix was written to avoid. * Unbounded-but-complete is a growth problem; short-and-quiet is a correctness one, and a staff * directory that omits colleagues with no error is the shape already paid for. * * THE GENERIC IS WIDENED AT THIS ONE CALL SITE, and the reason is worth reading before copying it * elsewhere. `fetchAllPagesAsBundle` is constrained to `FhirResource` because `Bundle` is, and * this endpoint does not return one: every entry is a COMPOSED wrapper - `{ practitioner, roles, * profileCompleteness }` - which is also why this route needed its own paging in the first place. * The paginator only ever touches Bundle PLUMBING (`link`, `entry`, `total`) and never reads a * field off the resource, so the constraint is a type-level artifact here rather than a semantic * one. Widening the helper's own constraint would have removed that check for every real FHIR * read in the product to accommodate the single composed one. */ const fetchPractitioners = useCallback(async (organizationId?: string): Promise> => { const started = startSearch(); return overlayHeldStaff(await ( // THIS WAS A HAND-ROLLED PAGING LOOP AND IT IS NOW THE SHARED PAGINATOR - one implementation, // and the change is not cosmetic in either direction: // - the sweep's ratchet counts a read as swept only when it goes through this helper, so the // loop was counted among the 74 UNPAGINATED reads while it was in fact paging. **A guard // reading a false negative is worse than the number being one higher.** // - the helper carries `truncated`, which the loop had nowhere to put: it followed `next` until // the server stopped, with no page bound at all. // The server half was renamed to emit a `page=` cursor in the same change, because this // paginator extracts a `page` token out of `next` - the url points at the datastore and a browser // cannot call it. A route emitting `_offset=` is invisible to it and silently reads one page. (fetchAllPagesAsBundle as unknown as ( fetchPage: (pageParam?: string) => Promise>, ) => Promise>)((pageParam) => { const params = new URLSearchParams(); // The shipping default is unchanged at 100; a probe shrinks the page from the browser // console so this can be WATCHED paging against staging's ten practitioners, instead of // needing three hundred seeded `utils/pageSizeOverride.ts`, instrument. // That makes this list a CONTROL for that probe rather than a subject: at a small page it // must still return EVERYONE. A subject truncates visibly; this must not, and those are the // two answers the probe exists to tell apart. params.append('_count', searchPageSize('100')); if (pageParam) params.set('page', pageParam); return get>( `${API_URL}/Practitioner?${params.toString()}`, { organizationId }, ); }) ), started); }, [get]); const fetchPractitionersWithPolling = useCallback(async (forceReload = false): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; if (!forceReload && !shouldReload && !writtenSince('Practitioner', lastFetchTimeRef.current) && practitionersBundleRef.current && cacheFresh) { return practitionersBundleRef.current; } // A forced reload wants what is true after a write, so it never joins a read that began before it. if (!forceReload && inFlightRef.current) { return inFlightRef.current; } const generation = ++generationRef.current; const read = (async () => { let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: PractitionerWithRoles[]; do { bundle = await fetchPractitioners(); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.practitioner.id === lastUpserted) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0); if (generation === generationRef.current) { setPractitionersBundle(bundle); setPractitionersList(entries); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; } return bundle as Bundle; })(); inFlightRef.current = read; latestReadRef.current = read; try { return await read; } catch (error) { // Superseded: the newest read's answer, not this one's failure, is what the caller gets. if (generation !== generationRef.current && latestReadRef.current) { return latestReadRef.current; } throw error; } finally { if (inFlightRef.current === read) inFlightRef.current = null; } }, [fetchPractitioners, unpackBundle, lastUpserted, shouldReload]); const fetchPractitioner = useCallback(async (id: string): Promise => { const cachedPractitioner = practitionersList.find(practitioner => practitioner.practitioner.id === id); if (cachedPractitioner) { return cachedPractitioner; } return get(`${API_URL}/Practitioner/${id}`); }, [get, practitionersList]); const createPractitioner = useCallback( (practitioner: CreatePractitionerInput): Promise => { return post(`${API_URL}/Practitioner`, practitioner); }, [post], ); const updatePractitioner = useCallback((practitioner: Practitioner): Promise => { return put(`${API_URL}/Practitioner/${practitioner.id}`, practitioner); }, [put]); const deletePractitioner = useCallback((practitioner: Practitioner): Promise => { return del(`${API_URL}/Practitioner/${practitioner.id}`); }, [del]); /** * - "does a practitioner with this NPI already exist?", asked at staff creation BEFORE the * form mints a second record for one human. * * AN NPI IS A CLINICIAN'S IDENTIFIER, so this only ever has an answer for a practitioner-role hire * (Spencer, 2026-08-14: *"it's just for practitioner role users"*). Non-clinician staff - reception, * HR, billing - have no NPI at all, which is why the caller triggers this on a VALID NPI being present * rather than on a role: the field is the gate, and it is empty for everyone else. * * IT SURFACES, IT NEVER LINKS. The server projects deliberately - a name and whether the person * already works at the CALLER'S org, never which other organizations they work at - so this returns * evidence for a human to act on, not a decision. See `handlers/PractitionerMatch/get.ts`. */ const findExistingByNpi = useCallback((npi: string): Promise => { return get(`${API_URL}/PractitionerMatch?npi=${encodeURIComponent(npi)}`); }, [get]); /** * step 4 - the same question, asked on the identifier a receptionist ACTUALLY TYPES. * * THE GAP THIS CLOSES WAS MEASURED IN A BROWSER (claude-4, 2026-08-15): entering an email that * already belonged to a practitioner AT THE SAME ORGANIZATION produced no warning and **no lookup * request at all**. The prevention was NPI-only, so it was blind to the one field every staff record * has - and entirely blind to reception, billing and admin staff, who have no NPI to type. * * Spencer, 2026-08-10: *"NPI helps satisfy practitioners but what about non-practitioner staff? * That's where a personal mobile number will matter."* * * SURFACES, NEVER LINKS - identical projection and identical trade as the NPI door. And a * SHARED front-desk number is the hazard here rather than a wrong one: several staff legitimately * list the same line, so a hit is evidence for a human to confirm, never a merge. */ const findExistingByContact = useCallback(( contact: { email?: string; phone?: string }, ): Promise => { const params = new URLSearchParams(); if (contact.email) params.set('email', contact.email); if (contact.phone) params.set('phone', contact.phone); // Never call with nothing. The server refuses a keyless lookup outright, and asking anyway // would spend a request to be told so on every empty blur. if ([...params.keys()].length === 0) return Promise.resolve({ matches: [] }); return get(`${API_URL}/PractitionerMatch?${params.toString()}`); }, [get]); /** * - the caller's OWN personal contact details, on their home gear. * * THERE IS NO ID IN EITHER CALL, and that is the security property rather than an omission. The * server resolves the subject from the caller's own token, so these can only ever read or write the * signed-in person's record - see `handlers/StaffHomeProfile/*`. * * DO NOT ADD AN ID PARAMETER to make this reusable for "view another staff member's details". * The whole card exists because an employer must not see this data; the org-facing surface is the * BOOLEAN completeness flag on `PractitionerWithRoles.profileCompleteness`, and that is the only * shape allowed to have an audience other than the person themselves. */ const fetchOwnPersonalProfile = useCallback((): Promise => { return get(`${API_URL}/staff-home-profile`); }, [get]); /** The practice's staff for messaging, readable by every staff role. `key` names the signed-in user and * practice the request is made for; an answer for a key no longer current is dropped. */ const loadColleagues = useCallback(async (key: string): Promise => { colleaguesKey.current = key; // A re-read of the same list keeps the held one on screen until the answer lands. setColleagues((held) => (held?.key === key && held.status === 'ready' ? held : { key, status: 'loading', list: [] })); try { // SEARCH-BOUNDED: `/colleagues` sends no `_count`, so the server composes the whole staff list, no `next` link. const bundle = await get>(`${API_URL}/colleagues`); if (colleaguesKey.current !== key) return; const list = (bundle.entry ?? []).map((entry) => entry.resource).filter((r): r is PractitionerWithRoles => !!r?.practitioner); setColleagues({ key, status: 'ready', list }); } catch (error) { if (colleaguesKey.current !== key) return; setColleagues({ key, status: isRetryableFailure(error) ? 'failed' : 'denied', list: [] }); } }, [get]); const updateOwnPersonalProfile = useCallback((input: StaffPersonalProfileInput): Promise => { return put(`${API_URL}/staff-home-profile`, input); }, [put]); const transformToFhirPractitioner = useCallback((input: any, photo: Attachment | undefined): Practitioner => { const identifier: Identifier[] = [ ...transformToIdentifier(input.identifier), ...transformToIdentifier(input.npi, npiSystem), // RE-ATTACHED, and this is the half that prevents the data loss. The form never showed // these and cannot have changed them, so whatever came in goes back out verbatim. ...((input.systemOwnedIdentifiers ?? []) as Identifier[]) ]; const qualification = input.qualification.map((qualification: any) => { const qualificationEntry: any = { code: transformToCodeableConcept(qualification.code) }; if (!qualificationEntry.id) { qualificationEntry.id = uuidv4(); } if (qualification.identifier && qualification.identifier.length > 0) { qualificationEntry.identifier = transformToIdentifier(qualification.identifier); } return addFhirPeriodIfApplicable(qualificationEntry, qualification); }); const address: Address[] = transformToAddress(input.address, 'practitioner'); const telecom: ContactPoint[] = transformToTelecom(input.telecom, 'practitioner'); const { active, gender, birthDate, name, communication, meta } = input; return { resourceType: 'Practitioner', active: active, name: transformToHumanName(name), ...(meta && { meta }), ...(gender && { gender: gender }), ...(birthDate && { birthDate: formatDate(birthDate) }), ...(communication && communication.length !== 0 && { communication: transformToCodeableConcept(communication) as CodeableConcept[] }), ...(photo && { photo: [photo] }), ...(identifier.length > 0 && { identifier: identifier }), ...(qualification.length > 0 && { qualification: qualification }), ...(address.length > 0 && { address: address }), ...(telecom.length > 0 && { telecom: telecom }) }; }, [ transformToIdentifier, addFhirPeriodIfApplicable, transformToCodeableConcept, transformToAddress, transformToTelecom, transformToHumanName, formatDate, npiSystem, identifierTypeSystem ]); const transformFromFhirPractitioner = useCallback((practitioner: Practitioner): { photo: Attachment | undefined, formData: any } => { const npiIdentifiers = (practitioner.identifier ?? []).filter((id: Identifier) => id.system === npiSystem); // THE ACCOUNT-HOME LINK IS NOT SOMETHING A PERSON ENTERED, so it must not reach the editable // Identifiers list. It used to: that list was everything whose system is not the NPI one, and // the save path writes `identifier` back as the WHOLE array - so an admin deleting a row they // did not recognise silently destroyed the link guardian resolution, the proxy list, pending // invitations and practitioner matching all search on. // // Kept as RAW FHIR rather than run through `transformFromIdentifier`, because it goes straight // back out on save. Round-tripping it through the form shape is how a value acquires a default // nobody chose. const { editable: identifiers, systemOwned: systemOwnedIdentifiers } = splitSystemOwnedIdentifiers( (practitioner.identifier ?? []).filter((id: Identifier) => id.system !== npiSystem), ); return { photo: practitioner.photo && practitioner.photo[0], formData: { meta: practitioner.meta, active: practitioner.active ?? true, gender: practitioner.gender ?? '', birthDate: practitioner.birthDate ?? '', npi: transformFromIdentifier(npiIdentifiers), identifier: transformFromIdentifier(identifiers), // Carried through the form untouched so the save can put it back. Absent on the create // path, where there is nothing to preserve - and absent means nothing is attached, // which is the correct answer there rather than a silent empty. systemOwnedIdentifiers, name: transformFromHumanName(practitioner), telecom: transformFromTelecom(practitioner), address: transformFromAddress(practitioner), communication: transformFromCodeableConcept(practitioner.communication ?? []), qualification: (practitioner.qualification ?? []).map(qualification => { const qualificationData: any = { id: qualification.id, code: transformFromCodeableConcept(qualification.code), identifier: transformFromIdentifier(qualification.identifier ?? []) }; return addPeriodControlsIfApplicable(qualificationData, qualification); }) } }; }, [ addPeriodControlsIfApplicable, transformFromCodeableConcept, transformFromHumanName, transformFromTelecom, transformFromAddress, npiSystem ]); return { practitionerState, setPractitionerState, practitionersBundle, practitionersList, setPractitionersList, colleagues, loadColleagues, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, npiSystem, identifierTypeSystem, fetchPractitioners, fetchPractitionersWithPolling, fetchPractitioner, createPractitioner, updatePractitioner, deletePractitioner, findExistingByNpi, findExistingByContact, fetchOwnPersonalProfile, updateOwnPersonalProfile, transformToFhirPractitioner, transformFromFhirPractitioner, userGroups }; }; const PractitionerServiceContext = createContext | undefined>(undefined); export const PractitionerServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const practitionerService = usePractitionerService(); return ( {children} ); }; export const usePractitionerServiceContext = () => { const context = useContext(PractitionerServiceContext); if (context === undefined) { throw new Error('usePractitionerServiceContext must be used within a PractitionerServiceProvider'); } return context; };