Longitudinal Health Record Architecture
Executive Summary
This document outlines the comprehensive architecture for transforming the current encounter-focused Careplan system into a full-featured Longitudinal Health Record (LHR) system. The design maintains backward compatibility while introducing patient-level health tracking, problem management, medication continuity, and FHIR-compliant ABDM integration.
Architecture Overview
Key Design Decisions
- Terminology: Rename
CareplantoEncounterfor industry standard alignment - Data Preservation: Maintain all existing data with backward compatibility
- Medication Strategy: Enhance existing careplan_medicines with additional fields for continuity tracking
- Health Scoring: Comprehensive Wellness Score (0-100 scale)
- Clinical Coding: Use clinical_codes lookup table (deferred implementation)
- Problem Management: Use existing careplan_complaints instead of new patient_problems table
- Summary Field: Use existing 'notes' field instead of adding encounter_summary
- Access Control: Configurable at clinic level with patient consent for referrals
- AI Integration: Mock implementation initially
- ABDM Compliance: FHIR-compliant data structures
System Components
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React) │
├─────────────────────────────────────────────────────────────┤
│ API Layer (Rails) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Backward Compatible APIs │ New LHR APIs │ │
│ └──────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Service Layer │
│ ┌────────────┬─────────────┬──────────────┬────────────┐ │
│ │ Health │ Medication │ Problem │ AI Scribe │ │
│ │ Score │ Continuity │ Management │ (Mock) │ │
│ └────────────┴─────────────┴──────────────┴────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Model Layer │
│ ┌────────────┬─────────────┬──────────────┬────────────┐ │
│ │ Encounter │ Patient │ Medication │ Clinical │ │
│ │ (Careplan) │ Problems │ Prescriptions│ Codes │ │
│ └────────────┴─────────────┴──────────────┴────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Database (PostgreSQL) │
└─────────────────────────────────────────────────────────────┘Database Design
1. Core Migration: Rename Careplan to Encounter
# db/migrate/[timestamp]_rename_careplans_to_encounters.rb
class RenameCareplansToEncounters < ActiveRecord::Migration[7.0]
def change
# Rename main table
rename_table :careplans, :encounters
# Rename foreign keys in related tables
rename_column :careplan_complaints, :careplan_id, :encounter_id
rename_table :careplan_complaints, :encounter_complaints
rename_column :careplan_medicines, :careplan_id, :encounter_id
rename_table :careplan_medicines, :encounter_medicines
rename_column :careplan_procedures, :careplan_id, :encounter_id
rename_table :careplan_procedures, :encounter_procedures
rename_column :careplan_tests, :careplan_id, :encounter_id
rename_table :careplan_tests, :encounter_tests
rename_column :careplan_test_lists, :careplan_id, :encounter_id
rename_table :careplan_test_lists, :encounter_test_lists
rename_column :careplan_diets, :careplan_id, :encounter_id
rename_table :careplan_diets, :encounter_diets
rename_column :careplan_advices, :careplan_id, :encounter_id
rename_table :careplan_advices, :encounter_advices
# Add new columns to encounters
add_column :encounters, :encounter_type, :string # routine/emergency/followup/telemedicine
add_column :encounters, :health_score_snapshot, :float
add_column :encounters, :reconciliation_completed, :boolean, default: false
add_column :encounters, :fhir_resource_id, :string
add_index :encounters, :fhir_resource_id
# Note: Use existing 'notes' field instead of encounter_summary
# Update polymorphic references
execute <<-SQL
UPDATE documents
SET documentable_type = 'Encounter'
WHERE documentable_type = 'Careplan';
UPDATE activities
SET trackable_type = 'Encounter'
WHERE trackable_type = 'Careplan';
SQL
end
end2. Enhanced Encounter Complaints (Using Existing Structure)
# db/migrate/[timestamp]_enhance_encounter_complaints.rb
class EnhanceEncounterComplaints < ActiveRecord::Migration[7.0]
def change
# Enhance existing encounter_complaints table for problem management
add_column :encounter_complaints, :status, :string, default: 'active' # active/resolved/chronic/in_remission
add_column :encounter_complaints, :severity, :string # low/medium/high/critical
add_column :encounter_complaints, :onset_date, :date
add_column :encounter_complaints, :resolved_date, :date
add_column :encounter_complaints, :clinical_notes, :jsonb
add_column :encounter_complaints, :affects_health_score, :boolean, default: true
# Add indexes for performance
add_index :encounter_complaints, :status
add_index :encounter_complaints, :severity
# Note: Use existing encounter_complaints instead of creating patient_problems
# This maintains current structure while adding longitudinal tracking capabilities
end
end3. Enhanced Encounter Medicines (Extending Existing Structure)
# db/migrate/[timestamp]_enhance_encounter_medicines.rb
class EnhanceEncounterMedicines < ActiveRecord::Migration[7.0]
def change
# Enhance existing encounter_medicines table for prescription continuity
add_column :encounter_medicines, :generic_name, :string
add_column :encounter_medicines, :route, :string
add_column :encounter_medicines, :prescribed_date, :date
add_column :encounter_medicines, :start_date, :date
add_column :encounter_medicines, :end_date, :date
add_column :encounter_medicines, :status, :string, default: 'active' # active/completed/discontinued/on_hold
add_column :encounter_medicines, :prescription_type, :string # acute/chronic/prn/prophylactic
add_column :encounter_medicines, :discontinuation_reason, :string
# Reconciliation tracking
add_column :encounter_medicines, :verified_in_reconciliation, :boolean, default: false
add_column :encounter_medicines, :last_reconciliation_date, :date
# Clinical decision support
add_column :encounter_medicines, :drug_interactions, :jsonb
add_column :encounter_medicines, :allergy_alerts, :jsonb
# Pharmacy integration
add_column :encounter_medicines, :pharmacy_order_id, :string
add_column :encounter_medicines, :pharmacy_status, :string
# Parent-child relationship for refills/modifications
add_column :encounter_medicines, :parent_medicine_id, :integer
add_index :encounter_medicines, :parent_medicine_id
# Add indexes for performance
add_index :encounter_medicines, :status
add_index :encounter_medicines, :prescription_type
# Note: Keeping existing encounter_medicines structure and extending it
# This maintains backward compatibility while adding new functionality
end
end4. Medication Reconciliation Tracking
# db/migrate/[timestamp]_create_medication_reconciliations.rb
class CreateMedicationReconciliations < ActiveRecord::Migration[7.0]
def change
create_table :medication_reconciliations do |t|
t.references :encounter, null: false, foreign_key: true
t.references :medication_prescription, foreign_key: true
t.string :action, null: false # continued/modified/discontinued/new
t.text :reason
t.jsonb :changes # tracks what was modified
t.references :performed_by_user, foreign_key: { to_table: :users }
t.timestamps
end
add_index :medication_reconciliations, [:encounter_id, :medication_prescription_id],
name: 'idx_med_recon_encounter_prescription'
end
end5. Health Score System
# db/migrate/[timestamp]_create_patient_health_scores.rb
class CreatePatientHealthScores < ActiveRecord::Migration[7.0]
def change
create_table :patient_health_scores do |t|
t.references :patient, null: false, foreign_key: true
t.references :clinic_patient, foreign_key: true
# Overall and component scores (0-100 scale)
t.float :overall_score, null: false
t.float :vital_score
t.float :problem_score
t.float :medication_score
t.float :preventive_score
# Detailed breakdown
t.jsonb :score_components
t.jsonb :risk_factors
# Tracking
t.date :calculated_date, null: false
t.references :encounter, foreign_key: true
t.timestamps
end
add_index :patient_health_scores, [:patient_id, :calculated_date]
end
end6. Longitudinal Health Summary
# db/migrate/[timestamp]_create_patient_health_summaries.rb
class CreatePatientHealthSummaries < ActiveRecord::Migration[7.0]
def change
create_table :patient_health_summaries do |t|
t.references :patient, null: false, foreign_key: true
t.references :clinic_patient, foreign_key: true
# Current state
t.float :current_health_score
t.jsonb :vital_trends
t.jsonb :active_problems_summary
t.jsonb :current_medications_summary
# Statistics
t.integer :total_encounters_count
t.date :last_encounter_date
t.date :next_appointment_date
# Key metrics
t.jsonb :key_metrics # BP trends, weight trends, etc
# ABDM Integration
t.jsonb :fhir_bundle
t.datetime :last_abdm_sync
t.timestamps
end
add_index :patient_health_summaries, :patient_id, unique: true
end
end7. Clinical Coding Support (DEFERRED)
# NOTE: Clinical codes implementation is deferred to a later phase
# The clinical_codes lookup table will be implemented when needed
# This will replace direct storage of ICD-10, ICD-11, SNOMED, RxNorm codes
# Future implementation will include:
# - clinical_codes table with code_system, code, display_name
# - Lookup functionality for encounter_complaints and encounter_medicines
# - Standardized medical coding across the system
# - Integration with international coding standards
# For now, medical codes will be stored as simple text fields in:
# - encounter_complaints (existing name field)
# - encounter_medicines (existing medicine_name field)8. AI Scribe Mock System (OPTIONAL)
# db/migrate/[timestamp]_create_ai_transcriptions.rb
# NOTE: AI Scribe is optional feature - can be implemented later
class CreateAiTranscriptions < ActiveRecord::Migration[7.0]
def change
create_table :ai_transcriptions do |t|
# Reference to encounters table (renamed from careplans)
t.references :encounter, null: false, foreign_key: { to_table: :encounters }
# Transcription data
t.text :raw_transcript
t.jsonb :extracted_entities
t.jsonb :suggestions
# Metadata
t.string :confidence_level
t.string :processing_status
t.float :processing_duration
# Acceptance tracking
t.boolean :accepted_by_doctor, default: false
t.jsonb :acceptance_details
t.timestamps
end
end
end9. Access Control and Consent (OPTIONAL)
# db/migrate/[timestamp]_add_longitudinal_access_settings.rb
# NOTE: Access control features can be implemented in later phases
class AddLongitudinalAccessSettings < ActiveRecord::Migration[7.0]
def change
# Clinic-level settings (if clinic_settings table exists)
add_column :clinic_settings, :longitudinal_record_access, :string, default: 'all_doctors'
# Options: all_doctors, treating_doctor_only, department_based
add_column :clinic_settings, :require_patient_consent_for_referral, :boolean, default: true
# Patient consent records - for managing cross-doctor access
create_table :patient_consent_records do |t|
t.references :patient, null: false, foreign_key: true
t.references :referred_doctor, foreign_key: { to_table: :doctors }
t.references :referring_doctor, foreign_key: { to_table: :doctors }
t.references :clinic, foreign_key: true
# Consent details
t.string :consent_type # view_only/full_access/emergency_only
t.string :consent_scope # all_records/specific_encounters/date_range
t.jsonb :scope_details
# Validity period
t.date :valid_from
t.date :valid_until
t.boolean :revoked, default: false
t.datetime :revoked_at
t.timestamps
end
# Indexes for efficient querying
add_index :patient_consent_records, [:patient_id, :referred_doctor_id]
add_index :patient_consent_records, :valid_until
end
endImplementation Challenges & Solutions
Challenge 1: Final Careplan Edit Restriction
Problem: Currently, when is_final = true, careplans cannot be edited, preventing longitudinal tracking of problems and medications.
Solution: Implement post-finalization editing for longitudinal data while preserving core careplan immutability.
# app/models/encounter.rb (renamed from careplan.rb)
class Encounter < ApplicationRecord
# ... existing associations and methods
# Allow editing of longitudinal fields even after finalization
def can_edit_longitudinal_fields?
true # Always allow editing longitudinal tracking fields
end
def can_edit_core_fields?
!is_final? # Existing behavior for core careplan fields
end
# Separate validation for core vs longitudinal fields
def validate_core_changes
if is_final? && core_fields_changed?
errors.add(:base, "Cannot modify finalized encounter core data")
end
end
private
def core_fields_changed?
changed_attributes.keys.any? do |attr|
%w[notes diagnosis advice diet observations].include?(attr)
end
end
endChallenge 2: End Date Calculation for Medicines
Problem: encounter_medicines has duration_unit and duration_frequency but no end_date field for tracking.
Solution: Add end_date calculation and automatic population.
# Enhanced encounter_medicines model
class EncounterMedicine < ApplicationRecord
# ... existing associations
# Callbacks
before_save :calculate_end_date
before_save :set_prescribed_date
private
def calculate_end_date
return unless start_date.present? && duration_unit.present? && duration_frequency.present?
self.end_date = case duration_frequency.upcase
when 'DAYS'
start_date + duration_unit.to_i.days
when 'WEEKS'
start_date + duration_unit.to_i.weeks
when 'MONTHS'
start_date + duration_unit.to_i.months
when 'YEARS'
start_date + duration_unit.to_i.years
else
start_date + duration_unit.to_i.days # Default to days
end
end
def set_prescribed_date
self.prescribed_date ||= Date.current
self.start_date ||= prescribed_date
end
endEnhanced CareplansController Strategy
Extend Existing Controller
Add new actions to the existing CareplansController for longitudinal features:
# parts/doctor/controllers/careplans_controller.rb (Enhanced)
class CareplansController < ApiController
before_action :policy_check
before_action :set_careplan, only: [:show, :update_longitudinal, :update_complaints, :update_medicines]
# Existing methods remain unchanged
def create
result = Careplans::Create.call(params)
render_result(result, result.success? ? CareplanSerializer : nil)
end
def index
result = Careplans::Index.call(params)
render_result(result, result.success? ? CareplanSerializer : nil)
end
# NEW: Update longitudinal fields even after finalization
def update_longitudinal
result = Careplans::UpdateLongitudinal.call(params.merge(careplan: @careplan))
render_result(result, CareplanSerializer)
end
# NEW: Update complaints with longitudinal tracking
def update_complaints
result = Careplans::UpdateComplaints.call(params.merge(careplan: @careplan))
render_result(result, CareplanComplaintSerializer)
end
# NEW: Update medicines with status tracking
def update_medicines
result = Careplans::UpdateMedicines.call(params.merge(careplan: @careplan))
render_result(result, CareplanMedicineSerializer)
end
private
def set_careplan
@careplan = Careplan.find(params[:id])
end
def policy_check
authorize params, policy_class: CareplansControllerPolicy
end
endEnhanced Interactor Design
Modified Existing Create Interactor
# parts/doctor/interactors/careplans/create.rb (Enhanced)
module Careplans
class Create < BaseOrganizer
# Keep existing organize structure, add new interactors
organize Careplans::SaveCareplan,
Careplans::SetEncounterFields, # NEW: Set encounter_type, health_score_snapshot
Careplans::SaveComplaints,
Careplans::EnhanceComplaints, # NEW: Add longitudinal fields to complaints
Careplans::SaveMedicines,
Careplans::EnhanceMedicines, # NEW: Add longitudinal fields to medicines
Careplans::SaveTests,
Careplans::SaveProcedures,
Careplans::SaveDiets,
Careplans::SaveAdvices,
Careplans::GenerateCareplan,
Careplans::GeneratePrescription,
Careplans::GenerateMedicines,
Careplans::GeneratePatientCases,
Careplans::SendToLab,
Careplans::SendToPharmacy,
Careplans::SendToAbdm,
Careplans::SavePatientHistory
# Note: Health score calculation happens after finalization
end
endNew Interactors for Longitudinal Updates
# parts/doctor/interactors/careplans/update_longitudinal.rb
module Careplans
class UpdateLongitudinal < BaseInteractor
delegate :careplan, to: :context
def call
# Allow updates to longitudinal fields even if careplan is final
update_longitudinal_fields
calculate_health_score if should_recalculate_health_score?
end
private
def update_longitudinal_fields
allowed_params = context.to_h.slice(
:encounter_type, :health_score_snapshot, :fhir_resource_id,
:reconciliation_completed
)
careplan.update!(allowed_params) if allowed_params.any?
context.careplan = careplan
end
def should_recalculate_health_score?
context.recalculate_health_score == true
end
def calculate_health_score
# Calculate and update health score
if careplan.patient_id.present?
score = LongitudinalHealthService.new(careplan.patient_id).calculate_health_score
careplan.update!(health_score_snapshot: score.overall_score)
end
end
end
end
# parts/doctor/interactors/careplans/update_complaints.rb
module Careplans
class UpdateComplaints < BaseInteractor
delegate :careplan, to: :context
def call
complaints_params = context.complaints || []
complaints_params.each do |complaint_data|
if complaint_data[:id].present?
update_existing_complaint(complaint_data)
else
create_new_complaint(complaint_data)
end
end
trigger_health_score_recalculation if any_health_affecting_changes?
end
private
def update_existing_complaint(complaint_data)
complaint = careplan.careplan_complaints.find(complaint_data[:id])
# Allow longitudinal field updates even if careplan is final
longitudinal_fields = complaint_data.slice(
:status, :severity, :onset_date, :resolved_date,
:clinical_notes, :affects_health_score
)
complaint.update!(longitudinal_fields) if longitudinal_fields.any?
end
def create_new_complaint(complaint_data)
# Set defaults for longitudinal tracking
enhanced_data = complaint_data.merge(
status: complaint_data[:status] || 'active',
onset_date: complaint_data[:onset_date] || Date.current,
affects_health_score: complaint_data[:affects_health_score] != false
)
careplan.careplan_complaints.create!(enhanced_data)
end
def any_health_affecting_changes?
context.complaints&.any? { |c| c[:affects_health_score] != false }
end
def trigger_health_score_recalculation
CalculateHealthScoreJob.perform_later(careplan.patient_id, careplan.id)
end
end
end
# parts/doctor/interactors/careplans/update_medicines.rb
module Careplans
class UpdateMedicines < BaseInteractor
delegate :careplan, to: :context
def call
medicines_params = context.medicines || []
medicines_params.each do |medicine_data|
if medicine_data[:id].present?
update_existing_medicine(medicine_data)
else
create_new_medicine(medicine_data)
end
end
trigger_health_score_recalculation
end
private
def update_existing_medicine(medicine_data)
medicine = careplan.careplan_medicines.find(medicine_data[:id])
# Allow longitudinal field updates even if careplan is final
longitudinal_fields = medicine_data.slice(
:status, :prescription_type, :discontinuation_reason,
:verified_in_reconciliation, :last_reconciliation_date,
:drug_interactions, :allergy_alerts, :pharmacy_order_id,
:pharmacy_status, :parent_medicine_id
)
medicine.update!(longitudinal_fields) if longitudinal_fields.any?
end
def create_new_medicine(medicine_data)
# Set defaults for longitudinal tracking
enhanced_data = medicine_data.merge(
status: medicine_data[:status] || 'active',
prescription_type: medicine_data[:prescription_type] || 'acute',
prescribed_date: medicine_data[:prescribed_date] || Date.current,
start_date: medicine_data[:start_date] || Date.current,
verified_in_reconciliation: false
)
careplan.careplan_medicines.create!(enhanced_data)
end
def trigger_health_score_recalculation
CalculateHealthScoreJob.perform_later(careplan.patient_id, careplan.id)
end
end
end
# parts/doctor/interactors/careplans/set_encounter_fields.rb
module Careplans
class SetEncounterFields < BaseInteractor
delegate :careplan, to: :context
def call
return unless careplan.present?
encounter_type = determine_encounter_type
careplan.update!(
encounter_type: encounter_type,
reconciliation_completed: false
)
# health_score_snapshot will be calculated after finalization
end
private
def determine_encounter_type
# Determine type based on context params
if context.encounter_type.present?
context.encounter_type
elsif context.emergency == true
'emergency'
elsif context.is_followup == true
'followup'
elsif context.telemedicine == true
'telemedicine'
else
'routine'
end
end
end
end
# parts/doctor/interactors/careplans/enhance_complaints.rb
module Careplans
class EnhanceComplaints < BaseInteractor
delegate :careplan, to: :context
def call
return unless careplan&.careplan_complaints&.any?
careplan.careplan_complaints.each do |complaint|
# Set default longitudinal tracking values for new complaints
next if complaint.status.present? # Skip if already enhanced
complaint.update!(
status: 'active',
onset_date: careplan.created_at.to_date,
affects_health_score: true
)
end
end
end
end
# parts/doctor/interactors/careplans/enhance_medicines.rb
module Careplans
class EnhanceMedicines < BaseInteractor
delegate :careplan, to: :context
def call
return unless careplan&.careplan_medicines&.any?
careplan.careplan_medicines.each do |medicine|
# Set default longitudinal tracking values for new medicines
next if medicine.status.present? # Skip if already enhanced
medicine.update!(
status: 'active',
prescription_type: 'acute',
prescribed_date: careplan.created_at.to_date,
start_date: careplan.created_at.to_date,
verified_in_reconciliation: false
)
# end_date will be calculated automatically via before_save callback
end
end
end
endRoutes Configuration
# Add to parts/doctor/routes.rb
Rails.application.routes.draw do
scope :doctor do
resources :careplans do
member do
patch :update_longitudinal # PATCH /careplans/:id/update_longitudinal
patch :update_complaints # PATCH /careplans/:id/update_complaints
patch :update_medicines # PATCH /careplans/:id/update_medicines
end
end
end
endModel Layer
Core Models
# app/models/encounter.rb (renamed from careplan.rb)
class Encounter < ApplicationRecord
include PublicActivity::Common
include AbdmCareContextFunctions
include AbdmFhirFunctions
include PatientHistoryActivity
# Associations
belongs_to :doctor
belongs_to :patient, optional: true
belongs_to :clinic_patient, optional: true
belongs_to :clinic, optional: true
belongs_to :event, polymorphic: true, optional: true
# Renamed associations
has_many :encounter_complaints, dependent: :destroy
has_many :encounter_medicines, dependent: :destroy
has_many :encounter_procedures, dependent: :destroy
has_many :encounter_test_lists, dependent: :destroy
has_many :encounter_tests, through: :encounter_test_lists
has_many :encounter_diets, dependent: :destroy
has_many :encounter_advices, dependent: :destroy
# New associations
has_many :medication_prescriptions, dependent: :destroy
has_many :medication_reconciliations, dependent: :destroy
has_many :encounter_problems, dependent: :destroy
has_many :patient_problems, through: :encounter_problems
has_one :ai_transcription, dependent: :destroy
has_one :patient_health_score, dependent: :nullify
# Existing associations (unchanged)
has_many :pharmacy_orders, as: :event, dependent: :destroy
has_many :lab_orders, as: :event, dependent: :destroy
has_many :documents
has_many :shared_prescriptions
# Scopes
scope :recent, -> { order(created_at: :desc) }
scope :final, -> { where(is_final: true) }
scope :with_problems, -> { joins(:patient_problems).distinct }
scope :with_reconciliation, -> { where(reconciliation_completed: true) }
# Callbacks
after_create :update_patient_health_summary
after_update :recalculate_health_score, if: :saved_change_to_is_final?
# Backward compatibility
alias_attribute :careplan_complaints, :encounter_complaints
alias_attribute :careplan_medicines, :encounter_medicines
# Validations
validates :encounter_type, inclusion: {
in: %w[routine emergency followup telemedicine],
allow_nil: true
}
private
def update_patient_health_summary
return unless patient_id.present?
UpdatePatientHealthSummaryJob.perform_later(patient_id)
end
def recalculate_health_score
return unless is_final? && patient_id.present?
CalculateHealthScoreJob.perform_later(patient_id, id)
end
end
# app/models/patient_problem.rb
class PatientProblem < ApplicationRecord
# Associations
belongs_to :patient
belongs_to :clinic_patient, optional: true
belongs_to :created_by_encounter, class_name: 'Encounter', optional: true
belongs_to :resolved_by_encounter, class_name: 'Encounter', optional: true
belongs_to :created_by_doctor, class_name: 'Doctor', optional: true
has_many :encounter_problems, dependent: :destroy
has_many :encounters, through: :encounter_problems
# Validations
validates :name, presence: true
validates :status, inclusion: {
in: %w[active resolved chronic in_remission]
}
validates :severity, inclusion: {
in: %w[low medium high critical],
allow_nil: true
}
validate :must_have_medical_code
# Scopes
scope :active, -> { where(status: 'active') }
scope :chronic, -> { where(status: 'chronic') }
scope :resolved, -> { where(status: 'resolved') }
scope :by_severity, -> { order(Arel.sql("CASE severity
WHEN 'critical' THEN 0
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
ELSE 4 END")) }
# Callbacks
after_save :update_patient_health_summary
# Methods
def duration_days
return nil unless onset_date.present?
end_date = resolved_date || Date.current
(end_date - onset_date).to_i
end
def medical_codes
codes = []
codes << { system: 'ICD-10', code: icd10_code } if icd10_code.present?
codes << { system: 'ICD-11', code: icd11_code } if icd11_code.present?
codes << { system: 'SNOMED-CT', code: snomed_code } if snomed_code.present?
codes
end
private
def must_have_medical_code
if icd10_code.blank? && icd11_code.blank? && snomed_code.blank?
errors.add(:base, "Must have at least one medical code (ICD-10, ICD-11, or SNOMED-CT)")
end
end
def update_patient_health_summary
UpdatePatientHealthSummaryJob.perform_later(patient_id)
end
end
# app/models/medication_prescription.rb
class MedicationPrescription < ApplicationRecord
# Associations
belongs_to :patient
belongs_to :clinic_patient, optional: true
belongs_to :encounter
belongs_to :doctor
belongs_to :parent_prescription,
class_name: 'MedicationPrescription',
optional: true
belongs_to :discontinued_by_encounter,
class_name: 'Encounter',
optional: true
has_many :refills,
class_name: 'MedicationPrescription',
foreign_key: :parent_prescription_id,
dependent: :nullify
has_many :medication_reconciliations, dependent: :destroy
# Validations
validates :medication_name, presence: true
validates :status, inclusion: {
in: %w[active completed discontinued on_hold]
}
validates :prescription_type, inclusion: {
in: %w[acute chronic prn prophylactic],
allow_nil: true
}
# Scopes
scope :active, -> { where(status: 'active') }
scope :chronic, -> { where(prescription_type: 'chronic') }
scope :requiring_reconciliation, -> {
active.where('last_reconciliation_date < ? OR last_reconciliation_date IS NULL',
30.days.ago)
}
# Callbacks
before_save :calculate_end_date
after_save :check_drug_interactions
# Methods
def days_remaining
return nil unless end_date.present? && end_date.future?
(end_date - Date.current).to_i
end
def duration_completed?
return false unless end_date.present?
Date.current >= end_date
end
def requires_refill?
chronic? && days_remaining.present? && days_remaining <= 7
end
private
def calculate_end_date
if start_date.present? && duration_value.present? && duration_unit.present?
self.end_date = start_date + duration_value.send(duration_unit)
end
end
def check_drug_interactions
CheckDrugInteractionsJob.perform_later(id) if saved_change_to_medication_name?
end
endService Layer
Health Score Calculation Service
# app/services/longitudinal_health_service.rb
class LongitudinalHealthService
def initialize(patient_id)
@patient = Patient.find(patient_id)
@clinic_patient = @patient.clinic_patients.last
end
def build_complete_record
{
demographics: patient_demographics,
health_score: current_health_score,
active_problems: active_problems_list,
current_medications: current_medications_list,
vital_trends: calculate_vital_trends,
encounter_timeline: encounter_history,
upcoming_preventive_care: preventive_care_reminders,
last_updated: Time.current
}
end
def calculate_health_score
# Comprehensive Wellness Score (0-100)
vital_score = calculate_vital_score
problem_score = calculate_problem_score
medication_score = calculate_medication_adherence_score
preventive_score = calculate_preventive_care_score
# Weighted average
overall = (
vital_score * 0.25 + # 25% weight
problem_score * 0.30 + # 30% weight
medication_score * 0.25 + # 25% weight
preventive_score * 0.20 # 20% weight
).round(2)
# Save the score
PatientHealthScore.create!(
patient: @patient,
clinic_patient: @clinic_patient,
overall_score: overall,
vital_score: vital_score,
problem_score: problem_score,
medication_score: medication_score,
preventive_score: preventive_score,
score_components: {
vital_details: vital_score_details,
problem_details: problem_score_details,
medication_details: medication_score_details,
preventive_details: preventive_score_details
},
calculated_date: Date.current
)
end
private
def calculate_vital_score
recent_vitals = @patient.patient_vitals.recent.limit(10)
return 100.0 if recent_vitals.empty?
normal_count = recent_vitals.count { |v| v.within_normal_range? }
(normal_count.to_f / recent_vitals.count * 100).round(2)
end
def calculate_problem_score
active_problems = @patient.patient_problems.active
base_score = 100.0
active_problems.each do |problem|
case problem.severity
when 'critical' then base_score -= 20
when 'high' then base_score -= 15
when 'medium' then base_score -= 10
when 'low' then base_score -= 5
end
end
# Factor in chronic conditions
chronic_count = @patient.patient_problems.chronic.count
base_score -= (chronic_count * 5)
[base_score, 0].max # Ensure non-negative
end
def calculate_medication_adherence_score
active_prescriptions = @patient.medication_prescriptions.active
return 100.0 if active_prescriptions.empty?
# For MVP, assume 80% adherence (will be calculated from actual data later)
base_adherence = 80.0
# Deduct for medications requiring reconciliation
requiring_recon = active_prescriptions.requiring_reconciliation.count
base_adherence -= (requiring_recon * 5)
[base_adherence, 0].max
end
def calculate_preventive_care_score
# For MVP, return a baseline score
# Future: Calculate based on immunizations, screenings, etc.
75.0
end
def vital_score_details
{
total_checked: @patient.patient_vitals.recent.limit(10).count,
normal_count: @patient.patient_vitals.recent.limit(10).count { |v| v.within_normal_range? },
last_checked: @patient.patient_vitals.recent.first&.created_at
}
end
def problem_score_details
{
active_count: @patient.patient_problems.active.count,
chronic_count: @patient.patient_problems.chronic.count,
by_severity: @patient.patient_problems.active.group(:severity).count
}
end
def medication_score_details
{
active_count: @patient.medication_prescriptions.active.count,
requiring_reconciliation: @patient.medication_prescriptions.requiring_reconciliation.count,
chronic_medications: @patient.medication_prescriptions.chronic.count
}
end
def preventive_score_details
{
status: 'baseline',
next_screening_due: nil
}
end
def patient_demographics
{
id: @patient.id,
name: @patient.title,
age: @patient.patient_age,
gender: @patient.gender,
blood_group: @patient.blood_group
}
end
def active_problems_list
@patient.patient_problems.active.includes(:created_by_encounter).map do |problem|
{
id: problem.id,
name: problem.name,
severity: problem.severity,
onset_date: problem.onset_date,
duration_days: problem.duration_days,
medical_codes: problem.medical_codes
}
end
end
def current_medications_list
@patient.medication_prescriptions.active.includes(:doctor, :encounter).map do |prescription|
{
id: prescription.id,
medication_name: prescription.medication_name,
dosage: prescription.dosage,
frequency: prescription.frequency,
start_date: prescription.start_date,
days_remaining: prescription.days_remaining,
requires_refill: prescription.requires_refill?,
prescribed_by: prescription.doctor.name
}
end
end
def calculate_vital_trends(days_back = 90)
end_date = Date.current
start_date = end_date - days_back.days
vitals = @patient.patient_vitals
.where(created_at: start_date..end_date)
.order(:created_at)
{
blood_pressure: extract_bp_trend(vitals),
weight: extract_weight_trend(vitals),
heart_rate: extract_heart_rate_trend(vitals)
}
end
def encounter_history
@patient.encounters.includes(:doctor).recent.limit(10).map do |encounter|
{
id: encounter.id,
date: encounter.created_at,
type: encounter.encounter_type,
doctor: encounter.doctor.name,
chief_complaint: encounter.encounter_complaints.first&.name,
summary: encounter.encounter_summary
}
end
end
def preventive_care_reminders
# Placeholder for preventive care logic
[]
end
endMedication Reconciliation Service
# app/services/medication_reconciliation_service.rb
class MedicationReconciliationService
def initialize(encounter)
@encounter = encounter
@patient = encounter.patient
@doctor = encounter.doctor
end
def perform_reconciliation
current_medications = @patient.medication_prescriptions.active.includes(:doctor)
reconciliation_list = current_medications.map do |medication|
{
prescription: serialize_prescription(medication),
suggested_action: suggest_action(medication),
drug_interactions: check_interactions(medication),
requires_review: requires_clinical_review?(medication),
last_reconciliation: medication.last_reconciliation_date
}
end
{
encounter_id: @encounter.id,
patient_id: @patient.id,
medications: reconciliation_list,
performed_at: Time.current
}
end
def record_reconciliation_decision(prescription_id, action, reason = nil, changes = nil)
prescription = MedicationPrescription.find(prescription_id)
ActiveRecord::Base.transaction do
# Create reconciliation record
reconciliation = MedicationReconciliation.create!(
encounter: @encounter,
medication_prescription: prescription,
action: action,
reason: reason,
changes: changes,
performed_by_user: Current.user
)
# Update prescription based on action
case action
when 'continued'
prescription.update!(
verified_in_reconciliation: true,
last_reconciliation_date: Date.current
)
when 'modified'
apply_modifications(prescription, changes)
when 'discontinued'
prescription.update!(
status: 'discontinued',
discontinuation_reason: reason,
discontinued_by_encounter: @encounter,
end_date: Date.current
)
when 'new'
# New prescription created separately
end
reconciliation
end
end
def complete_reconciliation
@encounter.update!(reconciliation_completed: true)
update_patient_medication_summary
end
private
def serialize_prescription(prescription)
{
id: prescription.id,
medication_name: prescription.medication_name,
generic_name: prescription.generic_name,
dosage: prescription.dosage,
frequency: prescription.frequency,
route: prescription.route,
start_date: prescription.start_date,
end_date: prescription.end_date,
prescription_type: prescription.prescription_type,
prescribed_by: prescription.doctor.name,
days_active: (Date.current - prescription.start_date).to_i
}
end
def suggest_action(medication)
if medication.end_date&.past?
'discontinue'
elsif medication.prescription_type == 'acute' && medication.duration_completed?
'complete'
elsif medication.requires_refill?
'refill'
else
'continue'
end
end
def check_interactions(medication)
# Mock implementation - would integrate with drug interaction API
other_medications = @patient.medication_prescriptions
.active
.where.not(id: medication.id)
interactions = []
# Simplified interaction check
other_medications.each do |other|
if potential_interaction?(medication, other)
interactions << {
medication: other.medication_name,
severity: 'moderate',
description: 'Potential interaction detected'
}
end
end
interactions
end
def requires_clinical_review?(medication)
# High-risk medications or those with interactions
medication.prescription_type == 'chronic' ||
medication.drug_interactions.present? ||
medication.last_reconciliation_date.nil? ||
medication.last_reconciliation_date < 60.days.ago
end
def potential_interaction?(med1, med2)
# Mock logic - would use real drug interaction database
false
end
def apply_modifications(prescription, changes)
updateable_attributes = changes.slice(
:dosage, :frequency, :route, :duration_value, :duration_unit
)
prescription.update!(
updateable_attributes.merge(
verified_in_reconciliation: true,
last_reconciliation_date: Date.current
)
)
end
def update_patient_medication_summary
UpdatePatientHealthSummaryJob.perform_later(@patient.id)
end
endMock AI Scribe Service
# app/services/mock_ai_scribe_service.rb
class MockAiScribeService
# Mock data for demonstration
MOCK_SYMPTOMS = [
'headache', 'fever', 'cough', 'fatigue', 'nausea',
'chest pain', 'shortness of breath', 'dizziness'
]
MOCK_MEDICATIONS = [
{ name: 'Paracetamol', dosage: '500mg', frequency: 'TID' },
{ name: 'Amoxicillin', dosage: '250mg', frequency: 'BID' },
{ name: 'Omeprazole', dosage: '20mg', frequency: 'OD' },
{ name: 'Metformin', dosage: '500mg', frequency: 'BID' }
]
MOCK_DIAGNOSES = [
{ name: 'Upper Respiratory Tract Infection', icd10: 'J06.9' },
{ name: 'Essential Hypertension', icd10: 'I10' },
{ name: 'Type 2 Diabetes Mellitus', icd10: 'E11.9' },
{ name: 'Acute Pharyngitis', icd10: 'J02.9' }
]
def process_audio(encounter_id, audio_data = nil)
encounter = Encounter.find(encounter_id)
# Simulate processing delay
sleep(1) if Rails.env.development?
# Generate mock transcript
transcript = generate_mock_transcript(encounter)
# Extract entities
entities = extract_mock_entities(transcript)
# Generate suggestions
suggestions = generate_mock_suggestions(entities, encounter)
# Save transcription
ai_transcription = AiTranscription.create!(
encounter: encounter,
raw_transcript: transcript,
extracted_entities: entities,
suggestions: suggestions,
confidence_level: 'high',
processing_status: 'completed',
processing_duration: rand(2.0..5.0)
)
ai_transcription
end
def get_suggestions(encounter_id)
transcription = AiTranscription.find_by(encounter_id: encounter_id)
return nil unless transcription
transcription.suggestions
end
def accept_suggestion(encounter_id, suggestion_type, suggestion_id)
transcription = AiTranscription.find_by(encounter_id: encounter_id)
return false unless transcription
# Update acceptance tracking
acceptance_details = transcription.acceptance_details || {}
acceptance_details[suggestion_type] ||= {}
acceptance_details[suggestion_type][suggestion_id] = {
accepted: true,
accepted_at: Time.current,
accepted_by: Current.user&.id
}
transcription.update!(
acceptance_details: acceptance_details,
accepted_by_doctor: true
)
true
end
private
def generate_mock_transcript(encounter)
patient = encounter.patient
complaints = encounter.encounter_complaints.pluck(:name).join(', ')
transcript = "Patient presents with #{complaints.presence || 'general health concerns'}. "
# Add mock vital signs narration
if rand > 0.5
transcript += "Blood pressure is 120 over 80. Heart rate is 72 beats per minute. "
transcript += "Temperature is 98.6 degrees Fahrenheit. "
end
# Add mock symptom description
symptoms = MOCK_SYMPTOMS.sample(rand(2..4))
transcript += "Patient reports #{symptoms.join(', ')}. "
# Add mock examination findings
transcript += "On examination, patient appears comfortable. "
transcript += "Chest is clear to auscultation bilaterally. " if rand > 0.6
transcript += "Abdomen is soft and non-tender. " if rand > 0.5
# Add mock assessment
transcript += "Assessment suggests #{MOCK_DIAGNOSES.sample[:name].downcase}. "
# Add mock plan
transcript += "Recommend symptomatic treatment and follow-up if symptoms persist."
transcript
end
def extract_mock_entities(transcript)
{
symptoms: extract_symptoms_from_transcript(transcript),
vitals: extract_vitals_from_transcript(transcript),
medications: extract_medications_from_transcript(transcript),
physical_exam: extract_exam_findings(transcript)
}
end
def extract_symptoms_from_transcript(transcript)
found_symptoms = []
MOCK_SYMPTOMS.each do |symptom|
if transcript.downcase.include?(symptom.downcase)
found_symptoms << {
name: symptom,
severity: %w[mild moderate severe].sample,
duration: "#{rand(1..7)} days"
}
end
end
found_symptoms
end
def extract_vitals_from_transcript(transcript)
vitals = {}
# Extract blood pressure
if transcript =~ /(\d+)\s*(?:over|\/)\s*(\d+)/
vitals[:blood_pressure] = {
systolic: $1.to_i,
diastolic: $2.to_i
}
end
# Extract heart rate
if transcript =~ /(\d+)\s*beats?\s*per\s*minute/i
vitals[:heart_rate] = $1.to_i
end
# Extract temperature
if transcript =~ /(\d+\.?\d*)\s*degrees/i
vitals[:temperature] = $1.to_f
end
vitals
end
def extract_medications_from_transcript(transcript)
medications = []
MOCK_MEDICATIONS.each do |med|
if transcript.downcase.include?(med[:name].downcase)
medications << med
end
end
medications
end
def extract_exam_findings(transcript)
findings = []
exam_patterns = [
'chest is clear',
'abdomen is soft',
'appears comfortable',
'no distress'
]
exam_patterns.each do |pattern|
if transcript.downcase.include?(pattern)
findings << pattern
end
end
findings
end
def generate_mock_suggestions(entities, encounter)
suggestions = {
diagnoses: generate_diagnosis_suggestions(entities),
medications: generate_medication_suggestions(entities),
tests: generate_test_suggestions(entities),
clinical_notes: generate_note_suggestions(entities)
}
suggestions
end
def generate_diagnosis_suggestions(entities)
suggestions = []
# Generate 1-3 diagnosis suggestions based on symptoms
num_suggestions = rand(1..3)
MOCK_DIAGNOSES.sample(num_suggestions).each do |diagnosis|
suggestions << {
id: SecureRandom.uuid,
name: diagnosis[:name],
icd10_code: diagnosis[:icd10],
confidence: rand(0.65..0.95).round(2),
supporting_evidence: entities[:symptoms].map { |s| s[:name] }
}
end
suggestions
end
def generate_medication_suggestions(entities)
suggestions = []
# Generate medication suggestions
MOCK_MEDICATIONS.sample(rand(1..3)).each do |med|
suggestions << {
id: SecureRandom.uuid,
medication_name: med[:name],
dosage: med[:dosage],
frequency: med[:frequency],
duration: "#{rand(3..7)} days",
route: 'oral',
confidence: rand(0.70..0.90).round(2)
}
end
suggestions
end
def generate_test_suggestions(entities)
tests = [
{ name: 'Complete Blood Count', code: 'CBC' },
{ name: 'Basic Metabolic Panel', code: 'BMP' },
{ name: 'Chest X-Ray', code: 'CXR' },
{ name: 'Urinalysis', code: 'UA' }
]
suggestions = []
tests.sample(rand(0..2)).each do |test|
suggestions << {
id: SecureRandom.uuid,
test_name: test[:name],
test_code: test[:code],
reason: 'Rule out infection',
confidence: rand(0.60..0.85).round(2)
}
end
suggestions
end
def generate_note_suggestions(entities)
note = "CHIEF COMPLAINT:\n"
note += entities[:symptoms].map { |s| s[:name] }.join(', ') + "\n\n"
note += "VITAL SIGNS:\n"
if entities[:vitals][:blood_pressure]
note += "BP: #{entities[:vitals][:blood_pressure][:systolic]}/#{entities[:vitals][:blood_pressure][:diastolic]}\n"
end
note += "HR: #{entities[:vitals][:heart_rate]}\n" if entities[:vitals][:heart_rate]
note += "\n"
note += "PHYSICAL EXAMINATION:\n"
entities[:physical_exam].each { |finding| note += "- #{finding.capitalize}\n" }
{
content: note,
confidence: 0.85
}
end
endAPI Layer
Controllers
# app/controllers/api/encounters_controller.rb
class Api::EncountersController < ApiController
before_action :policy_check
before_action :set_encounter, only: [:show, :update, :reconcile_medications, :process_transcription]
# GET /api/encounters
def index
result = Encounters::Index.call(params)
render_result(result, EncounterSerializer)
end
# POST /api/encounters
def create
result = Encounters::Create.call(params)
render_result(result, EncounterSerializer)
end
# GET /api/encounters/:id
def show
render json: EncounterSerializer.new(@encounter, include: [:patient_problems, :medication_prescriptions])
end
# PATCH /api/encounters/:id
def update
result = Encounters::Update.call(params.merge(encounter: @encounter))
render_result(result, EncounterSerializer)
end
# POST /api/encounters/:id/reconcile_medications
def reconcile_medications
service = MedicationReconciliationService.new(@encounter)
reconciliation = service.perform_reconciliation
render json: {
status: 'success',
reconciliation: reconciliation
}
end
# POST /api/encounters/:id/process_transcription
def process_transcription
service = MockAiScribeService.new
transcription = service.process_audio(@encounter.id, params[:audio_data])
render json: AiTranscriptionSerializer.new(transcription)
end
# GET /api/encounters/:id/ai_suggestions
def ai_suggestions
service = MockAiScribeService.new
suggestions = service.get_suggestions(@encounter.id)
if suggestions
render json: { status: 'success', suggestions: suggestions }
else
render json: { status: 'not_found', message: 'No AI suggestions available' }
end
end
private
def set_encounter
@encounter = Encounter.find(params[:id])
end
def policy_check
authorize params, policy_class: EncountersControllerPolicy
end
end
# app/controllers/api/longitudinal_health_controller.rb
class Api::LongitudinalHealthController < ApiController
before_action :policy_check
before_action :set_patient
# GET /api/patients/:patient_id/health_summary
def health_summary
service = LongitudinalHealthService.new(@patient.id)
summary = service.build_complete_record
render json: {
status: 'success',
data: summary,
generated_at: Time.current
}
end
# GET /api/patients/:patient_id/health_score
def health_score
service = LongitudinalHealthService.new(@patient.id)
score = service.calculate_health_score
render json: PatientHealthScoreSerializer.new(score)
end
# GET /api/patients/:patient_id/vital_trends
def vital_trends
days_back = params[:days] || 90
trends = VitalTrendService.new(@patient).calculate_trends(days_back: days_back)
render json: {
status: 'success',
trends: trends,
period: "#{days_back} days"
}
end
# GET /api/patients/:patient_id/encounter_timeline
def encounter_timeline
encounters = @patient.encounters
.includes(:doctor, :patient_problems)
.recent
.page(params[:page])
render json: EncounterSerializer.new(encounters,
meta: pagination_meta(encounters))
end
private
def set_patient
@patient = Patient.find(params[:patient_id])
end
def policy_check
authorize params, policy_class: LongitudinalHealthControllerPolicy
end
end
# app/controllers/api/patient_problems_controller.rb
class Api::PatientProblemsController < ApiController
before_action :policy_check
before_action :set_patient
before_action :set_problem, only: [:show, :update, :resolve]
# GET /api/patients/:patient_id/problems
def index
problems = @patient.patient_problems
.includes(:created_by_encounter, :created_by_doctor)
.by_severity
render json: PatientProblemSerializer.new(problems)
end
# POST /api/patients/:patient_id/problems
def create
problem = @patient.patient_problems.build(problem_params)
problem.created_by_doctor = current_doctor
problem.created_by_encounter_id = params[:encounter_id] if params[:encounter_id]
if problem.save
render json: PatientProblemSerializer.new(problem), status: :created
else
render json: { errors: problem.errors.full_messages }, status: :unprocessable_entity
end
end
# PATCH /api/patient_problems/:id
def update
if @problem.update(problem_params)
render json: PatientProblemSerializer.new(@problem)
else
render json: { errors: @problem.errors.full_messages }, status: :unprocessable_entity
end
end
# POST /api/patient_problems/:id/resolve
def resolve
@problem.update!(
status: 'resolved',
resolved_date: Date.current,
resolved_by_encounter_id: params[:encounter_id]
)
render json: PatientProblemSerializer.new(@problem)
end
private
def set_patient
@patient = Patient.find(params[:patient_id])
end
def set_problem
@problem = PatientProblem.find(params[:id])
end
def problem_params
params.require(:patient_problem).permit(
:name, :icd10_code, :icd11_code, :snomed_code,
:status, :severity, :onset_date, :clinical_notes
)
end
def policy_check
authorize params, policy_class: PatientProblemsControllerPolicy
end
end
# app/controllers/api/medication_prescriptions_controller.rb
class Api::MedicationPrescriptionsController < ApiController
before_action :policy_check
before_action :set_patient
before_action :set_prescription, only: [:show, :update, :discontinue, :refill]
# GET /api/patients/:patient_id/current_medications
def current_medications
medications = @patient.medication_prescriptions
.active
.includes(:doctor, :encounter)
render json: MedicationPrescriptionSerializer.new(medications)
end
# GET /api/patients/:patient_id/medication_history
def medication_history
medications = @patient.medication_prescriptions
.includes(:doctor, :encounter)
.order(created_at: :desc)
.page(params[:page])
render json: MedicationPrescriptionSerializer.new(medications,
meta: pagination_meta(medications))
end
# POST /api/encounters/:encounter_id/prescriptions
def create
encounter = Encounter.find(params[:encounter_id])
prescription = encounter.medication_prescriptions.build(prescription_params)
prescription.patient = encounter.patient
prescription.doctor = encounter.doctor
if prescription.save
render json: MedicationPrescriptionSerializer.new(prescription), status: :created
else
render json: { errors: prescription.errors.full_messages }, status: :unprocessable_entity
end
end
# POST /api/prescriptions/:id/discontinue
def discontinue
@prescription.update!(
status: 'discontinued',
discontinuation_reason: params[:reason],
discontinued_by_encounter_id: params[:encounter_id],
end_date: Date.current
)
render json: MedicationPrescriptionSerializer.new(@prescription)
end
# POST /api/prescriptions/:id/refill
def refill
refill = @prescription.refills.build(
@prescription.attributes.except('id', 'created_at', 'updated_at')
)
refill.parent_prescription = @prescription
refill.prescribed_date = Date.current
refill.start_date = Date.current
refill.encounter_id = params[:encounter_id]
if refill.save
render json: MedicationPrescriptionSerializer.new(refill), status: :created
else
render json: { errors: refill.errors.full_messages }, status: :unprocessable_entity
end
end
private
def set_patient
@patient = Patient.find(params[:patient_id])
end
def set_prescription
@prescription = MedicationPrescription.find(params[:id])
end
def prescription_params
params.require(:prescription).permit(
:medication_name, :generic_name, :snomed_medication_code,
:dosage, :frequency, :route, :duration_value, :duration_unit,
:prescription_type, :start_date, :instructions
)
end
def policy_check
authorize params, policy_class: MedicationPrescriptionsControllerPolicy
end
endBackward Compatibility Layer
# app/controllers/api/careplans_controller.rb
# Maintains backward compatibility with existing frontend
class Api::CareplansController < Api::EncountersController
# All methods inherited from EncountersController
# This ensures existing API calls to /api/careplans continue to work
endRoutes Configuration
# config/routes.rb (additions)
Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do
# Backward compatibility routes
resources :careplans do
member do
post :reconcile_medications
post :process_transcription
get :ai_suggestions
end
end
# New encounter routes (same functionality)
resources :encounters do
member do
post :reconcile_medications
post :process_transcription
get :ai_suggestions
end
resources :prescriptions, controller: 'medication_prescriptions', only: [:create]
end
# Longitudinal health routes
resources :patients do
# Health summary and scores
get :health_summary, controller: 'longitudinal_health'
get :health_score, controller: 'longitudinal_health'
get :vital_trends, controller: 'longitudinal_health'
get :encounter_timeline, controller: 'longitudinal_health'
# Problem management
resources :problems, controller: 'patient_problems' do
member do
post :resolve
end
end
# Medication management
get :current_medications, controller: 'medication_prescriptions'
get :medication_history, controller: 'medication_prescriptions'
end
# Prescription management
resources :prescriptions, controller: 'medication_prescriptions', only: [] do
member do
post :discontinue
post :refill
end
end
# ABDM FHIR endpoints
namespace :abdm do
resources :patients, only: [] do
member do
get :fhir_bundle
post :sync_health_records
end
end
end
# Clinical codes lookup
resources :clinical_codes, only: [:index] do
collection do
get :search
end
end
end
endImplementation Guide
Phase 1: Database & Models
- Create migration files in order
- Run migrations:
rails db:migrate - Create model files with associations
- Add validations and callbacks
- Test model relationships in console
Phase 2: Service Layer
- Implement
LongitudinalHealthService - Create
MedicationReconciliationService - Build
MockAiScribeService - Add FHIR bundle generator
- Create background jobs for async processing
Phase 3: API Controllers
- Rename
CareplansControllertoEncountersController - Create backward compatibility wrapper
- Implement new longitudinal controllers
- Add serializers for new models
- Test API endpoints
Phase 4: Data Migration
- Create rake task for data migration
- Rename existing careplan references
- Extract problems from diagnosis fields
- Build initial health summaries
- Generate health scores for existing patients
Phase 5: Integration Testing
- Test backward compatibility
- Verify new API endpoints
- Test medication reconciliation flow
- Validate health score calculations
- Test AI mock service
Migration Strategy
Data Migration Rake Task
# lib/tasks/migrate_to_longitudinal.rake
namespace :longitudinal do
desc "Migrate existing careplan data to longitudinal health record structure"
task migrate: :environment do
puts "Starting longitudinal health record migration..."
ActiveRecord::Base.transaction do
# 1. Update polymorphic references
update_polymorphic_references
# 2. Extract problems from existing diagnoses
extract_patient_problems
# 3. Convert existing medicines to prescriptions
convert_medicines_to_prescriptions
# 4. Generate initial health summaries
generate_health_summaries
# 5. Calculate initial health scores
calculate_initial_scores
end
puts "Migration completed successfully!"
end
private
def update_polymorphic_references
puts "Updating polymorphic references..."
# Update documents
Document.where(documentable_type: 'Careplan')
.update_all(documentable_type: 'Encounter')
# Update activities
PublicActivity::Activity.where(trackable_type: 'Careplan')
.update_all(trackable_type: 'Encounter')
end
def extract_patient_problems
puts "Extracting patient problems from diagnoses..."
Encounter.find_each do |encounter|
next if encounter.diagnosis.blank?
encounter.diagnosis.each do |diagnosis_text|
# Try to find or create a problem
problem = encounter.patient.patient_problems.find_or_create_by(
name: diagnosis_text,
created_by_encounter: encounter,
created_by_doctor: encounter.doctor
) do |p|
p.status = 'active'
p.onset_date = encounter.created_at.to_date
# Try to match with ICD codes from clinical_codes table
code = ClinicalCode.where("display_name ILIKE ?", "%#{diagnosis_text}%").first
if code
p.icd10_code = code.code if code.code_system == 'ICD10'
end
end
# Link to encounter
EncounterProblem.find_or_create_by(
encounter: encounter,
patient_problem: problem,
relevance: 'primary'
)
end
end
end
def convert_medicines_to_prescriptions
puts "Converting existing medicines to prescriptions..."
EncounterMedicine.includes(:encounter).find_each do |medicine|
MedicationPrescription.create!(
patient: medicine.encounter.patient,
clinic_patient: medicine.encounter.clinic_patient,
encounter: medicine.encounter,
doctor: medicine.encounter.doctor,
medication_name: medicine.name,
dosage: medicine.dosage,
frequency: medicine.frequency,
duration_value: medicine.duration,
duration_unit: medicine.duration_type,
prescribed_date: medicine.encounter.created_at.to_date,
start_date: medicine.encounter.created_at.to_date,
status: 'completed', # Assume old prescriptions are completed
prescription_type: 'acute'
)
end
end
def generate_health_summaries
puts "Generating patient health summaries..."
Patient.find_each do |patient|
next if patient.encounters.empty?
PatientHealthSummary.create!(
patient: patient,
clinic_patient: patient.clinic_patients.last,
total_encounters_count: patient.encounters.count,
last_encounter_date: patient.encounters.maximum(:created_at),
created_at: Time.current,
updated_at: Time.current
)
end
end
def calculate_initial_scores
puts "Calculating initial health scores..."
Patient.find_each do |patient|
next if patient.encounters.empty?
service = LongitudinalHealthService.new(patient.id)
service.calculate_health_score
end
end
endMonitoring & Metrics
Key Performance Indicators
- Health score calculation time < 500ms
- Medication reconciliation completion rate > 90%
- AI transcription mock response time < 2s
- Problem list accuracy > 95%
- API backward compatibility 100%
Monitoring Setup
# config/initializers/metrics.rb
Rails.application.config.after_initialize do
# Track health score calculations
ActiveSupport::Notifications.subscribe('health_score.calculated') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Rails.logger.info "[METRICS] Health score calculated in #{event.duration}ms"
end
# Track medication reconciliations
ActiveSupport::Notifications.subscribe('medication.reconciled') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Rails.logger.info "[METRICS] Medication reconciled in #{event.duration}ms"
end
endSecurity Considerations
Access Control
- Implement role-based access for longitudinal records
- Audit all access to patient health summaries
- Encrypt sensitive health scores at rest
- Implement patient consent management
HIPAA Compliance
- Log all PHI access
- Implement automatic session timeouts
- Encrypt all data in transit
- Regular security audits
Future Enhancements
Phase 2 Features
- Real AI integration (OpenAI/AWS Transcribe)
- Medication adherence tracking
- Predictive health analytics
- Patient portal access
- Mobile app integration
Phase 3 Features
- Advanced clinical decision support
- Population health analytics
- Quality measure reporting
- Interoperability with other EHRs
- Telemedicine integration
Conclusion
This architecture provides a robust foundation for longitudinal health records while maintaining backward compatibility. The modular design allows for incremental implementation and future enhancements without disrupting existing functionality.