import { holdWrite, overlayHeldBundle, startWrite, writtenSince } from '../utils/writeThroughHold'; import { ReactNode, useState, useCallback, createContext, useContext, useRef, useEffect } from 'react'; import { Address, Attachment, Bundle, CodeableConcept, ContactPoint, HumanName, Identifier, Patient, Reference, RelatedPerson } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { IValueSet } from './ValueSetService.tsx'; import { useHttpService } from './HttpService.tsx'; import { INVITATION_MAIL_HEADER, invitationMailOf, type InvitationMail } from './invitationMail'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { v4 as uuidv4 } from 'uuid'; import { usePatientServiceContext } from './PatientService.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { formatFhirPeriod } from '../utils/fhirPeriod.tsx'; import type { ProofingStatusValue } from './ProofingService.tsx'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; import type { SaveTransport } from '../utils/saveOperation'; const API_URL = environment.fhirIdentityApi; // RelatedPerson is now a thin link (Slice A, Phase 1) - no name/gender/birthDate/address on the // resource itself. This is the scoped server-side projection shape returned by // `GET /RelatedPerson/{id}/identity`: name + contact resolved from the helper's own home Patient, // access-scoped to callers who may already see this RelatedPerson. A legacy RelatedPerson with no // home-patient link degrades to `{}` (both fields absent) rather than erroring. // // `proofingStatus` (Phase 2, full-proxy identity-proofing hard-gate, server Task 5) is additive: // the identity-proofing status of THIS related person (not the caller), read-only, used by // CareAccess (client Task 8) to show a patient-facing "pending verification" badge on an inert // full-proxy grant. `import type` only - no runtime dependency on ProofingService.tsx. export interface RelatedPersonIdentity { name?: HumanName[]; contact?: ContactPoint[]; proofingStatus?: ProofingStatusValue; /** False until they first sign in as themselves; absent from a server that does not say. */ joined?: boolean; } // "My pending caregiver invitations" (Slice A, Phase 1, Task 11) - the shape returned by // `GET /RelatedPerson/my-pending-invitations`, a CALLER-SCOPED list for an existing-account holder // (logged in as a Patient) discovering pending RelatedPerson links where they are the invitee. // `invitedBy.name` is the inviting patient's HumanName, PHI-minimized server-side (name only); it is // undefined when that patient couldn't be resolved - callers must fall back gracefully, never throw. /** * WHAT AN INVITATION IS OFFERING, in the two values the copy branches on. * * Spencer's wording, and the difference between them IS the decision being asked for: * * view-only invited you to view their account and clinical records * acts-on-their-behalf ... and to act on their behalf */ export type InvitedAuthorityScope = 'view-only' | 'acts-on-their-behalf'; /** Set when a staff-witnessed guardian declaration made the invitation; `unavailable` when that could not be verified. */ export type GuardianInvitationOrigin = | { kind: 'staff-guardian-declaration'; relationship: 'parent' | 'legal-guardian'; practiceName?: string } | { kind: 'unavailable' }; export interface PendingInvitation { id: string; relationship: CodeableConcept[] | undefined; invitedBy: { reference: string | undefined; name?: HumanName[]; }; /** * ABSENT WHEN THE SERVER COULD NOT ESTABLISH IT, and it is never defaulted - by design, on both * sides of the wire. A screen with no answer must say the neutral sentence; one that assumes the * smaller of the two tells somebody they are being invited to LOOK at a record when they are being * invited to ACT for a person, and they agree to the larger thing having read the smaller one. * Understating a consent is the dangerous direction. Treat `undefined` as "do not claim a scope". * * AND DO NOT WRITE THE SENTENCE FROM `relationship` INSTEAD. Spencer, 2026-09-01: an invitation * cannot know which relationship it is, so it says what the person may DO rather than naming what * they ARE - a spouse, an adult child, a lawyer holding power of attorney and a live-in carer all * arrive through this same invitation, and only one of them is a caregiver. */ grantScope?: InvitedAuthorityScope; /** Present when a staff-witnessed guardian declaration made this invitation; absent for an ordinary one. */ origin?: GuardianInvitationOrigin; /** * Whether it can be accepted now. `ended`: a guardianship of someone who has since turned 18, shown but never * offered. `unavailable`: that could not be checked, so it is not offered until checked again. Absent from an * older server, which never refused on this. */ acceptability?: 'eligible' | 'ended' | 'unavailable'; } /** One invitation's current state, for its own invitee. `accepted` is what the link records, not that access works. */ export interface InvitationStatus { relatedPersonId: string; state: 'pending' | 'accepted' | 'closed' | 'ended' | 'unavailable'; canAccept: boolean; } export interface CaregiverInviteDeclineResponse { declined: boolean; /** ISO-8601, the instant the refusal was recorded. */ declinedAt: string; } export const useRelatedPersonService = () => { const { get, post, put, delete: del, writeWithHeaders } = useHttpService(); const { patientState } = usePatientServiceContext(); const { unpackBundle, transformToIdentifier, formatDate, transformToHumanName, transformToCodeableConcept, transformToAddress, transformToTelecom, createReferenceObject, addFhirPeriodIfApplicable, transformFromHumanName, transformFromIdentifier, transformFromAddress, transformFromTelecom, transformFromCodeableConcept, addPeriodControlsIfApplicable, extractReferenceId } = useFhirServiceContext(); const [relatedPersonsList, setRelatedPersonsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [relatedPersonsBundle, setRelatedPersonsBundle] = useState | null>(null); const relatedPersonsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); const relatedPersonPatientId = useCallback((person: RelatedPerson): string | undefined => { const extracted = extractReferenceId(person.patient); return Array.isArray(extracted) ? extracted[0] : extracted; }, [extractReferenceId]); useEffect(() => { relatedPersonsBundleRef.current = relatedPersonsBundle; }, [relatedPersonsBundle]); // WHOSE RECORDS ARE THESE? The reset below fires only when there is NO patient, which moving // between two charts never is - so the previous patient's bundle survived the switch and was // served as a cache hit, with no request and nothing on screen to say the list was not theirs. const cacheOwner = useCacheOwner('RelatedPerson', () => { setLastUpserted(null); setRelatedPersonsBundle(null); setRelatedPersonsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setRelatedPersonsBundle(null); setRelatedPersonsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchRelatedPersons = useCallback(async (patient: Patient, signal?: AbortSignal): Promise> => { const bundle = await fetchAllPagesAsBundle((pageParam) => { let params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('patient', patient.id as string); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/RelatedPerson?${params.toString()}`, { signal }); }); return overlayHeldBundle('RelatedPerson', bundle, (person) => relatedPersonPatientId(person) === patient.id) as Bundle; }, [get, relatedPersonPatientId]); // `owned`: a read made for somebody (a reread with a deadline) carries its abort and its admission, so a read // its owner has given up on stops, and publishes nothing even if it answers before the next one starts. const fetchRelatedPersonsWithPolling = useCallback(async ( patient: Patient, forceReload = false, owned?: { signal?: AbortSignal; admit: () => boolean }, ): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; // cacheOwner.isFor is the load-bearing clause: a fresh bundle belonging to the PREVIOUS // patient satisfies every other condition here and would be returned as a hit. if (!forceReload && !shouldReload && !writtenSince('RelatedPerson', lastFetchTimeRef.current) && relatedPersonsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return relatedPersonsBundleRef.current; } const operation = cacheOwner.begin(); /* * WHAT USED TO BE HERE AND WHY IT COULD NOT WORK - see writeThroughHold. A retry loop * polled this search up to ten times at three seconds, waiting for the id just written to turn * up, and served the list without it when the thirty seconds ran out. * * THE LAG IS NOT A CONSTANT TO TUNE AGAINST, which is why a longer loop is not the fix. This * resource was measured on 2026-08-31 as findable inside 14.4 seconds, where a Task written by * the redeem handler was still missing at 2m47s. A timeout is wrong at one end or the other, * and holding what the write returned is right at both. */ const published = await fetchRelatedPersons(patient, owned?.signal); if (!cacheOwner.publishes(operation) || (owned && !owned.admit())) return cacheOwner.answerFor(operation, published); setRelatedPersonsBundle(published); setRelatedPersonsList(unpackBundle(published)); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; // Stamped WITH the data, never before it - on a throw the owner must still name the list // that is actually in state. cacheOwner.claim(patient.id, operation); return published; // `lastUpserted` is no longer read here - the hold replaced the wait for it - so it is not a // dependency. It is still SET by a write, because it is what the write has to say. }, [fetchRelatedPersons, unpackBundle, relatedPersonPatientId, shouldReload]); const fetchRelatedPerson = useCallback(async (id: string): Promise => { const cachedPerson = cacheOwner.isCurrent(cacheOwner.claimed) ? relatedPersonsList.find(person => person.id === id) : undefined; if (cachedPerson) { return cachedPerson; } return get(`${API_URL}/RelatedPerson/${id}`); }, [get, relatedPersonsList]); const fetchRelatedPersonHistory = useCallback(async (id: string): Promise> => { return get>(`${API_URL}/RelatedPerson/${id}/_history`); }, [get]); // Scoped display projection (Slice A, Phase 1, Task 8/10) - reads name + contact from the // helper's home Patient without ever fetching that Patient's full record client-side. Callers // should treat a rejection the same as an empty result (graceful display fallback), not a hard // error - see `useRelatedPersonIdentity`. const fetchRelatedPersonIdentity = useCallback(async (id: string): Promise => { return get(`${API_URL}/RelatedPerson/${id}/identity`); }, [get]); // Slice A, Phase 1, Task 11 - the patient-side pending-invitation surface. Lists the CALLER's own // pending caregiver links (they are the invitee, not the inviting patient) via the caller-scoped // `my-pending-invitations` route - a distinct list endpoint, not a patient-scoped RelatedPerson // search (those are keyed on RelatedPerson.patient, which points at the INVITER, not the invitee). // `transport`: the banner's opening; nothing is sent, nor any answer kept, for one that has moved on. const fetchMyPendingInvitations = useCallback(async (transport?: SaveTransport): Promise => { return get(`${API_URL}/RelatedPerson/my-pending-invitations`, { ...transport }); }, [get]); // What one invitation stands at now: read after an answer was lost, a reopened banner or a wait that ran out. const readInvitationStatus = useCallback(async (id: string, transport?: SaveTransport): Promise => { return get(`${API_URL}/RelatedPerson/my-pending-invitations?invitation=${encodeURIComponent(id)}`, { ...transport }); }, [get]); // Activates a pending caregiver link the caller was invited onto. Only the invitee (the caller, // via their own home Patient) can accept - enforced entirely server-side; this is a thin POST. const acceptCaregiverInvite = useCallback(async (id: string, transport?: SaveTransport): Promise => { return post(`${API_URL}/RelatedPerson/${id}/accept`, {}, { ...transport }); }, [post]); /** * The OTHER answer, and until now there was not one. * * THIS IS WHAT MAKES A DECLINE CONTROL HONEST. The Family page's "Not now" only hides the row - * it comes back on reload, and the record shows nothing, so the person who sent the invitation * cannot tell a refusal from an invitation nobody opened. Do not build a Decline anywhere that * calls anything else; a control whose answer is not written down is worse in the app chrome than * on a page nobody visits, because on every screen it reads as an answer. * * Returns the instant the refusal was recorded. A repeat call is refused with a 409 rather than * re-dating the answer - it was given once. */ const declineCaregiverInvite = useCallback(async (id: string, transport?: SaveTransport): Promise => { return post(`${API_URL}/RelatedPerson/${id}/decline`, {}, { ...transport }); }, [post]); // `transport`: the opening this request belongs to, when a caller has one. Its admission is checked before the // answer is published to this shared list, so a retired opening publishes nothing. const noteCreated = useCallback((createdRelatedPerson: RelatedPerson) => { if (createdRelatedPerson?.resourceType === 'RelatedPerson' && createdRelatedPerson.id) { setRelatedPersonsList(prev => { const exists = prev.some(existingPerson => existingPerson.id === createdRelatedPerson.id); return exists ? prev.map(existingPerson => existingPerson.id === createdRelatedPerson.id ? createdRelatedPerson : existingPerson ) : [createdRelatedPerson, ...prev]; }); if (relatedPersonsBundleRef.current) { setRelatedPersonsBundle({ ...relatedPersonsBundleRef.current, entry: [ { resource: createdRelatedPerson }, ...(relatedPersonsBundleRef.current.entry || []) ] }); } setLastUpserted(createdRelatedPerson.id); setShouldReload(false); } else { setShouldReload(true); } }, []); const createRelatedPerson = useCallback(async (relatedPerson: any, transport?: { admit: () => void; signal: AbortSignal; organizationId?: string }): Promise => { const params = new URLSearchParams(); params.append('patient', extractReferenceId(relatedPerson.patient) as string); const createdRelatedPerson = await post(`${API_URL}/RelatedPerson?${params.toString()}`, relatedPerson, { ...transport }); noteCreated(createdRelatedPerson); return createdRelatedPerson; }, [post, extractReferenceId, noteCreated]); /** * The same create, also reading what the server said about the invitation's email. That answer travels beside the * link, never on it: the RelatedPerson is the server's resource as returned. */ const inviteRelatedPerson = useCallback(async (relatedPerson: any, transport?: { admit: () => void; signal: AbortSignal; organizationId?: string }): Promise<{ relatedPerson: RelatedPerson; mail: InvitationMail | undefined }> => { const params = new URLSearchParams(); params.append('patient', extractReferenceId(relatedPerson.patient) as string); const { data, header } = await writeWithHeaders('POST', `${API_URL}/RelatedPerson?${params.toString()}`, relatedPerson, { ...transport }); noteCreated(data); return { relatedPerson: data, mail: invitationMailOf(header(INVITATION_MAIL_HEADER)) }; }, [writeWithHeaders, extractReferenceId, noteCreated]); const updateRelatedPerson = useCallback(async (relatedPerson: RelatedPerson): Promise => { const relatedPersonId = relatedPerson.id; if (!relatedPersonId) { throw new Error('Cannot update RelatedPerson without an id.'); } const startedIn = startWrite(); const responseRelatedPerson = await put(`${API_URL}/RelatedPerson/${relatedPersonId}`, relatedPerson); const updatedRelatedPerson: RelatedPerson = responseRelatedPerson?.resourceType === 'RelatedPerson' && responseRelatedPerson.id ? responseRelatedPerson : relatedPerson; // A save that answered with no resource is held as what was sent. holdWrite(updatedRelatedPerson, Date.now(), startedIn); setRelatedPersonsList(prev => prev.map(existingPerson => existingPerson.id === relatedPersonId ? updatedRelatedPerson : existingPerson ) ); if (relatedPersonsBundleRef.current?.entry) { setRelatedPersonsBundle({ ...relatedPersonsBundleRef.current, entry: relatedPersonsBundleRef.current.entry.map(entry => ({ ...entry, resource: entry.resource?.id === relatedPersonId ? updatedRelatedPerson : entry.resource })) }); } setLastUpserted(updatedRelatedPerson.id || relatedPersonId); setShouldReload(false); return updatedRelatedPerson; }, [put]); const deleteRelatedPerson = useCallback(async (relatedPerson: RelatedPerson): Promise => { const deletedRelatedPerson = await del(`${API_URL}/RelatedPerson/${relatedPerson.id}`); setRelatedPersonsList(prev => prev.filter(existingPerson => existingPerson.id !== relatedPerson.id) ); if (relatedPersonsBundleRef.current?.entry) { setRelatedPersonsBundle({ ...relatedPersonsBundleRef.current, entry: relatedPersonsBundleRef.current.entry.filter( entry => entry.resource?.id !== relatedPerson.id ) }); } setShouldReload(false); return deletedRelatedPerson; }, [del]); const transformToFhirRelatedPerson = useCallback((item: any, patient: Patient, photo: Attachment | undefined): RelatedPerson => { const { identifier, address, telecom, communication, name, relationship, active, gender, birthDate } = item; const transformedIdentifier: Identifier[] = transformToIdentifier(identifier); const transformedAddress: Address[] = transformToAddress(address); const transformedTelecom: ContactPoint[] = transformToTelecom(telecom); const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); const usCoreRelatedpersonProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-relatedperson'; const relatedPerson: RelatedPerson = { resourceType: 'RelatedPerson', patient: patientReference, meta: { ...item.meta, profile: [usCoreRelatedpersonProfile] }, name: transformToHumanName(name), active: active, ...(gender && { gender: gender }), ...(birthDate && { birthDate: formatDate(birthDate) }), ...(photo && { photo: [photo] }), ...(relationship && relationship.length !== 0 && { relationship: transformToCodeableConcept(relationship) }), ...(transformedIdentifier.length !== 0 && { identifier: transformedIdentifier }), ...(transformedAddress.length !== 0 && { address: transformedAddress }), ...(transformedTelecom.length !== 0 && { telecom: transformedTelecom }), ...(communication.length !== 0 && { communication: communication.map((type: IValueSet) => ({ id: uuidv4(), preferred: false, language: transformToCodeableConcept(type) })), }), }; return addFhirPeriodIfApplicable(relatedPerson, item) as RelatedPerson; }, [ transformToIdentifier, transformToAddress, transformToTelecom, createReferenceObject, transformToHumanName, transformToCodeableConcept, formatDate, addFhirPeriodIfApplicable ]); const transformFromFhirRelatedPerson = useCallback((relatedPerson: RelatedPerson): { photo: Attachment | undefined, formData: any } => { const { meta, active, gender, birthDate, relationship, communication, identifier } = relatedPerson; const formData = { meta: meta, active: active ?? true, gender: gender ?? '', birthDate: birthDate ?? '', relationship: transformFromCodeableConcept(relationship), communication: (communication ?? []).map(c => transformFromCodeableConcept(c.language)), identifier: transformFromIdentifier(identifier ?? []), name: transformFromHumanName(relatedPerson), address: transformFromAddress(relatedPerson), telecom: transformFromTelecom(relatedPerson) }; return { photo: relatedPerson.photo && relatedPerson.photo[0], formData: addPeriodControlsIfApplicable(formData, relatedPerson) }; }, [ transformFromHumanName, transformFromCodeableConcept, transformFromIdentifier, transformFromAddress, transformFromTelecom, addPeriodControlsIfApplicable ]); const getRelatedPersonDisplay = (relatedPerson: RelatedPerson): string => { if (!relatedPerson) { return 'Unknown Related Person'; } const name = formatFhirHumanName(relatedPerson.name) || 'Unknown Person'; const relationship = relatedPerson.relationship && relatedPerson.relationship.length > 0 ? ` (${formatFhirCodeableConcept(relatedPerson.relationship)})` : ''; let dateSuffix = ''; if (relatedPerson.period) { dateSuffix = ` - ${formatFhirPeriod(relatedPerson.period)}`; } return `${name}${relationship}${dateSuffix}`; }; return { // WHO THE CACHED LIST IS FOR, so a consumer can ask whether it belongs to the patient on // screen rather than trusting an app-wide context with no patient in its name. cachedForPatientId: cacheOwner.ownerId, relatedPersonsBundle, relatedPersonsList, setRelatedPersonsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchRelatedPersons, fetchRelatedPersonsWithPolling, fetchRelatedPerson, fetchRelatedPersonHistory, fetchRelatedPersonIdentity, fetchMyPendingInvitations, readInvitationStatus, acceptCaregiverInvite, declineCaregiverInvite, createRelatedPerson, inviteRelatedPerson, updateRelatedPerson, deleteRelatedPerson, transformToFhirRelatedPerson, transformFromFhirRelatedPerson, getRelatedPersonDisplay }; }; const RelatedPersonServiceContext = createContext | undefined>(undefined); export const RelatedPersonServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const relatedPersonService = useRelatedPersonService(); return ( {children} ); }; export const useRelatedPersonServiceContext = () => { const context = useContext(RelatedPersonServiceContext); if (context === undefined) { throw new Error('useRelatedPersonServiceContext must be used within a RelatedPersonServiceProvider'); } return context; };