Kulcare Docs
DeveloperClinicalLhr

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 under patients/)

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:

  1. 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 on PatientDetailPage and the History Sheet embedded inside an encounter.
  2. 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 a Clinical::Patient row. Other-tenant rows are gated by Identity::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::CrossTenantConsent

Backend 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 verbatim

Embeddable, 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.

MethodPathSource tableNotes
GET/lhr/medicationsclinical.medication_requestsMaps medication.text → drug_name, first dosage_instruction[0].text → dosage. adherence_log is nil (UI shows "coming soon").
GET/lhr/conditionsclinical.conditionsSeverity 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_trendsclinical.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/symptomsclinical.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/encountersclinical.encountersPaginated envelope {data, meta:{total,page,limit,pages}}.
GET/lhr/lab_recordsclinical.documents (document_type IN (lab_report, laboratory_report, lab, pathology))Returns {records: [...]} envelope.
PATCH/lhr/conditions/:condition_id/link_medicationsclinical.conditionsReplaces 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?.

MethodPathBehaviour
POST/clinical/patients/:id/conditionsCreates 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_idUpdates 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_idMarks the condition entered_in_error (it drops out of /lhr/conditions and the clinical timeline) and soft-deletes the patient-app copy.

Field rules:

  • severity accepts the patient-app scale (low, medium, high, critical) or FHIR (mild, moderate, severe); anything else is 422. critical is stored as FHIR severe with the original value in source_reference.raw_severity, so it round-trips. Sending an empty severity clears it everywhere.
  • status must be a clinical_status value (active, recurrence, relapse, inactive, remission, resolved); anything else is 422. A resolved_at on an active problem moves it to resolved.

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: active clinical.allergy_intolerances, excluding refuted and entered_in_error. Unconfirmed (suspected) allergies are included so they still reach alerts and drug-allergy checks; verification_status is returned for labelling.
  • self_reported: patient.allergies for the linked app user, excluding clinical_imported copies.

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:

MethodPathPurpose
GET/clinical/patients/:id/longitudinal_recordUnified timeline across all tenants the patient has visited. Returns {timeline, facilities, meta:{fan_out, consent}}.
GET/clinical/patients/:id/longitudinal_record/resources/:resource_typeCross-tenant per-resource list.
POST/clinical/patients/:id/request_lhr_consentIdempotent — 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_recordCopies 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.

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 full medication_ids array is replaced.
  • Medications → Problems (MedicationHistory): the dialog gives back a set of selected problem ids. We diff against each problem's current linked_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::BaseController for tenant resolution + RLS context
  • Clinical::BaseService for the success/failure result envelope
  • Clinical::Patient for the patient lookup; no tenant fan-out — same-tenant only
  • Clinical::Patients::LongitudinalRecordService for the cross-tenant Timeline read
  • Identity::CrossTenantConsent for 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-query v5
  • recharts for VitalTrends
  • dayjs + dayjs/plugin/relativeTime

Common tasks

Add a new LHR tab backed by clinical.*

  1. Add a transformer at backend/domains/clinical/services/patients/lhr_views/<resource>_transformer.rb. Inherit from Clinical::BaseService, take clinical_patient:, return the patient-app row shape via success(rows).
  2. Add the action to Clinical::Patients::LhrViewsController and the route to backend/domains/clinical/routes.rb under the existing scope :lhr block.
  3. Add a request spec in backend/domains/clinical/spec/requests/lhr_views_spec.rb.
  4. Frontend: add getX in services/lhrViewsApi.ts, a key factory entry in hooks/queryKeys.ts, a hook in hooks/useX.ts, and a tab in components/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_id on 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.allergies in 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: nil and 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::Document for lab records: filter assumes document_type IN (lab_report, laboratory_report, lab, pathology). New historic enum values may need to be added to LAB_DOCUMENT_TYPES in LabRecordsTransformer.

On this page