import { useCallback, useEffect, useRef, useState } from 'react'; import { useAuthServiceContext } from '../services/AuthService.tsx'; import { PROXY_REREAD_DEADLINE_MS, useMyProxies } from './useMyProxies'; import { agreementLimitation, answerEligibilityOf, answeringWithEligibility, eligibilityLimitation, proxyStanding, type AgreementAnswering } from '../utils/proxyFormAuthority'; import { viewerEpoch } from '../utils/writeThroughHold'; import { renderedViewer } from '../components/OrgAgreements/agreementOwner'; import type { AgreementAnswerEligibility } from '../services/OrgAuthorizationService'; import { SaveRetiredError, useSaveScope, type SaveOperation, type SaveTransport } from '../utils/saveOperation'; /** Full access could not be confirmed, fresh, for the helper an answer was started for; nothing was sent. */ export class AgreementAuthorityLostError extends Error { constructor() { super('Full access could not be confirmed for this answer.'); this.name = 'AgreementAuthorityLostError'; } } export const isAgreementAuthorityLost = (error: unknown): error is AgreementAuthorityLostError => error instanceof AgreementAuthorityLostError || (error as { name?: unknown } | null)?.name === 'AgreementAuthorityLostError'; /** * One answer, or one run of answers, from the press that started it. It belongs to the subject and link it was * started for, the person signed in, the practice and the opening it was pressed in; any change retires it. */ export interface AgreementAnswerOperation { /** For the requests it sends: admitted again immediately before each is sent, cancelled when retired. */ transport: SaveTransport; /** Before every answer sent: fresh reads must show full access for the same link and the server's yes, or it throws. */ admitSend: () => Promise; /** Throws once retired; for the waits between sends. */ admit: () => void; belongs: () => boolean; retire: () => void; /** Runs one request inside this operation's ownership and deadline; `write` marks one that may change a record. */ step: SaveOperation['step']; } /** * One status read whose eligibility may be published: through its own transport, for the viewer, link, subject and * surface it was started for. Only the newest read still in that scope publishes; an older one, or one for a scope * since replaced, is dropped, so a held answer can never re-offer what a newer read refused. */ export interface EligibilityRead { transport: SaveTransport; belongs: () => boolean; /** Publishes the decision if this is still the newest read in scope; says whether it did. A caller uses nothing * else from a read whose decision was not published. */ publish: (decision: AgreementAnswerEligibility) => boolean; /** The scope generation the read was started in. */ generation: number; } /** * Whether the viewer may answer the practice agreements, and the operation every answer is sent through. The tier * comes first; then the server's decision, published by the caller's own status reads (`readEligibility`) and read * again through `eligibility.read` before every answer is sent. * Only a confirmed read showing full access offers an answer: loading, unknown, a failed read or a failed fresh * confirmation offer none, whatever row was shown before. `opening` names what is open (instrument, dialog); a document * read for an opening is read through `begin()` too, so it is shown or answered only while that opening stands. * `eligibility.scope` names the practice and document the surface answers for; changing it retires every read. */ export const useProxyMayAnswerAgreements = ( subjectName: string, opening: string, eligibility: { scope: string; read: (transport: SaveTransport) => Promise<{ answerEligibility?: AgreementAnswerEligibility }>; /** Another subject or viewer ends this page's scope; the caller clears what it shows for the last one. */ onScopeEnded?: () => void; }, ): { answering: AgreementAnswering; limitation: string | null; begin: () => AgreementAnswerOperation; /** For what follows a committed answer on this page: kept across its own dialog closing, retired otherwise. */ beginFollowUp: () => AgreementAnswerOperation; /** Opening or closing: everything started before is retired. */ renew: () => void; /** Before each status read of this surface: the read publishes its eligibility through it. */ readEligibility: () => EligibilityRead; } => { const { actingAs, currentUser } = useAuthServiceContext(); const { proxies, listAnswered, isLoading, error, confirmProxies, refetch } = useMyProxies(); const relatedPersonId = actingAs?.relatedPersonId; const linkNow = useRef(relatedPersonId); linkNow.current = relatedPersonId; // The link whose fresh confirmation last failed; a newer answer from the list is a fresh decision. const [unconfirmedFor, setUnconfirmedFor] = useState(); useEffect(() => { setUnconfirmedFor(undefined); }, [proxies]); const tier = relatedPersonId ? proxyStanding(proxies, relatedPersonId, listAnswered) : undefined; const byTier: AgreementAnswering = !relatedPersonId ? 'may' : error || unconfirmedFor === relatedPersonId ? 'failed' : isLoading || !listAnswered || tier === undefined ? 'checking' : tier === 'full-proxy' ? 'may' : 'may-not'; const owner = [relatedPersonId ?? 'self', actingAs?.subjectPatientId ?? ''].join('|'); // Whose decision this is: the viewer as well as the link, subject and surface. Any change drops it at once. const decisionScope = [viewerEpoch(), renderedViewer(currentUser), owner, eligibility.scope].join('|'); // Each change of scope is a new generation, so returning to an earlier one does not bring back its old decision. const lastScope = useRef(decisionScope); const generation = useRef(0); if (lastScope.current !== decisionScope) { lastScope.current = decisionScope; generation.current += 1; } const reads = useSaveScope(decisionScope); const order = useRef(0); const newest = useRef<{ generation: number; seq: number } | undefined>(); const [decision, setDecision] = useState<{ generation: number; eligibility: AgreementAnswerEligibility } | undefined>(); const decided = decision?.generation === generation.current ? decision.eligibility : undefined; const answering = answeringWithEligibility(byTier, decided); const limitation = byTier !== 'may' ? agreementLimitation(byTier, tier, subjectName) : eligibilityLimitation(answering, decided, subjectName, !relatedPersonId); const readEligibility = useCallback((): EligibilityRead => { const operation = reads.start(); const seq = ++order.current; const forGeneration = generation.current; // Overtaken once a newer read in the same scope has decided: its answer is then neither shown nor acted on. const overtaken = () => newest.current?.generation === forGeneration && newest.current.seq > seq; const belongs = () => operation.belongs() && generation.current === forGeneration && !overtaken(); const publish = (value: AgreementAnswerEligibility) => { if (!belongs()) return false; newest.current = { generation: forGeneration, seq }; setDecision({ generation: forGeneration, eligibility: value }); operation.retire(); return true; }; return { transport: operation.transport, belongs, publish, generation: forGeneration }; }, [reads]); const readFor = useRef(eligibility.read); readFor.current = eligibility.read; // The practice is part of every scope here: leaving it retires the page, its openings, previews and answers. const surface = owner + '|' + eligibility.scope; const pageScope = useSaveScope(surface, eligibility.onScopeEnded); const scope = useSaveScope(surface + '|' + opening); const operate = useCallback((operation: SaveOperation): AgreementAnswerOperation => { const forLink = relatedPersonId; const sameLink = () => linkNow.current === forLink; const admitSend = async () => { operation.admit(); if (forLink) { if (!sameLink()) throw new SaveRetiredError(); const fresh = await operation.step(() => confirmProxies(operation.transport.signal, PROXY_REREAD_DEADLINE_MS)); if (!sameLink()) throw new SaveRetiredError(); if (fresh.status !== 'confirmed') { setUnconfirmedFor(forLink); void refetch(); throw new AgreementAuthorityLostError(); } if (proxyStanding(fresh.proxies, forLink, true) !== 'full-proxy') throw new AgreementAuthorityLostError(); } // The fresh decision is a read of its own: owned by this answer and by the decision scope together. const read = readEligibility(); const both = jointTransport(operation.transport, read.transport); const now = await operation.step(() => readFor.current(both) .then((answer) => answerEligibilityOf(answer), (): AgreementAnswerEligibility => ({ status: 'unavailable' }))); if (!sameLink() || generation.current !== read.generation) throw new SaveRetiredError(); // A decision a newer read has overtaken authorizes nothing, whatever it said. if (!read.publish(now) || now.status !== 'eligible') throw new AgreementAuthorityLostError(); operation.admit(); }; return { transport: operation.transport, admitSend, admit: operation.admit, belongs: operation.belongs, retire: operation.retire, step: operation.step }; }, [relatedPersonId, confirmProxies, refetch, readEligibility]); const begin = useCallback(() => operate(scope.start()), [operate, scope]); const beginFollowUp = useCallback(() => operate(pageScope.start()), [operate, pageScope]); return { answering, limitation, begin, beginFollowUp, renew: scope.renew, readEligibility }; }; // A request two owners must both still admit, cancelled when either is. const jointTransport = (a: SaveTransport, b: SaveTransport): SaveTransport => { const controller = new AbortController(); const stop = () => controller.abort(); if (a.signal.aborted || b.signal.aborted) stop(); a.signal.addEventListener('abort', stop, { once: true }); b.signal.addEventListener('abort', stop, { once: true }); return { signal: controller.signal, admit: () => { a.admit(); b.admit(); } }; };