/** * WHAT TO TELL SOMEONE WHEN A WRITE WAS REFUSED. * * THIS EXISTS BECAUSE IT IS A CLASS, NOT A BUG. Two independent surfaces were found refusing correctly * on the server and saying nothing at all in the browser, on the same day, by different sessions: * - a guardian's booking 403s at **Confirm Booking** - no toast, no inline error, nothing; * - a patient's questionnaire 400s at **Submit** - no banner, no toast, nothing. * The client has no general treatment of a server refusal, so every screen either handles it or does * not, and nobody notices which. A message added to one screen leaves that true. * * ITS SIBLING, AND THE DIFFERENCE. `fetchFailureMessage.ts` answers *"a LOAD failed"* - where the * decision is whether retrying can help. This answers *"a WRITE was REFUSED"*, where the decision is * whether the server's own sentence is fit to show. Those are different questions and the answers do not * follow from each other, which is why this is not a branch of that file. * * THE RULE, AND IT IS STRUCTURAL RATHER THAN A GUESS ABOUT THE PROSE. * - **401 / 403 / 404 / 409** - a deliberate refusal by our own handler. Its `diagnostics` is copy * somebody wrote for a person to read, and it says the one thing the caller cannot know: *why*. * Show it. * - **400 / 422** - a rejected request body. That is our bug, and its text is a structural validator * talking to a developer: *"Array cannot be empty - the property should not be present if it has no * values"* is what a patient was one branch away from reading. Never show it. * - **anything else** - a 5xx, a timeout, a dropped connection. "Please try again", never the * server's text, which at that point is a status line rather than a sentence. * * IT NEVER RETURNS NOTHING. A refusal that renders as silence is the defect this was written for, so * every path here produces a sentence. The caller's job is to put it somewhere it will be SEEN - inline, * where the person is already looking, not only in a toast: a toast is `position: fixed` inside a * provider tree mounted into a practice's own WordPress page, so a transformed ancestor can position it * off-screen. * * THE TRADE, STATED. Hiding a 400's text loses the occasional 400 that carried a good sentence. That * is the safe direction for a patient-facing screen and the wrong one to reverse casually: the failure * it prevents is unreadable machine text in front of someone who cannot act on it, and the failure it * costs is a generic sentence in front of someone who could not have acted on the specific one either. */ /** Statuses whose body is a refusal we authored, meant to be read. */ const SHOWS_THE_SERVERS_OWN_SENTENCE = [401, 403, 404, 409]; /** * REFUSALS THAT ARE NOT OURS, ON A STATUS WHERE WE ASSUMED THEY WOULD BE. * * The rule above rests on a premise: a 401/403/404/409 body is copy somebody here wrote for a person to * read. The platform breaks that premise. API Gateway refuses before our handler is reached and answers * its own 403 - "User is not authorized to access this resource with an explicit deny in an * identity-based policy" - which is AWS talking to whoever configured AWS. * * WALKED 2026-09-14: an assist-only related person answered a questionnaire for the person they help * and read that sentence. The refusal itself was right; the words were our cloud provider's. * * Matched on the platform's phrasing rather than on the route, because any route can be refused this * way and a list of routes would go stale the first time one moved. */ const NOT_OUR_SENTENCE = [ /not authorized to access this resource/i, /explicit deny in an identity-based policy/i, /User: arn:aws:/i, /is not authorized to perform:/i, ]; /** * A SENTENCE THAT NAMES A RECORD BY ITS ID WAS WRITTEN FOR A LOG, NOT A PERSON. Nobody can read an id, * and such a sentence can give a reason that is not the one the reader needs. * * Only on 401 and 403, where the generic wording - "You do not have access to do this" - is what every * such refusal means. The generic for a 404 or a 409 says something narrower, so those stay the server's. */ const NAMES_A_RECORD_BY_ID = /\b[A-Z][A-Za-z]+\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-/; const ACCESS_REFUSALS = [401, 403]; /** Statuses that mean the request body was rejected - developer-facing text, never shown. */ const REJECTED_THE_REQUEST = [400, 422]; const statusOf = (error: unknown): number | undefined => { const status = (error as { status?: unknown } | null | undefined)?.status; return typeof status === 'number' ? status : undefined; }; /** * Strip the transport's own framing from a server sentence. * * `HttpService` renders an OperationOutcome as `"ERROR: "` per issue, joined by newlines, * and falls back to `"Request failed: <...>"`. Those prefixes are for a log. Only the framing is * removed - the sentence itself is never rewritten, because it is the server's to word. */ export const stripTransportFraming = (message: string): string => message .split('\n') .map((line) => line.replace(/^\s*(?:FATAL|ERROR|WARNING|INFORMATION)\s*:\s*/i, '').trim()) .map((line) => line.replace(/^Request failed:\s*/i, '').trim()) .filter((line) => line.length > 0) .join(' '); const serverSentence = (error: unknown): string | undefined => { const message = (error as { message?: unknown } | null | undefined)?.message; if (typeof message !== 'string') return undefined; const cleaned = stripTransportFraming(message); // A bare status line ("404 Not Found") is framing that survived, not a sentence. Nothing is lost by // falling through to the generic wording, and showing it would read as a fault in the page. if (!cleaned || /^\d{3}\b/.test(cleaned)) return undefined; // The platform's own refusal, on a status this file assumes is ours. Falling through to the generic // sentence is the whole point: it says the same true thing without naming a policy the reader has // never heard of and cannot act on. if (NOT_OUR_SENTENCE.some((pattern) => pattern.test(cleaned))) return undefined; return cleaned; }; /** * Build the sentence to show when a write was refused. * * @param error whatever was caught * @param context what did not happen, in the product's voice and already sentence-shaped, with no * trailing punctuation - e.g. `'Your answers were not submitted'` */ /** * WALKED 2026-09-14, in the meeting window of a live visit: the panel read * * That message was not sent.. Nobody else has joined this visit yet, so there is no one to * send this to. * * Two full stops. The doc comment above says the context arrives with no trailing punctuation and * five of six callers keep to it; the sixth passed one and nothing anywhere could tell. * * A DOCUMENTED CONTRACT THAT ONLY A READER ENFORCES IS THE SHAPE THIS CODEBASE KEEPS PAYING FOR, * so the punctuation is settled here rather than trusted to each caller. Only a trailing full stop * is taken: a question mark or an exclamation is somebody's deliberate voice and is left alone, * and a caller that ends in one is not making this mistake. */ const asLead = (context: string): string => context.replace(/\.\s*$/, ''); export const describeServerRefusal = (error: unknown, rawContext: string): string => { const context = asLead(rawContext); const status = statusOf(error); if (status !== undefined && SHOWS_THE_SERVERS_OWN_SENTENCE.includes(status)) { const sentence = serverSentence(error); const readable = sentence && !(ACCESS_REFUSALS.includes(status) && NAMES_A_RECORD_BY_ID.test(sentence)) ? sentence : undefined; if (readable) return `${context}. ${readable}`; return status === 404 ? `${context}. It is no longer available.` : `${context}. You do not have access to do this.`; } if (status !== undefined && REJECTED_THE_REQUEST.includes(status)) { // Names our side as the cause, so the person does not retry the same thing expecting a different // outcome - and does not conclude they filled it in wrongly, which the raw text would imply. return `${context}. Something on our side would not accept it, so nothing was saved. Please contact the practice if this keeps happening.`; } return `${context}. Please try again.`; }; /** * A refusal's reason where the write was refused, and the caller's own words for anything else: a failure that * may have been saved, or that a retry can mend, is not this helper's to describe. */ export const refusalOr = (error: unknown, context: string, otherwise: string): string => { const status = statusOf(error); return status !== undefined && SHOWS_THE_SERVERS_OWN_SENTENCE.includes(status) ? describeServerRefusal(error, context) : otherwise; };