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
create_clinical_phase2_enums.rb- All PostgreSQL enumscreate_clinical_encounters.rb- Core encounters tablecreate_clinical_encounter_participants.rb- Junction tablecreate_clinical_observations.rb- Vitals/labs tablecreate_clinical_conditions.rb- Diagnoses tablecreate_clinical_allergy_intolerances.rb- Allergies tablesetup_clinical_phase2_rls.rb- RLS policies for all 5 tables
Wave 2: Models (5 files)
encounter.rb- State machine, associations to Patient/Facilityencounter_participant.rb- Junction to Practitioner with role enumobservation.rb- Polymorphic value handling via JSONBcondition.rb- ICD-10/SNOMED coding, problem listallergy_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 recordingencounters/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.rbDomain 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.rbDatabase 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_errorKey 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
endNote: 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
endLOINC Codes for Vital Signs
| Vital | LOINC Code | Unit |
|---|---|---|
| Systolic BP | 8480-6 | mmHg |
| Diastolic BP | 8462-4 | mmHg |
| Heart Rate | 8867-4 | /min |
| Respiratory Rate | 9279-1 | /min |
| Temperature | 8310-5 | Cel |
| SpO2 | 2708-6 | % |
| Weight | 29463-7 | kg |
| Height | 8302-2 | cm |
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/:idObservations
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
-
After migrations:
mise exec -- bundle exec rails db:migrate mise exec -- bundle exec rails runner "puts Clinical::Encounter.table_name" -
After each model:
mise exec -- bundle exec rspec domains/clinical/spec/models/encounter_spec.rb -
After controllers:
mise exec -- bundle exec rspec domains/clinical/spec/requests/encounters_spec.rb -
Full Phase 2 validation:
mise exec -- bundle exec rspec domains/clinical/spec/ # Expect ~200+ specs (120 from Phase 1 + ~80+ from Phase 2) -
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
- Observation polymorphic values - Multiple value types via JSONB, component observations (BP)
- Encounter state machine - Valid transitions, side effects on state change
- Composite foreign keys - All tables use (tenant_id, entity_id) pattern
- FHIR compliance - Proper CodeableConcept structure, LOINC/SNOMED/ICD-10 systems
- 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