import { useCallback, useEffect, useRef, useState } from 'react'; import type { RelatedPerson } from 'fhir/r4'; import type { AuthorityIntent, AuthorityTier } from '../services/relatedPersonAuthority'; import type { DesignationAttestationRequest } from '../services/relatedPersonDesignation'; import { applyRelatedPersonAuthority, authorityFailureMayRetry, RelatedPersonAuthorityError, RelatedPersonInviteAbandoned, submitRelatedPersonInvite, writtenAuthorityState, type CreateRelatedPersonFn, type InviteGrants, type PutAuthorityFn, type RelatedPersonInviteInput, } from '../services/relatedPersonInvite'; import { useLifetime } from './useLifetime'; import { onViewerChange, viewerEpoch } from '../utils/writeThroughHold'; /** The access an invite's link was given but the server never confirmed: retried as exactly this. */ export interface PendingInviteAuthority { relatedPerson: RelatedPerson; tier: AuthorityTier; grants?: InviteGrants; attestation?: DesignationAttestationRequest; intent: AuthorityIntent; } /** * What the form shows after an invite (or its retry). * * - `done`: the link exists and its access was saved. `stillCurrent` guards anything the form does later. * - `retry`: the link exists and its access was not confirmed. The same access is offered again, as itself. * - `review`: the link exists and this access decision is over (another came first, it was refused, or the * choices on the form are no longer the ones that were sent). Nothing more is sent from this form; what is in * force is reviewed on the person's card. * - `gone`: the form was closed or reopened, or the viewer changed, while it waited. Nothing is shown. */ export type InviteAuthorityStep = | { kind: 'done'; relatedPerson: RelatedPerson; stillCurrent: () => boolean } | { kind: 'retry'; message: string } | { kind: 'review'; message: string; relatedPerson: RelatedPerson } | { kind: 'gone' }; const sameChoices = (pending: PendingInviteAuthority, input: Pick) => { const sent = writtenAuthorityState(pending.tier, pending.grants); const asked = writtenAuthorityState(input.tier, input.grants); return sent.tier === asked.tier && sent.allowMessaging === asked.allowMessaging && sent.allowBillingView === asked.allowBillingView && sent.allowBillingPay === asked.allowBillingPay; }; export const CHANGED_CHOICES_MESSAGE = "They were invited, but the access level first chosen was not confirmed and the choices here have changed since, so nothing more was sent. Set their access from their card, where what is in force is shown."; /** * One invite form's link-then-access sequence, shared by every form that invites somebody: the create, the access * that follows it, and the retry of an access the server did not confirm. Each opening of the form is its own; a * completion for an earlier one (or another viewer) changes nothing on screen. */ /** Which opening of the form, for which viewer, a retry or an ended decision belongs to. */ interface Owned { opening: number; viewer: number } export const useInviteAuthority = (putAuthority: PutAuthorityFn) => { const [pending, setPending] = useState<(PendingInviteAuthority & Owned) | null>(null); // A link whose access decision ended here: the form never creates it again. const [settled, setSettled] = useState<({ relatedPerson: RelatedPerson; message: string } & Owned) | null>(null); const lifetime = useLifetime(); const openingRef = useRef(0); const pendingRef = useRef(pending); pendingRef.current = pending; const settledRef = useRef(settled); settledRef.current = settled; /** A new opening of the form (or its close): nothing of an earlier one carries over. */ const reset = useCallback(() => { openingRef.current += 1; setPending(null); setSettled(null); }, []); // Another viewer, practice or acted-as link is another opening: an earlier viewer's link, request and choices // are never sent, retried or reviewed under it. useEffect(() => onViewerChange(reset), [reset]); /** * Called as a submit starts: whether the form that started it is still this opening, for the same viewer. * The caller guards everything it does afterwards with it (its busy state, its messages, closing, navigating), * so an earlier opening's completion never touches the one now on screen. */ const begin = useCallback(() => { const opening = openingRef.current; const alive = lifetime(); return () => alive() && openingRef.current === opening; }, [lifetime]); const submit = useCallback(async ( input: RelatedPersonInviteInput, createRelatedPerson: CreateRelatedPersonFn, stillCurrent: () => boolean = begin(), ): Promise => { const opening = openingRef.current; const viewer = viewerEpoch(); const ownedHere = (owned: Owned | null) => !!owned && owned.opening === opening && owned.viewer === viewer; const ended = settledRef.current; if (ended && ownedHere(ended)) return { kind: 'review', message: ended.message, relatedPerson: ended.relatedPerson }; const retrying = ownedHere(pendingRef.current) ? pendingRef.current : null; if (retrying && !sameChoices(retrying, input)) { // The form's choices are not the ones that were sent: never retry the older, now hidden ones. setPending(null); setSettled({ relatedPerson: retrying.relatedPerson, message: CHANGED_CHOICES_MESSAGE, opening, viewer }); return { kind: 'review', message: CHANGED_CHOICES_MESSAGE, relatedPerson: retrying.relatedPerson }; } try { let relatedPerson: RelatedPerson; if (retrying) { await applyRelatedPersonAuthority( retrying.relatedPerson, retrying.tier, putAuthority, retrying.attestation, retrying.grants, retrying.intent, stillCurrent, ); relatedPerson = retrying.relatedPerson; } else { relatedPerson = await submitRelatedPersonInvite(input, createRelatedPerson, putAuthority, stillCurrent); } if (!stillCurrent()) return { kind: 'gone' }; setPending(null); return { kind: 'done', relatedPerson, stillCurrent }; } catch (error) { if (!stillCurrent() || error instanceof RelatedPersonInviteAbandoned) return { kind: 'gone' }; if (error instanceof RelatedPersonAuthorityError) { if (authorityFailureMayRetry(error)) { setPending({ relatedPerson: error.relatedPerson, tier: error.tier, grants: error.grants, attestation: error.attestation, intent: error.intent, opening, viewer, }); return { kind: 'retry', message: error.message }; } setPending(null); setSettled({ relatedPerson: error.relatedPerson, message: error.message, opening, viewer }); return { kind: 'review', message: error.message, relatedPerson: error.relatedPerson }; } throw error; } }, [begin, putAuthority]); return { pending, settled, reset, begin, submit }; };