Kulcare Docs
General

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 SuggestionAcceptanceService field 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 typeMirror functionEdge cases
medicationinline codedText(resource.medication, display_text)dosage_text concatenates `dosage
ordersuggestedOrderCategory(raw), suggestedOrderPriority(raw)enum candidates + synonyms (radiology → imaging, high → urgent)
diagnosisappends to Assessment observation, ;-separated
observationobservationValueText(value_data, fallback)unwraps [{ text: "..." }]
follow_upaddInterval(value, unit)calendar months/years, one-week fallback
procedureacceptedOrderStatus(encounterFinished)draft until encounter finish, then active
care_plan, goal, allergynone — patient-level resources, no chart mirror

Adding a new suggestion type

  1. Add an entry to RESOURCE_SPECS in the backend service, including model, fixed, fields, practitioner_attr, and timestamp_attr.
  2. Add the matching RESOURCE_SPECS migration's FK to the cascade list in backend/db/migrate/<ts>_cascade_ai_suggestion_links_on_resource_delete.rb (the constraint name → referenced table map).
  3. Add a branch to handleSuggestionAccept in EncounterPage.tsx that calls appendClinicalRow (for medications / investigations / followUps) or a direct setClinicalData updater (for observations), using the same field-extraction the service performs.
  4. Add a suggestedXxx(raw) helper to aiSuggestionMirror.ts if the type's value needs to be normalised before it lands in the local state.
  5. Cover both sides with a test: an suggestion_acceptance_service_test.rb scenario that asserts the server-side attribute values, and an aiSuggestionMirror.test.ts case that asserts the client-side extraction.

On this page