Kulcare Docs
DeveloperPatient domain

Patient Domain

Status: ✅ Backend complete (Phases 1–8). Frontend integration tracked in 2026-04-23 integration plan.

Module: backend/domains/patient/

Architecture: ADR-002 — Patient Domain Architecture

API reference: docs/api-reference/patient/README.md


Overview

The Patient:: domain is the backend that serves the standalone patient React app (located outside this monorepo at patients/). It provides two surfaces under one namespace:

  1. Patient-reported records — 10 resources (vitals, symptoms, medications, allergies, conditions, immunizations, family history, procedures, health notes, documents) owned by the patient and stored in a new patient.* PostgreSQL schema. Full CRUD with soft-delete and restore.
  2. Clinician-authored records — read-only access to the patient's own clinical history (conditions, prescriptions, allergy intolerances, observations, encounters, documents) across every tenant they've visited, via the pre-existing Clinical::Patients::LongitudinalRecordService. Appointments are not part of the LHR — they're forward-looking scheduling, not retrospective history. They live on a dedicated GET /patient/appointments endpoint backed by Patient::Appointments::ListService, which does cross-tenant fan-out for practitioner appointments and unions in vendor appointments (where tenant_id IS NULL and the user is attendee_user_id).

The patient never hits Clinical::* controllers directly. The façade keeps patient auth (user-scoped JWT) and clinician auth (tenant + practitioner) cleanly separated at the HTTP layer.


Technical Architecture

Module layout

backend/domains/patient/
├── base_record.rb                         # Abstract AR base (table_name_prefix = 'patient.')
├── concerns/
│   └── owned.rb                           # Shared provenance/soft-delete/scoping behavior
├── controllers/
│   ├── base_controller.rb                 # Patient JWT auth; skips all tenant before_actions
│   ├── owned_resource_controller.rb       # Abstract CRUD — every resource inherits
│   ├── vitals_controller.rb               # + bulk endpoint
│   ├── symptoms_controller.rb
│   ├── medications_controller.rb
│   ├── allergies_controller.rb
│   ├── conditions_controller.rb
│   ├── immunizations_controller.rb
│   ├── family_history_entries_controller.rb
│   ├── procedures_controller.rb
│   ├── health_notes_controller.rb
│   ├── documents_controller.rb            # ActiveStorage attach handling
│   └── clinical/
│       ├── records_controller.rb          # Dispatcher for /patient/clinical/:resource_type
│       └── timeline_controller.rb         # Aggregated cross-resource feed
├── models/
│   ├── base_record.rb
│   └── <resource>.rb × 10
├── blueprints/<resource>_blueprint.rb × 10
├── policies/
│   ├── owned_policy.rb                    # Base — owner-only, 404 on stranger access
│   ├── <resource>_policy.rb × 9           # One-line subclasses
│   └── clinical_read_policy.rb            # Authenticated-only gate for the clinical façade
├── services/
│   └── vitals/bulk_create_service.rb      # Idempotent device sync
├── migrations/
│   └── 10 CreateXInPatientSchema migrations
├── routes.rb
└── spec/                                  # 180+ specs

Two data surfaces, one façade

┌───────────────────────── Patient React app (`patients/`) ─────────────────────────┐
│                                                                                    │
│    /patient/<resource>        ←───  Patient-reported writes & reads                │
│                                      (patient.* schema, user-scoped)               │
│                                                                                    │
│    /patient/clinical/<type>   ←───  Clinician-authored reads across tenants        │
│    /patient/clinical/timeline ←───  Aggregated feed                                 │
│                                                                                    │
└─────────────────────────────────────────────┬──────────────────────────────────────┘
                                              │
                          ┌───────────────────┴────────────────────┐
                          │                                        │
                     Patient:: controllers         Clinical::Patients::LongitudinalRecordService
                          │                                        │
                   patient.* tables                     (per-tenant RLS iteration)
                    (no RLS, user_id)                             │
                                                                  │
                                                          clinical.* tables
                                                         (tenant-scoped, RLS)

Schema — patient.*

10 tables, all sharing the same provenance columns:

ColumnTypeNotes
iduuid PK, public.generate_uuidv7()Time-sortable — matches identity/clinical convention
user_iduuid, FK → identity.users.id, ON DELETE RESTRICTOwner. Scoping key.
sourcetext, NOT NULL, default 'self_reported'self_reported | clinical_imported | device | external (CHECK)
source_referencejsonb, nullableRequired when source != 'self_reported' (CHECK)
recorded_by_user_iduuid, FK → identity.users.id, ON DELETE RESTRICTWho authored. V1: must equal user_id.
occurred_attimestamp, nullableCanonical "when" — uniform across resources for timeline aggregation
created_at, updated_attimestamp
deleted_attimestamp, nullableSoft-delete marker; default_scope excludes tombstoned rows

Plus resource-specific columns — see each migration in backend/domains/patient/migrations/.

Idempotency indexes

Two partial unique indexes per table enforce idempotent writes at the database layer:

-- Prevents double-importing the same clinical record
CREATE UNIQUE INDEX idx_p_<table>_clinical_import
  ON patient.<table> (user_id, (source_reference->>'type'), (source_reference->>'id'))
  WHERE source = 'clinical_imported';

-- Makes device sync retry-safe
CREATE UNIQUE INDEX idx_p_<table>_device_sample
  ON patient.<table> (user_id, (source_reference->>'vendor'), (source_reference->>'sample_id'))
  WHERE source = 'device';

Authentication flow

1. Patient app sends request with  Authorization: Bearer <identity JWT>
2. Patient::BaseController extracts the token (skip_before_action :require_tenant!)
3. Identity::IdentityTokenIssuer.decode_access_token → { user_id: ... }
4. Identity::User.find(user_id) → current_user
5. require_patient_authentication! runs as the sole auth before_action
6. Controller action runs; policy_scope(resource) auto-filters by current_user.id

No tenant context is ever set on Patient:: requests, even if the JWT's owning session has one. set_tenant_from_session is explicitly skipped. When a controller needs clinical data across tenants, it goes through LongitudinalRecordService, which sets and clears RLS per-tenant inside an ensure block.


API Reference

Full endpoint reference with request/response examples:

➡ docs/api-reference/patient/README.md

Quick map

SurfaceVerbPathController
Patient-reportedGET/POST/PATCH/DELETE/patient/<resource>Patient::<Resource>sController
Patient-reportedPOST/patient/<resource>/:id/restoresame
Bulk ingestionPOST/patient/vitals/bulkPatient::VitalsController#bulk
DocumentsPOST multipart/patient/documentsPatient::DocumentsController#create
Clinical readGET/patient/clinical/:resource_typePatient::Clinical::RecordsController#index
Clinical readGET/patient/clinical/:resource_type/:idPatient::Clinical::RecordsController#show
Clinical timelineGET/patient/clinical/timelinePatient::Clinical::TimelineController#show

Allowed :resource_type values: conditions, prescriptions, allergy_intolerances, observations, encounters, documents. Unknown types 404 at the route layer. Appointments are served separately via GET /patient/appointments — see Patient::Appointments::ListService and the cross-tenant booking flow at POST /patient/appointment_orders.


Dependencies

Runtime

ComponentWhyLocation
Identity::UserJWT decoding → current_userdomains/identity/models/user.rb
Identity::IdentityTokenIssuerAccess token verificationdomains/identity/services/identity_token_issuer.rb
Clinical::PatientAnchor row used to find all tenants a patient has visiteddomains/clinical/models/patient.rb
Clinical::Patients::LongitudinalRecordServiceCross-tenant read iteratordomains/clinical/services/patients/longitudinal_record_service.rb
Shared::BaseControllerBlueprinter / Pundit / pagination helpersdomains/shared/controllers/base_controller.rb
Shared::BasePolicyPundit basedomains/shared/policies/base_policy.rb
Shared::BaseBlueprintBlueprinter basedomains/shared/blueprints/base_blueprint.rb
ActiveStorageDocument file attachmentsRails built-in
UUIDv7 SQL functionpublic.generate_uuidv7() for all PKsbackend/db/migrate/20251231000004_add_uuidv7_support.rb

Gem

All already in the Gemfile (Rails 8.1, blueprinter, pundit, activestorage — built in).


Configuration

Environment variables

Nothing specific to the patient domain — identity JWT secrets and database credentials are shared with the rest of the app.

ActiveStorage

Patient::Document uses has_one_attached :file. In config/storage.yml, any of the existing services (:local, :amazon, etc.) will serve. For production isolation, consider a dedicated S3 bucket; the model declaration doesn't need to change to adopt one — just add a service: argument:

has_one_attached :file, service: :amazon_patient_documents

Schema migration

The patient PostgreSQL schema is created by backend/db/migrate/20260423000001_create_patient_schema.rb. The 10 tables live in backend/domains/patient/migrations/. Standard rails db:migrate picks both up via the domain migrations path config in config/application.rb.

Rails autoload

backend/config/application.rb already pushes domains/patient/{models,controllers,blueprints,policies,concerns,services} with the Patient namespace. Files at domains/patient/controllers/clinical/records_controller.rb resolve to Patient::Clinical::RecordsController — this coexists cleanly with the top-level Clinical:: module.


Common Tasks

Add a new patient-reported resource

The abstract base makes this a small, templated change. Say you want to add Patient::SleepEntry:

  1. Migration — copy one of the existing CreateXInPatientSchema files. Keep the common columns, add resource-specific columns, set the right enum CHECK constraints.
  2. Model — domains/patient/models/sleep_entry.rb:
    module Patient
      class SleepEntry < BaseRecord
        include Patient::Owned
        validates :started_at, presence: true
    
        before_validation :sync_occurred_at
        private def sync_occurred_at = (self.occurred_at ||= started_at)
      end
    end
  3. Blueprint — domains/patient/blueprints/sleep_entry_blueprint.rb.
  4. Policy — domains/patient/policies/sleep_entry_policy.rb:
    module Patient
      class SleepEntryPolicy < OwnedPolicy
      end
    end
  5. Controller — domains/patient/controllers/sleep_entries_controller.rb:
    module Patient
      class SleepEntriesController < OwnedResourceController
        self.resource_class  = Patient::SleepEntry
        self.blueprint_class = Patient::SleepEntryBlueprint
    
        private
    
        def permitted_attributes
          params.require(:sleep_entry).permit(:started_at, :ended_at, :quality, :notes,
                                              :source, :occurred_at, source_reference: {})
        end
      end
    end
  6. Routes — add :sleep_entries to the resource list in domains/patient/routes.rb.
  7. Spec — add one more config block to domains/patient/spec/requests/owned_resources_spec.rb. The parameterized spec covers CRUD, ownership 404, and source constraints automatically.

Total: ~6 new files, ~40 lines of actual code.

Add a new clinical-read resource

Even smaller — the Patient::Clinical::RecordsController is a dispatcher. Adding clinical.care_plans to the surface:

  1. Service registry — add a line to CLINICAL_RESOURCE_TYPES in Clinical::Patients::LongitudinalRecordService:
    care_plans: { model: 'Clinical::CarePlan', sort_by: :created_at }
  2. Route constraint — add care_plans to the allowed-types list in domains/patient/routes.rb.
  3. Timeline builder (optional) — if you want the resource in the aggregated timeline, add a block to build_patient_timeline.

No new controller, no new route block, no new policy. The dispatcher picks up the new type via the registry.

Change a resource's filter logic

Filters are resource-specific and live in each controller's apply_filters method. To add, say, a category filter to conditions:

# domains/patient/controllers/conditions_controller.rb
def apply_filters(scope)
  scope = scope.where(status: params[:status])     if params[:status].present?
  scope = scope.where(category: params[:category]) if params[:category].present?   # new
  # ...
  scope.order(occurred_at: :desc, created_at: :desc)
end

Nothing else changes.

Debug a cross-tenant read returning empty

The most common cause: the user has no Clinical::Patient row anchored to their identity. Check:

Clinical::Patient.unscoped.where(user_id: <user_id>).pluck(:id, :tenant_id)

If that's empty, the longitudinal service will iterate zero tenants and return []. This is the correct behavior for new patients who haven't visited any clinic yet — the frontend should render an empty state.


Testing

Spec layout

backend/domains/patient/spec/
├── rails_helper.rb                        # Includes Patient::Specs::AuthStubs
├── foundation_spec.rb                     # Schema + autoload + Clinical::Condition coexistence
├── models_smoke_spec.rb                   # All 10 models inherit & validate correctly
├── concerns/
│   └── owned_spec.rb                      # Patient::Owned concern behaviors directly
├── policies/
│   ├── owned_policy_spec.rb               # Base policy + Scope
│   └── resource_policies_smoke_spec.rb    # 9 subclass inheritance checks
├── requests/
│   ├── vitals_spec.rb                     # Full CRUD + filters
│   ├── vitals_bulk_spec.rb                # Idempotent bulk ingestion
│   ├── owned_resources_spec.rb            # Parameterized CRUD for 8 resources
│   ├── documents_spec.rb                  # ActiveStorage file attach
│   ├── clinical_records_spec.rb           # Cross-tenant dispatcher
│   └── clinical_timeline_spec.rb          # Aggregated feed
└── support/
    └── auth_stubs.rb                      # stub_patient_auth(user) helper

179+ examples, all green, rubocop-clean.

Running specs

cd backend

# Everything patient-related
bundle exec rspec domains/patient/spec/

# Only the cross-tenant clinical reads (requires clinical schema)
bundle exec rspec domains/patient/spec/requests/clinical_records_spec.rb \
                  domains/patient/spec/requests/clinical_timeline_spec.rb \
                  domains/clinical/spec/services/patients/longitudinal_record_service_spec.rb

# One request spec with doc output
bundle exec rspec domains/patient/spec/requests/vitals_spec.rb --format doc

Auth stubs

Every request spec authenticates via stub_patient_auth(user) (from Patient::Specs::AuthStubs). The helper stubs two things:

  1. Patient::BaseController#load_user_from_token → returns the given Identity::User.
  2. Shared::BaseController#current_session → returns an instance_double(Identity::Session, active?: true, expired?: false).

This sidesteps the full JWT + session creation flow while still exercising the require_patient_authentication! and require_authentication! before_actions end-to-end.

QA testing

For a step-by-step test plan covering each endpoint, edge cases, and regression checks, see:

➡ Patient Domain QA Guide


References

On this page