import { applies, readValue, type RecordField } from './composeRecordForm'; import { readPair, pairedComponents } from './pairedObservation'; import { isFileableDate, isFileableTime } from './dateUtils'; /** * Turning what the clinician typed into what the existing transform already knows how to file. * * THERE IS ONE PATH TO FHIR AND THIS IS NOT IT. Every kind already has a `transformToFhirX` that builds * its resource, stamps the patient, the encounter and the recorder, and omits whatever it was not * given. Writing a second builder here would be a second answer to "what does a MedicationRequest look * like" - the exact drift the derived catalogue exists to avoid. So this only reshapes the overlay's * flat strings into the loose object those transforms already take. * * WHAT THE SHAPES BUY. The overlay collects one string per field. `transformToCodeableConcept` wants a * value-set object, a list field wants a list, and a Reference wants the id of a resource that exists. * Sending a bare string into any of those produces malformed FHIR that no test on this side would see - * it is valid JSON, it posts, and it is wrong in the record. */ /** * A field that files a code, in either of the two shapes FHIR uses for one. * * The pair is deliberately treated together: whether the wire wants a bare token or a whole * CodeableConcept is a serialisation difference, and *"is a code required here"* is one question with * one answer. Asking it in two places is how they came to disagree about `status`. */ const isCoded = (field: RecordField): boolean => field.shape === 'code' || field.shape === 'codeableConcept'; /** Shapes that never reach the resource. See `fieldsForKind` for why the first two are not even shown. */ const NEVER_SENT: readonly RecordField['shape'][] = ['stamped', 'choiceSelector', 'unfileable']; /** * FIELDS WHOSE ONE LINE OF TEXT IS THE `text` OF A LIST-VALUED BACKBONE ELEMENT. * * `MedicationRequest.dosageInstruction` is `Dosage[]`, not a string. The full form edits it with the * structured dosage editor; the condensed overlay has one box, and FHIR made room for exactly that - * `Dosage.text` is the sig as written. Handed the bare string, `transformToFhirDosage` finds none of the * properties it reads and returns nothing, so **the directions were dropped in silence** on a field the * overlay had just demanded as required. A prescription with no sig is worse than one that would not save. * * STATED RATHER THAN DERIVED, which is this flow's own rule for the field that IS the thing: the * catalogue calls this `text` because the form binds one input to it, and no mechanical rule tells a * `text` field that means a sentence apart from one that means a backbone element's `text`. */ const TEXT_IS_A_BACKBONE_TEXT: readonly string[] = ['dosageInstruction']; /** * FIELDS WHOSE ONE LINE OF TEXT IS A NUMBER. * * `dispenseRequest.numberOfRepeatsAllowed` is an `unsignedInt`. Sent as the string the box holds, the * server answers *"the primitive value must be a number"* and the whole prescription is refused - so a * refill count of zero, which is most of them, could not be filed. * * Same reason as the list above: the catalogue calls it `text` because the form binds one input to it, * and the FHIR type is what decides, not the control. */ const TEXT_IS_A_NUMBER: readonly string[] = ['dispenseRequest.numberOfRepeatsAllowed']; /** * One typed value, in the form the transform expects for that shape. * * Returns `undefined` for anything that should not be sent, which the caller drops. An empty string is * one of those: the transforms guard on truthiness, so an empty value would be omitted anyway - doing * it here makes it a decision rather than a coincidence. */ const asFormValue = (field: RecordField, raw: unknown): unknown => { // A choice files the answer picked, false as much as true; anything else is unanswered and is not sent. if (field.shape === 'choice') return (field.choices ?? []).some((choice) => choice.value === raw) ? raw : undefined; // A coded list sends the entries that carry a code. The note's uncoded words beside them are a suggestion, and // sent they filed as text the clinician had removed or never chose. if (Array.isArray(raw) && isCoded(field)) { const coded = raw.filter((entry) => typeof (entry as { code?: unknown })?.code === 'string' && (entry as { code: string }).code.trim() !== ''); return coded.length > 0 ? coded : undefined; } /* A `code` FIELD IS A FHIR PRIMITIVE, NOT A CodeableConcept, and the two arrive here looking identical. Both render the product's value-set SEARCH, so both hold a value-set OBJECT - but `MedicationRequest.status` and `.intent` are plain strings on the wire, and the transforms pass them straight through. Sending the object produced ERROR: This property must be an simple value, not an object (status at ...status) ERROR: MedicationRequest.status: minimum required = 1, but only found 0 and NO prescription could be filed from this overlay at all. Walked 2026-09-06. A value-set object with no `code` is a DISPLAY-ONLY seed - the extraction read words, not a terminology. It is not sent: a code element carrying words is not a weaker answer, it is an invalid one, and the field stays on screen for the clinician to pick. */ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw) && isCoded(field)) { const code = (raw as { code?: unknown }).code; if (typeof code !== 'string' || code.trim() === '') return undefined; // A `code` is the bare token; a CodeableConcept keeps the whole object, because its system is // what makes it readable outside Akashic. return field.shape === 'code' ? code : raw; } /* A MEASUREMENT WITHOUT A NUMBER IS NOT SENT, and it reaches here as an object because its control binds a value and a unit. The generic object passthrough below would hand `transformToFhirQuantity` a unit with nothing to measure, which builds a Quantity carrying a scale and no reading - the observation would post, the row would appear, and the record would state that something was measured in millimetres of mercury without saying what. A number that is not a number is refused for the reason the single-box shape already gave: `Number('two')` is NaN, and a NaN serialises to null and files a measurement of nothing. */ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw) && field.shape === 'quantity') { const held = (raw as { value?: unknown; unit?: unknown }).value; /* A PAIR IS FILED AS THE TWO HALVES IT IS, not as half of itself and not as nothing. `master-system-overview.md` says a proposal opens THE REAL FORM condensed, and the real observation form already edits `Observation.component`. A condensed form that could not hold a pair was the condensing losing a shape the real form has - so the row offers the two boxes and this sends what they hold. THE OBSERVATION'S OWN CODE IS NOT TOUCHED. The clinician picks it in the terminology search like any coded field, and deciding which observation this is from the shape of a number would be exactly the inference this flow refuses everywhere else. */ const pair = readPair(held); if (pair) return { __component: pairedComponents(pair) }; const numeric = typeof held === 'string' ? Number(held.trim()) : Number(held); if (!Number.isFinite(numeric) || (typeof held === 'string' && held.trim() === '')) return undefined; const unit = (raw as { unit?: unknown }).unit; return { value: numeric, ...(unit ? { unit } : {}) }; } /* ALREADY THE RIGHT SHAPE, so it is passed through untouched. A coded field renders the product's own value-set SEARCH, which writes a value-set object carrying a real code and system - exactly what the transform reads. Re-wrapping it here would bury that inside another object and lose the coding, which is the one thing making it coded. */ if (raw !== null && typeof raw === 'object') { if (Array.isArray(raw)) return raw.length > 0 ? raw : undefined; return raw; } const value = String(raw ?? '').trim(); if (!value) return undefined; switch (field.shape) { /* WORDS ARE NOT A CODED VALUE, AND ARE NOT SENT. Spencer, 2026-09-04, asked about exactly this - whether a medication or a diagnosis confirmed as typed text may be filed: *"No, it needs to be coded. We should have a search select option where they can select the coded prescription."* The reader tree states it as a product rule in the paragraph describing this very flow: *"Coded fields file only through the terminology search; free text sits beside a code, never instead of it."* This used to return `{ display: value }`, which files as `{ text: "..." }` - a CodeableConcept carrying words and no code. It was defended here as honest rather than ideal. It is not allowed: `terminology-conventions.md` gives the reason, and it is permanence rather than tidiness - nothing can search it, nothing outside Akashic can read it, and unlike a code it can never be mapped later. */ case 'codeableConcept': return undefined; // A bare code, which is what the form binds and the transform passes through. case 'code': case 'text': return value; // One moment: a zoned instant as given, or a local time; the transform turns it into the instant. case 'dateTime': return (field.dayAllowed ? isFileableDate(value) : isFileableTime(value)) ? value : undefined; /* A NUMBER, and NOT a number if it is not one. `Number('two')` is NaN, and a NaN in a Quantity serialises to null and files a measurement of nothing. Refusing is the safe direction: the field simply is not sent, and it is still on screen for the clinician to correct. */ case 'quantity': { const numeric = Number(value); return Number.isFinite(numeric) ? { value: numeric } : undefined; } // FHIR's Annotation is a list, and its `text` is the note itself. case 'annotation': return [{ text: value }]; default: return undefined; } }; /** * THE ARMS OF A FHIR CHOICE, AND THE SELECTOR EACH ONE HAS TO SET. * * `Observation.value[x]`, `Condition.onset[x]`, `Condition.abatement[x]`, `Procedure.performed[x]` and * `Observation.effective[x]` are choices: the resource carries ONE of several typed fields. The forms * ask which arm with a selector - `valueType`, `onsetType` - and every `transformToFhirX` gates the arm * on it: `...(valueType === 'quantity' && valueQuantity && { valueQuantity })`. * * THE CONDENSED OVERLAY NEVER SHOWS THAT SELECTOR, deliberately: `choiceSelector` is in `NOT_OFFERED` * because it is not carried by the resource and, being required on a procedure, would sit in * "Still needed" as something nobody can answer and lock Save for good. * * WHICH LEFT THE VALUE ITSELF ON THE FLOOR. With no selector set, every one of those gates is false, so * a measured value a clinician read off the note, confirmed and saved was dropped on the way to the * wire. The observation posted, the panel refreshed, the row appeared - carrying no reading at all. It * is the same shape as the dosage and the refill count before it: the screen and the record disagreed, * and only the record was right. * * SO THE ARM IS DERIVED FROM WHAT WAS ANSWERED, which is the same inference each service already makes * in the other direction - `transformFromFhirAllergyIntolerance` reads `if (onsetAge) onsetType = 'age'` * to open the full form on the right arm. A clinician who filled one box has said which arm they mean. * * THE PREFIXES ARE STATED, not derived from the catalogue, and the reason is `value`: the observation * form picks its value arm with a TAB rather than a control, so `valueType` is in no form the generator * reads and appears nowhere in the catalogue - deriving the list would have missed the single most * important arm on this surface. `theFiledValueCarriesItsChoiceArm` greps the nine services and fails * if a selector they gate on is missing from here. */ export const CHOICE_ARM_PREFIXES: readonly string[] = [ 'abatement', 'effective', 'onset', 'performed', 'value', ]; /** * Name the arm that was answered, so the transform will send it. * * FIRST ANSWERED WINS, in the catalogue's own order, because the arms are mutually exclusive: a * resource carries one. Nothing is set when nothing was answered - an absent selector is exactly what * an unanswered choice should look like, and inventing one would name an arm holding nothing. */ const resolveChoiceArms = ( fields: readonly RecordField[], out: Record, ): void => { for (const prefix of CHOICE_ARM_PREFIXES) { const selector = `${prefix}Type`; if (out[selector] !== undefined) continue; const answered = fields.find((field) => field.name !== selector && field.name.startsWith(prefix) && field.name.length > prefix.length && field.name[prefix.length] === field.name[prefix.length].toUpperCase() && out[field.name] !== undefined); if (!answered) continue; const arm = answered.name.slice(prefix.length); out[selector] = arm[0].toLowerCase() + arm.slice(1); } }; /** * What the clinician confirmed, in the shape `transformToFhirX` takes. * * A LIST FIELD GETS A LIST, and it is not cosmetic: the transforms pass a list straight through, so a * bare string on `category` files `"food"` where FHIR requires `["food"]` - invalid, and silent. */ export const toFormValues = ( fields: readonly RecordField[], typed: Record, ): Record => { const out: Record = {}; for (const field of fields) { if (NEVER_SENT.includes(field.shape)) continue; // A field that does not apply now is not sent, whatever it held: a report origin left from a secondary answer // is not filed once the clinician says the vaccine information is the administrator's own. if (!applies(field, typed)) continue; /* READ BY PATH, NOT BY FLAT KEY. `react-hook-form` treats a dot as nesting, so a clinician who types into `dispenseRequest.quantity` puts it at `{dispenseRequest: {quantity}}` - and `typed['dispenseRequest.quantity']` is then undefined. The banding was given this reader on 2026-09-04 and this was not, so a dotted field left "Still needed", enabled Save, and was DROPPED on the way to the wire: the quantity and the refill count of every prescription, demanded as required and then discarded. The screen and the record disagreed and only the record was right. Walked 2026-09-06. */ const value = asFormValue(field, readValue(typed, field.name)); if (value === undefined) continue; if (TEXT_IS_A_BACKBONE_TEXT.includes(field.name) && typeof value === 'string') { setPath(out, field.name, [{ text: value }]); continue; } if (TEXT_IS_A_NUMBER.includes(field.name) && typeof value === 'string') { const numeric = Number(value); // NOT A NUMBER MEANS NOT SENT, the same judgement the `quantity` shape already makes: a NaN // serialises to null and records a count of nothing, which is worse than the field being // absent and still on screen to correct. if (Number.isFinite(numeric)) setPath(out, field.name, numeric); continue; } // An annotation is already a list, and so is anything the search wrote for a multiple field - // wrapping either again would nest one. const shaped = field.list && field.shape !== 'annotation' && !Array.isArray(value) ? [value] : value; /* THE HALVES LEAVE UNDER `component`, WHICH IS THE RESOURCE'S FIELD AND NOT THIS ONE'S. A paired reading is not a value OF the quantity field - it replaces it - so it is lifted out rather than written at `valueQuantity`, and the choice arm below then correctly finds nothing to name. An observation carrying both a value and components would be two answers to what was measured. */ if (shaped !== null && typeof shaped === 'object' && '__component' in (shaped as object)) { out.component = (shaped as { __component: unknown[] }).__component; continue; } setPath(out, field.name, shaped); } resolveChoiceArms(fields, out); return out; }; /** * Write a dotted path into a nested object - `dispenseRequest.quantity` becomes * `{ dispenseRequest: { quantity } }`, which is how the form binds it and how the transform reads it. */ export const setPath = (target: Record, path: string, value: unknown): void => { const parts = path.split('.'); let cursor = target; for (const part of parts.slice(0, -1)) { // Only ever an object here. A path segment colliding with a value already set would silently // discard one of them, so the existing object wins and the new leaf joins it. if (typeof cursor[part] !== 'object' || cursor[part] === null || Array.isArray(cursor[part])) { cursor[part] = {}; } cursor = cursor[part] as Record; } cursor[parts[parts.length - 1]] = value; };