Kulcare Docs
DeveloperEncounters

Phase 2: Encounters & Observations Implementation Plan

Status: COMPLETE Completed: 2026-01-09 Stream: Clinical (Stream A) Dependencies: Phase 1 Patient Core (complete)

Implementation Summary: All Phase 2 deliverables have been implemented including 7 migrations, 5 models (Encounter, EncounterParticipant, Observation, Condition, AllergyIntolerance), 5 blueprints, 5 controllers, 5 policies, 6 services (StatusService, VitalsRecorderService, 4 FHIR export services), RLS policies, and comprehensive test coverage (100+ additional specs).

Overview

Implement Clinical Schema Phase 2 with 5 new entities: Encounters, EncounterParticipants, Observations, Conditions, and AllergyIntolerances. Follows established Phase 1 patterns with FHIR R4 compliance.

Implementation Sequence

Wave 1: Database Migrations

  1. create_clinical_phase2_enums.rb - All PostgreSQL enums
  2. create_clinical_encounters.rb - Core encounters table
  3. create_clinical_encounter_participants.rb - Junction table
  4. create_clinical_observations.rb - Vitals/labs table
  5. create_clinical_conditions.rb - Diagnoses table
  6. create_clinical_allergy_intolerances.rb - Allergies table
  7. setup_clinical_phase2_rls.rb - RLS policies for all 5 tables

Wave 2: Models (5 files)

  • encounter.rb - State machine, associations to Patient/Facility
  • encounter_participant.rb - Junction to Practitioner with role enum
  • observation.rb - Polymorphic value handling via JSONB
  • condition.rb - ICD-10/SNOMED coding, problem list
  • allergy_intolerance.rb - Reactions JSONB array

Wave 3: API Layer (15 files)

  • 5 Blueprints (views: normal, detailed, select, admin, fhir)
  • 5 Policies (tenant_member? + permission checks)
  • 5 Controllers (CRUD + custom actions)

Wave 4: Services (6 files)

  • vitals/recorder_service.rb - Batch vital recording
  • encounters/status_service.rb - State machine transitions
  • 4 FHIR export services (one per entity type)

Wave 5: Tests & Routes

  • Model specs (5 files)
  • Request specs (5 files)
  • Service specs (2 files)
  • Update routes.rb

Files to Create

Migrations (backend/db/migrate/)

YYYYMMDD000001_create_clinical_phase2_enums.rb
YYYYMMDD000002_create_clinical_encounters.rb
YYYYMMDD000003_create_clinical_encounter_participants.rb
YYYYMMDD000004_create_clinical_observations.rb
YYYYMMDD000005_create_clinical_conditions.rb
YYYYMMDD000006_create_clinical_allergy_intolerances.rb
YYYYMMDD000007_setup_clinical_phase2_rls.rb

Domain Structure (backend/domains/clinical/)

models/
  encounter.rb
  encounter_participant.rb
  observation.rb
  condition.rb
  allergy_intolerance.rb
blueprints/
  encounter_blueprint.rb
  encounter_participant_blueprint.rb
  observation_blueprint.rb
  condition_blueprint.rb
  allergy_intolerance_blueprint.rb
controllers/
  encounters_controller.rb
  encounter_participants_controller.rb
  observations_controller.rb
  conditions_controller.rb
  allergy_intolerances_controller.rb
policies/
  encounter_policy.rb
  encounter_participant_policy.rb
  observation_policy.rb
  condition_policy.rb
  allergy_intolerance_policy.rb
services/
  vitals/recorder_service.rb
  encounters/status_service.rb
  fhir/encounter_export_service.rb
  fhir/observation_export_service.rb
  fhir/condition_export_service.rb
  fhir/allergy_intolerance_export_service.rb
spec/
  models/encounter_spec.rb, encounter_participant_spec.rb, observation_spec.rb, condition_spec.rb, allergy_intolerance_spec.rb
  requests/encounters_spec.rb, encounter_participants_spec.rb, observations_spec.rb, conditions_spec.rb, allergy_intolerances_spec.rb
  services/vitals/recorder_service_spec.rb, encounters/status_service_spec.rb

Database Schema Details

Enums to Create

-- Encounter
clinical.encounter_status: planned, arrived, triaged, in_progress, onleave, finished, cancelled, entered_in_error
clinical.encounter_class: ambulatory, inpatient, emergency, virtual, home, observation, short_stay
clinical.participant_role: primary, consultant, assistant, observer, referrer, admitter, discharger

-- Observation
clinical.observation_status: registered, preliminary, final, amended, corrected, cancelled, entered_in_error, unknown
clinical.observation_category: vital_signs, laboratory, imaging, social_history, exam, procedure, survey, activity
clinical.observation_value_type: quantity, string, codeable_concept, boolean, integer, range, ratio, time, datetime, period

-- Condition
clinical.condition_clinical_status: active, recurrence, relapse, inactive, remission, resolved
clinical.condition_verification_status: unconfirmed, provisional, differential, confirmed, refuted, entered_in_error
clinical.condition_severity: mild, moderate, severe

-- AllergyIntolerance (FHIR R4 AllergyIntolerance value sets - different from Condition)
clinical.allergy_type: allergy, intolerance
clinical.allergy_category: food, medication, environment, biologic
clinical.allergy_criticality: low, high, unable_to_assess
clinical.allergy_clinical_status: active, inactive, resolved
clinical.allergy_verification_status: unconfirmed, presumed, confirmed, refuted, entered_in_error

Key Table Columns

Encounters:

  • id, tenant_id, patient_id, facility_id, episode_of_care_id (optional)
  • status, encounter_class, service_type, priority
  • start_time, end_time, planned_start, planned_end, length_minutes
  • reason_codes (JSONB), hospitalization (JSONB), notes

EncounterParticipants:

  • id, tenant_id, encounter_id, practitioner_id, practitioner_role_id (optional)
  • role, period_start, period_end

Observations:

  • id, tenant_id, patient_id, encounter_id (optional), performer_id
  • status (observation_status enum), category (observation_category enum)
  • code (JSONB), value_type (observation_value_type enum), value_data (JSONB)
  • effective_datetime, issued, interpretation (JSONB)
  • reference_range (JSONB), component (JSONB), body_site (JSONB)

Conditions:

  • id, tenant_id, patient_id, encounter_id (optional), recorder_id, asserter_id
  • clinical_status, verification_status, category, severity
  • code (JSONB), body_site (JSONB), stage (JSONB), evidence (JSONB)
  • onset_datetime, abatement_datetime, recorded_date, note

AllergyIntolerances:

  • id, tenant_id, patient_id, encounter_id (optional), recorder_id, asserter_id
  • clinical_status (allergy_clinical_status enum), verification_status (allergy_verification_status enum)
  • allergy_type (allergy_type enum), category (allergy_category enum), criticality (allergy_criticality enum)
  • code (JSONB), reactions (JSONB array), onset_datetime, recorded_date, note

Note: AllergyIntolerance status enums follow FHIR R4 AllergyIntolerance value sets (not Condition value sets).

Foreign Keys (Composite Pattern)

-- All tables reference tenants
(tenant_id) -> identity.tenants(id)

-- Patient-scoped tables use composite FK
(tenant_id, patient_id) -> clinical.patients(tenant_id, id)

-- Encounter-scoped tables
(tenant_id, encounter_id) -> clinical.encounters(tenant_id, id)

-- Facility reference
(tenant_id, facility_id) -> identity.facilities(tenant_id, id)

Indexes

-- Encounters
CREATE INDEX idx_encounters_patient ON clinical.encounters(tenant_id, patient_id);
CREATE INDEX idx_encounters_facility ON clinical.encounters(tenant_id, facility_id);
CREATE INDEX idx_encounters_status ON clinical.encounters(tenant_id, status);
CREATE INDEX idx_encounters_start_time ON clinical.encounters(tenant_id, start_time);

-- EncounterParticipants
CREATE INDEX idx_encounter_participants_encounter ON clinical.encounter_participants(tenant_id, encounter_id);
CREATE INDEX idx_encounter_participants_practitioner ON clinical.encounter_participants(tenant_id, practitioner_id);

-- Observations
CREATE INDEX idx_observations_patient ON clinical.observations(tenant_id, patient_id);
CREATE INDEX idx_observations_encounter ON clinical.observations(tenant_id, encounter_id);
CREATE INDEX idx_observations_category ON clinical.observations(tenant_id, category);
CREATE INDEX idx_observations_effective ON clinical.observations(tenant_id, effective_datetime);

-- Conditions
CREATE INDEX idx_conditions_patient ON clinical.conditions(tenant_id, patient_id);
CREATE INDEX idx_conditions_encounter ON clinical.conditions(tenant_id, encounter_id);
CREATE INDEX idx_conditions_clinical_status ON clinical.conditions(tenant_id, clinical_status);
CREATE INDEX idx_conditions_recorded_date ON clinical.conditions(tenant_id, recorded_date);

-- AllergyIntolerances
CREATE INDEX idx_allergy_intolerances_patient ON clinical.allergy_intolerances(tenant_id, patient_id);
CREATE INDEX idx_allergy_intolerances_clinical_status ON clinical.allergy_intolerances(tenant_id, clinical_status);

Uniqueness Constraints

-- EncounterParticipants: No uniqueness constraint on (encounter_id, role)
-- Multiple participants with same role are allowed (e.g., two consultants)
-- Only prevent exact duplicate entries:
CREATE UNIQUE INDEX idx_encounter_participants_unique
  ON clinical.encounter_participants(tenant_id, encounter_id, practitioner_id, role);

Key Model Patterns

Encounter State Machine

VALID_TRANSITIONS = {
  planned: [:arrived, :cancelled, :entered_in_error],
  arrived: [:triaged, :in_progress, :cancelled, :entered_in_error],
  triaged: [:in_progress, :cancelled, :entered_in_error],
  in_progress: [:onleave, :finished, :cancelled, :entered_in_error],
  onleave: [:in_progress, :finished, :entered_in_error],
  finished: [:entered_in_error],
  cancelled: [:entered_in_error],
  entered_in_error: []  # Terminal state - no transitions out
}

def can_transition_to?(new_status)
  VALID_TRANSITIONS[status.to_sym]&.include?(new_status.to_sym) || false
end

Note: entered_in_error is allowed from any state (for data correction) but is terminal.

Observation Polymorphic Value

# value_type: quantity, string, code, boolean, integer, range, ratio, time, datetime, period
# value_data: JSONB storing the value in appropriate format

def value=(val)
  case val
  when Hash
    if val[:value] && val[:unit]
      self.value_type = 'quantity'
      self.value_data = val.stringify_keys
    end
  when String
    self.value_type = 'string'
    self.value_data = { 'value' => val }
  # ... handle other types
  end
end

LOINC Codes for Vital Signs

VitalLOINC CodeUnit
Systolic BP8480-6mmHg
Diastolic BP8462-4mmHg
Heart Rate8867-4/min
Respiratory Rate9279-1/min
Temperature8310-5Cel
SpO22708-6%
Weight29463-7kg
Height8302-2cm

API Endpoints

Encounters

GET    /clinical/encounters
POST   /clinical/encounters
GET    /clinical/encounters/:id
PATCH  /clinical/encounters/:id
DELETE /clinical/encounters/:id
POST   /clinical/encounters/:id/transition
GET    /clinical/encounters/:id/participants
GET    /clinical/encounters/:id/observations
GET    /clinical/encounters/:id/conditions
GET    /clinical/encounters/:id/export (FHIR)

EncounterParticipants

GET    /clinical/encounter_participants
POST   /clinical/encounter_participants
GET    /clinical/encounter_participants/:id
PATCH  /clinical/encounter_participants/:id
DELETE /clinical/encounter_participants/:id

Observations

GET    /clinical/observations
POST   /clinical/observations
GET    /clinical/observations/:id
PATCH  /clinical/observations/:id
DELETE /clinical/observations/:id
GET    /clinical/observations/:id/export (FHIR)

Conditions

GET    /clinical/conditions
POST   /clinical/conditions
GET    /clinical/conditions/:id
PATCH  /clinical/conditions/:id
DELETE /clinical/conditions/:id
GET    /clinical/conditions/:id/export (FHIR)

AllergyIntolerances

GET    /clinical/allergy_intolerances
POST   /clinical/allergy_intolerances
GET    /clinical/allergy_intolerances/:id
PATCH  /clinical/allergy_intolerances/:id
DELETE /clinical/allergy_intolerances/:id
GET    /clinical/allergy_intolerances/:id/export (FHIR)

Nested Patient Resources

GET    /clinical/patients/:patient_id/encounters
GET    /clinical/patients/:patient_id/observations
GET    /clinical/patients/:patient_id/conditions
GET    /clinical/patients/:patient_id/allergies
GET    /clinical/patients/:patient_id/vitals (summary)

Verification Steps

  1. After migrations:

    mise exec -- bundle exec rails db:migrate
    mise exec -- bundle exec rails runner "puts Clinical::Encounter.table_name"
  2. After each model:

    mise exec -- bundle exec rspec domains/clinical/spec/models/encounter_spec.rb
  3. After controllers:

    mise exec -- bundle exec rspec domains/clinical/spec/requests/encounters_spec.rb
  4. Full Phase 2 validation:

    mise exec -- bundle exec rspec domains/clinical/spec/
    # Expect ~200+ specs (120 from Phase 1 + ~80+ from Phase 2)
  5. RLS verification:

    mise exec -- bundle exec rails runner "
      %w[encounters encounter_participants observations conditions allergy_intolerances].each do |t|
        puts ActiveRecord::Base.connection.execute(\"SELECT policyname FROM pg_policies WHERE tablename = '\#{t}'\").to_a
      end
    "

Key Complexity Areas

  1. Observation polymorphic values - Multiple value types via JSONB, component observations (BP)
  2. Encounter state machine - Valid transitions, side effects on state change
  3. Composite foreign keys - All tables use (tenant_id, entity_id) pattern
  4. FHIR compliance - Proper CodeableConcept structure, LOINC/SNOMED/ICD-10 systems
  5. Allergy reactions - JSONB array with nested manifestations

Dependencies

  • Phase 1 complete (Patient, PatientFacility, PatientInsuranceCoverage)
  • Identity domain (Tenant, Facility, Practitioner, PractitionerRole)
  • Shared concerns (TenantScoped, BaseController, BaseBlueprint, BasePolicy)
  • UUIDv7 function exists

Frontend Documentation

  • EncounterPage Architecture — Complete functional workflow of the frontend EncounterPage, covering tabs, forms, API data flow, user workflows, AI Scribe integration, and state management patterns.

Deliverables

  • 7 migration files
  • ~32 Ruby files (models, controllers, services, blueprints, policies, specs)
  • FHIR R4 compliant export for all entities
  • ~80+ new test specs

On this page