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:
- 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. - 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 dedicatedGET /patient/appointmentsendpoint backed byPatient::Appointments::ListService, which does cross-tenant fan-out for practitioner appointments and unions in vendor appointments (wheretenant_id IS NULLand the user isattendee_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+ specsTwo 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:
| Column | Type | Notes |
|---|---|---|
id | uuid PK, public.generate_uuidv7() | Time-sortable — matches identity/clinical convention |
user_id | uuid, FK → identity.users.id, ON DELETE RESTRICT | Owner. Scoping key. |
source | text, NOT NULL, default 'self_reported' | self_reported | clinical_imported | device | external (CHECK) |
source_reference | jsonb, nullable | Required when source != 'self_reported' (CHECK) |
recorded_by_user_id | uuid, FK → identity.users.id, ON DELETE RESTRICT | Who authored. V1: must equal user_id. |
occurred_at | timestamp, nullable | Canonical "when" — uniform across resources for timeline aggregation |
created_at, updated_at | timestamp | |
deleted_at | timestamp, nullable | Soft-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.idNo 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
| Surface | Verb | Path | Controller |
|---|---|---|---|
| Patient-reported | GET/POST/PATCH/DELETE | /patient/<resource> | Patient::<Resource>sController |
| Patient-reported | POST | /patient/<resource>/:id/restore | same |
| Bulk ingestion | POST | /patient/vitals/bulk | Patient::VitalsController#bulk |
| Documents | POST multipart | /patient/documents | Patient::DocumentsController#create |
| Clinical read | GET | /patient/clinical/:resource_type | Patient::Clinical::RecordsController#index |
| Clinical read | GET | /patient/clinical/:resource_type/:id | Patient::Clinical::RecordsController#show |
| Clinical timeline | GET | /patient/clinical/timeline | Patient::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
| Component | Why | Location |
|---|---|---|
Identity::User | JWT decoding → current_user | domains/identity/models/user.rb |
Identity::IdentityTokenIssuer | Access token verification | domains/identity/services/identity_token_issuer.rb |
Clinical::Patient | Anchor row used to find all tenants a patient has visited | domains/clinical/models/patient.rb |
Clinical::Patients::LongitudinalRecordService | Cross-tenant read iterator | domains/clinical/services/patients/longitudinal_record_service.rb |
Shared::BaseController | Blueprinter / Pundit / pagination helpers | domains/shared/controllers/base_controller.rb |
Shared::BasePolicy | Pundit base | domains/shared/policies/base_policy.rb |
Shared::BaseBlueprint | Blueprinter base | domains/shared/blueprints/base_blueprint.rb |
| ActiveStorage | Document file attachments | Rails built-in |
| UUIDv7 SQL function | public.generate_uuidv7() for all PKs | backend/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_documentsSchema 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:
- Migration — copy one of the existing
CreateXInPatientSchemafiles. Keep the common columns, add resource-specific columns, set the right enum CHECK constraints. - 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 - Blueprint —
domains/patient/blueprints/sleep_entry_blueprint.rb. - Policy —
domains/patient/policies/sleep_entry_policy.rb:module Patient class SleepEntryPolicy < OwnedPolicy end end - 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 - Routes — add
:sleep_entriesto the resource list indomains/patient/routes.rb. - 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:
- Service registry — add a line to
CLINICAL_RESOURCE_TYPESinClinical::Patients::LongitudinalRecordService:care_plans: { model: 'Clinical::CarePlan', sort_by: :created_at } - Route constraint — add
care_plansto the allowed-types list indomains/patient/routes.rb. - 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)
endNothing 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) helper179+ 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 docAuth stubs
Every request spec authenticates via stub_patient_auth(user) (from Patient::Specs::AuthStubs). The helper stubs two things:
Patient::BaseController#load_user_from_token→ returns the givenIdentity::User.Shared::BaseController#current_session→ returns aninstance_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:
References
- Design doc: 2026-04-10-011 Patient Domain Schema
- Implementation plan: 2026-04-22 Backend Implementation
- ADR: 2026-04-23-002 Patient Domain Architecture
- API reference: patient/README.md
- Origin brainstorm: 2026-04-08 Patient Portal Domain Architecture
- Frontend follow-up: 2026-04-23 patients/ app integration plan
- Mobile integration guide: mobile-integration-guide.md