Phase 1: Patient Core Implementation Plan
Status: COMPLETE Completed: 2026-01-09 Stream: Clinical (Stream A) Dependencies: Identity domain (complete), Shared infrastructure (complete)
Implementation Summary: All Phase 1 deliverables have been implemented including 3 migrations, 3 models (Patient, PatientFacility, PatientInsuranceCoverage), 3 blueprints, 2 controllers, 3 policies, 1 service (RegistrationService with auto-MRN), 1 FHIR export service, RLS policies, and comprehensive test coverage (120+ specs).
Executive Summary
This document provides a detailed implementation plan for Phase 1 of the Clinical stream: Patient Core. This phase establishes the foundation for all clinical functionality by implementing the Patient and PatientFacility entities with full CRUD operations, FHIR compliance, and proper tenant isolation.
Scope
In Scope
clinical.patientstable with all FHIR-aligned attributesclinical.patient_facilitiesjunction table for multi-facility support- PostgreSQL enums for gender and marital status
- Row-Level Security (RLS) policies for tenant isolation
- Patient and PatientFacility Rails models
- Patient API endpoints (CRUD, search, facilities)
- Patient registration service with auto-MRN generation
- FHIR Patient resource export service
- Comprehensive test coverage
Out of Scope (Future Phases)
- Encounters and encounter participants
- Clinical observations and conditions
- Medication requests
- Episodes of care
- Care plans and goals
Design Decision: JSONB vs Separate Tables
JSONB (kept for):
emergency_contact- Rarely queried, simple structure, no business logicidentifiers- Flexible array for various ID types (ABHA, Aadhaar, passport)
Separate table (added):
patient_insurance_coverages- Complex queries, eligibility checks, billing integration, history tracking
Database Schema
Entity: Patient
Table: clinical.patients
| Column | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Primary key (UUIDv7) |
| tenant_id | UUID | Yes | Foreign key to identity.tenants |
| mrn | VARCHAR(50) | No | Medical Record Number (unique per tenant) |
| abha_id | VARCHAR(50) | No | Ayushman Bharat Health Account ID (India) |
| first_name | VARCHAR(100) | Yes | Patient first name |
| middle_name | VARCHAR(100) | No | Patient middle name |
| last_name | VARCHAR(100) | No | Patient last name |
| display_name | VARCHAR(200) | No | Preferred display name |
| gender | ENUM | No | male, female, other, unknown |
| birth_date | DATE | No | Date of birth |
| deceased | BOOLEAN | Yes | Deceased flag (default: false) |
| deceased_date | DATE | No | Date of death |
| marital_status | ENUM | No | single, married, divorced, widowed, separated, unknown |
| photo_url | VARCHAR(500) | No | URL to patient photo |
| VARCHAR(255) | No | Email address | |
| phone_country_code | VARCHAR(5) | No | Phone country code |
| phone_number | VARCHAR(20) | No | Phone number |
| address | JSONB | No | Structured address |
| emergency_contact | JSONB | No | Emergency contact details |
| communication_preferences | JSONB | No | Language and contact preferences |
| identifiers | JSONB | No | Additional identifiers (insurance, etc.) |
| is_active | BOOLEAN | Yes | Active flag (default: true) |
| created_at | TIMESTAMP | Yes | Record creation timestamp |
| updated_at | TIMESTAMP | Yes | Last update timestamp |
JSONB Structures:
// address
{
"line1": "123 Main Street",
"line2": "Apt 4B",
"city": "Bangalore",
"state": "Karnataka",
"postalCode": "560001",
"country": "IN"
}
// emergency_contact
{
"name": "Jane Doe",
"relationship": "spouse",
"phone": "+919876543210"
}
// communication_preferences
{
"preferred_language": "en",
"sms_preferred": true,
"email_preferred": false
}
// identifiers
[
{ "system": "insurance", "value": "INS123456" },
{ "system": "employer", "value": "EMP789" }
]Indexes:
idx_cl_patients_tenanton (tenant_id)idx_cl_patients_tenant_mrnUNIQUE on (tenant_id, mrn) WHERE mrn IS NOT NULLidx_cl_patients_tenant_abhaUNIQUE on (tenant_id, abha_id) WHERE abha_id IS NOT NULLidx_cl_patients_nameon (lower(first_name), lower(last_name))idx_cl_patients_display_nameon (lower(display_name)) WHERE display_name IS NOT NULLidx_cl_patients_phoneon (phone_number) WHERE phone_number IS NOT NULLidx_cl_patients_activeon (is_active) WHERE is_active = trueidx_cl_patients_dobon (birth_date)
Constraints:
- CHECK:
deceased_date IS NULL OR deceased = true(deceased_date requires deceased flag) - CHECK:
deceased_date IS NULL OR birth_date IS NULL OR deceased_date >= birth_date
Entity: PatientFacility
Table: clinical.patient_facilities
| Column | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Primary key (UUIDv7) |
| tenant_id | UUID | Yes | Foreign key to identity.tenants |
| patient_id | UUID | Yes | Foreign key to clinical.patients |
| facility_id | UUID | Yes | Foreign key to identity.facilities |
| facility_mrn | VARCHAR(50) | No | Facility-specific MRN |
| registration_date | DATE | No | First registration date |
| is_primary | BOOLEAN | Yes | Primary facility flag (default: false) |
| is_active | BOOLEAN | Yes | Active flag (default: true) |
| created_at | TIMESTAMP | Yes | Record creation timestamp |
| updated_at | TIMESTAMP | Yes | Last update timestamp |
Indexes:
idx_cl_pf_tenanton (tenant_id)idx_cl_pf_patienton (patient_id)idx_cl_pf_facilityon (facility_id)idx_cl_pf_tenant_patient_facilityUNIQUE on (tenant_id, patient_id, facility_id)idx_cl_pf_facility_mrnUNIQUE on (tenant_id, facility_id, facility_mrn) WHERE facility_mrn IS NOT NULLidx_cl_pf_primary_uniqueUNIQUE on (tenant_id, patient_id) WHERE is_primary = true
Constraints:
- Composite FK:
(tenant_id, patient_id)→patients(tenant_id, id)(ensures tenant consistency) - Composite FK:
(tenant_id, facility_id)→facilities(tenant_id, id)(ensures tenant consistency)
Entity: PatientInsuranceCoverage
Table: clinical.patient_insurance_coverages
| Column | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Primary key (UUIDv7) |
| tenant_id | UUID | Yes | Foreign key to identity.tenants |
| patient_id | UUID | Yes | Foreign key to clinical.patients |
| insurance_plan_id | UUID | No | Foreign key to reference.insurance_plans (future) |
| policy_number | VARCHAR(100) | Yes | Insurance policy number |
| group_number | VARCHAR(100) | No | Group/employer number |
| subscriber_id | VARCHAR(100) | No | Subscriber/member ID |
| subscriber_name | VARCHAR(200) | No | Name of policy holder |
| relationship_to_subscriber | ENUM | Yes | self, spouse, child, other |
| coverage_type | ENUM | Yes | primary, secondary, tertiary |
| payer_name | VARCHAR(200) | No | Insurance company name |
| payer_type | VARCHAR(50) | No | private, government, employer, self_pay |
| effective_date | DATE | No | Coverage start date |
| termination_date | DATE | No | Coverage end date |
| copay_amount | DECIMAL(10,2) | No | Copay amount |
| deductible_amount | DECIMAL(10,2) | No | Deductible amount |
| coverage_details | JSONB | No | Additional coverage info |
| is_verified | BOOLEAN | Yes | Verification status (default: false) |
| verified_at | TIMESTAMP | No | Last verification timestamp |
| is_active | BOOLEAN | Yes | Active flag (default: true) |
| created_at | TIMESTAMP | Yes | Record creation timestamp |
| updated_at | TIMESTAMP | Yes | Last update timestamp |
Enums:
CREATE TYPE clinical.subscriber_relationship AS ENUM ('self', 'spouse', 'child', 'other');
CREATE TYPE clinical.coverage_type AS ENUM ('primary', 'secondary', 'tertiary');JSONB Structure (coverage_details):
{
"plan_name": "Gold PPO",
"plan_type": "PPO",
"in_network_coverage": 80,
"out_of_network_coverage": 60,
"max_annual_benefit": 100000,
"notes": "Pre-authorization required for specialist visits"
}Indexes:
idx_cl_pic_tenanton (tenant_id)idx_cl_pic_patienton (patient_id)idx_cl_pic_policyon (policy_number)idx_cl_pic_active_uniqueUNIQUE on (tenant_id, patient_id, coverage_type) WHERE is_active = trueidx_cl_pic_effectiveon (effective_date, termination_date)
Constraints:
- Composite FK:
(tenant_id, patient_id)→patients(tenant_id, id)(ensures tenant consistency) - CHECK:
termination_date IS NULL OR effective_date IS NULL OR termination_date >= effective_date
Note: The unique constraint is partial on is_active = true to allow multiple historical (inactive) coverages of the same type while enforcing only one active coverage per type per patient.
Implementation Tasks
Task 1: Database Migrations
1.1 Create Clinical Enums
File: backend/db/migrate/YYYYMMDDHHMMSS_create_clinical_enums.rb
# frozen_string_literal: true
class CreateClinicalEnums < ActiveRecord::Migration[8.0]
def up
execute "SET search_path TO clinical, public"
execute <<-SQL
DO $$ BEGIN
CREATE TYPE clinical.patient_gender AS ENUM ('male', 'female', 'other', 'unknown');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
SQL
execute <<-SQL
DO $$ BEGIN
CREATE TYPE clinical.marital_status AS ENUM (
'single', 'married', 'divorced', 'widowed', 'separated', 'unknown'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
SQL
execute <<-SQL
DO $$ BEGIN
CREATE TYPE clinical.subscriber_relationship AS ENUM ('self', 'spouse', 'child', 'other');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
SQL
execute <<-SQL
DO $$ BEGIN
CREATE TYPE clinical.coverage_type AS ENUM ('primary', 'secondary', 'tertiary');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
SQL
execute "SET search_path TO public"
say "Created clinical domain enums", true
end
def down
execute "DROP TYPE IF EXISTS clinical.coverage_type CASCADE"
execute "DROP TYPE IF EXISTS clinical.subscriber_relationship CASCADE"
execute "DROP TYPE IF EXISTS clinical.marital_status CASCADE"
execute "DROP TYPE IF EXISTS clinical.patient_gender CASCADE"
say "Dropped clinical domain enums", true
end
end1.2 Create Patients Table
File: backend/db/migrate/YYYYMMDDHHMMSS_create_clinical_patients.rb
# frozen_string_literal: true
class CreateClinicalPatients < ActiveRecord::Migration[8.0]
UUIDV7_DEFAULT = -> { 'generate_uuidv7()' }
def up
execute "SET search_path TO clinical, public"
create_table :patients, id: false do |t|
t.uuid :id, primary_key: true, null: false, default: UUIDV7_DEFAULT
t.uuid :tenant_id, null: false
t.string :mrn, limit: 50
t.string :abha_id, limit: 50
t.string :first_name, limit: 100, null: false
t.string :middle_name, limit: 100
t.string :last_name, limit: 100
t.string :display_name, limit: 200
t.column :gender, :patient_gender
t.date :birth_date
t.boolean :deceased, default: false, null: false
t.date :deceased_date
t.column :marital_status, :marital_status
t.string :photo_url, limit: 500
t.string :email, limit: 255
t.string :phone_country_code, limit: 5
t.string :phone_number, limit: 20
t.jsonb :address, default: {}
t.jsonb :emergency_contact, default: {}
t.jsonb :communication_preferences, default: {}
t.jsonb :identifiers, default: []
t.boolean :is_active, default: true, null: false
t.timestamps
end
execute <<-SQL
ALTER TABLE clinical.patients
ADD CONSTRAINT fk_patients_tenant
FOREIGN KEY (tenant_id) REFERENCES identity.tenants(id);
SQL
# Composite unique index for FK references from child tables
add_index :patients, %i[tenant_id id], unique: true, name: 'idx_cl_patients_tenant_id'
add_index :patients, :tenant_id, name: 'idx_cl_patients_tenant'
add_index :patients, %i[tenant_id mrn], unique: true,
where: "mrn IS NOT NULL", name: 'idx_cl_patients_tenant_mrn'
add_index :patients, %i[tenant_id abha_id], unique: true,
where: "abha_id IS NOT NULL", name: 'idx_cl_patients_tenant_abha'
add_index :patients, "lower(first_name), lower(last_name)", name: 'idx_cl_patients_name'
add_index :patients, "lower(display_name)", where: "display_name IS NOT NULL", name: 'idx_cl_patients_display_name'
add_index :patients, :phone_number, where: "phone_number IS NOT NULL", name: 'idx_cl_patients_phone'
add_index :patients, :is_active, where: "is_active = true", name: 'idx_cl_patients_active'
add_index :patients, :birth_date, name: 'idx_cl_patients_dob'
# CHECK constraints for data integrity
execute <<-SQL
ALTER TABLE clinical.patients
ADD CONSTRAINT chk_patients_deceased_date
CHECK (deceased_date IS NULL OR deceased = true);
SQL
execute <<-SQL
ALTER TABLE clinical.patients
ADD CONSTRAINT chk_patients_deceased_after_birth
CHECK (deceased_date IS NULL OR birth_date IS NULL OR deceased_date >= birth_date);
SQL
execute "SET search_path TO public"
say "Created clinical.patients table", true
end
def down
execute "SET search_path TO clinical, public"
drop_table :patients, if_exists: true
execute "SET search_path TO public"
say "Dropped clinical.patients table", true
end
end1.3 Create Patient Facilities Table
File: backend/db/migrate/YYYYMMDDHHMMSS_create_clinical_patient_facilities.rb
# frozen_string_literal: true
class CreateClinicalPatientFacilities < ActiveRecord::Migration[8.0]
UUIDV7_DEFAULT = -> { 'generate_uuidv7()' }
def up
execute "SET search_path TO clinical, public"
create_table :patient_facilities, id: false do |t|
t.uuid :id, primary_key: true, null: false, default: UUIDV7_DEFAULT
t.uuid :tenant_id, null: false
t.uuid :patient_id, null: false
t.uuid :facility_id, null: false
t.string :facility_mrn, limit: 50
t.date :registration_date
t.boolean :is_primary, default: false, null: false
t.boolean :is_active, default: true, null: false
t.timestamps
end
# Simple FK to tenants
execute <<-SQL
ALTER TABLE clinical.patient_facilities
ADD CONSTRAINT fk_pf_tenant FOREIGN KEY (tenant_id) REFERENCES identity.tenants(id);
SQL
# Composite FK to patients (ensures tenant consistency)
execute <<-SQL
ALTER TABLE clinical.patient_facilities
ADD CONSTRAINT fk_pf_patient FOREIGN KEY (tenant_id, patient_id)
REFERENCES clinical.patients(tenant_id, id);
SQL
# Composite FK to facilities (ensures tenant consistency)
execute <<-SQL
ALTER TABLE clinical.patient_facilities
ADD CONSTRAINT fk_pf_facility FOREIGN KEY (tenant_id, facility_id)
REFERENCES identity.facilities(tenant_id, id);
SQL
add_index :patient_facilities, :tenant_id, name: 'idx_cl_pf_tenant'
add_index :patient_facilities, :patient_id, name: 'idx_cl_pf_patient'
add_index :patient_facilities, :facility_id, name: 'idx_cl_pf_facility'
add_index :patient_facilities, %i[tenant_id patient_id facility_id], unique: true,
name: 'idx_cl_pf_tenant_patient_facility'
add_index :patient_facilities, %i[tenant_id facility_id facility_mrn], unique: true,
where: "facility_mrn IS NOT NULL", name: 'idx_cl_pf_facility_mrn'
# Partial unique index: only one primary facility per patient
add_index :patient_facilities, %i[tenant_id patient_id], unique: true,
where: "is_primary = true", name: 'idx_cl_pf_primary_unique'
execute "SET search_path TO public"
say "Created clinical.patient_facilities table", true
end
def down
execute "SET search_path TO clinical, public"
drop_table :patient_facilities, if_exists: true
execute "SET search_path TO public"
say "Dropped clinical.patient_facilities table", true
end
end1.4 Create Patient Insurance Coverages Table
File: backend/db/migrate/YYYYMMDDHHMMSS_create_clinical_patient_insurance_coverages.rb
# frozen_string_literal: true
class CreateClinicalPatientInsuranceCoverages < ActiveRecord::Migration[8.0]
UUIDV7_DEFAULT = -> { 'generate_uuidv7()' }
def up
execute "SET search_path TO clinical, public"
create_table :patient_insurance_coverages, id: false do |t|
t.uuid :id, primary_key: true, null: false, default: UUIDV7_DEFAULT
t.uuid :tenant_id, null: false
t.uuid :patient_id, null: false
t.uuid :insurance_plan_id
t.string :policy_number, limit: 100, null: false
t.string :group_number, limit: 100
t.string :subscriber_id, limit: 100
t.string :subscriber_name, limit: 200
t.column :relationship_to_subscriber, :subscriber_relationship, null: false
t.column :coverage_type, :coverage_type, null: false
t.string :payer_name, limit: 200
t.string :payer_type, limit: 50
t.date :effective_date
t.date :termination_date
t.decimal :copay_amount, precision: 10, scale: 2
t.decimal :deductible_amount, precision: 10, scale: 2
t.jsonb :coverage_details, default: {}
t.boolean :is_verified, default: false, null: false
t.datetime :verified_at
t.boolean :is_active, default: true, null: false
t.timestamps
end
# Simple FK to tenants
execute <<-SQL
ALTER TABLE clinical.patient_insurance_coverages
ADD CONSTRAINT fk_pic_tenant FOREIGN KEY (tenant_id) REFERENCES identity.tenants(id);
SQL
# Composite FK to patients (ensures tenant consistency)
execute <<-SQL
ALTER TABLE clinical.patient_insurance_coverages
ADD CONSTRAINT fk_pic_patient FOREIGN KEY (tenant_id, patient_id)
REFERENCES clinical.patients(tenant_id, id);
SQL
# CHECK constraint for date validity
execute <<-SQL
ALTER TABLE clinical.patient_insurance_coverages
ADD CONSTRAINT chk_pic_dates
CHECK (termination_date IS NULL OR effective_date IS NULL OR termination_date >= effective_date);
SQL
add_index :patient_insurance_coverages, :tenant_id, name: 'idx_cl_pic_tenant'
add_index :patient_insurance_coverages, :patient_id, name: 'idx_cl_pic_patient'
add_index :patient_insurance_coverages, :policy_number, name: 'idx_cl_pic_policy'
# Partial unique: only one ACTIVE coverage per type per patient (allows history)
add_index :patient_insurance_coverages, %i[tenant_id patient_id coverage_type], unique: true,
where: "is_active = true", name: 'idx_cl_pic_active_unique'
add_index :patient_insurance_coverages, %i[effective_date termination_date], name: 'idx_cl_pic_effective'
execute "SET search_path TO public"
say "Created clinical.patient_insurance_coverages table", true
end
def down
execute "SET search_path TO clinical, public"
drop_table :patient_insurance_coverages, if_exists: true
execute "SET search_path TO public"
say "Dropped clinical.patient_insurance_coverages table", true
end
end1.5 Setup Clinical RLS
File: backend/db/migrate/YYYYMMDDHHMMSS_setup_clinical_rls.rb
# frozen_string_literal: true
class SetupClinicalRls < ActiveRecord::Migration[8.0]
TENANT_SCOPED_TABLES = %w[patients patient_facilities patient_insurance_coverages].freeze
def up
execute "SET search_path TO clinical, public"
TENANT_SCOPED_TABLES.each do |table|
execute "ALTER TABLE clinical.#{table} ENABLE ROW LEVEL SECURITY;"
execute "ALTER TABLE clinical.#{table} FORCE ROW LEVEL SECURITY;"
execute <<-SQL
CREATE POLICY tenant_isolation ON clinical.#{table}
FOR ALL
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
SQL
end
execute "SET search_path TO public"
say "Set up clinical schema RLS policies", true
end
def down
execute "SET search_path TO clinical, public"
TENANT_SCOPED_TABLES.each do |table|
execute "DROP POLICY IF EXISTS tenant_isolation ON clinical.#{table}"
execute "ALTER TABLE clinical.#{table} DISABLE ROW LEVEL SECURITY"
end
execute "SET search_path TO public"
say "Removed clinical schema RLS policies", true
end
endTask 2: Domain Foundation
2.1 Clinical Base Record
File: backend/domains/clinical/models/base_record.rb
2.2 Clinical Base Service
File: backend/domains/clinical/services/base_service.rb
2.3 Clinical Base Controller
File: backend/domains/clinical/controllers/base_controller.rb
Task 3: Models
3.1 Patient Model
File: backend/domains/clinical/models/patient.rb
Key features:
- TenantScoped concern for RLS
- Enums with prefix: true
- Associations to patient_facilities and facilities
- Validations (presence, uniqueness, format)
- Scopes (active, alive, deceased, by_facility)
- Instance methods (full_name, age, phone_full, primary_facility)
- JSONB field accessors
3.2 PatientFacility Model
File: backend/domains/clinical/models/patient_facility.rb
Key features:
- TenantScoped concern
- Belongs to patient and facility
- Unique constraint validation
- Primary facility enforcement (only one per patient)
3.3 PatientInsuranceCoverage Model
File: backend/domains/clinical/models/patient_insurance_coverage.rb
Key features:
- TenantScoped concern for RLS
- Enums: relationship_to_subscriber, coverage_type (with prefix: true)
- Belongs to patient
- Validations:
- Presence: policy_number, relationship_to_subscriber, coverage_type
- Uniqueness: one per coverage_type per patient (tenant-scoped)
- Date: termination_date > effective_date (when both present)
- Scopes:
active- is_active = truecurrent- effective and not terminatedprimary,secondary,tertiary- by coverage typeverified,unverified- by verification status
- Instance methods:
current?- checks if coverage is currently activedisplay_name- "payer_name - policy_number"
Task 4: Blueprints
4.1 Patient Blueprint
File: backend/domains/clinical/blueprints/patient_blueprint.rb
Views:
:normal- Basic fields for lists:detailed- Full patient data:select- Minimal for dropdowns:admin- Includes tenant_id, timestamps:with_facilities- Includes facility registrations:fhir- FHIR R4 Patient resource format
4.2 PatientFacility Blueprint
File: backend/domains/clinical/blueprints/patient_facility_blueprint.rb
4.3 PatientInsuranceCoverage Blueprint
File: backend/domains/clinical/blueprints/patient_insurance_coverage_blueprint.rb
Views:
:normal- Basic fields: id, policy_number, payer_name, coverage_type, is_active:detailed- Full coverage data including dates, amounts, coverage_details:select- Minimal: id, policy_number, payer_name:admin- Includes tenant_id, timestamps, verified_at:with_patient- Includes patient association (normal view)
Task 5: Policies
5.1 Patient Policy
File: backend/domains/clinical/policies/patient_policy.rb
Actions: index?, show?, create?, update?, destroy?, facilities?, insurance_coverages?, search?
5.2 PatientFacility Policy
File: backend/domains/clinical/policies/patient_facility_policy.rb
Actions: index?, show?, create?, update?, destroy?, set_primary?
5.3 PatientInsuranceCoverage Policy
File: backend/domains/clinical/policies/patient_insurance_coverage_policy.rb
Actions: index?, show?, create?, update?, destroy?, verify?
Task 6: Controllers
6.1 Patients Controller
File: backend/domains/clinical/controllers/patients_controller.rb
Endpoints:
GET /clinical/patients- List with filters, search, paginationPOST /clinical/patients- Create patientGET /clinical/patients/:id- Show patientPATCH /clinical/patients/:id- Update patientDELETE /clinical/patients/:id- Delete patientGET /clinical/patients/:id/facilities- List facility registrationsGET /clinical/patients/:id/insurance_coverages- List patient's insurance coveragesGET /clinical/patients/search- Quick search for autocomplete
6.2 PatientInsuranceCoverages Controller
File: backend/domains/clinical/controllers/patient_insurance_coverages_controller.rb
Endpoints:
GET /clinical/patient_insurance_coverages- List coverages (with patient filter)POST /clinical/patient_insurance_coverages- Create coverageGET /clinical/patient_insurance_coverages/:id- Show coveragePATCH /clinical/patient_insurance_coverages/:id- Update coverageDELETE /clinical/patient_insurance_coverages/:id- Delete coveragePOST /clinical/patient_insurance_coverages/:id/verify- Mark coverage as verified
Task 7: Services
7.1 Registration Service
File: backend/domains/clinical/services/patients/registration_service.rb
Features:
- Create patient with auto-MRN generation
- Optionally link to facility with facility_mrn
- Transaction-wrapped for atomicity
7.2 FHIR Patient Export Service
File: backend/domains/clinical/services/fhir/patient_export_service.rb
Features:
- Export single patient or all patients from facility
- Generate FHIR Bundle with Patient resources
- Support for active/inactive filtering
Task 8: Routes
8.1 Clinical Routes
File: backend/domains/clinical/routes.rb
Rails.application.routes.draw do
scope :clinical, module: 'clinical' do
resources :patients do
collection do
get :search
end
member do
get :facilities
get :insurance_coverages
end
end
resources :patient_facilities, only: %i[index create show update destroy]
resources :patient_insurance_coverages, only: %i[index create show update destroy] do
member do
post :verify
end
end
end
endTask 9: Tests
9.1 Model Specs
backend/domains/clinical/spec/models/patient_spec.rbbackend/domains/clinical/spec/models/patient_facility_spec.rbbackend/domains/clinical/spec/models/patient_insurance_coverage_spec.rb
9.2 Request Specs
backend/domains/clinical/spec/requests/patients_spec.rbbackend/domains/clinical/spec/requests/patient_facilities_spec.rbbackend/domains/clinical/spec/requests/patient_insurance_coverages_spec.rb
9.3 Service Specs
backend/domains/clinical/spec/services/patients/registration_service_spec.rb
API Endpoints
Patients
| Method | Endpoint | Description |
|---|---|---|
| GET | /clinical/patients | List patients with filters |
| POST | /clinical/patients | Create a new patient |
| GET | /clinical/patients/:id | Get patient details |
| PATCH | /clinical/patients/:id | Update patient |
| DELETE | /clinical/patients/:id | Delete patient |
| GET | /clinical/patients/:id/facilities | List patient's facility registrations |
| GET | /clinical/patients/:id/insurance_coverages | List patient's insurance coverages |
| GET | /clinical/patients/search | Search patients (autocomplete) |
Patient Insurance Coverages
| Method | Endpoint | Description |
|---|---|---|
| GET | /clinical/patient_insurance_coverages | List coverages |
| POST | /clinical/patient_insurance_coverages | Create coverage |
| GET | /clinical/patient_insurance_coverages/:id | Get coverage details |
| PATCH | /clinical/patient_insurance_coverages/:id | Update coverage |
| DELETE | /clinical/patient_insurance_coverages/:id | Delete coverage |
| POST | /clinical/patient_insurance_coverages/:id/verify | Mark as verified |
Query Parameters
List Patients:
search- Search by name, MRN, phonefacility_id- Filter by facilityalive_only- Exclude deceased patientsactive_only- Only active patientsgender- Filter by genderpage,per_page- Pagination
Search Patients:
q- Search querymrn- Exact MRN matchphone- Exact phone matchlimit- Max results (default: 20)
List Insurance Coverages:
patient_id- Filter by patient (required unless nested under patient)coverage_type- Filter by type (primary, secondary, tertiary)is_active- Filter by active statusis_verified- Filter by verification statuscurrent- Show only current coverages (effective and not terminated)page,per_page- Pagination
FHIR Compliance
The Patient entity maps to FHIR R4 Patient resource:
| FHIR Field | Patient Attribute |
|---|---|
| Patient.id | id |
| Patient.identifier | mrn, abha_id, identifiers |
| Patient.name | first_name, middle_name, last_name, display_name |
| Patient.gender | gender |
| Patient.birthDate | birth_date |
| Patient.deceasedBoolean | deceased |
| Patient.deceasedDateTime | deceased_date |
| Patient.maritalStatus | marital_status |
| Patient.telecom | email, phone_country_code + phone_number |
| Patient.address | address (JSONB) |
| Patient.contact | emergency_contact (JSONB) |
| Patient.communication | communication_preferences (JSONB) |
Verification Checklist
Database
- Run migrations successfully
- Verify tables exist:
\dt clinical.*(patients, patient_facilities, patient_insurance_coverages) - Verify enums exist:
\dT clinical.*(patient_gender, marital_status, subscriber_relationship, coverage_type) - Verify RLS policies:
SELECT * FROM pg_policies WHERE schemaname = 'clinical'
Models
- Patient model loads without errors
- PatientFacility model loads without errors
- PatientInsuranceCoverage model loads without errors
- TenantScoped concern works correctly
- All validations pass/fail as expected
API - Patients
- POST /clinical/patients creates patient
- GET /clinical/patients returns list
- GET /clinical/patients/:id returns patient
- PATCH /clinical/patients/:id updates patient
- DELETE /clinical/patients/:id deletes patient
- GET /clinical/patients/search returns results
- GET /clinical/patients/:id/facilities returns facilities
- GET /clinical/patients/:id/insurance_coverages returns coverages
API - Patient Facilities
- POST /clinical/patient_facilities creates facility link
- GET /clinical/patient_facilities returns list
- GET /clinical/patient_facilities/:id returns facility link
- PATCH /clinical/patient_facilities/:id updates facility link
- DELETE /clinical/patient_facilities/:id deletes facility link
- Only one primary facility per patient enforced (partial unique constraint)
API - Insurance Coverages
- POST /clinical/patient_insurance_coverages creates coverage
- GET /clinical/patient_insurance_coverages returns list
- GET /clinical/patient_insurance_coverages/:id returns coverage
- PATCH /clinical/patient_insurance_coverages/:id updates coverage
- DELETE /clinical/patient_insurance_coverages/:id deletes coverage
- POST /clinical/patient_insurance_coverages/:id/verify marks as verified
- Only one primary coverage per patient enforced
Tests
- Model specs pass:
bundle exec rspec domains/clinical/spec/models/ - Request specs pass:
bundle exec rspec domains/clinical/spec/requests/ - Service specs pass:
bundle exec rspec domains/clinical/spec/services/
RLS Verification
- Create patients in tenant A
- Switch to tenant B context
- Verify tenant B cannot see tenant A patients
- Verify tenant B cannot see tenant A insurance coverages
Timeline Estimate
| Task | Files | Complexity |
|---|---|---|
| Database Migrations | 5 | Medium |
| Domain Foundation | 3 | Low |
| Models | 3 | Medium |
| Blueprints | 3 | Medium |
| Policies | 3 | Low |
| Controllers | 2 | Medium |
| Services | 2 | Medium |
| Routes | 1 | Low |
| Tests | 6+ | Medium |
Total Files: ~28 Ruby files