Longitudinal Health Record (LHR) — CMS integration
Status: Same-tenant LHR views live in CMS, cross-tenant consent + import-to-encounter behind the same surface. Adherence capture and shared-package extraction tracked as follow-ups (see #1391).
Backend module:
backend/domains/clinical/(controllers + services underpatients/)Frontend feature:
frontend/apps/cms/src/features/patientLhr/Related: Patient-domain guide · Patient-app LHR guide · LHR product overview · LHR QA guide · Cross-tenant patient experience
Overview
LHR is the patient's longitudinal health record surfaced inside CMS. It composes two distinct backend reads behind one frontend feature:
- Same-tenant per-resource views. Each LHR tab (Vitals, Symptoms, Problems, Medications, Visits, Lab Results) reads from the authoring tenant's
clinical.*tables and gets transformed into the patient-app row shape. These endpoints power the tabbed views inside the LhrTab onPatientDetailPageand the History Sheet embedded inside an encounter. - Cross-tenant timeline. The Timeline tab plus the cross-tenant consent banner read from
Clinical::Patients::LongitudinalRecordService, which fans out across every tenant where the patient has aClinical::Patientrow. Other-tenant rows are gated byIdentity::CrossTenantConsent.
Both surfaces share a contract: rows are emitted in the patient-app shape so the same view components render in both the patient React app (at patient/) and CMS. There is no shape adapter layer — Clinical::Patients::LhrViews::* transformers emit the patient-app contract directly.
Technical architecture
Two backend reads, one frontend feature
┌────────────────────── CMS app (apps/cms/src/features/patientLhr/) ───────────────────┐
│ │
│ <PatientLhr patientId> ──► PatientLhrProvider (threads patientId via context) │
│ │
│ Tabs ──► same-tenant hooks Timeline tab ──► cross-tenant hook │
│ (useVitals, useMedications, …) (usePatientLongitudinalTimeline) │
│ │ │ │
└───────┼────────────────────────────────────────────┼─────────────────────────────────-┘
│ │
▼ ▼
/clinical/patients/:id/lhr/* /clinical/patients/:id/longitudinal_record
(same-tenant transformers) (cross-tenant fan-out + consent)
│ │
▼ ▼
clinical.* (authoring tenant only) clinical.* (every tenant the patient has visited)
── consent-gated via Identity::CrossTenantConsentBackend module layout
backend/domains/clinical/
├── controllers/patients/
│ ├── lhr_views_controller.rb # 7 same-tenant endpoints (NEW)
│ └── longitudinal_records_controller.rb # cross-tenant aggregator wrapper
└── services/patients/
├── lhr_views/ # per-resource transformers (NEW)
│ ├── medications_transformer.rb
│ ├── conditions_transformer.rb
│ ├── vitals_trends_transformer.rb
│ ├── symptoms_transformer.rb
│ ├── encounters_transformer.rb
│ └── lab_records_transformer.rb
└── longitudinal_record_service.rb # cross-tenant aggregator (pre-existing)Frontend feature layout
frontend/apps/cms/src/features/patientLhr/
├── components/
│ ├── PatientLhr.tsx # Tab orchestration; wraps in PatientLhrProvider
│ ├── ConsentBanner.tsx # Renders when meta.consent != null
│ ├── Timeline.tsx # Cross-tenant timeline tab
│ ├── MedicationHistory.tsx # Medications tab + reverse link dialog
│ ├── SymptomMedicationCorrelation.tsx # Correlations tab
│ ├── LinkMedicationsDialog.tsx # 3-dot menu on Problems
│ ├── LinkProblemsDialog.tsx # 3-dot menu on Medications
│ └── tabs/
│ ├── vitals/VitalTrends.tsx
│ ├── problems/ProblemList.tsx
│ ├── symptoms/SymptomList.tsx
│ ├── encounters/EncounterHistory.tsx
│ └── lab-results/LabResults.tsx
├── context/PatientLhrContext.tsx # Threads patientId; required for all hooks
├── services/lhrViewsApi.ts # apiClient wrappers
├── hooks/
│ ├── queryKeys.ts # React Query key factory
│ ├── useMedications.ts
│ ├── useMedicationsWithFilters.ts
│ ├── useProblems.ts
│ ├── useVitals.ts
│ ├── useSymptoms.ts
│ ├── useEncounters.ts
│ ├── useMedicalRecords.ts
│ └── useLinkMedications.ts # PATCH mutation
└── types/ # Mirrors patient-app types verbatimEmbeddable, surface-agnostic
PatientLhr is a single embeddable component. Two surfaces consume it today:
features/patientDetail/components/LhrTab.tsx— the 6th tab on the patient detail page.features/encounter/components/PatientLhrSheet.tsx— a Sheet opened from inside an active encounter, used to review history without leaving the encounter context.
Per the embeddable_components convention: any new surface that needs LHR imports <PatientLhr patientId={…} /> rather than building a duplicate view.
Same-tenant endpoints (/clinical/patients/:id/lhr/*)
All seven endpoints live on Clinical::Patients::LhrViewsController. They are tenant-scoped via Clinical::BaseController and read only the authoring tenant's rows (no fan-out, no consent gate). Each one runs a Clinical::Patients::LhrViews::*Transformer service that emits the patient-app contract.
| Method | Path | Source table | Notes |
|---|---|---|---|
GET | /lhr/medications | clinical.medication_requests | Maps medication.text → drug_name, first dosage_instruction[0].text → dosage. adherence_log is nil (UI shows "coming soon"). |
GET | /lhr/conditions | clinical.conditions | Severity remapped (moderate→medium; CMS-authored rows keep the value they were entered with, e.g. critical); linked_medications hydrated from linked_medication_ids. is_chronic uses an explicit source_reference.is_chronic when set, otherwise clinical status + onset > 90 days. Rows marked entered_in_error (deleted from the Problems tab) are excluded. |
GET | /lhr/vitals_trends | clinical.observations (category=vital_signs) | LOINC codes mapped to vital_type enum. Systolic + diastolic paired by effective_datetime into one BloodPressureValue. Normal ranges hardcoded for non-BP metrics. |
GET | /lhr/symptoms | clinical.observations (category IN (clinical_history, exam, survey, social_history)) | The Observation category enum has no symptom literal — these four categories are where clinicians capture symptom-style entries in practice. |
GET | /lhr/encounters | clinical.encounters | Paginated envelope {data, meta:{total,page,limit,pages}}. |
GET | /lhr/lab_records | clinical.documents (document_type IN (lab_report, laboratory_report, lab, pathology)) | Returns {records: [...]} envelope. |
PATCH | /lhr/conditions/:condition_id/link_medications | clinical.conditions | Replaces linked_medication_ids set. Validates all ids exist in same tenant; rejects unknown ids with 422. |
The transformers are intentionally minimal — they pull from one clinical.* table each, no joins, no caching. They only exist to translate FHIR-shaped JSONB columns into the flat patient-app row shape the views expect.
Why a transformer layer at all?
The CMS LHR views were ported verbatim from the patient React app. The patient app reads from patient.* (a flat, patient-friendly schema), but CMS needs to render the same UI against clinical.* (FHIR-aligned). Rather than dual-rendering or writing adapter shims at every callsite, the backend emits the patient-app contract directly. This means:
- Zero per-tab UI changes when wiring to CMS.
- Patient app and CMS can share view components in a later extraction (tracked at GH #1391).
- New clinical fields show up by adding them to the transformer, not by branching UI logic.
linked_medication_ids on clinical.conditions
The link feature on the Problems and Medications tabs (3-dot menu → "Link to medications" / "Link to problems") needs many-to-many association between conditions and medication requests. We chose a uuid[] array column on clinical.conditions with a GIN index over a join table:
- Lookups are always "given this condition, what medications" (rarely the reverse, and the reverse direction is computed once for the dialog).
- The reverse-direction dialog (link from a medication to problems) issues one PATCH per affected condition; the controller computes the delta.
- No new model, no migration churn when adding/removing.
Migration: backend/db/migrate/20260509000001_add_linked_medication_ids_to_clinical_conditions.rb.
Adding problems from the CMS (/clinical/patients/:id/conditions)
Clinical::Patients::ConditionsController lets clinic staff add, edit and delete standalone problems from the Problems tab ("Add Problem"). All three actions authorize with PatientPolicy#update?.
| Method | Path | Behaviour |
|---|---|---|
POST | /clinical/patients/:id/conditions | Creates a Clinical::Condition with source: 'native', the current practitioner as recorder_id (when they have a role in the tenant), and the staff user in source_reference.recorded_by_user_id. name is required. |
PATCH | /clinical/patients/:id/conditions/:condition_id | Updates a native, standalone (no encounter), not-deleted condition of the current tenant. Changing the ICD code replaces only the ICD-10 coding and keeps others (e.g. SNOMED). A blank name is rejected; a blank last_review_date or is_chronic leaves the stored value unchanged. |
DELETE | /clinical/patients/:id/conditions/:condition_id | Marks the condition entered_in_error (it drops out of /lhr/conditions and the clinical timeline) and soft-deletes the patient-app copy. |
Field rules:
severityaccepts the patient-app scale (low,medium,high,critical) or FHIR (mild,moderate,severe); anything else is 422.criticalis stored as FHIRseverewith the original value insource_reference.raw_severity, so it round-trips. Sending an empty severity clears it everywhere.statusmust be aclinical_statusvalue (active,recurrence,relapse,inactive,remission,resolved); anything else is 422. Aresolved_aton anactiveproblem moves it toresolved.
Patient-app copy. When the patient has a linked app account (user_id, or identity_patient.user_id), the condition is also written to patient.conditions with source: 'clinical_imported' and source_reference: {type: 'clinical_condition', id, tenant_id}, in the same transaction. The copy's occurred_at (its position in the patient-app timeline) only moves when an explicit occurred_at is sent or the onset date changes. Patients without an app account get the clinic record only; the Add Problem modal says so.
Allergies in the patient detail (GET /clinical/patients/:id)
The :detailed patient view returns allergies merged from two sources, each tagged with source:
clinic: activeclinical.allergy_intolerances, excludingrefutedandentered_in_error. Unconfirmed (suspected) allergies are included so they still reach alerts and drug-allergy checks;verification_statusis returned for labelling.self_reported:patient.allergiesfor the linked app user, excludingclinical_importedcopies.
If the self-reported lookup fails, the clinic allergies are still returned, the error is reported, and self_reported_allergies_unavailable: true is set. The CMS Allergies tab, New Encounter page and encounter page then show a warning instead of an empty self-reported list. The lookup runs in a savepoint so a failure cannot abort an enclosing transaction.
Cross-tenant endpoints
The Timeline tab and the cross-tenant consent flow use the pre-existing Clinical::Patients::LongitudinalRecordsController:
| Method | Path | Purpose |
|---|---|---|
GET | /clinical/patients/:id/longitudinal_record | Unified timeline across all tenants the patient has visited. Returns {timeline, facilities, meta:{fan_out, consent}}. |
GET | /clinical/patients/:id/longitudinal_record/resources/:resource_type | Cross-tenant per-resource list. |
POST | /clinical/patients/:id/request_lhr_consent | Idempotent — returns the existing active grant or pending request if one exists. Otherwise creates a requested row and pings the patient. |
POST | /clinical/patients/:id/import_lhr_record | Copies the origin record into the receiving tenant's clinical.* with source = 'clinical_imported'. Idempotent on re-import. |
meta.consent on any read surfaces the consent banner state. The controller never blocks reads on consent — same-tenant data is always shown — meta.consent tells the UI whether to render the "Request access for other-clinic data" banner.
Frontend integration
PatientLhrContext
Every LHR hook calls usePatientLhrPatientId(). The provider is wrapped automatically inside PatientLhr, so consumers don't need to know about it — they just call useMedications() etc.
Why context over props: the views were ported from the patient app verbatim. The patient app doesn't take a patientId prop (the route owns the user). Threading patientId through every component would have meant rewriting every view; context preserves the port.
React Query keys
All keys are scoped to a patient and namespaced under ['patient-lhr', patientId, <kind>]. Mutations (currently just useLinkMedicationsToCondition) invalidate the affected key on success.
import { patientLhrKeys } from '@/features/patientLhr/hooks/queryKeys'
patientLhrKeys.conditions(patientId)
// => ['patient-lhr', patientId, 'conditions']Hook contract
Every hook returns a { data, isLoading, isError, error, refetch } envelope. Hooks that take filter params (useMedications, usePatientProblemsWithFilters, useSymptoms, useMedicalRecords) apply filtering client-side over the full dataset returned by the endpoint. This is intentional for v1 — datasets are small enough per-patient that round-tripping each filter change is wasteful. If a hook starts feeling slow, the filter goes server-side; the contract doesn't change.
useMedicationsWithFilters returns the legacy { medications, pagination, filters, updateFilter, setPage, setPerPage } shape — that's what MedicationHistory expects.
Link mutation flow
useLinkMedicationsToCondition is the single mutation hook in the feature. Both link dialogs route through it:
- Problems → Medications (
ProblemList): one mutation call per dialog save — the condition's fullmedication_idsarray is replaced. - Medications → Problems (
MedicationHistory): the dialog gives back a set of selected problem ids. We diff against each problem's currentlinked_medication_ids, then issue one mutation per affected condition. A single toast confirms when all complete; the conditions query invalidates and the UI updates.
Dependencies
Backend
Clinical::BaseControllerfor tenant resolution + RLS contextClinical::BaseServicefor thesuccess/failureresult envelopeClinical::Patientfor the patient lookup; no tenant fan-out — same-tenant onlyClinical::Patients::LongitudinalRecordServicefor the cross-tenant Timeline readIdentity::CrossTenantConsentfor the consent gate (cross-tenant reads only)
Frontend
@kulcare/api-client(apiClient)@kulcare/shared-services(usePatientLongitudinalTimeline)@kulcare/ui-kit(Tabs, Card, DropdownMenu, Skeleton, TablePagination, Sonner toasts via the shared instance)@tanstack/react-queryv5rechartsforVitalTrendsdayjs+dayjs/plugin/relativeTime
Common tasks
Add a new LHR tab backed by clinical.*
- Add a transformer at
backend/domains/clinical/services/patients/lhr_views/<resource>_transformer.rb. Inherit fromClinical::BaseService, takeclinical_patient:, return the patient-app row shape viasuccess(rows). - Add the action to
Clinical::Patients::LhrViewsControllerand the route tobackend/domains/clinical/routes.rbunder the existingscope :lhrblock. - Add a request spec in
backend/domains/clinical/spec/requests/lhr_views_spec.rb. - Frontend: add
getXinservices/lhrViewsApi.ts, a key factory entry inhooks/queryKeys.ts, a hook inhooks/useX.ts, and a tab incomponents/PatientLhr.tsx.
Wire a new mutation
Add the api function to services/lhrViewsApi.ts, then a hook under hooks/ using useMutation with queryClient.invalidateQueries({ queryKey: patientLhrKeys.<kind>(patientId) }) on success.
Surface LHR in a new place
Import <PatientLhr patientId={…} /> from @kulcare/cms/src/features/patientLhr. Do not duplicate the tabs or transformers in another feature folder.
Testing
See LHR QA guide for the test plan covering:
- Same-tenant LHR view endpoints (request specs + manual matrix)
- Cross-tenant timeline + consent flow
- Link mutations (Problems ↔ Medications)
- Sheet embed inside encounters
Backend request specs: backend/domains/clinical/spec/requests/lhr_views_spec.rb (8 examples covering all 7 endpoints + the unknown-medication-id rejection path).
Frontend: type-check via pnpm --filter=@kulcare/cms typecheck. Component-level tests not yet added; the views are ported verbatim from the patient app where they already have coverage.
Manual QA: Add Problem and allergies (PR #2706)
- Add a problem from the Problems tab with only a name: it appears in the tab with no severity, and (for a patient with an app account) in the patient app.
- Add a problem with severity High, then edit it to clear the severity: the CMS and the patient app both show no severity.
- Add a problem with a resolved date while status is Active: it saves as Resolved.
- Add a problem for a patient without an app account: the modal says it is saved to clinic records only, and it saves.
- (API only; the CMS has no edit/delete UI yet)
DELETE /clinical/patients/:id/conditions/:condition_idon a CMS-added problem: it disappears from the Problems tab, the LHR timeline and the patient app. - Link a CMS-added problem to a medication from the Problems tab (three-dot menu, "Link to medications").
- Record a clinic allergy as Unconfirmed: it appears in the encounter allergy bar and New Encounter alerts.
- Add an allergy in the patient app: it shows as Self-reported in the CMS Allergies tab, New Encounter page and encounter page.
- With the self-reported allergy lookup failing (e.g. revoke access to
patient.allergiesin a test database): the patient page still loads, and each of the three screens shows the "could not be loaded" warning. - A user without patient update permission gets 403 on add, edit and delete.
Known limitations / follow-ups
- Adherence: backend returns
adherence_log: niland the UI shows a "coming soon" affordance. The patient-app vertical owns adherence capture; once that lands, the medications transformer will pass it through. - Shared package extraction: the CMS LHR view components are duplicated from the patient app. Tracked at GH #1391 for eventual extraction into a shared package.
- Client-side filter pagination: works for small per-patient datasets. Move to server-side when a patient crosses a few hundred rows in any one tab.
Clinical::Documentfor lab records: filter assumesdocument_type IN (lab_report, laboratory_report, lab, pathology). New historic enum values may need to be added toLAB_DOCUMENT_TYPESinLabRecordsTransformer.