import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { HandHeart } from 'lucide-react'; import { useRelatedPersonServiceContext, type InvitationStatus, type PendingInvitation, } from '../../services/RelatedPersonService.tsx'; import { HttpError } from '../../services/HttpService.tsx'; import type { SaveTransport } from '../../utils/saveOperation'; import { useActAs } from '../../hooks/useActAs'; import { readMyProxies } from '../../hooks/useMyProxies'; import { useAuthServiceContext } from '../../services/AuthService.tsx'; import { useHttpService } from '../../services/HttpService.tsx'; import { useLifetime } from '../../hooks/useLifetime'; import { announceAcceptedLink, cancelAcceptedLink } from '../../hooks/acceptedLinkRefresh'; import { useFirstSignInGateStatus } from '../FirstSignInGate/firstSignInGateStatus.tsx'; import { formatFhirHumanName } from '../../utils/fhirHumanName.tsx'; import { relayServerFailure } from '../../utils/fetchFailureMessage'; import { invitationSentence } from './invitationSentence'; import { DemoVisitRoleContext } from '../../pages/Demo/DemoRoleSwitch'; // Somebody has asked for access to your record, and it is sitting still. // // Spencer, 2026-09-01: *"When someone invites you to be a related person it should display everywhere // on all screens at the top... That can't be buried within an inner page since it needs acted on now."* // // THIS REPLACES THE FAMILY-PAGE LIST rather than joining it. That list renders on a page somebody has // to go looking for - and a person who has just been invited has no reason to go there, because they // were not expecting anything. The only people who ever saw it were the ones who already knew. // And the person on the other side is waiting, unable to tell a refusal from never having been seen. // // DO NOT MOUNT BOTH. The same invitation answered in one place and still live in the other is how a // patient answers twice and how the second answer contradicts the first. // // ZERO WordPress: Tailwind + lucide only. const ENDED_TEXT = 'This invitation was to act as a guardian of someone who has since turned 18, so it can no longer be accepted. ' + 'They can invite you themselves if they want your help.'; const UNCHECKED_TEXT = 'We couldn\u2019t check whether this invitation can still be accepted. Nothing was changed.'; const NOT_CONFIRMED_TEXT = 'We couldn\u2019t confirm whether that went through. Nothing more was sent; try again.'; const isEndedRefusal = (error: unknown): boolean => error instanceof HttpError && error.status === 403 && (error.body as { code?: unknown } | undefined)?.code === 'guardianship-ended'; // A transport for one request of this banner: refused before it leaves, and cancelled, once the banner has moved on. const ownedTransport = (alive: () => boolean): SaveTransport => { const controller = new AbortController(); return { signal: controller.signal, admit: () => { if (alive()) return; controller.abort(); throw new DOMException('This was put aside before it was sent.', 'AbortError'); }, onSend: () => undefined, }; }; const RelatedPersonInvitationNotice: React.FC = () => { const { fetchMyPendingInvitations, readInvitationStatus, acceptCaregiverInvite, declineCaregiverInvite } = useRelatedPersonServiceContext(); const { actingAs } = useActAs(); const { currentUser } = useAuthServiceContext(); const { get } = useHttpService(); const accountId = currentUser?.fhir?.id; const lifetime = useLifetime(); // Leaving the signed-in shell ends any wait for an accepted link. useEffect(() => () => cancelAcceptedLink(), []); /** * THE MANDATORY AKASHIC GATE GOES FIRST, and this was found by walking rather than reasoned about. * * OBSERVED 2026-09-02: a brand-new account signed in, Akashic's own Privacy Notice opened as a * blocking modal, and this banner was rendering behind it - having already fetched. The agreements * notice beside it correctly returned before its fetch and stayed silent. * Same principle, already settled: nothing data-backed renders behind a blocking agreement gate, * and an org-scoped authenticated request must not fire behind a gate the person has not answered. * `unknown` - the gate has not finished asking - is treated like unanswered. */ const mandatoryGateIsClear = useFirstSignInGateStatus() === 'clear'; // An answer belongs to the account, practice and gate opening it was asked under. A gate that closes retires that // opening for good: opening it again admits new answers only, never one started before. const gateClear = useRef(mandatoryGateIsClear); const gateOpening = useRef(0); if (gateClear.current !== mandatoryGateIsClear) { if (!mandatoryGateIsClear) gateOpening.current += 1; gateClear.current = mandatoryGateIsClear; } const owner = useCallback(() => { const viewer = lifetime(); const opening = gateOpening.current; return () => viewer() && gateClear.current && gateOpening.current === opening; }, [lifetime]); const [invitations, setInvitations] = useState([]); // Busy belongs to the answer that set it: shown only while that answer's opening lives, cleared only by that answer. const [busy, setBusy] = useState<{ id: string; alive: () => boolean } | null>(null); const busyFor = busy?.alive() ? busy.id : null; const [error, setError] = useState(null); const [outcome, setOutcome] = useState(null); const [loadFailed, setLoadFailed] = useState(false); const [reloadToken, setReloadToken] = useState(0); // A demo visit offers no invitations: the server refuses the list, so it is not asked. const inDemoVisit = useContext(DemoVisitRoleContext) !== undefined; useEffect(() => { // SUPPRESSED WHILE ACTING AS SOMEBODY ELSE, and the drawing settles this rather than leaving // it to taste: an invitation *"is addressed to your own account - so it cannot be answered at all // while you are acting as somebody else."* Returning BEFORE the fetch, not hiding the result, // because the list is scoped to whoever the request is made as. if (actingAs || !mandatoryGateIsClear || inDemoVisit) return; let cancelled = false; const opening = owner(); const alive = () => !cancelled && opening(); (async () => { try { const result = await fetchMyPendingInvitations(ownedTransport(alive)); if (!alive()) return; setInvitations(result); setLoadFailed(false); } catch (err) { console.error('Failed to load pending invitations:', err); if (alive()) setLoadFailed(true); } })(); return () => { cancelled = true; }; }, [fetchMyPendingInvitations, actingAs, mandatoryGateIsClear, inDemoVisit, reloadToken, owner]); const settle = (id: string, acceptability: PendingInvitation['acceptability']) => setInvitations((current) => current.map((item) => (item.id === id ? { ...item, acceptability } : item))); const answer = useCallback(async (invitation: PendingInvitation, decision: 'accept' | 'decline') => { // The account, practice, gate and screen that answered. An answer the server admitted stands; what follows it // (the wait, the sentence, the busy state) is this banner's only while it is still that one. const alive = owner(); const account = accountId; const mine = { id: invitation.id, alive }; setBusy(mine); setError(null); const inviter = formatFhirHumanName(invitation.invitedBy?.name, { emptyText: 'That person' }); const gone = () => setInvitations((current) => current.filter((item) => item.id !== invitation.id)); const accepted = () => { // Only this account waits for the link, by this invitation's own id, asking the server once if it never shows. if (account) { void announceAcceptedLink( { relatedPersonId: invitation.id, subjectName: inviter }, { accountId: account, read: (signal, admit) => readMyProxies(get, signal, account, admit) }, async (signal) => (await readInvitationStatus(invitation.id, { ...ownedTransport(alive), signal })).state, alive, ); } gone(); // Acceptance, not access: what the person can now do is shown by their list once it holds the link. setOutcome(`You accepted ${inviter}'s invitation.`); }; // What the server holds for this invitation, when the answer was lost, malformed or not a plain success. const fromStatus = async (failure?: unknown) => { let status: InvitationStatus; try { status = await readInvitationStatus(invitation.id, ownedTransport(alive)); } catch { if (alive()) setError(failure !== undefined ? relayServerFailure(failure, NOT_CONFIRMED_TEXT) : NOT_CONFIRMED_TEXT); return; } if (!alive()) return; if (status?.relatedPersonId !== invitation.id) setError(NOT_CONFIRMED_TEXT); else if (status.state === 'accepted') accepted(); else if (status.state === 'ended') settle(invitation.id, 'ended'); else if (status.state === 'closed') { gone(); setOutcome(`${inviter}'s invitation is no longer open.`); } else if (status.state === 'unavailable') settle(invitation.id, 'unavailable'); else setError(failure !== undefined ? relayServerFailure(failure, NOT_CONFIRMED_TEXT) : NOT_CONFIRMED_TEXT); }; try { if (decision === 'accept') { const answered = await acceptCaregiverInvite(invitation.id, ownedTransport(alive)); if (!alive()) return; // Only an answer naming this very invitation is its acceptance; anything else is checked with the server. if (answered?.resourceType === 'RelatedPerson' && answered.id === invitation.id) accepted(); else await fromStatus(); return; } // A REAL DECLINE, against a real route. The Family page's "Not now" only hid the row, so // it returned on reload and the inviter still could not tell a refusal from silence. await declineCaregiverInvite(invitation.id, ownedTransport(alive)); if (!alive()) return; // REMOVED LOCALLY rather than re-fetched, so it disappears on every screen the moment it is answered. gone(); // Says the answer is FINAL rather than implying it can be undone here. setOutcome(`${inviter} has been told you declined. Nothing was added to your account.`); } catch (err) { if (!alive()) return; if (decision === 'accept' && isEndedRefusal(err)) settle(invitation.id, 'ended'); else if (decision === 'accept') await fromStatus(err); else setError(relayServerFailure(err, 'That could not be completed.')); } finally { setBusy((current) => (current === mine ? null : current)); } }, [acceptCaregiverInvite, declineCaregiverInvite, readInvitationStatus, accountId, get, owner]); if (actingAs || !mandatoryGateIsClear) return null; /** * AN EMPTY LIST AND A FAILED LOAD ARE DIFFERENT FACTS, and carrying this across from the * Family-page surface is deliberate rather than incidental - it was found live on 2026-08-23, where * the request answered 403 on the first load after a sign-in and the section rendered exactly as it * does for somebody nobody has invited. * * It matters more here than in most places because of WHAT is hidden: somebody asking for access * to your record. A person who was invited and never shown it simply does not respond, and nothing * anywhere reports that they were never told. */ if (invitations.length === 0 && !outcome) { if (!loadFailed) return null; return (
{/* Says what we could not DO, never what exists. We do not know whether anybody has invited them, so any wording implying either answer would be inventing one. */}

We couldn’t check whether anyone has invited you to help with their care.

); } return (
{invitations.map((invitation) => (

{invitation.acceptability === 'ended' ? (

{ENDED_TEXT}

) : invitation.acceptability === 'unavailable' ? (

{UNCHECKED_TEXT}

) : (

Nothing has been shared yet. If you were not expecting this, decline it — your account stays exactly as it is.

)} {/* Destructive left, affirmative right, matching the settled pattern on the family screens. Decline is a real answer here, so it is a button rather than a dismissal. */}
{/* An ended invitation is never offered; an unchecked one is checked again, not accepted blind. */} {invitation.acceptability === 'unavailable' && ( )} {invitation.acceptability !== 'ended' && invitation.acceptability !== 'unavailable' && ( )}
))} {error &&

{error}

} {outcome &&

{outcome}

}
); }; export default RelatedPersonInvitationNotice;