import { useActivationContinuation } from '../../../hooks/useActivationContinuation'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { useEffect, useRef, useState, type ReactNode } from 'react'; import { TriangleAlert, UserPlus, KeyRound, Users } from 'lucide-react'; import { toast } from 'sonner'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '../../../components/ui/dialog'; import { Button } from '../../../components/ui/button'; import { Input } from '../../../components/ui/input'; import { Label } from '../../../components/ui/label'; // Imported DIRECTLY, not through the services barrel. The barrel pulls the whole service graph into // any type-check or render of this file (the 2.1GB tsc problem this repo knows well); a direct import // keeps this component testable on its own, which is the difference between the logic here being // covered and being build-gated like the page it mounts into. import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../../components/ui/select'; import { useDependentService, type CoGuardianRelationship } from '../../../services/DependentService.tsx'; import { calculateAge } from '../../../utils/dateUtils'; import { HttpError } from '../../../services/HttpService.tsx'; import { onViewerChange, viewerEpoch } from '../../../utils/writeThroughHold'; import { invitationMailNotice, invitationMailOf } from '../../../services/invitationMail'; import { outcomeUnknown, type SaveTransport } from '../../../utils/saveOperation'; import { forgetGuardianRequest, guardianProducerAnswerOf, guardianProducerRefusalOf, keepGuardianRequest, keptGuardianRequest, newGuardianRequestId, type GuardianProducerAnswer, type GuardianProducerFlow, } from '../../../services/guardianProducerRequest'; // - the minimal parent-facing client. // // The server has been deployed and complete to spec since 2026-07-31, but nothing called it: three live // endpoints with no way in, so the whole minor-child lifecycle was unreachable from the UI. This is the // "server-first with MINIMAL client" the design asked for - two dialogs, no orchestration. // A refusal reads as one, never as the gateway raw denial. const errorText = (error: unknown, fallback: string): string => relayServerFailure(error, fallback); /** A fresh standing check: only a read that answered may say allowed or denied; a failed one is unknown. */ export type GuardianStanding = 'allowed' | 'denied' | 'unknown'; /** What a guardian-only form needs from the page that offers it: a fresh check that the caller still may. */ export interface GuardianOnlyScope { /** Confirmed only by a fresh read for this send; denied or unknown sends nothing. */ confirmEligible?: (signal: AbortSignal) => Promise; /** A settled answer the page keeps for this viewer and person after the form itself is gone. */ onSettled?: (text: string) => void; } const NO_LONGER = (childName: string) => `You can no longer do this for ${childName}. Nothing was sent.`; // Unknown standing is not a denial: nothing is sent, and the form keeps what was entered for another try. const UNCHECKED = (childName: string) => `We could not check that you can still do this for ${childName}. Nothing was sent; your details are kept, so try again.`; /** How long a guardian-only send may spend checking standing and preparing before it gives up, sending nothing. */ export const GUARDIAN_SEND_DEADLINE_MS = 20_000; const TOO_LONG = 'This could not be checked in time. Nothing was sent; try again.'; type OpeningOwner = { signal: AbortSignal; isCurrent: () => boolean }; // One opening of a guardian-only form owns its send. Closing it, another child, unmounting or another viewer // retires that send at once; the child is compared as rendered, before any effect runs. const useOwnedOpening = (childPatientId: string) => { const lifetime = useRef({ child: childPatientId, controller: new AbortController() }); const shownChild = useRef(childPatientId); shownChild.current = childPatientId; const retire = () => { lifetime.current.controller.abort(); lifetime.current = { child: shownChild.current, controller: new AbortController() }; }; useEffect(() => { const stop = onViewerChange(retire); return () => { stop(); retire(); }; }, [childPatientId]); return { retire, owner: (): OpeningOwner => { if (lifetime.current.child !== shownChild.current) retire(); const held = lifetime.current; return { signal: held.controller.signal, isCurrent: () => lifetime.current === held && held.child === shownChild.current && !held.controller.signal.aborted }; }, }; }; // One send of a guardian-only form: it belongs to the opening, child and viewer it began under, is checked again // within a deadline just before it leaves, and publishes nothing once retired. A server refusal for the same viewer // still reaches the page, which keeps it beside the person after the form is gone. const ownedGuardianSend = async ( scope: GuardianOnlyScope, childName: string, owner: OpeningOwner, send: (transport: { admit: () => void; onSend: () => void; signal: AbortSignal }) => Promise, ): Promise<{ kind: 'sent'; value: T } | { kind: 'settled' | 'expired' | 'unknown'; text: string; error?: unknown } | { kind: 'failed'; error: unknown } | { kind: 'retired' }> => { const controller = new AbortController(); const sentFor = viewerEpoch(); const sameViewer = () => viewerEpoch() === sentFor; const ours = () => owner.isCurrent() && sameViewer() && !controller.signal.aborted; const leave = () => controller.abort(); owner.signal.addEventListener('abort', leave); const stop = onViewerChange(leave); let expired = false; // Set by the transport immediately before the request is handed to the network; until then the send can be given up. let sent = false; const deadlineAt = Date.now() + GUARDIAN_SEND_DEADLINE_MS; const timer = setTimeout(() => { if (!sent) { expired = true; controller.abort(); } }, GUARDIAN_SEND_DEADLINE_MS); const ended = () => (expired && owner.isCurrent() && sameViewer() ? { kind: 'expired' as const, text: TOO_LONG } : { kind: 'retired' as const }); try { // A confirmation still out when the send is retired or overdue is not waited for. const retired = new Promise((resolve) => controller.signal.addEventListener('abort', () => resolve('unknown'), { once: true })); const standing = scope.confirmEligible ? await Promise.race([scope.confirmEligible(controller.signal), retired]) : 'allowed'; if (standing !== 'allowed') { if (!ours()) return ended(); if (standing === 'unknown') return { kind: 'unknown', text: UNCHECKED(childName) }; scope.onSettled?.(NO_LONGER(childName)); return { kind: 'settled', text: NO_LONGER(childName) }; } if (!ours()) return ended(); const sending = send({ signal: controller.signal, admit: () => { if (!ours()) throw new DOMException('This was put aside before it was sent.', 'AbortError'); // Checked here too, so a late continuation cannot slip in before the deadline's timer runs. if (!sent && Date.now() >= deadlineAt) { expired = true; controller.abort(); throw new DOMException('This took too long to prepare.', 'AbortError'); } }, onSend: () => { sent = true; }, }); // Until it is admitted to the network the send is given up with its form or deadline; after that it is awaited. const givenUp = new Promise((_, reject) => controller.signal.addEventListener('abort', () => { if (!sent) reject(new DOMException('This was put aside before it was sent.', 'AbortError')); }, { once: true })); sending.catch(() => undefined); givenUp.catch(() => undefined); const value = await Promise.race([sending, givenUp]); return ours() ? { kind: 'sent', value } : { kind: 'retired' }; } catch (error) { if (error instanceof HttpError && error.status >= 400 && error.status < 500) { const text = errorText(error, NO_LONGER(childName)); if (sameViewer()) scope.onSettled?.(text); return ours() ? { kind: 'settled', text, error } : { kind: 'retired' }; } if (!ours()) return ended(); return { kind: 'failed', error }; } finally { clearTimeout(timer); stop(); owner.signal.removeEventListener('abort', leave); } }; // What a guardian is told about one add-a-child or invite-a-co-guardian request. The server finishes one request once // however often it is sent, so every unfinished or unconfirmed answer says to press again rather than to start over. const NOT_FINISHED = (action: string) => `This is not finished yet, and nothing more was made. Press ${action} again with the same details to finish it; it will not be done twice.`; const NOT_CONFIRMED = (action: string) => `We could not confirm whether this finished. Press ${action} again: it finishes this same request and never does it twice.`; const EARLIER_OPEN = (action: string) => `Your earlier request is not finished. Enter the same details and press ${action} to finish it; it will not be done twice.`; const EARLIER_UNREAD = (action: string) => `We could not check your earlier request just now. Pressing ${action} finishes that same request rather than starting another.`; const NOT_THE_SAME = (action: string) => `These are not the details of your earlier request, which is not finished. Enter the same details and press ${action} to finish it.`; const OVERTAKEN_TEXT = 'A later decision about this person was made first, so this request was not applied over it.'; const EARLIER_UNBEGUN = (action: string) => `Your earlier request has not started yet. Press ${action} to send it; it is the same request, so it will not be done twice.`; type EarlierRequest = { text: string; tone: 'pending' | 'done' }; /** * The one request a guardian-only form is finishing. Only its opaque id is kept across reopening, and a reopened form * reads how far it got first. A fresh id is made only once the server said it finished or was overtaken: a request it * has not seen yet may still arrive, so the same id is sent again rather than a second request started beside it. */ const useGuardianRequest = ( flow: GuardianProducerFlow, childId: string, action: string, readEarlier: (requestId: string, transport: SaveTransport) => Promise, onEarlierComplete: (answer: GuardianProducerAnswer) => void, ) => { const [requestId, setRequestId] = useState(undefined); const [earlier, setEarlier] = useState(null); const forget = (id: string) => { forgetGuardianRequest(flow, childId, id); setRequestId(undefined); }; // Reads the kept request for this opening; nothing it learns is shown once the opening or viewer has moved on. const check = (owner: OpeningOwner) => { const kept = keptGuardianRequest(flow, childId); setRequestId(kept); setEarlier(null); if (!kept) return; const askedAs = viewerEpoch(); const current = () => owner.isCurrent() && viewerEpoch() === askedAs; const transport: SaveTransport = { signal: owner.signal, admit: () => { if (!current()) throw new DOMException('This was put aside before it was sent.', 'AbortError'); }, onSend: () => undefined, }; readEarlier(kept, transport).then((read) => { if (!current()) return; const answer = guardianProducerAnswerOf(read); if (answer?.status === 'complete') { forget(kept); setEarlier({ text: 'Your earlier request finished.', tone: 'done' }); onEarlierComplete(answer); } else if (answer?.status === 'overtaken') { forget(kept); setEarlier({ text: OVERTAKEN_TEXT, tone: 'done' }); } else if (answer) { setEarlier({ text: answer.phase === 'start' ? EARLIER_UNBEGUN(action) : EARLIER_OPEN(action), tone: 'pending' }); } else { setEarlier({ text: EARLIER_UNREAD(action), tone: 'pending' }); } }, (error) => { if (!current()) return; // Not seen yet is not no effect: the original send may still arrive, so the id is kept and sent again. if (error instanceof HttpError && error.status === 404) return; setEarlier({ text: EARLIER_UNREAD(action), tone: 'pending' }); }); }; // The id this send carries, kept before it leaves so a lost answer, a closed form or a reload still finds it. const begin = (): string => { const id = requestId ?? newGuardianRequestId(); keepGuardianRequest(flow, childId, id); setRequestId(id); return id; }; return { requestId, earlier, setEarlier, check, begin, forget }; }; /** What a guardian-only send's answer says, as one of: finished, not finished, overtaken, or a sentence to show. */ const readProducerOutcome = ( outcome: Awaited>>, action: string, ): { kind: 'complete'; answer: GuardianProducerAnswer } | { kind: 'overtaken' | 'not-the-same' | 'open'; text: string } | { kind: 'retired' } => { if (outcome.kind === 'retired') return { kind: 'retired' }; if (outcome.kind === 'sent') { const answer = guardianProducerAnswerOf(outcome.value); if (answer?.status === 'complete') return { kind: 'complete', answer }; if (answer?.status === 'overtaken') return { kind: 'overtaken', text: OVERTAKEN_TEXT }; return { kind: 'open', text: NOT_FINISHED(action) }; } if (outcome.kind === 'failed') { const refusal = guardianProducerRefusalOf(outcome.error); if (refusal?.code === 'GUARDIAN_UNFINISHED') return { kind: 'open', text: NOT_FINISHED(action) }; if (outcomeUnknown(outcome.error)) return { kind: 'open', text: NOT_CONFIRMED(action) }; return { kind: 'open', text: errorText(outcome.error, NOT_CONFIRMED(action)) }; } const code = guardianProducerRefusalOf(outcome.error)?.code; if (code === 'OVERTAKEN') return { kind: 'overtaken', text: OVERTAKEN_TEXT }; if (code === 'REQUEST_REUSED') return { kind: 'not-the-same', text: NOT_THE_SAME(action) }; return { kind: 'open', text: outcome.text }; }; const EarlierRequestNotice = ({ earlier }: { earlier: EarlierRequest | null }) => { if (!earlier) return null; return

{earlier.text}

; }; /** * 2a - create a login-less minor plus an immediately-active parent proxy. * * The server makes the child, a pending link, the grant and the guardianship attestation, then publishes both on the * link. It is one request a retry finishes, so this form keeps the request's id until the server says it finished. */ // A caller can draw its own trigger; `open` shows the dialog. export const AddDependentDialog = ({ onCreated, renderTrigger }: { onCreated: (added: { childPatientId: string; relatedPersonId: string; subjectName: string }) => void; renderTrigger?: (open: () => void) => ReactNode; }) => { const { createDependent, readDependentRequest } = useDependentService(); const opening = useOwnedOpening(''); const [open, setOpen] = useState(false); const [firstname, setFirstname] = useState(''); const [lastname, setLastname] = useState(''); const [birthDate, setBirthDate] = useState(''); const [error, setError] = useState(null); // Busy only for the opening that sent: a retired send leaves no busy state and cannot clear a newer one. const [sendingFor, setSendingFor] = useState(null); const saving = !!sendingFor?.isCurrent(); // An earlier request that finished is the same child: the list waits for it like any other added child. const request = useGuardianRequest('dependent', '', 'Add child', readDependentRequest, (answer) => { if (answer.childPatientId && answer.relatedPersonId) { onCreated({ childPatientId: answer.childPatientId, relatedPersonId: answer.relatedPersonId, subjectName: 'your child' }); } }); const openForm = () => { setError(null); setOpen(true); request.check(opening.owner()); }; const submit = async () => { setError(null); // birthDate gates EVERYTHING downstream - the 12-17 redaction band, the 18+ messaging and // collaboration gate, the invoice-author notice. A child created without one sits silently // outside all of them, so this is refused here rather than left to the server to reject. if (!firstname.trim() || !lastname.trim() || !birthDate.trim()) { setError("A first name, last name and date of birth are all required."); return; } /* * - SAID BEFORE THE REQUEST, BECAUSE IT IS ALREADY TRUE WHEN THE DATE IS TYPED. * * Adding somebody already 18 used to answer 201 and close like any success, and the * dependent then never appeared: a guardian link self-expires at 18, so the record existed and * its creator could never see, reach or delete it. The obvious human response to a silent * success is to try again - which is how two orphaned records were created in one sitting. * * The server refuses this too, and that refusal is the real gate - a client check is not a * gate. This one exists so the guardian is told at the moment they can still act on it, rather * than after a round trip; the two say the SAME sentence on purpose. */ const age = calculateAge(birthDate.trim()); if (age !== null && age >= 18) { setError( "They are already 18, so they cannot be added as a child — a guardian link ends at 18. " + "They need their own account, and you can ask them to sign up and then invite you as a caregiver.", ); return; } // The send belongs to this opening, viewer and practice; an answer arriving after any of them moved on says nothing. const requestId = request.begin(); const owner = opening.owner(); setSendingFor(owner); const outcome = await ownedGuardianSend({}, 'this child', owner, (transport) => createDependent({ firstname, lastname, birthDate, requestId }, transport)); setSendingFor((current) => (current === owner ? null : current)); if (!owner.isCurrent()) return; const read = readProducerOutcome(outcome, 'Add child'); if (read.kind === 'retired') return; if (read.kind === 'complete' && read.answer.childPatientId && read.answer.relatedPersonId) { request.forget(requestId); const subjectName = firstname.trim() + ' ' + lastname.trim(); toast.success(`${firstname.trim()} was added.`); setOpen(false); setFirstname(''); setLastname(''); setBirthDate(''); // The new child's id is handed on so the list can WAIT for them rather than refetching once // and hoping. HealthLake is read-after-write eventual, so the refetch behind a 201 can come // back without the child - and a guardian told "they were added" who then does not see them // adds them again. Duplicate child records are worse than a slow list. onCreated({ childPatientId: read.answer.childPatientId, relatedPersonId: read.answer.relatedPersonId, subjectName }); return; } if (read.kind === 'overtaken') request.forget(requestId); // A request that is not finished is never shown as added: the guardian finishes it by pressing again. if (read.kind === 'not-the-same') request.check(owner); setError(read.kind === 'complete' ? NOT_FINISHED('Add child') : read.text); }; return ( <> {renderTrigger ? renderTrigger(openForm) : ( )} { if (!next) { opening.retire(); setSendingFor(null); } setOpen(next); }}> Add a child This creates a record for your child with you as their guardian. They will not have their own login — you can give them one later.
setFirstname(e.target.value)} />
setLastname(e.target.value)} />
setBirthDate(e.target.value)} />

Required — their date of birth determines what they and you can each see as they get older.

{error && (

{error}

)}
); }; /** * 2b - give an existing child their OWN login, bound to their existing record. * * Parent-initiated by design: a minor cannot bind terms of service, so the adult with capacity is * always the acting party. The child's record and full history carry over - the Cognito user is created * with the existing patient_id pre-set rather than minting a fresh Patient. */ export const ActivateChildDialog = ({ childPatientId, childName, hasLogin = false, onActivated, renderTrigger, confirmEligible, onSettled, }: { childPatientId: string; childName: string; hasLogin?: boolean; onActivated: (isCurrent: () => boolean) => void; renderTrigger?: (open: () => void, checking: boolean) => ReactNode; } & GuardianOnlyScope) => { const [open, setOpen] = useState(false); const flow = useActivationContinuation({ open, childPatientId, onActivated, confirmEligible, onSettled }); const { email, setEmail, error, submitting: saving } = flow; const submit = () => { void flow.submit(); }; if (hasLogin && !flow.checking) return null; return ( <> {/* Card 238 / finding B17 — this is the ONE action button on the row whose label grows with the data (`childName`), and at 390px it ran 196px past the right edge on a page that could not scroll sideways: ~140px of it was on screen reading "Give Adole", still tappable, on a control that CREATES A LOGIN ACCOUNT FOR A CHILD. 🚨 The cause is in the shared primitive, not here: `ui/button.tsx` carries `whitespace-nowrap shrink-0` in its base classes, so a long label can neither wrap nor let the button shrink. ⛔ That base is NOT changed — it is right for the short, fixed labels every other button has, and altering it is a design decision across the whole product. Overridden here instead, where the label is variable-length. `cn()` runs tailwind-merge, so these genuinely replace the base rather than fighting it. 📌 Its two siblings on this row ("Add a child", "Invite a co-guardian") take no override: their labels are fixed and short, so they cannot reproduce this. */} {renderTrigger ? renderTrigger(() => setOpen(true), flow.checking) : ( )} { if (!next && !flow.retire()) return; setOpen(next); }}> Give {childName} their own login This gives them a login for their existing record and history. Setup and sign-in notices may need a status check.
{/* * ⭐ This changes WHO CAN SEE WHAT, so it is stated plainly rather than left as a * neutral convenience toggle. From 12 the child's own login shows their record * unfiltered, while the parent's proxy view stays filtered for state-protected * categories — and the parent's access is NOT removed here (that is the * separate 18-transition, Slice 3). */}
setEmail(e.target.value)} disabled={saving} />
{flow.notice &&

{flow.notice}

} {flow.answer?.caseReference &&

Reference: {flow.answer.caseReference}

} {error && (

{error}

)} {flow.canStartNew && }
); }; /** * - invite ANOTHER adult to be a guardian of this child. * * The third endpoint of the slice, and the last one the client could not reach: the server has been live * since the slice shipped while nothing in the UI called it. * * OFFERED ONLY FOR A MINOR, decided from the server's `subjectIsMinor` flag rather than guessed here. * The activate-login action shipped without such a gate and appeared beside an adult spouse; this is the * same class of action and must not repeat that. * * The invite creates a PENDING link the invitee has to accept - it grants nothing on its own. The copy * says so, because "invited" being read as "can now see everything" is exactly the misunderstanding that * matters when a second adult is added to a child's record. */ export const InviteCoGuardianDialog = ({ childPatientId, childName, onInvited, renderTrigger, confirmEligible, onSettled, }: { childPatientId: string; childName: string; onInvited: () => void; renderTrigger?: (open: () => void) => ReactNode; } & GuardianOnlyScope) => { const { inviteCoGuardian, readCoGuardianRequest } = useDependentService(); const opening = useOwnedOpening(childPatientId); const [open, setOpen] = useState(false); const [firstname, setFirstname] = useState(''); const [lastname, setLastname] = useState(''); const [email, setEmail] = useState(''); const [relationship, setRelationship] = useState('parent'); const [error, setError] = useState(null); // Busy only for the opening that sent: a retired send leaves no busy state and cannot clear a newer one. const [sendingFor, setSendingFor] = useState(null); const saving = !!sendingFor?.isCurrent(); const request = useGuardianRequest('co-guardian', childPatientId, 'Send invitation', (requestId, transport) => readCoGuardianRequest(childPatientId, requestId, transport), () => onInvited()); const openForm = (show: () => void) => { setError(null); show(); request.check(opening.owner()); }; const submit = async () => { setError(null); if (!firstname.trim() || !lastname.trim()) { setError('Their first and last name are required.'); return; } if (!email.trim()) { setError('An email address is required so we can send the invitation.'); return; } const sentTo = email.trim(); const requestId = request.begin(); const owner = opening.owner(); setSendingFor(owner); const outcome = await ownedGuardianSend({ confirmEligible, onSettled }, childName, owner, (transport) => inviteCoGuardian(childPatientId, { email, firstname, lastname, relationship, requestId }, transport)); // Only the opening that sent this settles it: a newer opening's busy state and messages are its own. setSendingFor((current) => (current === owner ? null : current)); if (!owner.isCurrent()) return; const read = readProducerOutcome(outcome, 'Send invitation'); if (read.kind === 'retired') return; if (read.kind === 'complete') { request.forget(requestId); // Deliberately "invited", never "added" - nothing is granted until they accept. A finished request has // queued its email; an earlier invitation sent again says what its email did. const notice = invitationMailNotice(invitationMailOf(read.answer.invitation ?? 'queued'), sentTo); toast[notice.tone](notice.text); onInvited(); setOpen(false); setFirstname(''); setLastname(''); setEmail(''); return; } if (read.kind === 'overtaken') request.forget(requestId); if (read.kind === 'not-the-same') request.check(owner); setError(read.text); }; return ( <> {renderTrigger ? renderTrigger(() => openForm(() => setOpen(true))) : ( )} { if (!next) { opening.retire(); setSendingFor(null); } setOpen(next); }}> Invite a co-guardian for {childName} They will be emailed an invitation. Nothing is shared until they accept it, and your own access is unchanged.
{/* Said plainly BEFORE the act, not after: adding a second adult to a child's record is consequential, and a parent should understand the scope of it. */}
setFirstname(e.target.value)} />
setLastname(e.target.value)} />
setEmail(e.target.value)} />
{/* Exactly the three values the server accepts — anything else is a 400 there, so the UI cannot offer a fourth. */}
{error && (

{error}

)}
); };