Frontend ↔ backend data mirroring
The encounter save rebuilds every clinical category it touches with
delete_all and a fresh insert from the client's state. Any row the
backend created on a prior AI Scribe accept that the client did not
mirror is silently destroyed by the next save.
Why this exists
Encounters::SaveClinicalDataService is the source of truth for the
encounter's chart. To keep the contract simple it accepts the full
category payload from the client, deletes the existing rows for that
category, and inserts what the client sent. This keeps the save
idempotent and avoids drift detection on every field — but it also
means "what the backend has" must equal "what the client sends" within
that category, otherwise the next save erases rows the backend already
created.
The mirror
frontend/apps/cms/src/features/encounter/utils/aiSuggestionMirror.ts
is the field-for-field mirror of Clinical::Ai::SuggestionAcceptanceService::RESOURCE_SPECS.
Each type's accept handler in EncounterPage.tsx#handleSuggestionAccept
runs the same field-extraction logic the backend uses, so the row the
backend persists on accept is identical to the row the client sends on
the next save.
The file's header comment is explicit about the contract:
Everything below mirrors
SuggestionAcceptanceServicefield for field. The encounter save deletes and rebuilds each category it sends from local state, so a mirror that disagrees with the row the backend created on accept either duplicates it or deletes it. Change one side, change both.
Per-type mirroring rules
| Suggestion type | Mirror function | Edge cases |
|---|---|---|
medication | inline codedText(resource.medication, display_text) | dosage_text concatenates `dosage |
order | suggestedOrderCategory(raw), suggestedOrderPriority(raw) | enum candidates + synonyms (radiology → imaging, high → urgent) |
diagnosis | appends to Assessment observation, ;-separated | |
observation | observationValueText(value_data, fallback) | unwraps [{ text: "..." }] |
follow_up | addInterval(value, unit) | calendar months/years, one-week fallback |
procedure | acceptedOrderStatus(encounterFinished) | draft until encounter finish, then active |
care_plan, goal, allergy | none — patient-level resources, no chart mirror |
Adding a new suggestion type
- Add an entry to
RESOURCE_SPECSin the backend service, includingmodel,fixed,fields,practitioner_attr, andtimestamp_attr. - Add the matching
RESOURCE_SPECSmigration's FK to the cascade list inbackend/db/migrate/<ts>_cascade_ai_suggestion_links_on_resource_delete.rb(the constraint name → referenced table map). - Add a branch to
handleSuggestionAcceptinEncounterPage.tsxthat callsappendClinicalRow(formedications/investigations/followUps) or a directsetClinicalDataupdater (forobservations), using the same field-extraction the service performs. - Add a
suggestedXxx(raw)helper toaiSuggestionMirror.tsif the type's value needs to be normalised before it lands in the local state. - Cover both sides with a test: an
suggestion_acceptance_service_test.rbscenario that asserts the server-side attribute values, and anaiSuggestionMirror.test.tscase that asserts the client-side extraction.