import { useState } from 'react'; import type { Patient } from 'fhir/r4'; import { saveOverCurrent } from '../../../utils/saveOverCurrent'; import { isRecordChanged } from '../../../utils/recordChanged'; import { isSaveRetired, isSaveTimedOut, useSaveScope } from '../../../utils/saveOperation'; import { emailSelectionOf } from '../../../utils/contactSelectionOwned'; import { Mail, Smartphone } from 'lucide-react'; import { toast } from 'sonner'; 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 - same rule DependentActions.tsx records: 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), and a direct import keeps this component testable on its own. import { useDependentService } from '../../../services/DependentService.tsx'; import { usePatientServiceContext } from '../../../services/PatientService.tsx'; import { setContactEmail } from '../../../config/communicationPreferences'; import { ownFailureSentence } from '../../../utils/fetchFailureMessage'; //, decision 9 (Spencer, 2026-08-02): // "We should be urging the parent(s) to add an email and mobile phone contact for the minor from age 16 // up to the day of turning 18 if none exist so the minor child can seamlessly take over the account at // age 18." // // WHY THIS EXISTS AT ALL. `Patient/dependent.ts` creates a child with NO telecom whatsoever. Left alone, // the guardianship simply ends at 18 with no channel to notify the teen AND no contact for the self-claim // to verify against - one gap quietly blocking two halves of the transition. Prompting from 16 stops that // state persisting rather than trying to work around it on the day. // // WHETHER TO ASK IS THE SERVER'S DECISION (`subjectNeedsTakeoverContact`, derived from age + telecom at // read time). This component only renders the ask. That split is the rule `subjectHasLogin` and // `subjectIsMinor` were introduced under, after a client-side guess offered "give this person their own // login" beside an adult who already had one. // // AND WHY A CODE, NOT JUST A FIELD. Nothing stops a parent typing their OWN mobile or their own inbox. // That satisfies the readiness flag while leaving the teen exactly as unreachable - an unverified contact // is WORSE than none, because it turns a visible gap into an invisible one. The code has to land on the // teen's handset, and in the teen's inbox. // // --------------------------------------------------------------------------------------------- // added the EMAIL half below, and own design says it is the half that matters most: // a text can nudge, but the SELF-CLAIM LINK at 18 lands in an INBOX. `subjectNeedsTakeoverContact` // requires BOTH channels, so until the contact-email doors existed this prompt could not be satisfied at // all - the flag was right and the means was missing. // --------------------------------------------------------------------------------------------- /** * The server's own message first, then anything the error itself carries, then the fallback. * * THESE DOORS REFUSE WITH SENTENCES WORTH READING. "That contact is already the guardian's own. Use the * young person's own email address so they can be reached once they turn 18." tells the guardian exactly * what to change; "Too many codes sent. Please try again later." tells them to stop retrying. Replacing * either with generic copy produces a guardian retrying the same address forever against a door that will * never open for it. * * READ FROM `error.body`, NOT `error.message`. HttpService builds `message` as * `Request failed: ${body.message}` and carries the PARSED body alongside precisely so callers can use the * fields instead of the lossy string. Duck-typed rather than importing `HttpError`, to keep this leaf * component free of the HTTP service's module graph (same reasoning as the direct imports above). */ const errorText = (error: unknown, fallback: string): string => { const message = (error as { body?: { message?: unknown } } | null | undefined)?.body?.message; if (typeof message === 'string' && message.trim()) return message.trim(); return (error instanceof Error && error.message) ? error.message : fallback; }; export const TakeoverContactPrompt = ({ childPatientId, childName, onCompleted, }: { childPatientId: string; childName: string; onCompleted: () => void; }) => { const { requestDependentSmsCode, verifyDependentSmsCode, requestDependentEmailCode, verifyDependentEmailCode, } = useDependentService(); const { fetchPatient, updatePatient } = usePatientServiceContext(); const [pendingVerificationId, setPendingVerificationId] = useState(null); const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // - the email half. Deliberately separate state from the mobile half: the two channels are // independent facts, each with its own server-side start-cap budget, and a guardian may well complete // one weeks before the other. const [emailAddress, setEmailAddress] = useState(''); const [emailPendingVerificationId, setEmailPendingVerificationId] = useState(null); const [emailCode, setEmailCode] = useState(''); const [emailBusy, setEmailBusy] = useState(false); const [emailError, setEmailError] = useState(null); // Each send belongs to this child and the person signed in; leaving either retires it, so no code goes out // for a prompt that is no longer on screen. const saveScope = useSaveScope(childPatientId); const sendCode = async () => { const operation = saveScope.start(); setBusy(true); setError(null); try { const result = await operation.step(() => requestDependentSmsCode(childPatientId, operation.transport), true); // The code step only appears once a message has actually gone out - otherwise a guardian types // into a box for a text that was never sent, and blames the code rather than the send. setPendingVerificationId(result?.pendingVerificationId ?? null); toast.success(`We texted a code to ${childName}'s mobile.`); } catch (sendError) { if (isSaveRetired(sendError)) return; setError(isSaveTimedOut(sendError) ? ownFailureSentence(sendError) : errorText(sendError, 'We could not send the code. Please try again.')); } finally { if (operation.belongs()) setBusy(false); } }; const verifyCode = async () => { if (!pendingVerificationId) return; const operation = saveScope.start(); setBusy(true); setError(null); try { await operation.step(() => verifyDependentSmsCode(childPatientId, pendingVerificationId, code, operation.transport), true); toast.success(`${childName}'s mobile is verified.`); // Only after the server confirms. Reporting success on the send would mark the row done while // the number was still unproven - precisely the false-comfort this flow exists to prevent. onCompleted(); } catch (verifyError) { // A verification already sent may have been accepted by the server; a prompt that is gone reports // neither that nor a failure - the child's record says which, next time it is read. if (isSaveRetired(verifyError)) return; setError(isSaveTimedOut(verifyError) ? ownFailureSentence(verifyError) : errorText(verifyError, 'That code was not accepted. Please check it and try again.')); } finally { if (operation.belongs()) setBusy(false); } }; /** * - SELECT the address on the child's record, then ask for a code. Two server calls behind * one button, and the ordering is not incidental. * * THE REQUEST DOOR SENDS TO THE ADDRESS ALREADY SELECTED ON THE PATIENT, never to one in its body * (utils/emailChannel.ts::resolveEmailAddress - it wants the `patient-email` extension AND a matching * `system: 'email'` telecom entry). A dependent Patient is created with NO telecom at all, so asking * for a code first would answer "No verifiable contact email is selected" every time, with the * address the guardian just typed sitting on screen. Selecting it is part of THIS flow. * * The selection is written from a FRESHLY FETCHED Patient: this is a full PUT of the child's whole * record, so writing from a cached copy would silently revert everything changed since it was cached. * A failed fetch or PUT throws to the catch below - nothing is written, no code is sent, and the * guardian is told, which is the correct outcome. `verified: false` is what setContactEmail writes * (and handlers/Patient/put.ts re-pins it regardless), so this can never mint the trust itself. */ const sendEmailCode = async () => { const address = emailAddress.trim(); if (!address) { setEmailError(`Enter ${childName}'s own email address first.`); return; } const operation = saveScope.start(); setEmailBusy(true); setEmailError(null); // The child's email choice as it stood when this send began (the first read): which address was chosen, // whether it was proved, and the addresses on the record. let opening: string | undefined; try { // A command, but not a blind one. The address is set on the record as it is now, conditional on its // version, and only while the child's email choice is as it was when this send began. A lost race to an // unrelated change is read and set again; a race to someone choosing, changing or proving an address // writes nothing over that, sends no code, and asks the guardian to decide again. await saveOverCurrent({ operation, read: () => fetchPatient(childPatientId, false, undefined, true, operation.transport), stillHolds: (current) => { const owned = JSON.stringify(emailSelectionOf(current, { withVerification: true })); opening ??= owned; return owned === opening; }, apply: (fresh) => setContactEmail(fresh, address, { addToProfile: true }), write: (candidate, basedOn) => updatePatient(candidate, basedOn, operation.transport), }); const result = await operation.step(() => requestDependentEmailCode(childPatientId, operation.transport), true); setEmailPendingVerificationId(result?.pendingVerificationId ?? null); toast.success(`We emailed a code to ${childName}'s address.`); } catch (sendError) { if (isSaveRetired(sendError)) return; if (isSaveTimedOut(sendError)) { setEmailError(ownFailureSentence(sendError)); return; } if (isRecordChanged(sendError) && !sendError.outOfDate) { // Said plainly, nothing compared: each send starts again from the record as it is then. setEmailError( `${childName}'s email was changed elsewhere while this was sending. Nothing was saved and no code ` + 'was sent. Check the address, then send again.', ); return; } // One message for both steps would be a lie about one of them: a failed SELECTION means // nothing was saved and nothing was sent, while a failed SEND means the address IS now on the // record. The server's own message carries that distinction, which is why it is preferred // over the fallback rather than decorated with it. setEmailError(errorText(sendError, 'We could not email the code. Please try again.')); } finally { if (operation.belongs()) setEmailBusy(false); } }; const verifyEmailCode = async () => { if (!emailPendingVerificationId) return; const operation = saveScope.start(); setEmailBusy(true); setEmailError(null); try { await operation.step(() => verifyDependentEmailCode(childPatientId, emailPendingVerificationId, emailCode, operation.transport), true); toast.success(`${childName}'s email address is verified.`); // Same rule as the mobile half: only after the server confirms. An address reported as done // on the SEND is worse here than for SMS, because the self-claim link at 18 goes to whatever // address ends up on the record. onCompleted(); } catch (verifyError) { if (isSaveRetired(verifyError)) return; setEmailError(isSaveTimedOut(verifyError) ? ownFailureSentence(verifyError) : errorText(verifyError, 'That code was not accepted. Please check it and try again.')); } finally { if (operation.belongs()) setEmailBusy(false); } }; return (
{/* --------------------------------------------------------------------------------------- * Card 127 — the EMAIL half, beside the mobile one rather than behind it. Both are needed, * and a guardian who completes one should still see the other outstanding: a prompt that * went quiet after the mobile verified would let them believe the job was finished while * the teen was still half-unreachable. * --------------------------------------------------------------------------------------- */}
{/* Both channels, stated once for the whole prompt rather than inside either half — a guardian who has just verified the mobile needs to see that the job is not finished. */}

A mobile number and an email address are both needed before they turn 18.

); };