LHR Service Architecture - Implementation Guide
Overview
This document outlines the service architecture for implementing Longitudinal Health Records (LHR) in our Rails API. The approach enhances the existing patient history service while maintaining full backward compatibility with all current functionality.
Core Principle: Extend existing services without breaking changes. All current workflows continue to work exactly as before, with powerful LHR features added through background processing.
System Architecture Overview
The LHR system follows a layered architecture that builds upon our existing parts-based Rails structure:
┌─────────────────────────────────────────────────────────────┐
│ Frontend Layer │
│ Enhanced Encounter Forms • LHR Dashboard • Health Summary │
└─────────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────────┐
│ API Layer (Rails) │
│ Enhanced Existing APIs • New LHR Endpoints • Serializers │
└─────────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────────┐
│ Service Layer (Interactors) │
│ SaveHistory • LhrEnhancementJob • Background Queue │
└─────────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────────┐
│ Model Layer (ActiveRecord) │
│ Enhanced Encounters • New LHR Models • Associations │
└─────────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────────┐
│ Database Layer (PostgreSQL + Redis) │
│ Enhanced Tables • New LHR Tables • Background Jobs │
└─────────────────────────────────────────────────────────────┘Core Service Components
1. Enhanced Patient History Service
The existing PatientHistory::SaveHistory service is extended to trigger LHR processing:
# Enhanced parts/clinic/services/patient_history/save_history.rb
module PatientHistory
class SaveHistory < ActiveJob::Base
queue_as :patient_history
def self.perform(obj, key)
# 1. MAINTAIN EXISTING FUNCTIONALITY (zero breaking changes)
legacy_result = perform_legacy_save(obj, key)
# 2. ADD LHR ENHANCEMENTS (only if legacy save succeeded)
if legacy_result && should_enhance_longitudinally?(key)
perform_lhr_enhancement(obj, key)
end
legacy_result
rescue => e
Rails.logger.error "Patient History Error: #{e.message}"
return
end
private
# All existing save methods remain unchanged
def self.perform_legacy_save(obj, key)
case key
when 'APPOINTMENT'
PatientHistory::SaveAppt.perform(obj)
when 'CAREPLAN'
PatientHistory::SaveCareplan.perform(obj)
when 'PATIENT_PHOTO'
PatientHistory::SavePatientPhoto.perform(obj)
when 'PATIENT_VITAL_GROUP'
PatientHistory::SavePatientVital.perform(obj)
when 'PATIENT_VITAL'
PatientHistory::SaveApptVital.perform(obj)
when 'TELEMEDICINE'
PatientHistory::SaveTelemedicinePrescription.perform(obj)
when 'LAB_REPORT'
PatientHistory::SaveLabReport.perform(obj)
when 'THERAPY_NOTES'
PatientHistory::SaveTherapyNotes.perform(obj)
when 'PATIENT_NOTES'
PatientHistory::SavePatientNotes.perform(obj)
when 'IMMUNISATIONS'
PatientHistory::SaveImmunisation.perform(obj)
when 'SHARED_RECORDS'
PatientHistory::SaveSharedRecord.perform(obj)
when 'MEDICAL_RECORD'
PatientHistory::SaveMedicalRecord.perform(obj)
end
end
# NEW: Separate LHR enhancement services (follows same pattern as legacy services)
def self.perform_lhr_enhancement(obj, key)
case key
when 'CAREPLAN'
PatientHistory::LhrEncounterEnhancement.perform(obj)
when 'PATIENT_VITAL_GROUP', 'PATIENT_VITAL'
PatientHistory::LhrVitalEnhancement.perform(obj)
when 'LAB_REPORT'
PatientHistory::LhrLabReportEnhancement.perform(obj)
when 'IMMUNISATIONS'
PatientHistory::LhrImmunizationEnhancement.perform(obj)
end
end
# Check if this event type should trigger LHR processing
def self.should_enhance_longitudinally?(key)
%w[CAREPLAN PATIENT_VITAL_GROUP PATIENT_VITAL LAB_REPORT IMMUNISATIONS].include?(key)
end
end
end2. Separate LHR Enhancement Services
Following the same pattern as legacy services, we create dedicated LHR enhancement services:
# New service: parts/clinic/services/patient_history/lhr_encounter_enhancement.rb
module PatientHistory
class LhrEncounterEnhancement
def self.perform(encounter)
return unless encounter.is_a?(Encounter) && encounter.persisted?
# Fast field updates only (no heavy processing)
encounter.update_columns(
encounter_type: determine_encounter_type(encounter),
visit_type: determine_visit_type(encounter),
urgency: determine_urgency(encounter),
# Note: reconciliation_completed field removed - using simple continuity
)
# Queue background job for heavy processing
LhrEnhancementJob.perform_later(encounter.id, 'ENCOUNTER_CREATED')
rescue => e
Rails.logger.error "LHR encounter enhancement failed: #{e.message}"
# Don't raise - LHR failure shouldn't break main flow
end
private
def self.determine_encounter_type(encounter)
return encounter.encounter_type if encounter.encounter_type.present?
# Simple logic based on context
if encounter.telemedicine?
'telemedicine'
elsif encounter.urgent_appointment?
'emergency'
elsif encounter.follow_up?
'followup'
else
'routine'
end
end
def self.determine_visit_type(encounter)
return encounter.visit_type if encounter.visit_type.present?
if encounter.telemedicine?
'telemedicine'
elsif encounter.scheduled?
'scheduled'
else
'walkin'
end
end
def self.determine_urgency(encounter)
return encounter.urgency if encounter.urgency.present?
if encounter.emergency_keywords_present?
'emergency'
elsif encounter.urgent_keywords_present?
'urgent'
else
'routine'
end
end
end
end
# New service: parts/clinic/services/patient_history/lhr_vital_enhancement.rb
module PatientHistory
class LhrVitalEnhancement
def self.perform(vital_obj)
return unless vital_obj.respond_to?(:patient_id) && vital_obj.patient_id.present?
# Queue background job to update health trends and scores
LhrEnhancementJob.perform_later(vital_obj.patient_id, 'VITALS_RECORDED', { vital_id: vital_obj.id })
rescue => e
Rails.logger.error "LHR vitals enhancement failed: #{e.message}"
end
end
end
# New service: parts/clinic/services/patient_history/lhr_lab_report_enhancement.rb
module PatientHistory
class LhrLabReportEnhancement
def self.perform(lab_report)
return unless lab_report.respond_to?(:patient_id) && lab_report.patient_id.present?
# Queue background job for lab result processing
LhrEnhancementJob.perform_later(lab_report.patient_id, 'LAB_RESULTS_RECEIVED', { lab_report_id: lab_report.id })
rescue => e
Rails.logger.error "LHR lab report enhancement failed: #{e.message}"
end
end
end
# New service: parts/clinic/services/patient_history/lhr_immunization_enhancement.rb
module PatientHistory
class LhrImmunizationEnhancement
def self.perform(immunization)
return unless immunization.respond_to?(:patient_id) && immunization.patient_id.present?
# Queue background job for immunization schedule updates
LhrEnhancementJob.perform_later(immunization.patient_id, 'IMMUNIZATION_GIVEN', { immunization_id: immunization.id })
rescue => e
Rails.logger.error "LHR immunization enhancement failed: #{e.message}"
end
end
end3. LHR Enhancement Background Job
Heavy LHR processing happens asynchronously to maintain system performance:
# New job: app/jobs/lhr_enhancement_job.rb
class LhrEnhancementJob < ActiveJob::Base
queue_as :lhr_processing
def perform(object_id, event_type, context = {})
case event_type
when 'ENCOUNTER_CREATED'
enhance_encounter_longitudinally(object_id)
when 'VITALS_RECORDED'
update_vital_trends(object_id)
when 'CHRONIC_MEDICATIONS_CONTINUED'
# Simple tracking of chronic medication continuity
log_medication_continuation(object_id)
else
Rails.logger.warn "Unknown LHR enhancement event: #{event_type}"
end
rescue => e
Rails.logger.error "LHR Enhancement failed for #{object_id}: #{e.message}"
# Don't re-raise - LHR enhancement is supplementary
end
private
def enhance_encounter_longitudinally(encounter_id)
encounter = Encounter.find(encounter_id)
patient = encounter.patient
ActiveRecord::Base.transaction do
# 1. Enhance complaints with longitudinal tracking
enhance_complaints_longitudinally(encounter)
# 2. Enhance medicines with continuity tracking
enhance_medicines_longitudinally(encounter)
# 3. Calculate patient health score
calculate_health_score(patient.id, encounter.id)
# 4. Update patient health summary
update_patient_health_summary(patient.id)
# 5. Generate clinical alerts if needed
generate_clinical_alerts(encounter)
end
end
def enhance_complaints_longitudinally(encounter)
encounter.encounter_complaints.find_each do |complaint|
next if complaint.problem_status.present? # Skip if already enhanced
complaint.update!(
problem_status: 'active',
onset_date: encounter.created_at.to_date,
severity_level: determine_complaint_severity(complaint.name),
affects_health_score: true
)
# Link to similar complaints from previous encounters
link_to_existing_problems(complaint)
# Mark as chronic if this is 3rd+ occurrence
check_chronic_condition(complaint)
end
end
def enhance_medicines_longitudinally(encounter)
encounter.encounter_medicines.find_each do |medicine|
next if medicine.medication_status.present? # Skip if already enhanced
medicine.update!(
medication_status: 'active',
prescription_type: determine_prescription_type(medicine.medicine_name),
start_date: encounter.created_at.to_date,
end_date: calculate_medication_end_date(medicine),
# Note: reconciliation_status removed - using medication_status only
)
# Check for drug interactions with patient's other medications
check_drug_interactions(medicine)
end
end
# Calculate comprehensive health score using weighted components (0-100 scale)
def calculate_health_score(patient_id, encounter_id = nil)
patient = Patient.find(patient_id)
# Calculate each component score (0-100 scale)
vital_score = calculate_vital_score(patient) # Weight: 25% - Recent vital signs
problem_score = calculate_problem_score(patient) # Weight: 30% - Active health problems (highest impact)
medication_score = calculate_medication_score(patient) # Weight: 25% - Medication management
preventive_score = calculate_preventive_score(patient) # Weight: 20% - Preventive care
# Weighted overall score (0-100 scale)
overall_score = (
vital_score * 0.25 + # Vitals contribute 25%
problem_score * 0.30 + # Problems contribute 30% (most important)
medication_score * 0.25 + # Medications contribute 25%
preventive_score * 0.20 # Preventive care contributes 20%
).round(2)
# Save health score record with detailed breakdown
health_score = PatientHealthScore.create!(
patient: patient,
clinic_patient: patient.clinic_patients.last,
encounter_id: encounter_id,
overall_score: overall_score,
vital_score: vital_score,
problem_score: problem_score,
medication_score: medication_score,
preventive_score: preventive_score,
calculated_date: Date.current,
calculation_method: 'weighted_algorithm_v1',
score_components: build_score_components(patient),
risk_factors: identify_risk_factors(patient),
data_completeness_percentage: calculate_data_completeness(patient)
)
# Update encounter with health score snapshot
if encounter_id
Encounter.where(id: encounter_id).update_all(health_score_snapshot: overall_score)
end
Rails.logger.info "Calculated health score #{overall_score} for patient #{patient_id} " \
"(vital: #{vital_score}, problem: #{problem_score}, medication: #{medication_score}, preventive: #{preventive_score})"
health_score
end
def update_patient_health_summary(patient_id)
patient = Patient.find(patient_id)
# Find or create health summary
summary = PatientHealthSummary.find_or_initialize_by(patient: patient)
# Calculate current metrics
active_problems = patient.encounter_complaints.where(problem_status: 'active')
current_medications = patient.encounter_medicines.where(medication_status: 'active')
latest_health_score = patient.patient_health_scores.recent.first
recent_encounters = patient.encounters.recent.limit(5)
summary.update!(
clinic_patient: patient.clinic_patients.last,
current_health_score: latest_health_score&.overall_score,
health_score_trend: calculate_health_trend(patient),
last_health_score_date: latest_health_score&.calculated_date,
# Encounter statistics
total_encounters_count: patient.encounters.count,
last_encounter_date: patient.encounters.maximum(:created_at)&.to_date,
encounters_this_year: patient.encounters.where('created_at >= ?', 1.year.ago).count,
# Active problems and medications summaries
active_problems_summary: build_problems_summary(active_problems),
current_medications_summary: build_medications_summary(current_medications),
# Vital trends over time
vital_trends: calculate_vital_trends(patient),
# Key longitudinal metrics
key_metrics: {
active_problems_count: active_problems.count,
chronic_conditions_count: active_problems.where(chronic_condition: true).count,
current_medications_count: current_medications.count,
chronic_medications: current_medications.where(prescription_type: 'chronic').count,
last_vital_check: patient.patient_vitals.maximum(:created_at)
},
# Care quality indicators
# Note: reconciliation fields removed - tracking chronic medications instead
# Data freshness tracking
summary_generated_at: Time.current,
needs_recalculation: false
)
end
endHealth Score Calculation System
The health scoring system provides a comprehensive 0-100 scale assessment of patient wellness using four weighted components:
Scoring Formula
Overall Score = (Vital Score × 0.25) + (Problem Score × 0.30) + (Medication Score × 0.25) + (Preventive Score × 0.20)Component Breakdown
1. Vital Score (25% weight)
Purpose: Measures how well vital signs are within normal ranges
def calculate_vital_score(patient)
recent_vitals = patient.patient_vitals.recent.limit(5)
return 85.0 if recent_vitals.empty? # Default score if no vitals recorded
# Count vitals within normal range
normal_count = recent_vitals.count { |vital| vital_within_normal_range?(vital) }
base_score = (normal_count.to_f / recent_vitals.count * 100).round(2)
# Bonus for recent vitals (within 30 days) - shows active monitoring
recent_bonus = recent_vitals.where('created_at > ?', 30.days.ago).any? ? 5 : 0
# Ensure score stays within 0-100 range
[[base_score + recent_bonus, 100].min, 0].max
end
def vital_within_normal_range?(vital)
normal = true
# Blood pressure ranges
normal = false if vital.systolic_bp.present? && (vital.systolic_bp < 90 || vital.systolic_bp > 140)
normal = false if vital.diastolic_bp.present? && (vital.diastolic_bp < 60 || vital.diastolic_bp > 90)
# Heart rate range
normal = false if vital.heart_rate.present? && (vital.heart_rate < 60 || vital.heart_rate > 100)
# Temperature range (Fahrenheit)
normal = false if vital.temperature.present? && (vital.temperature < 97.0 || vital.temperature > 99.5)
normal
endExamples:
- Patient with all normal vitals in last 5 visits: 95-100 points
- Patient with 3/5 normal vitals, recent monitoring: 65 points (60% + 5 bonus)
- Patient with no recent vitals: 85 points (default)
2. Problem Score (30% weight - Highest Impact)
Purpose: Assesses burden of active health problems
def calculate_problem_score(patient)
active_problems = patient.encounter_complaints.where(problem_status: 'active')
base_score = 100.0 # Start with perfect health
# Deduct points for each active problem based on severity
active_problems.each do |problem|
deduction = case problem.severity_level
when 'critical' then 25 # Major health threat
when 'severe' then 20 # Significant impact
when 'moderate' then 10 # Noticeable impact
when 'mild' then 5 # Minor impact
else 8 # Unknown severity (conservative)
end
# Additional penalty for chronic conditions (20% more impact)
deduction *= 1.2 if problem.chronic_condition?
base_score -= deduction
end
# Ensure score stays within 0-100 range
[[base_score, 100].min, 0].max.round(2)
endExamples:
- Healthy patient (no active problems): 100 points
- Patient with mild hypertension (chronic): 94 points (100 - 5×1.2)
- Patient with diabetes + severe arthritis: 55 points (100 - 25×1.2 - 20)
3. Medication Score (25% weight)
Purpose: Evaluates medication safety and adherence
def calculate_medication_score(patient)
active_meds = patient.encounter_medicines.where(medication_status: 'active')
return 95.0 if active_meds.empty? # High score if no medications needed
base_score = 90.0 # Good baseline for patients on medications
# Deduct for medication management issues
chronic_count = active_meds.where(prescription_type: 'chronic').count
base_score -= (unreconciled_count * 5) # -5 points per unreconciled med
# Deduct for drug interactions
interaction_count = active_meds.where('drug_interactions IS NOT NULL AND drug_interactions != ?', '[]').count
base_score -= (interaction_count * 8) # -8 points per interaction
# Bonus for having chronic medications tracked
chronic_bonus = chronic_count > 0 ? 5 : 0
[[base_score + chronic_bonus, 100].min, 0].max.round(2)
endExamples:
- Patient not on medications: 95 points
- Patient on 3 medications, all reconciled: 95 points (90 + 5 bonus)
- Patient with 2 drug interactions, 1 unreconciled: 77 points (90 - 16 - 5)
4. Preventive Score (20% weight)
Purpose: Measures engagement with preventive healthcare
def calculate_preventive_score(patient)
age = patient.patient_age
# Age-based baseline (reflects typical healthcare engagement patterns)
base_score = case age
when 0..18 then 85 # Pediatric - usually good preventive care
when 19..40 then 75 # Young adults - often skip preventive care
when 41..65 then 80 # Middle age - more health conscious
else 85 # Elderly - typically well-engaged with healthcare
end
# Bonus for recent immunizations (shows preventive care engagement)
recent_immunizations = patient.patient_immunizations.where('created_at > ?', 2.years.ago).count
immunization_bonus = [recent_immunizations * 2, 10].min # Max 10 points bonus
[[base_score + immunization_bonus, 100].min, 0].max.round(2)
endExamples:
- 30-year-old with recent flu shot: 77 points (75 + 2)
- 55-year-old with 3 recent immunizations: 86 points (80 + 6)
- Elderly patient with full immunizations: 95 points (85 + 10)
Score Interpretation
| Range | Grade | Clinical Meaning |
|---|---|---|
| 90-100 | Excellent | Optimal health, minimal risk factors |
| 80-89 | Good | Well-managed health with minor issues |
| 70-79 | Fair | Some health concerns requiring attention |
| 60-69 | Needs Improvement | Multiple health issues, active management needed |
| 0-59 | Poor | Significant health risks, comprehensive intervention required |
Real-World Examples
Example 1: Healthy Young Adult
- Vitals: 95 (all normal, recent checkup)
- Problems: 100 (no active problems)
- Medications: 95 (no medications needed)
- Preventive: 77 (age 28, recent flu shot)
- Overall: 92 (Excellent) = 95×0.25 + 100×0.30 + 95×0.25 + 77×0.20
Example 2: Well-Managed Diabetic
- Vitals: 85 (mostly normal, regular monitoring)
- Problems: 70 (diabetes chronic moderate + mild hypertension)
- Medications: 90 (3 meds, all reconciled, no interactions)
- Preventive: 86 (age 55, good immunization record)
- Overall: 80 (Good) = 85×0.25 + 70×0.30 + 90×0.25 + 86×0.20
Example 3: Complex Patient
- Vitals: 60 (some abnormal readings, inconsistent monitoring)
- Problems: 45 (diabetes, heart disease, arthritis - multiple chronic)
- Medications: 65 (5+ meds, 2 interactions, 1 unreconciled)
- Preventive: 85 (elderly, good preventive engagement)
- Overall: 59 (Poor) = 60×0.25 + 45×0.30 + 65×0.25 + 85×0.20
Supporting Methods
# Build detailed score components for transparency
def build_score_components(patient)
{
vital_details: {
recent_vitals_count: patient.patient_vitals.recent.count,
normal_vitals_count: patient.patient_vitals.recent.count { |v| vital_within_normal_range?(v) },
last_vital_date: patient.patient_vitals.maximum(:created_at)
},
problem_details: {
active_problems_count: patient.encounter_complaints.where(problem_status: 'active').count,
chronic_conditions_count: patient.encounter_complaints.where(chronic_condition: true, problem_status: 'active').count,
severity_breakdown: patient.encounter_complaints.where(problem_status: 'active').group(:severity_level).count
},
medication_details: {
active_medications_count: patient.encounter_medicines.where(medication_status: 'active').count,
chronic_medications_count: patient.encounter_medicines.where(prescription_type: 'chronic', medication_status: 'active').count,
interactions_count: patient.encounter_medicines.where('drug_interactions IS NOT NULL').count
},
preventive_details: {
patient_age: patient.patient_age,
recent_immunizations_count: patient.patient_immunizations.where('created_at > ?', 2.years.ago).count
}
}
end
# Identify high-risk factors for clinical attention
def identify_risk_factors(patient)
risk_factors = {}
# High blood pressure risk
recent_bp_readings = patient.patient_vitals.recent.limit(3).select { |v| v.systolic_bp.present? }
if recent_bp_readings.any? && recent_bp_readings.all? { |v| v.systolic_bp > 140 }
risk_factors[:hypertension] = { severity: 'high', description: 'Consistently elevated blood pressure' }
end
# Multiple chronic conditions risk
chronic_count = patient.encounter_complaints.where(chronic_condition: true, problem_status: 'active').count
if chronic_count >= 3
risk_factors[:multiple_chronic_conditions] = { severity: 'high', description: "#{chronic_count} active chronic conditions" }
end
# Medication interaction risk
interaction_count = patient.encounter_medicines.where('drug_interactions IS NOT NULL').count
if interaction_count > 0
risk_factors[:drug_interactions] = { severity: 'moderate', description: "#{interaction_count} potential drug interactions" }
end
risk_factors
end
# Calculate data completeness for score reliability
def calculate_data_completeness(patient)
total_elements = 4 # vitals, problems, medications, preventive
present_elements = 0
present_elements += 1 if patient.patient_vitals.recent.any?
present_elements += 1 if patient.encounter_complaints.any?
present_elements += 1 if patient.encounter_medicines.any?
present_elements += 1 if patient.patient_immunizations.any?
(present_elements.to_f / total_elements * 100).round(0)
end4. Enhanced Model Layer
Encounter Model (Renamed from Careplan)
# app/models/encounter.rb (Enhanced from Careplan)
class Encounter < ApplicationRecord
# Maintain backward compatibility
self.table_name = 'encounters' # Renamed from careplans
# All existing associations remain unchanged
belongs_to :patient
belongs_to :clinic
has_many :encounter_complaints, foreign_key: 'encounter_id'
has_many :encounter_medicines, foreign_key: 'encounter_id'
# NEW: LHR associations
has_many :patient_health_scores, foreign_key: 'encounter_id', dependent: :nullify
# Note: medication_reconciliations removed - using simple continuity tracking
# NEW: LHR convenience methods
def has_lhr_enhancements?
encounter_type.present? || health_score_snapshot.present?
end
def lhr_enhanced_complaints
encounter_complaints.where.not(problem_status: nil)
end
def lhr_enhanced_medicines
encounter_medicines.where.not(medication_status: nil)
end
def current_health_score
patient&.patient_health_scores&.recent&.first&.overall_score
end
def health_score_trend
scores = patient&.patient_health_scores&.recent&.limit(3)&.pluck(:overall_score)
return 'unknown' if scores.blank? || scores.size < 2
recent, older = scores.first, scores.last
if recent > older + 5
'improving'
elsif recent < older - 5
'declining'
else
'stable'
end
end
def has_chronic_medications?
encounter_medicines.where(prescription_type: 'chronic').exists?
# Check if patient has active medications from previous encounters
patient&.encounter_medicines&.where(medication_status: 'active')&.any?
end
def clinical_alerts
alerts = []
# Drug interaction alerts
medicines_with_interactions = encounter_medicines
.where('drug_interactions IS NOT NULL AND drug_interactions != ?', '[]')
if medicines_with_interactions.any?
alerts << {
type: 'drug_interaction',
severity: 'high',
message: "#{medicines_with_interactions.count} medication(s) have potential interactions",
medicines: medicines_with_interactions.pluck(:medicine_name)
}
end
# Health score alerts
if health_score_snapshot.present? && health_score_snapshot < 60
alerts << {
type: 'low_health_score',
severity: 'moderate',
message: "Patient health score is #{health_score_snapshot}% - consider comprehensive review",
score: health_score_snapshot
}
end
# Chronic condition management alerts
chronic_conditions = encounter_complaints.where(chronic_condition: true, problem_status: 'active')
if chronic_conditions.count > 2
alerts << {
type: 'multiple_chronic_conditions',
severity: 'moderate',
message: "Patient has #{chronic_conditions.count} active chronic conditions requiring management",
conditions: chronic_conditions.pluck(:name)
}
end
alerts
end
# Trigger LHR enhancement after creation (only if not already enhanced)
after_create :trigger_lhr_enhancement, unless: :has_lhr_enhancements?
private
def trigger_lhr_enhancement
LhrEnhancementJob.perform_later(id, 'ENCOUNTER_CREATED')
end
endNew LHR Models
# app/models/patient_health_score.rb
class PatientHealthScore < ApplicationRecord
belongs_to :patient
belongs_to :clinic_patient, optional: true
belongs_to :encounter, optional: true
validates :overall_score, presence: true, inclusion: { in: 0..100 }
validates :calculated_date, presence: true
scope :recent, -> { order(calculated_date: :desc) }
scope :this_year, -> { where('calculated_date >= ?', 1.year.ago) }
def score_grade
case overall_score
when 90..100 then 'Excellent'
when 80..89 then 'Good'
when 70..79 then 'Fair'
when 60..69 then 'Needs Improvement'
else 'Poor'
end
end
def primary_risk_factors
risk_factors.select { |factor, details| details['severity'] == 'high' }
end
end
# app/models/patient_health_summary.rb
class PatientHealthSummary < ApplicationRecord
belongs_to :patient
belongs_to :clinic_patient, optional: true
validates :patient_id, uniqueness: true
scope :needs_update, -> { where(needs_recalculation: true) }
scope :by_health_score, ->(min_score) { where('current_health_score >= ?', min_score) }
def health_status
return 'Unknown' unless current_health_score.present?
case current_health_score
when 85..100 then 'Excellent'
when 70..84 then 'Good'
when 55..69 then 'Fair'
else 'Needs Attention'
end
end
def summary_fresh?
summary_generated_at.present? && summary_generated_at > 1.day.ago
end
def active_chronic_conditions
active_problems_summary.select { |problem| problem['chronic'] == true }
end
end4. Enhanced Controller Layer
Encounters Controller
# parts/doctor/controllers/encounters_controller.rb (Enhanced from CareplansController)
class EncountersController < ApiController
before_action :policy_check
before_action :set_encounter, only: [:show, :update, :lhr_summary, :update_longitudinal_fields]
# Enhanced to accept new UI fields
def create
encounter_params = params.require(:encounter).permit(
:clinic_patient_id, # From patient selection dropdown
:doctor_id, # From doctor selection dropdown
:visit_type, # UI field: walkin/scheduled/telemedicine/home_visit
:urgency, # UI field: routine/urgent/emergency
:chief_complaint, # UI field: free text entry
# ... existing fields ...
)
result = Encounters::Create.call(encounter_params)
render_result(result, result.success? ? EncounterSerializer : nil)
end
def index
result = Encounters::Index.call(params)
render_result(result, result.success? ? EncounterSerializer : nil)
end
def show
# Enhanced serialization with optional LHR data
serializer_options = {
include_lhr_data: params[:include_lhr] == 'true',
include_health_score: params[:include_health_score] == 'true'
}
render json: EncounterSerializer.new(@encounter, serializer_options)
end
# NEW: LHR-specific endpoints
def lhr_summary
summary = {
encounter_id: @encounter.id,
health_score: @encounter.health_score_snapshot,
health_score_trend: @encounter.health_score_trend,
encounter_type: @encounter.encounter_type,
enhanced_data: {
complaints_enhanced: @encounter.lhr_enhanced_complaints.count,
medicines_enhanced: @encounter.lhr_enhanced_medicines.count,
total_complaints: @encounter.encounter_complaints.count,
total_medicines: @encounter.encounter_medicines.count
},
chronic_medications: {
present: @encounter.has_chronic_medications?,
count: @encounter.encounter_medicines.where(prescription_type: 'chronic').count
},
clinical_alerts: @encounter.clinical_alerts,
patient_summary: @encounter.patient.patient_health_summary&.as_json(
only: [:current_health_score, :health_score_trend, :last_encounter_date],
methods: [:health_status]
)
}
render json: { lhr_summary: summary }
end
def update_longitudinal_fields
# Allow updates to specific LHR fields only
allowed_fields = params.slice(
:encounter_type,
:visit_type,
:urgency,
:chief_complaint,
# Note: reconciliation_completed removed
).to_h
if allowed_fields.any? && @encounter.update(allowed_fields)
# Trigger recalculation if significant fields changed
# Note: reconciliation tracking removed
render json: {
success: true,
message: 'Longitudinal fields updated successfully',
updated_fields: allowed_fields.keys
}
else
render json: {
errors: @encounter.errors.full_messages
}, status: :unprocessable_entity
end
end
# NEW: Get card-based health summary for dashboard UI
def health_summary
patient = @encounter.patient
summary_data = {
patient_demographics: {
id: patient.id,
name: patient.full_name,
age: patient.patient_age,
gender: patient.gender
},
health_score_card: {
overall_score: patient.current_health_score,
trend: patient.health_score_trend,
last_calculated: patient.patient_health_scores.recent.first&.calculated_date
},
summary_cards: [
{
type: 'recent_encounters',
title: 'Recent Encounters',
count: patient.encounters.recent.count,
items: patient.encounters.recent.limit(3).map do |enc|
{
date: enc.created_at.to_date,
visit_type: enc.visit_type,
urgency: enc.urgency,
chief_complaint: enc.chief_complaint
}
end
},
{
type: 'active_problems',
title: 'Active Problems',
count: patient.encounter_complaints.where(problem_status: 'active').count,
items: patient.encounter_complaints.where(problem_status: 'active').limit(3).map do |problem|
{
name: problem.name,
severity: problem.severity_level,
chronic: problem.chronic_condition?
}
end
},
{
type: 'current_medications',
title: 'Current Medications',
count: patient.encounter_medicines.where(medication_status: 'active').count,
items: patient.encounter_medicines.where(medication_status: 'active').limit(3).map do |med|
{
name: med.medicine_name,
dosage: med.dosage,
frequency: med.frequency
}
end
}
]
}
render json: { health_summary: summary_data }
end
private
def set_encounter
@encounter = Encounter.find(params[:id])
end
def policy_check
authorize params, policy_class: EncountersControllerPolicy
end
end5. Enhanced Serializers
# app/serializers/encounter_serializer.rb (Enhanced from CareplanSerializer)
class EncounterSerializer < ActiveModel::Serializer
attributes :id, :patient_id, :clinic_id, :created_at, :updated_at
# All existing attributes remain the same
attributes :prescription, :test_description, :advice, :notes
# NEW: LHR attributes (conditionally included)
attributes :encounter_type, :visit_type, :urgency, :chief_complaint
attributes :health_score_snapshot
# Enhanced associations with LHR data
has_many :encounter_complaints, serializer: EncounterComplaintSerializer
has_many :encounter_medicines, serializer: EncounterMedicineSerializer
# Conditionally include LHR data based on serializer options
def include_lhr_data?
instance_options[:include_lhr_data] == true
end
def include_health_score?
instance_options[:include_health_score] == true
end
# LHR-specific attributes
attribute :lhr_summary, if: :include_lhr_data?
attribute :clinical_alerts, if: :include_lhr_data?
attribute :patient_health_score, if: :include_health_score?
def lhr_summary
{
has_enhancements: object.has_lhr_enhancements?,
enhanced_complaints: object.lhr_enhanced_complaints.count,
enhanced_medicines: object.lhr_enhanced_medicines.count,
health_trend: object.health_score_trend,
has_chronic_medications: object.has_chronic_medications?
}
end
def clinical_alerts
object.clinical_alerts
end
def patient_health_score
object.current_health_score
end
end
# Enhanced complaint serializer with LHR fields
class EncounterComplaintSerializer < ActiveModel::Serializer
attributes :id, :name, :description, :created_at
# NEW: LHR attributes
attributes :problem_status, :severity_level, :onset_date, :resolved_date
attributes :chronic_condition, :affects_health_score
attributes :linked_complaint_ids, :problem_trend
# Clinical coding attributes
attributes :icd10_code, :icd11_code, :snomed_code
belongs_to :primary_clinical_code, if: -> { object.primary_clinical_code.present? }
end
# Enhanced medicine serializer with LHR fields
class EncounterMedicineSerializer < ActiveModel::Serializer
attributes :id, :medicine_name, :dosage, :frequency, :duration_unit, :duration_frequency
# NEW: LHR attributes
attributes :medication_status, :prescription_type, :generic_name
attributes :start_date, :end_date, :discontinuation_reason
attributes :drug_interactions, :allergy_warnings
attributes :medication_status, :prescription_type
# Clinical coding attributes
attributes :ndc_code
belongs_to :rxnorm_code, if: -> { object.rxnorm_code.present? }
belongs_to :generic_rxnorm_code, if: -> { object.generic_rxnorm_code.present? }
endData Flow Architecture
1. Encounter Creation Flow
User creates encounter → API Controller → Interactor
↓
PatientHistory::SaveHistory.perform
↓
┌─────────────────────────────────────┐
│ EXISTING FLOW (unchanged) │
│ SaveCareplan.perform(encounter) │
│ ↓ │
│ Core encounter data saved │
│ ↓ │
│ Legacy processing complete │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ NEW LHR FLOW (added) │
│ enhance_with_lhr_data(encounter) │
│ ↓ │
│ Basic LHR fields updated │
│ ↓ │
│ LhrEnhancementJob queued │
└─────────────────────────────────────┘
↓
Response returned to user
│
┌─────────────┐
│ BACKGROUND │
│ PROCESSING │
└─────────────┘
↓
LhrEnhancementJob.perform
↓
┌──────────────────┬──────────────────┬──────────────────┐
↓ ↓ ↓ ↓
enhance_complaints enhance_medicines calculate_health update_patient
_score _summary2. Background Processing Queue Structure
Redis Queue: lhr_processing
├── High Priority: encounter_created, vitals_recorded
├── Medium Priority: chronic_medication_tracking, problem_linking
└── Low Priority: health_score_calculation, summary_updates
Processing Timeouts:
├── Encounter enhancement: 30 seconds
├── Health score calculation: 60 seconds
└── Patient summary update: 45 seconds
Retry Policy:
├── Max retries: 3
├── Exponential backoff: 2^attempt seconds
└── Dead letter queue after max retriesPerformance Optimizations
1. Database Query Optimization
# Efficient queries for longitudinal data
class Patient < ApplicationRecord
# Cached calculations to avoid expensive recalculation
def current_health_score
Rails.cache.fetch("patient_#{id}_health_score", expires_in: 1.hour) do
patient_health_scores.recent.first&.overall_score
end
end
def active_problems_count
Rails.cache.fetch("patient_#{id}_active_problems", expires_in: 5.minutes) do
encounter_complaints.where(problem_status: 'active').count
end
end
end2. Caching Strategy
# app/models/concerns/lhr_cacheable.rb
module LhrCacheable
extend ActiveSupport::Concern
included do
# Cache patient health summaries
after_update :clear_health_summary_cache
after_create :clear_health_summary_cache
end
def cached_health_summary
Rails.cache.fetch(health_summary_cache_key, expires_in: 5.minutes) do
patient.patient_health_summary&.as_json(
include: [:active_problems_summary, :current_medications_summary]
)
end
end
private
def health_summary_cache_key
"patient_#{patient_id}_health_summary_#{patient.patient_health_summary&.updated_at&.to_i}"
end
def clear_health_summary_cache
Rails.cache.delete_matched("patient_#{patient_id}_*")
end
end3. Background Job Monitoring
# app/jobs/concerns/lhr_job_monitoring.rb
module LhrJobMonitoring
extend ActiveSupport::Concern
included do
around_perform :monitor_performance
rescue_from StandardError, with: :handle_lhr_job_error
end
private
def monitor_performance
start_time = Time.current
yield
duration = Time.current - start_time
Rails.logger.info "LHR Job #{self.class.name} completed in #{duration}s"
# Alert if job takes too long
if duration > 30.seconds
Rails.logger.warn "LHR Job #{self.class.name} took #{duration}s - consider optimization"
end
end
def handle_lhr_job_error(error)
Rails.logger.error "LHR Job #{self.class.name} failed: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
# Don't re-raise for LHR jobs - they're supplementary
# Could send to error tracking service here
end
endError Handling & Resilience
1. Fail-Safe Design
# Ensure core functionality never fails due to LHR issues
module LhrSafety
def safe_lhr_operation
yield
rescue => e
Rails.logger.error "LHR operation failed safely: #{e.message}"
# Return nil or default value, never raise
nil
end
end
# Usage in SaveHistory
def self.enhance_with_lhr_data(obj, key)
include LhrSafety
safe_lhr_operation do
case key
when 'CAREPLAN'
enhance_encounter_with_lhr(obj)
end
end
end2. Data Consistency Checks
# app/jobs/lhr_consistency_check_job.rb
class LhrConsistencyCheckJob < ActiveJob::Base
queue_as :lhr_maintenance
def perform
check_health_score_consistency
check_chronic_medication_consistency
check_problem_linking_consistency
end
private
def check_health_score_consistency
# Find encounters with health scores but missing patient health records
orphaned_scores = Encounter
.where.not(health_score_snapshot: nil)
.left_joins(:patient_health_scores)
.where(patient_health_scores: { id: nil })
orphaned_scores.find_each do |encounter|
Rails.logger.warn "Encounter #{encounter.id} has health score but no health record"
LhrEnhancementJob.perform_later(encounter.id, 'HEALTH_SCORE_REPAIR')
end
end
endTesting Strategy
1. Service Layer Tests
# spec/services/patient_history/save_history_spec.rb
RSpec.describe PatientHistory::SaveHistory do
describe '.perform' do
let(:encounter) { create(:encounter) }
context 'when LHR enhancement is enabled' do
it 'preserves existing functionality' do
expect(PatientHistory::SaveCareplan).to receive(:perform).with(encounter)
described_class.perform(encounter, 'CAREPLAN')
end
it 'triggers LHR enhancement after successful save' do
allow(PatientHistory::SaveCareplan).to receive(:perform).and_return(true)
expect(LhrEnhancementJob).to receive(:perform_later).with(encounter.id, 'ENCOUNTER_CREATED')
described_class.perform(encounter, 'CAREPLAN')
end
it 'skips LHR enhancement if core save fails' do
allow(PatientHistory::SaveCareplan).to receive(:perform).and_return(false)
expect(LhrEnhancementJob).not_to receive(:perform_later)
described_class.perform(encounter, 'CAREPLAN')
end
end
context 'when LHR enhancement fails' do
it 'does not affect core save operation' do
allow(PatientHistory::SaveCareplan).to receive(:perform).and_return(true)
allow_any_instance_of(described_class).to receive(:enhance_with_lhr_data).and_raise(StandardError)
expect { described_class.perform(encounter, 'CAREPLAN') }.not_to raise_error
end
end
end
end2. Background Job Tests
# spec/jobs/lhr_enhancement_job_spec.rb
RSpec.describe LhrEnhancementJob do
describe '#perform' do
let(:encounter) { create(:encounter, :with_complaints_and_medicines) }
it 'enhances complaints with longitudinal data' do
described_class.new.perform(encounter.id, 'ENCOUNTER_CREATED')
encounter.reload
expect(encounter.encounter_complaints.first.problem_status).to eq('active')
expect(encounter.encounter_complaints.first.onset_date).to be_present
end
it 'calculates patient health score' do
expect {
described_class.new.perform(encounter.id, 'ENCOUNTER_CREATED')
}.to change(PatientHealthScore, :count).by(1)
score = encounter.patient.patient_health_scores.first
expect(score.overall_score).to be_between(0, 100)
end
it 'updates patient health summary' do
described_class.new.perform(encounter.id, 'ENCOUNTER_CREATED')
summary = encounter.patient.patient_health_summary
expect(summary).to be_present
expect(summary.total_encounters_count).to eq(1)
end
end
endMonitoring & Observability
1. Metrics Collection
# app/services/lhr_metrics_service.rb
class LhrMetricsService
def self.track_enhancement_performance(encounter_id, duration)
# Track performance metrics
Rails.logger.info "LHR Enhancement Duration", {
encounter_id: encounter_id,
duration_seconds: duration,
timestamp: Time.current
}
# Could integrate with monitoring service like DataDog, NewRelic
end
def self.track_health_score_calculation(patient_id, score)
Rails.logger.info "Health Score Calculated", {
patient_id: patient_id,
score: score,
timestamp: Time.current
}
end
end2. Health Checks
# app/controllers/health_controller.rb
class HealthController < ApplicationController
def lhr_status
status = {
lhr_processing_queue: check_lhr_queue,
recent_enhancements: check_recent_enhancements,
health_score_calculations: check_health_score_freshness,
system_health: 'operational'
}
render json: status
end
private
def check_lhr_queue
{
pending_jobs: Sidekiq::Queue.new('lhr_processing').size,
failed_jobs: Sidekiq::DeadSet.new.size,
processed_today: Sidekiq::Stats.new.processed
}
end
def check_recent_enhancements
{
encounters_enhanced_today: Encounter.where('updated_at >= ?', 1.day.ago)
.where.not(encounter_type: nil).count,
health_scores_calculated_today: PatientHealthScore.where('created_at >= ?', 1.day.ago).count
}
end
endSummary
This service architecture provides a robust, scalable foundation for implementing LHR features while maintaining complete backward compatibility. Key benefits include:
- Zero Breaking Changes: All existing functionality preserved
- Performance Optimized: Background processing keeps the system fast
- Fail-Safe Design: LHR failures don't affect core operations
- Comprehensive Testing: Full test coverage for reliability
- Monitoring Ready: Built-in observability and metrics
- Scalable Architecture: Can handle growing data volumes
The implementation follows Rails best practices and integrates seamlessly with the existing parts-based architecture of the KulCare API.
UI Integration Requirements
Frontend Form Updates
Encounter Creation Form needs these fields:
- Patient Selection: Dropdown →
clinic_patient_id - Doctor Selection: Dropdown →
doctor_id - Visit Type: Select →
walkin|scheduled|telemedicine|home_visit - Urgency: Select →
routine|urgent|emergency - Chief Complaint: Textarea → Free text entry
Dashboard Implementation
Health Summary Cards consume data from /patients/:id/health_summary:
- Health Score Card: Circular progress (0-100) with trend arrow
- Recent Encounters: List of last 3 visits with visit_type, urgency
- Active Problems: List with severity badges
- Current Medications: List with dosage/frequency
API Response Structure:
{
"health_summary": {
"patient_demographics": { "name": "...", "age": 45 },
"health_score_card": { "overall_score": 78.5, "trend": "improving" },
"summary_cards": [
{ "type": "recent_encounters", "count": 3, "items": [...] },
{ "type": "active_problems", "count": 2, "items": [...] }
]
}
}Next Steps:
- Review database-design.md for schema implementation details
- Check api-design.md for complete API specifications