import { useEffect, useRef, useState, type ReactNode } from 'react'; import { ShieldCheck } from 'lucide-react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '../../../components/ui/dialog'; import { Input } from '../../../components/ui/input'; import { Label } from '../../../components/ui/label'; import { Checkbox } from '../../../components/ui/checkbox'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../../components/ui/select'; import { HttpError } from '../../../services/HttpService.tsx'; import { useAuthServiceContext } from '../../../services/AuthService.tsx'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { onViewerChange, viewerEpoch } from '../../../utils/writeThroughHold'; import { useDependentService, type StaffGuardianDeclaration, type StaffGuardianRelationship, type StaffGuardianResult, } from '../../../services/DependentService.tsx'; const RELATIONSHIP_WORDS: Record = { 'parent': 'parent', 'legal-guardian': 'legal guardian' }; /** The statement the staff member confirms, worded as the server records it. */ export const guardianDeclarationText = (adultName: string, childName: string, relationship: StaffGuardianRelationship) => `${adultName} told me in person that they are ${childName}'s ${RELATIONSHIP_WORDS[relationship]}.`; type Existing = { relatedPersonId: string; generation: number; adultName: string }; // Each answer carries the adult it was sent for, never whatever the fields hold when it lands. type Outcome = { kind: 'done'; result: StaffGuardianResult; adultName: string; declaration: StaffGuardianDeclaration } | { kind: 'already-recorded'; adultName: string; declaration: StaffGuardianDeclaration; invitation?: StaffGuardianResult['invitation'] }; // A recorded guardian whose invitation email is not known to be queued: the outcome and its request are kept until it is. const owesInvitation = (outcome: Outcome | undefined) => (outcome?.kind === 'done' ? outcome.result.invitation : outcome?.invitation) === 'pending'; // A declaration that was sent and not answered: only the same request, unchanged, may settle it. `stalledIn` is the // opening in which a retry was refused before the route took it, so that opening offers no further retry. type Unresolved = { declaration: StaffGuardianDeclaration; adultName: string; stalledIn?: number }; const UNCONFIRMED = 'This could not be confirmed. Try again; the same declaration is sent, so it is never recorded twice.'; // A refusal of the retry says nothing about the earlier attempt, which stays this request's to settle later. const STALLED = 'Whether the earlier attempt was recorded is still unconfirmed, and nothing new was saved. You can close this; reopen it when you can act for this patient again to check the same declaration.'; const INVITATION_UNCONFIRMED = 'Whether the invitation email was sent could not be confirmed. Send it again; the same declaration is sent, so nothing is recorded twice.'; const INVITATION_STALLED = 'The guardian stays recorded, and whether the invitation email was sent is still unconfirmed. You can close this; reopen it when you can act for this patient again to send it.'; const bodyOf = (error: unknown): Record => (error instanceof HttpError && error.body && typeof error.body === 'object' ? error.body as Record : {}); // Answers given after the route has taken the request, or while another run of it may hold it: only that same // request can settle them. Every other refusal the route gives in its own words comes before it takes anything. const CLAIMED_UNFINISHED = new Set(['AUTHORITY_CHANGING', 'GUARDIAN_IN_PROGRESS']); const isDefinite = (error: unknown): boolean => error instanceof HttpError && error.status >= 400 && error.status < 500 && !CLAIMED_UNFINISHED.has(String(bodyOf(error).code)); // The staff member is the witness, taken by the server from who is signed in. One request id travels with one // declaration: an unconfirmed answer is retried as exactly that declaration, and only a definite answer frees it. export const RecordGuardianDialog = ({ childPatientId, childName, onRecorded, renderTrigger, }: { childPatientId: string; childName: string; onRecorded: () => void; renderTrigger?: (open: () => void) => ReactNode; }) => { const { recordStaffGuardian } = useDependentService(); const { currentUser, wpOrganization } = useAuthServiceContext(); const staffName = [currentUser?.fhir?.name?.[0]?.given?.[0], currentUser?.fhir?.name?.[0]?.family].filter(Boolean).join(' ') || 'You'; // Each opening owns what it sends: closing it, another child, or another viewer or practice retires the send. const live = useRef(true); const opening = useRef(0); const inFlight = useRef(undefined); const retire = () => { opening.current += 1; inFlight.current?.abort(); inFlight.current = undefined; }; useEffect(() => { live.current = true; const stop = onViewerChange(retire); return () => { live.current = false; stop(); retire(); }; }, []); const [open, setOpen] = useState(false); const [firstname, setFirstname] = useState(''); const [lastname, setLastname] = useState(''); const [email, setEmail] = useState(''); const [birthDate, setBirthDate] = useState(''); const [relationship, setRelationship] = useState('parent'); const [declared, setDeclared] = useState(false); const [requestId, setRequestId] = useState(); const [existing, setExisting] = useState(); const [unresolved, setUnresolved] = useState(); const [outcome, setOutcome] = useState(); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const adultName = `${firstname.trim()} ${lastname.trim()}`.trim(); const locked = saving || Boolean(unresolved); // Any change to what is declared is a different declaration: it starts over with a new request. const changed = (set: (value: T) => void) => (value: T) => { if (unresolved) return; set(value); setRequestId(undefined); setExisting(undefined); setDeclared(false); setError(null); }; // Back to the details from a proposal that wrote nothing; never from one whose confirmation went unanswered. const putProposalAside = () => { if (unresolved) return; setExisting(undefined); setDeclared(false); setError(null); }; // The send this opening has in flight. fetchWithAuth admits on entry and again just before sending, so a second // admission means the declaration may have reached the server and must be reconciled, not replaced. const sending = useRef<{ unresolved: Unresolved; admitted: number } | undefined>(undefined); // Requests that went unanswered or were left unfinished: a later refusal made before any claim does not show // that an earlier attempt of the same request claimed nothing, so they stay this request's to settle. const mayBeClaimed = useRef(new Set()); const keepUnresolved = (held: Unresolved) => { mayBeClaimed.current.add(held.declaration.requestId); setUnresolved(held); }; // `invitation` marks a resend for a recorded guardian: only the email is in question, never the declaration. const transmit = async (declaration: StaffGuardianDeclaration, submittedName: string, invitation = false) => { const controller = new AbortController(); inFlight.current = controller; const sentIn = opening.current; const sentFor = viewerEpoch(); const organizationId = wpOrganization?.current?.id; const ours = () => live.current && opening.current === sentIn && viewerEpoch() === sentFor && !controller.signal.aborted; const mine = { unresolved: { declaration, adultName: submittedName }, admitted: 0 }; sending.current = mine; setError(null); setSaving(true); try { const result = await recordStaffGuardian(childPatientId, declaration, { admit: () => { if (!ours()) throw new DOMException('This declaration was put aside before it was sent.', 'AbortError'); mine.admitted += 1; }, signal: controller.signal, ...(organizationId ? { organizationId } : {}), }); if (!ours()) return; setUnresolved(undefined); setExisting(undefined); setOutcome({ kind: 'done', result, adultName: submittedName, declaration }); onRecorded(); } catch (failure) { if (!ours()) return; const body = bodyOf(failure); const code = failure instanceof HttpError && failure.status === 409 ? body.code : undefined; if (code === 'ALREADY_RECORDED') { // Final: the declaration already on record stays exactly as it was recorded, and nothing new was saved. setUnresolved(undefined); setExisting(undefined); const owed = body.invitation === 'queued' || body.invitation === 'pending' ? body.invitation : undefined; setOutcome({ kind: 'already-recorded', adultName: submittedName, declaration, ...(owed ? { invitation: owed } : {}) }); } else if (code === 'EXISTING_LINK') { setUnresolved(undefined); setExisting({ relatedPersonId: String(body.relatedPersonId), generation: Number(body.generation), adultName: submittedName }); } else if (code === 'AUTHORITY_CHANGED') { // Final for this request: nothing it wrote is in force. The staff member checks the details and // records again, which asks afresh what the decision is now; the old one is never rebased onto it. setUnresolved(undefined); setRequestId(undefined); setExisting(undefined); setDeclared(false); setError(`${String(body.message ?? "A newer change to this person's access was saved; this declaration was not applied.")} Check the details and record it again.`); } else if (code === 'REQUEST_REUSED') { // This request id is spent; the details stay, and the next send is a new request. setUnresolved(undefined); setRequestId(undefined); setError(relayServerFailure(failure, UNCONFIRMED)); } else if (invitation && isDefinite(failure)) { // The guardian is already recorded, so a refused resend leaves that result standing: this opening // offers only an exit, and a later one may send the same request again. keepUnresolved({ ...mine.unresolved, stalledIn: sentIn }); setError(relayServerFailure(failure, 'This was refused.') + ' ' + INVITATION_STALLED); } else if (isDefinite(failure) && !mayBeClaimed.current.has(declaration.requestId)) { setUnresolved(undefined); setError(relayServerFailure(failure, UNCONFIRMED)); } else if (isDefinite(failure)) { keepUnresolved({ ...mine.unresolved, stalledIn: sentIn }); setError(relayServerFailure(failure, 'This was refused.') + ' ' + (invitation ? INVITATION_STALLED : STALLED)); } else { keepUnresolved(mine.unresolved); setError(relayServerFailure(failure, invitation ? INVITATION_UNCONFIRMED : UNCONFIRMED)); } } finally { if (sending.current === mine) sending.current = undefined; if (inFlight.current === controller) inFlight.current = undefined; if (ours()) setSaving(false); } }; const send = (basedOnGeneration?: number) => { setError(null); if (!firstname.trim() || !lastname.trim() || !email.trim() || !birthDate) { setError("The adult's name, email and date of birth are required."); return; } if (!declared) { setError('Confirm the declaration the adult made to you.'); return; } const id = requestId ?? crypto.randomUUID(); setRequestId(id); void transmit({ requestId: id, relationship, adult: { email: email.trim(), firstname: firstname.trim(), lastname: lastname.trim(), birthDate }, ...(basedOnGeneration !== undefined ? { basedOnGeneration } : {}), }, adultName); }; const reset = (next: boolean) => { if (next) { setOpen(true); return; } // A send this opening had already let go of is kept to be reconciled; one never sent is simply dropped. const held = sending.current && sending.current.admitted >= 2 ? sending.current.unresolved : undefined; retire(); setOpen(false); setSaving(false); if (owesInvitation(outcome)) { // The guardian stays recorded; the same request is kept so a later opening can still send its invitation. if (held) keepUnresolved(held); setError(held || unresolved ? INVITATION_UNCONFIRMED : null); } else if (outcome) { setFirstname(''); setLastname(''); setEmail(''); setBirthDate(''); setDeclared(false); setRequestId(undefined); setExisting(undefined); setUnresolved(undefined); setOutcome(undefined); setError(null); } else if (held) { keepUnresolved(held); setError(UNCONFIRMED); } else if (unresolved?.stalledIn !== undefined) { // A later opening may check the same declaration again. setError(UNCONFIRMED); } else if (existing && !unresolved) { putProposalAside(); } }; // Another signed-in viewer or practice: nothing of this one's declaration is kept or shown. useEffect(() => onViewerChange(() => { setOpen(false); setSaving(false); setFirstname(''); setLastname(''); setEmail(''); setBirthDate(''); setDeclared(false); setRequestId(undefined); setExisting(undefined); setUnresolved(undefined); setOutcome(undefined); setError(null); }), []); const done = outcome?.kind === 'done' ? outcome.result : undefined; const doneName = outcome ? outcome.adultName : adultName; const doneText = done?.status === 'recorded' ? `Recorded. ${doneName} is ${childName}'s guardian.` : done?.invitation === 'pending' ? `Recorded. ${doneName} becomes ${childName}'s guardian once they accept. Sending their invitation email could not be confirmed, or is still pending. Send it again; the same declaration is sent, so nothing is recorded twice.` : `Sent. ${doneName} becomes ${childName}'s guardian once they accept the invitation in their account.`; // Retrying an unanswered declaration sends that declaration, as it was sent. const retryUnresolved = () => { if (unresolved) void transmit(unresolved.declaration, unresolved.adultName); }; // This opening's retry was refused before anything was taken: another try here cannot settle it. const stalled = unresolved?.stalledIn !== undefined && unresolved.stalledIn === opening.current; const closeButton = ( ); // An opening whose invitation resend was refused offers only an exit; a later opening sends the same request. const resendInvitation = outcome && owesInvitation(outcome) && (
{stalled ? closeButton : ( )}
); return ( <> {renderTrigger ? renderTrigger(() => setOpen(true)) : ( )} Record a parent or guardian for {childName} For an adult who is here and tells you they are {childName}'s parent or legal guardian. You are recorded as the witness to what they declared. {done ? (

{doneText}

{error &&

{error}

} {resendInvitation}
) : outcome?.kind === 'already-recorded' ? (

A guardian declaration by {doneName} for {childName} is already on record. It stays as it was recorded, and nothing new was saved. {outcome.invitation === 'queued' && ' Their invitation email has been queued.'} {outcome.invitation === 'pending' && ' Sending their invitation email could not be confirmed, or is still pending.'}

{error &&

{error}

} {resendInvitation}
) : existing ? (

{existing.adultName} already helps manage {childName}'s account. Record them as {childName}'s{' '} {RELATIONSHIP_WORDS[relationship]}? Their messaging and billing choices stay as they are.

{error &&

{error}

}
{!unresolved && ( )} {stalled ? closeButton : ( )}
) : (
changed(setFirstname)(e.target.value)} />
changed(setLastname)(e.target.value)} />
changed(setEmail)(e.target.value)} />
changed(setBirthDate)(e.target.value)} />
setDeclared(value === true)} />

Witness: {staffName}

{error &&

{error}

}
{stalled ? closeButton : ( )}
)}
); };