Multi-Practitioner Encounter Documentation Architecture
Status: Planning Created: 2025-12-25 Last Updated: 2025-12-25 Related: Clinical Data Hierarchy
Objective
Extend the existing encounter system to support practitioner-specific documentation beyond the current doctor-centric Prescription model.
Target Practitioners:
- Doctor: Prescriptions, medical certificates (existing - Prescription model)
- Therapist/Psychologist: Session notes, mood assessments, safety plans (NEW)
- Physiotherapist: Session notes, ROM measurements, exercise prescriptions (NEW)
- Dietitian: Consultation notes, meal plans, nutritional assessments (NEW)
Existing Infrastructure (ALREADY BUILT)
1. Encounter System (100% Complete)
Location: backend/parts/clinic/models/encounter.rb, frontend/apps/cms/src/features/encounter/
Already supports:
- Multiple practitioner types:
doctor | physiotherapist | psychologist | therapist | nurse | dietitian | other - Polymorphic
practitionerassociation - Visit types:
in_person | teleconsultation | follow_up | home_visit - Full CRUD API and frontend hooks
- Navigates to PrescriptionPage for documentation
Gap: All encounters use the same Prescription model, which is doctor-centric.
2. Longitudinal Health Record (100% Complete)
Location: frontend/apps/cms/src/pages/care-plan/LongitudinalHealthRecord.tsx
Already built:
PatientSummaryDashboard- Key metrics and recent activityVitalsTrendChart- BP, weight, heart rate trends over timeProblemListManager- Persistent problem tracking with severityMedicationTimeline- Medication history visualizationEncounterHistory- All patient encounters- Route:
/careplan/patient/:patientId
Relationship to EpisodeOfCare: See Clinical Data Hierarchy for the complete relationship between:
- Longitudinal Health Record (LHR) - Patient-centric aggregated view across ALL encounters
- Episode of Care - Program-specific container for a course of treatment (e.g., 12-session physio program)
- Encounter - Single visit/session (atomic clinical event)
These three concepts form a clinical data hierarchy and are complementary, not conflicting. LHR will be updated to display active Episodes and group encounters by Episode.
3. Prescription Model (Existing)
- Medications with dosage, frequency
- Investigations (lab tests)
- Clinical observations
- Advice and follow-up
Gap: Works great for doctors but inappropriate for therapists, physiotherapists, dietitians.
What This Plan Adds
| Component | Status | Purpose |
|---|---|---|
| EncounterDocument | NEW | Polymorphic documentation (1:N per encounter) |
| EncounterParticipant | NEW | Multi-provider encounters |
| EpisodeOfCare | NEW | Longitudinal treatment programs |
| ClinicalMeasurement | NEW | Unified vitals/ROM/anthropometrics |
| AssessmentInstrument | NEW | Data-driven assessments (PHQ-9, GAD-7) |
| Safety alerts | NEW | Risk monitoring for therapy |
Key Architecture Decisions
Based on architectural review, the design addresses these critical concerns:
- Flexible 1:N documents per encounter - Not rigid 1:1 mapping
- Multi-practitioner support - EncounterParticipant for co-managed encounters
- Episode vs Encounter separation - Treatment programs vs per-visit sessions
- Document lifecycle - draft/signed/locked/amended workflow
- Granular visibility - Not just "is_private" boolean
- Data-driven assessments - Instruments defined in data, not code
- Generalized measurements - ClinicalMeasurement instead of just "vitals"
- Backend-driven registry - Single source of truth via API
- Clinical safety hooks - Risk alerts for therapy
Core Data Model
Patient
|
+-- has_many :episodes_of_care (longitudinal)
| |
| +-- has_many :encounters
| +-- has_many :treatment_goals
| +-- has_many :progress_notes
|
+-- has_many :encounters (can exist without episode)
Encounter (hub)
|
+-- has_many :encounter_participants (providers)
+-- has_many :encounter_documents (1..N documents)
| |
| +-- doc_type: prescription | therapy_session | physio_session | nutrition_consultation
| +-- status: draft | signed | locked | amended
| +-- data: JSONB (structured content)
| +-- has_many :child records (ROM, exercises, meal plans)
|
+-- has_many :clinical_measurements (generalized vitals/ROM/anthropometrics)
+-- has_many :assessment_responses (PHQ-9, GAD-7, etc.)
+-- has_many :encounter_attachments (files/media)
+-- has_many :encounter_notes (shared notes with visibility)Phase 1: Core Infrastructure Tables
1.1 EpisodeOfCare (Longitudinal Care)
File: backend/parts/clinic/models/episode_of_care.rb
# Represents a course of treatment spanning multiple encounters
create_table :episodes_of_care do |t|
t.references :patient, null: false, foreign_key: true
t.references :clinic, null: false, foreign_key: true
t.references :primary_provider, foreign_key: { to_table: :users }
t.string :episode_type # therapy | physio | nutrition | chronic_care | etc
t.string :status # active | on_hold | completed | discharged
t.string :reason # chief complaint / referral reason
t.date :start_date
t.date :end_date
t.date :target_end_date
t.integer :total_sessions_planned
t.integer :sessions_completed, default: 0
t.string :frequency # weekly | biweekly | monthly | as_needed
t.text :long_term_goals
t.text :discharge_summary
t.jsonb :metadata, default: {}
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
end
add_index :episodes_of_care, [:patient_id, :status]
add_index :episodes_of_care, [:clinic_id, :episode_type]1.2 EncounterParticipant (Multi-Provider Support)
File: backend/parts/clinic/models/encounter_participant.rb
# Enables multi-practitioner encounters and proper attribution
create_table :encounter_participants do |t|
t.references :encounter, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true # provider
t.string :role, null: false # primary | assistant | supervisor | scribe | consultant
t.string :practitioner_type # doctor | psychologist | physiotherapist | dietitian
t.boolean :can_view_private_notes, default: false
t.boolean :can_sign, default: false
t.boolean :can_edit, default: true
t.datetime :joined_at
t.datetime :left_at
t.timestamps
end
add_index :encounter_participants, [:encounter_id, :user_id], unique: true
add_index :encounter_participants, [:encounter_id, :role]1.3 EncounterDocument (Polymorphic Documentation)
File: backend/parts/clinic/models/encounter_document.rb
# Generic document container - 1:N documents per encounter
create_table :encounter_documents do |t|
t.references :encounter, null: false, foreign_key: true
t.references :episode_of_care, foreign_key: true # optional link to episode
t.references :author, null: false, foreign_key: { to_table: :users }
t.string :doc_type, null: false
# Types: prescription | therapy_session | therapy_assessment | therapy_safety_plan |
# physio_session | physio_evaluation | nutrition_consultation |
# nutrition_followup | general_note | medical_certificate
t.string :status, null: false, default: 'draft'
# Status: draft | pending_signature | signed | locked | amended | void
t.string :title
t.jsonb :data, null: false, default: {} # structured content
# Signature workflow
t.references :signed_by, foreign_key: { to_table: :users }
t.datetime :signed_at
t.references :co_signer, foreign_key: { to_table: :users }
t.datetime :co_signed_at
# Amendment tracking
t.integer :version, default: 1
t.references :amends_document, foreign_key: { to_table: :encounter_documents }
t.text :amendment_reason
# Visibility
t.string :visibility, default: 'care_team'
# Visibility: private_to_author | care_team | clinic | patient_visible
t.boolean :is_psychotherapy_note, default: false # stricter access per 42 CFR Part 2
t.jsonb :allowed_user_ids, default: []
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
end
add_index :encounter_documents, [:encounter_id, :doc_type]
add_index :encounter_documents, [:author_id, :status]
add_index :encounter_documents, :episode_of_care_id
add_index :encounter_documents, :status
add_index :encounter_documents, :data, using: :gin # for JSONB queries1.4 ClinicalMeasurement (Generalized Vitals/ROM/Anthropometrics)
File: backend/parts/clinic/models/clinical_measurement.rb
# Unified measurement system for all clinical data points
create_table :clinical_measurements do |t|
t.references :encounter, null: false, foreign_key: true
t.references :encounter_document, foreign_key: true # optional link
t.references :recorded_by, null: false, foreign_key: { to_table: :users }
t.string :measurement_type, null: false
# Types: bp_systolic | bp_diastolic | heart_rate | temperature | spo2 | weight | height |
# bmi | waist_circumference | body_fat_pct | pain_scale |
# rom_[joint]_[movement] | grip_strength | etc
t.decimal :value, precision: 10, scale: 2
t.string :value_text # for non-numeric (e.g., "regular" for pulse rhythm)
t.string :unit # mmHg | bpm | F | C | kg | lb | cm | in | degrees | etc
t.string :method # measured | self_reported | calculated | device_recorded
t.string :body_site # left | right | bilateral | n/a
t.string :position # sitting | standing | supine | prone
t.jsonb :metadata, default: {} # device info, reference range, etc
t.text :notes
t.datetime :measured_at
t.timestamps
end
add_index :clinical_measurements, [:encounter_id, :measurement_type]
add_index :clinical_measurements, :measurement_type1.5 AssessmentInstrument & AssessmentResponse (Data-Driven Assessments)
File: backend/parts/clinic/models/assessment_instrument.rb
# Definition of assessment tools (PHQ-9, GAD-7, ROM scales, etc.)
create_table :assessment_instruments do |t|
t.string :code, null: false # phq9 | gad7 | bdi2 | oswestry | vas_pain | etc
t.string :name, null: false
t.string :version
t.string :category # mental_health | pain | functional | nutrition | etc
t.jsonb :questions, null: false, default: [] # array of question definitions
t.jsonb :scoring_rules, null: false, default: {} # how to calculate scores
t.jsonb :severity_bands, default: [] # score ranges and interpretations
t.integer :min_score
t.integer :max_score
t.boolean :higher_is_worse, default: true
t.text :description
t.text :instructions
t.string :license_info
t.boolean :active, default: true
t.timestamps
end
add_index :assessment_instruments, :code, unique: true
add_index :assessment_instruments, :categoryFile: backend/parts/clinic/models/assessment_response.rb
# Completed assessment with answers and computed score
create_table :assessment_responses do |t|
t.references :encounter, null: false, foreign_key: true
t.references :encounter_document, foreign_key: true
t.references :assessment_instrument, null: false, foreign_key: true
t.references :administered_by, null: false, foreign_key: { to_table: :users }
t.jsonb :answers, null: false, default: {} # question_id => answer mapping
t.decimal :total_score, precision: 6, scale: 2
t.string :severity_level # computed from scoring_rules
t.text :clinical_interpretation
t.datetime :administered_at
t.integer :time_to_complete_seconds
t.boolean :requires_follow_up, default: false
t.boolean :safety_alert_triggered, default: false # for suicidal ideation questions
t.text :follow_up_notes
t.timestamps
end
add_index :assessment_responses, [:encounter_id, :assessment_instrument_id]
add_index :assessment_responses, :safety_alert_triggered1.6 EncounterNote (Shared Notes with Visibility)
File: backend/parts/clinic/models/encounter_note.rb
create_table :encounter_notes do |t|
t.references :encounter, null: false, foreign_key: true
t.references :encounter_document, foreign_key: true # optional link
t.references :author, null: false, foreign_key: { to_table: :users }
t.string :note_type, null: false
# Types: clinical_observation | progress | assessment | plan | general | addendum
t.text :content, null: false
t.jsonb :structured_data, default: {}
t.string :visibility, null: false, default: 'care_team'
# Visibility: private_to_author | care_team | clinic | patient_visible
t.boolean :is_psychotherapy_note, default: false
t.jsonb :allowed_user_ids, default: []
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
end
add_index :encounter_notes, [:encounter_id, :note_type]
add_index :encounter_notes, :visibility1.7 EncounterAttachment (Files/Media)
File: backend/parts/clinic/models/encounter_attachment.rb
create_table :encounter_attachments do |t|
t.references :encounter, null: false, foreign_key: true
t.references :encounter_document, foreign_key: true
t.references :uploaded_by, null: false, foreign_key: { to_table: :users }
t.string :file_type # image | video | pdf | document | audio
t.string :category # photo | exercise_video | worksheet | consent_form | etc
t.string :title
t.text :description
# CarrierWave/ActiveStorage fields
t.string :file
t.string :content_type
t.integer :file_size
t.boolean :patient_visible, default: false
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
end
add_index :encounter_attachments, :encounter_id
add_index :encounter_attachments, :encounter_document_idPhase 2: Specialized Child Tables (for relational data)
2.1 Therapy-Specific
# TreatmentGoal - shared across therapy/physio/nutrition episodes
create_table :treatment_goals do |t|
t.references :episode_of_care, null: false, foreign_key: true
t.references :created_by, null: false, foreign_key: { to_table: :users }
t.string :goal_type # short_term | long_term | maintenance
t.text :description, null: false
t.string :status # active | achieved | modified | discontinued
t.date :target_date
t.date :achieved_date
t.integer :progress_percent, default: 0
t.text :progress_notes
t.jsonb :milestones, default: []
t.timestamps
end
# SafetyPlan - for therapy when risk is elevated
create_table :safety_plans do |t|
t.references :encounter_document, null: false, foreign_key: true
t.references :patient, null: false, foreign_key: true
t.jsonb :warning_signs, default: []
t.jsonb :coping_strategies, default: []
t.jsonb :support_contacts, default: [] # name, phone, relationship
t.jsonb :professional_contacts, default: []
t.text :environment_safety_steps
t.text :reasons_for_living
t.datetime :reviewed_at
t.references :reviewed_by, foreign_key: { to_table: :users }
t.timestamps
end2.2 Physio-Specific
# ROMMeasurement - linked to encounter_document
create_table :rom_measurements do |t|
t.references :encounter_document, null: false, foreign_key: true
t.string :joint, null: false # shoulder | elbow | wrist | hip | knee | ankle | spine
t.string :movement, null: false # flexion | extension | abduction | adduction | rotation
t.string :side # left | right | bilateral
t.decimal :active_rom, precision: 5, scale: 2
t.decimal :passive_rom, precision: 5, scale: 2
t.string :unit, default: 'degrees'
t.integer :pain_during, default: 0 # 0-10
t.text :notes
t.timestamps
end
add_index :rom_measurements, [:encounter_document_id, :joint, :movement]
# ExercisePrescription - linked to encounter_document
create_table :exercise_prescriptions do |t|
t.references :encounter_document, null: false, foreign_key: true
t.references :exercise_library_item, foreign_key: true # optional link to library
t.string :exercise_name, null: false
t.string :category # strengthening | stretching | balance | cardio | functional
t.text :description
t.text :instructions
t.integer :sets
t.integer :reps
t.integer :hold_seconds
t.string :frequency # daily | 2x_daily | 3x_week | etc
t.string :intensity # light | moderate | vigorous
t.text :precautions
t.text :progression_criteria
t.integer :order_index
t.string :video_url
t.string :image_url
t.timestamps
end2.3 Nutrition-Specific
# MealPlan - linked to encounter_document
create_table :meal_plans do |t|
t.references :encounter_document, null: false, foreign_key: true
t.string :plan_type # daily | weekly | template
t.date :effective_date
t.jsonb :meals, null: false, default: []
# Structure: [{ meal_type: 'breakfast', time: '08:00', foods: [...], portions: [...], calories: N }]
t.integer :target_calories
t.integer :target_protein_g
t.integer :target_carbs_g
t.integer :target_fat_g
t.integer :target_fiber_g
t.jsonb :restrictions, default: [] # gluten_free | dairy_free | low_sodium | etc
t.text :special_instructions
t.timestamps
end
# NutritionalAssessment - detailed intake analysis
create_table :nutritional_assessments do |t|
t.references :encounter_document, null: false, foreign_key: true
t.decimal :height_cm, precision: 5, scale: 2
t.decimal :weight_kg, precision: 5, scale: 2
t.decimal :bmi, precision: 4, scale: 2
t.decimal :waist_cm, precision: 5, scale: 2
t.decimal :body_fat_pct, precision: 4, scale: 2
t.string :weight_goal # lose | gain | maintain
t.decimal :target_weight_kg, precision: 5, scale: 2
t.text :diet_history
t.jsonb :food_allergies, default: []
t.jsonb :dietary_restrictions, default: []
t.jsonb :supplements, default: []
t.jsonb :medications_affecting_nutrition, default: []
t.integer :meals_per_day
t.integer :snacks_per_day
t.text :eating_schedule
t.text :hydration_notes
t.timestamps
endPhase 3: Encounter Type Registry (Backend-Driven)
3.1 EncounterTypeConfig Model
File: backend/parts/clinic/models/encounter_type_config.rb
# Database-driven configuration per encounter type
create_table :encounter_type_configs do |t|
t.string :practitioner_type, null: false # doctor | psychologist | physiotherapist | dietitian
t.references :clinic, foreign_key: true # null = system default, clinic_id = override
t.jsonb :allowed_doc_types, null: false, default: []
t.jsonb :tabs, null: false, default: []
t.string :default_tab
t.boolean :has_prescriptions, default: false
t.jsonb :required_measurements, default: []
t.jsonb :enabled_assessments, default: [] # codes from AssessmentInstrument
t.jsonb :print_templates, default: []
t.jsonb :ai_scribe_config, default: {}
t.boolean :active, default: true
t.timestamps
end
add_index :encounter_type_configs, [:practitioner_type, :clinic_id], unique: true3.2 API Endpoint for Registry
Controller: encounter_type_configs_controller.rb
# GET /api/v1/encounter_type_configs
# Returns configuration for current clinic/practitioner type
# Frontend fetches and caches this on app load
def index
configs = EncounterTypeConfig.for_clinic(current_clinic)
.includes(:assessment_instruments)
render json: EncounterTypeConfigSerializer.new(configs)
end3.3 Default Configurations
# Seeded system defaults
DEFAULTS = {
doctor: {
allowed_doc_types: ['prescription', 'medical_certificate', 'general_note'],
tabs: ['vitals', 'observations', 'medications', 'investigations', 'advice', 'next_visit'],
default_tab: 'vitals',
has_prescriptions: true,
required_measurements: ['bp_systolic', 'bp_diastolic', 'heart_rate', 'temperature'],
enabled_assessments: []
},
psychologist: {
allowed_doc_types: ['therapy_session', 'therapy_assessment', 'therapy_safety_plan', 'general_note'],
tabs: ['measurements', 'assessments', 'session_notes', 'treatment_plan', 'homework', 'next_visit'],
default_tab: 'session_notes',
has_prescriptions: false,
required_measurements: [],
enabled_assessments: ['phq9', 'gad7', 'bdi2', 'pcl5']
},
physiotherapist: {
allowed_doc_types: ['physio_session', 'physio_evaluation', 'general_note'],
tabs: ['measurements', 'assessment', 'rom', 'treatment', 'exercises', 'progress', 'next_visit'],
default_tab: 'assessment',
has_prescriptions: false,
required_measurements: ['pain_scale'],
enabled_assessments: ['oswestry', 'dash', 'lefs']
},
dietitian: {
allowed_doc_types: ['nutrition_consultation', 'nutrition_followup', 'general_note'],
tabs: ['measurements', 'nutritional_assessment', 'meal_plan', 'goals', 'progress', 'next_visit'],
default_tab: 'nutritional_assessment',
has_prescriptions: false,
required_measurements: ['weight', 'height', 'bmi', 'waist_circumference'],
enabled_assessments: []
}
}Phase 4: Encounter Model Updates
4.1 Updated Encounter Associations
File: backend/parts/clinic/models/encounter.rb
class Encounter < ApplicationRecord
# Existing associations...
# New associations
belongs_to :episode_of_care, optional: true
has_many :encounter_participants, dependent: :destroy
has_many :providers, through: :encounter_participants, source: :user
has_many :encounter_documents, dependent: :destroy
has_many :clinical_measurements, dependent: :destroy
has_many :assessment_responses, dependent: :destroy
has_many :encounter_notes, dependent: :destroy
has_many :encounter_attachments, dependent: :destroy
# Primary provider convenience
def primary_provider
encounter_participants.find_by(role: 'primary')&.user
end
def primary_practitioner_type
encounter_participants.find_by(role: 'primary')&.practitioner_type
end
# Get applicable configuration
def type_config
EncounterTypeConfig.for_practitioner_type(primary_practitioner_type, clinic)
end
# Get all documents of specific type
def documents_of_type(doc_type)
encounter_documents.where(doc_type: doc_type)
end
# Get primary document (most common use case)
def primary_document
encounter_documents.where.not(status: ['void', 'amended']).order(:created_at).first
end
end4.2 Document Audit Log
File: backend/parts/clinic/models/document_audit_log.rb
# Immutable audit trail for medico-legal defensibility
create_table :document_audit_logs do |t|
t.references :encounter_document, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.string :action, null: false # created | viewed | edited | signed | co_signed | amended | voided
t.jsonb :changes, default: {} # diff of what changed
t.jsonb :previous_data, default: {} # snapshot before change
t.string :ip_address
t.string :user_agent
t.datetime :performed_at, null: false
t.timestamps
end
add_index :document_audit_logs, [:encounter_document_id, :performed_at]
add_index :document_audit_logs, :actionPhase 5: Clinical Safety System
5.1 Safety Alert Configuration
# SafetyAlertRule - configurable thresholds
create_table :safety_alert_rules do |t|
t.references :clinic, foreign_key: true # null = system default
t.string :rule_type, null: false
# Types: assessment_threshold | measurement_threshold | keyword_detection
t.string :trigger_source # assessment_code or measurement_type
t.string :operator # >= | > | <= | < | == | contains
t.decimal :threshold_value, precision: 6, scale: 2
t.string :threshold_text # for keyword matching
t.string :severity, null: false # info | warning | critical
t.text :alert_message
t.jsonb :required_actions, default: [] # safety_plan | supervisor_review | etc
t.jsonb :notify_roles, default: []
t.boolean :active, default: true
t.timestamps
end
# SafetyAlert - triggered alerts
create_table :safety_alerts do |t|
t.references :encounter, null: false, foreign_key: true
t.references :patient, null: false, foreign_key: true
t.references :safety_alert_rule, null: false, foreign_key: true
t.references :triggered_by_response, foreign_key: { to_table: :assessment_responses }
t.string :severity, null: false
t.text :alert_message
t.jsonb :trigger_data, default: {}
t.string :status, default: 'open' # open | acknowledged | resolved
t.references :acknowledged_by, foreign_key: { to_table: :users }
t.datetime :acknowledged_at
t.text :resolution_notes
t.references :resolved_by, foreign_key: { to_table: :users }
t.datetime :resolved_at
t.timestamps
end
add_index :safety_alerts, [:patient_id, :status]
add_index :safety_alerts, [:encounter_id, :severity]5.2 Safety Alert Service
class SafetyAlertService
def self.check_assessment_response(response)
rules = SafetyAlertRule.for_assessment(response.assessment_instrument.code)
rules.each do |rule|
if rule.triggered_by?(response)
create_alert(response.encounter, response, rule)
end
end
end
def self.create_alert(encounter, trigger, rule)
alert = SafetyAlert.create!(
encounter: encounter,
patient: encounter.patient,
safety_alert_rule: rule,
triggered_by_response: trigger,
severity: rule.severity,
alert_message: rule.alert_message,
trigger_data: { score: trigger.total_score, threshold: rule.threshold_value }
)
# Notify relevant parties
SafetyAlertNotificationJob.perform_later(alert.id)
# Require safety plan if configured
if rule.required_actions.include?('safety_plan')
# Flag that safety plan is required
end
end
endPhase 6: Frontend Architecture
6.1 Backend-Driven Tab Registry
// Frontend fetches configuration from backend on app load
// GET /api/v1/encounter_type_configs
interface EncounterTypeConfig {
practitioner_type: string;
allowed_doc_types: string[];
tabs: TabConfig[];
default_tab: string;
has_prescriptions: boolean;
required_measurements: string[];
enabled_assessments: AssessmentInstrument[];
}
interface TabConfig {
id: string;
label: string;
icon?: string;
component: string; // Component name to render
required?: boolean;
}
// Tab components are mapped client-side
const TAB_COMPONENTS: Record<string, React.LazyExoticComponent<any>> = {
'vitals': lazy(() => import('./tabs/VitalsTab')),
'measurements': lazy(() => import('./tabs/MeasurementsTab')),
'observations': lazy(() => import('./tabs/ObservationsTab')),
'session_notes': lazy(() => import('./tabs/SessionNotesTab')),
'assessments': lazy(() => import('./tabs/AssessmentsTab')),
'rom': lazy(() => import('./tabs/ROMTab')),
'exercises': lazy(() => import('./tabs/ExercisesTab')),
'meal_plan': lazy(() => import('./tabs/MealPlanTab')),
'nutritional_assessment': lazy(() => import('./tabs/NutritionalAssessmentTab')),
'treatment_plan': lazy(() => import('./tabs/TreatmentPlanTab')),
'homework': lazy(() => import('./tabs/HomeworkTab')),
'progress': lazy(() => import('./tabs/ProgressTab')),
'next_visit': lazy(() => import('./tabs/NextVisitTab')),
// ... etc
};6.2 Dynamic Encounter Page
// apps/cms/src/pages/encounter/EncounterPage.tsx
export function EncounterPage() {
const { encounterId } = useParams();
const { data: encounter } = useEncounter(encounterId);
const { data: typeConfig } = useEncounterTypeConfig(encounter?.primary_practitioner_type);
const [activeTab, setActiveTab] = useState<string>();
useEffect(() => {
if (typeConfig && !activeTab) {
setActiveTab(typeConfig.default_tab);
}
}, [typeConfig]);
if (!typeConfig) return <Loading />;
const TabComponent = TAB_COMPONENTS[activeTab];
return (
<div className="encounter-page">
<EncounterHeader encounter={encounter} />
<TabNavigation
tabs={typeConfig.tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<Suspense fallback={<TabLoading />}>
<TabComponent
encounter={encounter}
config={typeConfig}
/>
</Suspense>
</div>
);
}6.3 Component Directory Structure
frontend/apps/cms/src/components/
├── encounter/
│ ├── tabs/
│ │ ├── VitalsTab.tsx # Traditional vitals (BP, HR, temp)
│ │ ├── MeasurementsTab.tsx # Generic measurements (vitals + ROM + anthropometrics)
│ │ ├── ObservationsTab.tsx # Clinical observations
│ │ ├── SessionNotesTab.tsx # Therapy/session documentation
│ │ ├── AssessmentsTab.tsx # PHQ-9, GAD-7, etc. (data-driven)
│ │ ├── ROMTab.tsx # Range of motion for physio
│ │ ├── ExercisesTab.tsx # Exercise prescription
│ │ ├── MealPlanTab.tsx # Nutrition meal planning
│ │ ├── NutritionalAssessmentTab.tsx
│ │ ├── TreatmentPlanTab.tsx # Goals and treatment planning
│ │ ├── HomeworkTab.tsx # Therapy homework assignments
│ │ ├── ProgressTab.tsx # Episode progress tracking
│ │ ├── NextVisitTab.tsx # Follow-up scheduling
│ │ └── MedicationsTab.tsx # Prescriptions (doctor only)
│ │
│ ├── documents/
│ │ ├── DocumentList.tsx # List of encounter documents
│ │ ├── DocumentEditor.tsx # Generic document editor
│ │ ├── DocumentSignature.tsx # Signature workflow
│ │ └── DocumentViewer.tsx # Read-only view with audit trail
│ │
│ ├── assessments/
│ │ ├── AssessmentRenderer.tsx # Data-driven assessment form
│ │ ├── AssessmentResults.tsx # Score display with severity
│ │ └── AssessmentHistory.tsx # Prior assessment comparison
│ │
│ ├── safety/
│ │ ├── SafetyAlertBanner.tsx # Alert display
│ │ ├── SafetyPlanEditor.tsx # Safety plan form
│ │ └── RiskAssessment.tsx # Risk level indicator
│ │
│ └── shared/
│ ├── MeasurementInput.tsx # Generic measurement entry
│ ├── VisibilitySelector.tsx # Note visibility control
│ └── ParticipantList.tsx # Multi-provider displayImplementation Sprints
Sprint 1: Core Infrastructure
- Create migrations: episode_of_care, encounter_participant, encounter_document
- Create migrations: clinical_measurement, assessment_instrument, assessment_response
- Create migrations: encounter_note, encounter_attachment, document_audit_log
- Create base models with associations and validations
- Seed assessment instruments (PHQ-9, GAD-7, etc.)
Sprint 2: Encounter Type System
- Create encounter_type_config model and migration
- Create seeder for default configurations
- Add GET /encounter_type_configs API endpoint
- Update Encounter model with new associations
- Create serializers for new models
Sprint 3: Document Workflow
- Implement document status state machine
- Add signature workflow
- Implement amendment tracking
- Add document audit logging
- Create document visibility scopes
Sprint 4: Safety System
- Create safety_alert_rule and safety_alert models
- Implement SafetyAlertService
- Add safety plan model and workflow
- Create notification system for alerts
- Add safety dashboard for supervisors
Sprint 5: Frontend - Infrastructure
- Create useEncounterTypeConfig hook
- Refactor EncounterPage to use dynamic tabs
- Create TabNavigation component
- Create MeasurementsTab (unified vitals/ROM/anthropometrics)
- Create AssessmentsTab with data-driven rendering
Sprint 6: Frontend - Practitioner Tabs
- SessionNotesTab for therapy
- ROMTab for physio
- ExercisesTab for physio
- MealPlanTab for nutrition
- NutritionalAssessmentTab for nutrition
Sprint 7: Frontend - Document System
- DocumentList component
- DocumentEditor with visibility controls
- DocumentSignature workflow
- ParticipantList for multi-provider
- SafetyAlertBanner and SafetyPlanEditor
Sprint 8: Integration & Polish
- Print templates per document type
- AI Scribe context adaptation per practitioner type
- Episode of care management UI
- Progress tracking across episodes
- Testing and documentation
Database Constraints & Indexes
# Key constraints to add
add_foreign_key :encounter_documents, :encounter_documents, column: :amends_document_id
add_foreign_key :episodes_of_care, :users, column: :primary_provider_id
# Partial unique indexes
add_index :encounter_participants, [:encounter_id, :role],
where: "role = 'primary'",
unique: true,
name: 'idx_one_primary_per_encounter'
# GIN indexes for JSONB querying
add_index :encounter_documents, :data, using: :gin
add_index :clinical_measurements, :metadata, using: :gin
add_index :assessment_responses, :answers, using: :gin
# Composite indexes for common queries
add_index :encounter_documents, [:encounter_id, :status, :doc_type]
add_index :clinical_measurements, [:encounter_id, :measurement_type, :measured_at]Migration from Existing Prescription Model
The existing Prescription model will continue to work. The migration path:
- Prescription remains as-is for doctor encounters
- New encounter types use EncounterDocument
- Optionally, migrate Prescription data to EncounterDocument over time
- Frontend detects practitioner type and renders appropriate UI
# Encounter model supports both patterns during transition
class Encounter
has_one :prescription # Legacy
has_many :encounter_documents # New
def documentation
# Returns prescription for doctors, encounter_documents for others
if primary_practitioner_type == 'doctor'
prescription
else
encounter_documents
end
end
endFeature Flags
# Backend feature flags
ENABLE_MULTI_PRACTITIONER_ENCOUNTERS: false
ENABLE_EPISODE_OF_CARE: false
ENABLE_SAFETY_ALERTS: false
ENABLE_DOCUMENT_SIGNATURES: false// Frontend feature flags
ENABLE_THERAPY_ENCOUNTERS: false
ENABLE_PHYSIO_ENCOUNTERS: false
ENABLE_NUTRITION_ENCOUNTERS: false
ENABLE_DYNAMIC_TABS: falseEnable incrementally as each component is ready.
Related Documentation
- Clinical Data Hierarchy - LHR, Episode, Encounter relationships and navigation design
- Architecture Overview
- Care Plan Module
- AI Scribe Integration