Kulcare Docs
DeveloperPatients

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.patients table with all FHIR-aligned attributes
  • clinical.patient_facilities junction 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 logic
  • identifiers - 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

ColumnTypeRequiredDescription
idUUIDYesPrimary key (UUIDv7)
tenant_idUUIDYesForeign key to identity.tenants
mrnVARCHAR(50)NoMedical Record Number (unique per tenant)
abha_idVARCHAR(50)NoAyushman Bharat Health Account ID (India)
first_nameVARCHAR(100)YesPatient first name
middle_nameVARCHAR(100)NoPatient middle name
last_nameVARCHAR(100)NoPatient last name
display_nameVARCHAR(200)NoPreferred display name
genderENUMNomale, female, other, unknown
birth_dateDATENoDate of birth
deceasedBOOLEANYesDeceased flag (default: false)
deceased_dateDATENoDate of death
marital_statusENUMNosingle, married, divorced, widowed, separated, unknown
photo_urlVARCHAR(500)NoURL to patient photo
emailVARCHAR(255)NoEmail address
phone_country_codeVARCHAR(5)NoPhone country code
phone_numberVARCHAR(20)NoPhone number
addressJSONBNoStructured address
emergency_contactJSONBNoEmergency contact details
communication_preferencesJSONBNoLanguage and contact preferences
identifiersJSONBNoAdditional identifiers (insurance, etc.)
is_activeBOOLEANYesActive flag (default: true)
created_atTIMESTAMPYesRecord creation timestamp
updated_atTIMESTAMPYesLast 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_tenant on (tenant_id)
  • idx_cl_patients_tenant_mrn UNIQUE on (tenant_id, mrn) WHERE mrn IS NOT NULL
  • idx_cl_patients_tenant_abha UNIQUE on (tenant_id, abha_id) WHERE abha_id IS NOT NULL
  • idx_cl_patients_name on (lower(first_name), lower(last_name))
  • idx_cl_patients_display_name on (lower(display_name)) WHERE display_name IS NOT NULL
  • idx_cl_patients_phone on (phone_number) WHERE phone_number IS NOT NULL
  • idx_cl_patients_active on (is_active) WHERE is_active = true
  • idx_cl_patients_dob on (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

ColumnTypeRequiredDescription
idUUIDYesPrimary key (UUIDv7)
tenant_idUUIDYesForeign key to identity.tenants
patient_idUUIDYesForeign key to clinical.patients
facility_idUUIDYesForeign key to identity.facilities
facility_mrnVARCHAR(50)NoFacility-specific MRN
registration_dateDATENoFirst registration date
is_primaryBOOLEANYesPrimary facility flag (default: false)
is_activeBOOLEANYesActive flag (default: true)
created_atTIMESTAMPYesRecord creation timestamp
updated_atTIMESTAMPYesLast update timestamp

Indexes:

  • idx_cl_pf_tenant on (tenant_id)
  • idx_cl_pf_patient on (patient_id)
  • idx_cl_pf_facility on (facility_id)
  • idx_cl_pf_tenant_patient_facility UNIQUE on (tenant_id, patient_id, facility_id)
  • idx_cl_pf_facility_mrn UNIQUE on (tenant_id, facility_id, facility_mrn) WHERE facility_mrn IS NOT NULL
  • idx_cl_pf_primary_unique UNIQUE 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

ColumnTypeRequiredDescription
idUUIDYesPrimary key (UUIDv7)
tenant_idUUIDYesForeign key to identity.tenants
patient_idUUIDYesForeign key to clinical.patients
insurance_plan_idUUIDNoForeign key to reference.insurance_plans (future)
policy_numberVARCHAR(100)YesInsurance policy number
group_numberVARCHAR(100)NoGroup/employer number
subscriber_idVARCHAR(100)NoSubscriber/member ID
subscriber_nameVARCHAR(200)NoName of policy holder
relationship_to_subscriberENUMYesself, spouse, child, other
coverage_typeENUMYesprimary, secondary, tertiary
payer_nameVARCHAR(200)NoInsurance company name
payer_typeVARCHAR(50)Noprivate, government, employer, self_pay
effective_dateDATENoCoverage start date
termination_dateDATENoCoverage end date
copay_amountDECIMAL(10,2)NoCopay amount
deductible_amountDECIMAL(10,2)NoDeductible amount
coverage_detailsJSONBNoAdditional coverage info
is_verifiedBOOLEANYesVerification status (default: false)
verified_atTIMESTAMPNoLast verification timestamp
is_activeBOOLEANYesActive flag (default: true)
created_atTIMESTAMPYesRecord creation timestamp
updated_atTIMESTAMPYesLast 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_tenant on (tenant_id)
  • idx_cl_pic_patient on (patient_id)
  • idx_cl_pic_policy on (policy_number)
  • idx_cl_pic_active_unique UNIQUE on (tenant_id, patient_id, coverage_type) WHERE is_active = true
  • idx_cl_pic_effective on (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
end

1.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
end

1.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
end

1.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
end

1.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
end

Task 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 = true
    • current - effective and not terminated
    • primary, secondary, tertiary - by coverage type
    • verified, unverified - by verification status
  • Instance methods:
    • current? - checks if coverage is currently active
    • display_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, pagination
  • POST /clinical/patients - Create patient
  • GET /clinical/patients/:id - Show patient
  • PATCH /clinical/patients/:id - Update patient
  • DELETE /clinical/patients/:id - Delete patient
  • GET /clinical/patients/:id/facilities - List facility registrations
  • GET /clinical/patients/:id/insurance_coverages - List patient's insurance coverages
  • GET /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 coverage
  • GET /clinical/patient_insurance_coverages/:id - Show coverage
  • PATCH /clinical/patient_insurance_coverages/:id - Update coverage
  • DELETE /clinical/patient_insurance_coverages/:id - Delete coverage
  • POST /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
end

Task 9: Tests

9.1 Model Specs

  • backend/domains/clinical/spec/models/patient_spec.rb
  • backend/domains/clinical/spec/models/patient_facility_spec.rb
  • backend/domains/clinical/spec/models/patient_insurance_coverage_spec.rb

9.2 Request Specs

  • backend/domains/clinical/spec/requests/patients_spec.rb
  • backend/domains/clinical/spec/requests/patient_facilities_spec.rb
  • backend/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

MethodEndpointDescription
GET/clinical/patientsList patients with filters
POST/clinical/patientsCreate a new patient
GET/clinical/patients/:idGet patient details
PATCH/clinical/patients/:idUpdate patient
DELETE/clinical/patients/:idDelete patient
GET/clinical/patients/:id/facilitiesList patient's facility registrations
GET/clinical/patients/:id/insurance_coveragesList patient's insurance coverages
GET/clinical/patients/searchSearch patients (autocomplete)

Patient Insurance Coverages

MethodEndpointDescription
GET/clinical/patient_insurance_coveragesList coverages
POST/clinical/patient_insurance_coveragesCreate coverage
GET/clinical/patient_insurance_coverages/:idGet coverage details
PATCH/clinical/patient_insurance_coverages/:idUpdate coverage
DELETE/clinical/patient_insurance_coverages/:idDelete coverage
POST/clinical/patient_insurance_coverages/:id/verifyMark as verified

Query Parameters

List Patients:

  • search - Search by name, MRN, phone
  • facility_id - Filter by facility
  • alive_only - Exclude deceased patients
  • active_only - Only active patients
  • gender - Filter by gender
  • page, per_page - Pagination

Search Patients:

  • q - Search query
  • mrn - Exact MRN match
  • phone - Exact phone match
  • limit - 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 status
  • is_verified - Filter by verification status
  • current - Show only current coverages (effective and not terminated)
  • page, per_page - Pagination

FHIR Compliance

The Patient entity maps to FHIR R4 Patient resource:

FHIR FieldPatient Attribute
Patient.idid
Patient.identifiermrn, abha_id, identifiers
Patient.namefirst_name, middle_name, last_name, display_name
Patient.gendergender
Patient.birthDatebirth_date
Patient.deceasedBooleandeceased
Patient.deceasedDateTimedeceased_date
Patient.maritalStatusmarital_status
Patient.telecomemail, phone_country_code + phone_number
Patient.addressaddress (JSONB)
Patient.contactemergency_contact (JSONB)
Patient.communicationcommunication_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

TaskFilesComplexity
Database Migrations5Medium
Domain Foundation3Low
Models3Medium
Blueprints3Medium
Policies3Low
Controllers2Medium
Services2Medium
Routes1Low
Tests6+Medium

Total Files: ~28 Ruby files


On this page