import { QuestionnaireResponse, QuestionnaireResponseItem } from 'fhir/r4'; /** * WHAT A QUESTIONNAIRE WITH NOTHING IN IT MUST NOT DO - send itself. * * Watched in a browser 2026-08-20: a patient opened the questionnaire her practice had assigned her, * answered nothing, pressed **Submit**, and confirmed the "this cannot be undone" dialog. The server * refused it with a 400. The screen behaved exactly as it does on success - the dialog closed and * nothing objected. * * That is worse than an ordinary unhandled error. The patient has every reason to believe the form * went in; the practice never receives it; and the task stays open against someone who is sure they have * already done it. The next thing that happens is a phone call at the front desk. * * THE ANSWER IS TO NOT SEND IT, not to explain the refusal afterwards. The server is right to refuse, * and there is nothing useful to tell a patient about a FHIR array-cardinality error. Surfacing a * refusal is a separate and also-necessary fix - see `serverRefusalMessage.ts` - but it is what catches * the failures nobody has hit yet, not this one. * * THE FENCE, and it is the thing an obvious fix breaks. **The bar is AT LEAST ONE answer, never all * of them.** A partial answer must stay submittable: the form has its own *Save for Later* button for * the incomplete case, and a PHQ deliberately allows unanswered items. "Empty submissions are blocked" * must not be satisfied by blocking everything a patient can realistically send. */ /** What the person pressed. The bar is identical either way; only the sentence differs. */ export type QuestionnaireSubmissionAction = 'submit' | 'saveForLater'; /** * Does this response carry a single answer anywhere in it? * * Asked of the TRANSFORMED FHIR resource rather than of the form state, on purpose. The resource is * what the server sees, so this cannot disagree with it about what "answered" means - and form state * shape varies per item type (a boolean, a coding, a quantity and an attachment are all "a value"), * which is exactly the kind of per-type test that goes stale as item types are added. * * Walks the nested form too: a grouped item carries its children under `answer[].item[]`, so a response * whose only answer sits inside a group is answered. */ export const hasAnyQuestionnaireAnswer = (response: QuestionnaireResponse | undefined): boolean => { if (!response) return false; const itemHasAnswer = (item: QuestionnaireResponseItem): boolean => { const answers = item.answer ?? []; for (const answer of answers) { // A group's `answer` entry is a container, not a value: it carries only `id` and `item`. // A value of any kind at this level means the question was genuinely answered. const isContainerOnly = Object.keys(answer).every((key) => key === 'id' || key === 'item'); if (!isContainerOnly) return true; if ((answer.item ?? []).some(itemHasAnswer)) return true; } return (item.item ?? []).some(itemHasAnswer); }; return (response.item ?? []).some(itemHasAnswer); }; /** * The sentence to show when there is nothing to send, or `undefined` when the submission may proceed. * * Returns a reason rather than a boolean so the caller cannot invent its own wording, and so the two * actions cannot drift apart into one being explained and the other silently doing nothing. */ export const emptyQuestionnaireSubmissionRefusal = ( response: QuestionnaireResponse | undefined, action: QuestionnaireSubmissionAction, ): string | undefined => { if (hasAnyQuestionnaireAnswer(response)) return undefined; // Says what to do, not what went wrong. "At least one" is the literal rule, and stating it is what // stops the message reading as "you must answer everything" - which is the behaviour this must not // acquire. return action === 'submit' ? 'Answer at least one question before submitting this questionnaire.' : 'Answer at least one question before saving this questionnaire for later.'; };