import { useEffect, useRef, useState, type ReactNode } from 'react'; import { ArrowLeft, Loader2, TriangleAlert } from 'lucide-react'; import { FormSubmission, FormSubmissionReply as SentReply, useFormSubmissionsService, } from '../../../services/FormSubmissionsService.tsx'; import { operationStatusToast } from '../../../components/OperationStatusToast/OperationStatusToast.tsx'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; /** * ANSWERING SOMEBODY WHO WROTE IN -, and Spencer's reframe is the whole design of this file. * * 2026-08-18: *"remember this is public facing form submissions for things like 'contact us' forms and * stuff. not actual clinical care stuff."* **That reset the risk model this card had been reasoning * from.** The reply had been weighed as *"outbound PHI to an unverified address"* and shelved as * near-undecidable. It is a contact form: the reply goes to whoever wrote in, about what they wrote in * about, at the address they typed. * * SO THE EXPOSURE IS NOT THE CHANNEL - IT IS A STAFF MEMBER TYPING SOMETHING CLINICAL INTO IT, and the * control follows from that: **a disclaimer aimed at the person composing**, not a check on the recipient. * Deliberately not built: address verification, a consent gate, a content scanner. All three answer a * question nobody is asking, and each would stop a practice answering its own contact form. * * AND IT SITS WHERE THE REPLY IS TYPED, permanently. Spencer: *"The disclaimer must sit where the reply * is TYPED, not in a settings page or a one-time notice."* A warning shown once is a warning nobody sees * on the day it matters - the day somebody starts typing a test result into a contact-form reply. */ /** One sentence, above the box, always. Not a tooltip, not a first-run notice, not a settings page. */ export const REPLY_DISCLAIMER = 'This is an ordinary email to an address someone typed into a public form — nobody has verified it. Do not include test results, diagnoses, or anything else from a patient\'s record.'; interface FormSubmissionReplyProps { submission: FormSubmission; /** The draft belongs to the overlay, so Back and a return keep it for this visit. */ draft: string; onDraftChange: (draft: string) => void; onBack: () => void; /** Who sent it and what they sent, drawn above the reply until a send fails. */ facts?: ReactNode; onBusyChange?: (busy: boolean) => void; /** A failed send, held by the overlay so it survives a trip to the discard question and back. */ error?: string; onErrorChange?: (error: string | undefined) => void; /** * THE WHOLE REPLY, BODY INCLUDED - not the server's receipt. The receipt carries when, by whom and * to where; the body is only here. Handing back the receipt alone put an EMPTY message into the thread * on screen, which is worse than not showing it: it reads as a reply that was sent blank. */ onReplySent: (reply: SentReply) => void; } const formatSentAt = (iso: string): string => { const date = new Date(iso); return isNaN(date.getTime()) ? iso : date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); }; /** What was already sent, so two people do not answer differently. */ export const SentReplies = ({ replies }: { replies: SentReply[] }) => ( <> {replies.map((reply, index) => (
Sent to {reply.to} · {formatSentAt(reply.sentAt)}

{reply.body}

))} ); const FormSubmissionReply = ({ submission, draft, onDraftChange, onBack, facts, onBusyChange, onReplySent, error: heldError, onErrorChange, }: FormSubmissionReplyProps) => { const { replyToSubmission } = useFormSubmissionsService(); const [sending, setSending] = useState(false); const [localError, setLocalError] = useState(); const error = onErrorChange ? heldError : localError; const setError = onErrorChange ?? setLocalError; const alive = useRef(true); useEffect(() => () => { alive.current = false; }, []); const recipient = submission.submitterContact?.email?.trim(); const send = async () => { if (!draft.trim() || sending) return; setSending(true); onBusyChange?.(true); setError(undefined); const toastId = operationStatusToast.saving(); try { const result = await replyToSubmission(submission.submissionId, draft); onReplySent({ ...result, body: draft }); } catch (err) { console.error('Failed to reply to a form submission:', err); if (alive.current) { setError(relayServerFailure(err, 'Nothing was sent.')); } } finally { operationStatusToast.dismiss(toastId); onBusyChange?.(false); if (alive.current) setSending(false); } }; return ( <>
{error !== undefined ? (
) : facts} {/* The disclaimer is read on the way to typing, which is the only moment it can change what gets typed. */}

{REPLY_DISCLAIMER}