Patient Self-Service Signup
Architecture, API contracts, and implementation details for independent patient registration via the Identity API.
Table of Contents
- Overview
- Architecture
- Database Changes
- Backend Services
- Patient-Clinic Linking
- API Endpoints
- Frontend (Patient Webapp)
- Mobile API Support
- Legacy Migration
- ABDM and ABHA Integration
- Security Considerations
- Key Design Decisions
Overview
Patients can independently register and use the Kulcare platform without being invited by a clinic. The system uses the Identity API for authentication (not the legacy parts/auth system), producing tenant-agnostic JWTs that work across the patient webapp and mobile apps.
Two-Record Patient Model
A patient exists as two records:
Identity::User(global, no tenant) -- Authentication, profile, available_profilesClinical::Patient(per-tenant, RLS-scoped) -- Created when a patient visits a specific clinic
This separation means a patient can exist in the Identity system before ever visiting a clinic.
Architecture
Patient Signup Flow
-------------------
1. POST /identity/registrations -> Creates Identity::User (signed_up_as: 'patient')
2. POST /identity/registrations/verify -> Verifies OTP, issues JWT + refresh token
3. Patient logs in -> Tenant-agnostic JWT (no membership needed)
4. GET /identity/patient/profile -> Returns user + linked_facilities
5. Patient visits clinic -> Clinical::Patient created with user_id linkJWT Model
Patient JWTs are tenant-agnostic: they contain only user_id, no tenant_id or facility_id. This is because patients don't belong to any single tenant. The TokenIssuanceService supports membership: nil for this case.
A session is still created during login (stored in identity.sessions), so require_authentication! works normally for patient endpoints.
Database Changes
Migration: add_user_id_to_clinical_patients
# backend/db/migrate/20260313100000_add_user_id_to_clinical_patients.rb
# Adds identity.users reference to clinical.patients for cross-schema linking- Adds
user_id(UUID, nullable) toclinical.patients - Foreign key to
identity.users - Unique index on
(tenant_id, user_id)-- one patient record per tenant per user - Index on
user_idfor reverse lookups
Backend Services
Clinical Domain
| Service | Purpose | Location |
|---|---|---|
FindOrCreateService | Central patient creation with automatic Identity user linking | backend/domains/clinical/services/patients/ |
SelfRegistrationService | Create Clinical::Patient when patient visits a clinic (Identity-first) | backend/domains/clinical/services/patients/ |
LinkedFacilitiesQuery | Find all clinics where a patient has records | backend/domains/clinical/services/patients/ |
LongitudinalRecordService | Aggregate patient data across tenants | backend/domains/clinical/services/patients/ |
Identity Domain
| Service | Purpose | Location |
|---|---|---|
ProfileOrchestrationService | Build available_profiles including patient profiles | backend/domains/identity/services/users/ |
GetAvailableProfilesService | Return available_profiles for login response | backend/domains/identity/services/profiles/ |
Controllers
| Controller | Endpoints | Auth |
|---|---|---|
PatientProfilesController | GET/PATCH /identity/patient/profile, POST /identity/patient/register_at_facility | JWT (tenant-agnostic) |
PatientDashboardController | GET /identity/patient/dashboard | JWT (tenant-agnostic) |
DevicesController | POST /identity/devices/push_token | JWT |
QueueController | POST /clinical/facilities/:id/queue/check_in (uses FindOrCreateService) | JWT (tenant-scoped) |
Patient-Clinic Linking
When a patient visits a clinic, a Clinical::Patient record must be created in the clinic's tenant and linked to the patient's Identity::User via user_id. This happens through three integration points, all of which automatically resolve the Identity user link.
How It Works
Identity::User (global)
|
| user_id (UUID FK)
|
v
Clinical::Patient (per-tenant, RLS-scoped)
|
| patient_id + facility_id
|
v
Clinical::PatientFacility (facility link)Integration Point 1: FindOrCreateService (Central)
FindOrCreateService is the central patient creation service used by public appointment booking and clinical appointment flows. It was enhanced to automatically link Identity::User records:
- Accepts optional
identity_user:parameter - Auto-looks up
Identity::Userby phone number if not provided - Sets
user_idon newly created patients - Backfills
user_idon existing patients that were created before Identity linking existed
# All callers automatically get Identity linking:
Clinical::Patients::FindOrCreateService.call(
tenant: tenant,
facility: facility,
patient_params: { phone_number: '9876543210', country_code: '91', name: 'John' }
)
# -> If Identity::User with phone 91-9876543210 exists, patient.user_id is setFlows using FindOrCreateService:
- Public appointment booking (3-step verification flow)
- Clinical appointment creation (staff-authenticated)
- Queue walk-in check-in (staff-authenticated)
Integration Point 2: Queue Check-In (Walk-In)
QueueController#check_in now routes through FindOrCreateService instead of creating patients inline. This fixes a previous bug where walk-in patients were created without PatientFacility links and without Identity::User linking.
Integration Point 3: Dedicated Patient API
POST /identity/patient/register_at_facility allows an authenticated patient to explicitly register at a facility. This calls SelfRegistrationService which:
- Creates
Clinical::Patientlinked to theIdentity::User - Creates
PatientFacilitylink - Generates an MRN (Medical Record Number)
- Is idempotent -- returning the existing patient on repeat calls
# Called from PatientProfilesController#register_at_facility
Clinical::Patients::SelfRegistrationService.call(
user: current_user, # Identity::User from JWT
facility: facility, # Identity::Facility
additional_data: { gender: 'female', birth_date: Date.new(1990, 1, 1) }
)Backfill Strategy
For patients created before the Identity linking existed, FindOrCreateService backfills the user_id when it encounters them:
- Uses
update_columns(skips callbacks/validations) for performance - Only backfills if
patient.user_idis nil - Handles
ActiveRecord::RecordNotUniquefor race conditions (another process linked first)
API Endpoints
Registration
POST /identity/registrations
Content-Type: application/json
{
"user_sign_up_form": {
"first_name": "John",
"last_name": "Doe",
"signed_up_as": "patient",
"phone_country_code": "91",
"phone_number": "9876543210",
"password": "SecurePass1!",
"email": "john@example.com" // optional
}
}
Response: 200 OK
{
"data": { "id": "uuid", "phone_number": "9876543210", ... },
"meta": { "request_id": "..." }
}OTP Verification
POST /identity/registrations/verify
{
"phone_country_code": "91",
"phone_number": "9876543210",
"verification_code": "123456",
"device_details": {
"device_type": "web",
"device_token": "browser-token",
"device_uuid": "uuid-v4",
"app_type": "patient"
}
}
Response: 200 OK
{
"data": {
"id": "uuid",
"first_name": "John",
"last_name": "Doe",
"access_token": "jwt-string",
"refresh_token": "jwt-string",
"expires_in": 86400,
"token_type": "Bearer",
"available_profiles": [
{ "type": "patient", "linked_facilities": [] }
]
},
"meta": { "request_id": "..." }
}Login
POST /identity/sessions
{
"user_signin_form": {
"phone_country_code": "91",
"phone_number": "9876543210",
"password": "SecurePass1!"
},
"device_details": {
"device_type": "web",
"device_token": "browser-token",
"device_uuid": "uuid-v4"
}
}Patient Profile
GET /identity/patient/profile
Authorization: Bearer <jwt>
Response: 200 OK
{
"data": {
"user": { "id": "uuid", "first_name": "John", ... },
"linked_facilities": [
{ "tenant_id": "uuid", "facility_name": "City Clinic", "patient_id": "uuid" }
]
}
}Register at Facility
POST /identity/patient/register_at_facility
Authorization: Bearer <jwt>
{
"facility_id": "uuid",
"gender": "female", // optional
"birth_date": "1990-05-15" // optional
}
Response: 201 Created
{
"data": {
"patient_id": "uuid",
"facility_id": "uuid",
"facility_name": "City Clinic",
"mrn": "MRN00012345"
},
"meta": { "request_id": "..." }
}Idempotent: calling again with the same facility_id returns the existing patient record.
Push Token Registration
POST /identity/devices/push_token
Authorization: Bearer <jwt>
{
"device_details": {
"device_type": "ios",
"device_token": "apns-token-abc123",
"app_type": "patient",
"app_version": "1.0.0",
"device_uuid": "device-uuid-123"
}
}Password Reset Flow
1. POST /identity/passwords/reset { "login_id": "9876543210", "country_code": "91" }
2. POST /identity/passwords/verify_code { "phone_country_code": "91", "phone_number": "...", "code": "123456" }
-> Returns { "data": { "access_token": "temp-jwt" } }
3. POST /identity/passwords/change { "password": "NewPass1!" }
Authorization: Bearer <temp-jwt-from-step-2>Frontend (Patient Webapp)
Key Type Changes
The patient webapp (patient/) was migrated from the legacy auth API to the Identity API:
| Aspect | Legacy | Identity |
|---|---|---|
| User type | name, user_accounts[] | first_name, last_name, available_profiles[] |
| Auth response | { success, auth_token, data: { user } } | { data: { ...user, access_token, refresh_token }, meta } |
| Registration key | user_signup_form | user_sign_up_form |
| Country code param | country_code | phone_country_code |
| Logout method | POST | DELETE |
| Error format | { message } | { errors: [{ source, title }] } |
Files Modified
| File | Change |
|---|---|
patient/src/types/auth.types.ts | Complete rewrite for Identity types |
patient/src/constants/api.ts | All auth endpoints remapped to /identity/* |
patient/src/services/auth.service.ts | Rewritten for Identity API contracts |
patient/src/stores/auth.store.ts | New extractUser(), extractErrorMessage() |
patient/src/utils/patient.ts | available_profiles-based utilities |
patient/src/features/auth/components/*.tsx | Updated all auth forms |
Patient Utilities
// patient/src/utils/patient.ts
getPatientProfile(user) // Find 'patient' profile in available_profiles
hasPatientAccount(user) // Boolean check
getPatientId(user) // Numeric ID from first linked_facility (null for UUID IDs)
getPatientName(user) // user.full_name
getPatientFacilities(user) // All linked_facilities from patient profileMobile API Support
All endpoints work identically for mobile apps. Key considerations:
- Device tracking: Send
device_detailswithdevice_type: 'ios'|'android'during login/verify - Push tokens: Use
POST /identity/devices/push_tokento register/update tokens after login - Token refresh: Use
POST /identity/sessions/refreshwithrefresh_token - Rate limiting: 5 requests/5min for registration, 10 requests/5min for login (by IP)
Legacy Migration
Problem
The legacy public.users table and identity.users table have separate phone uniqueness constraints. Without migration, existing patients could re-register, creating duplicates.
Migration Rake Task
# Dry run (no changes)
bundle exec rake patient_migration:dry_run
# Execute migration
bundle exec rake patient_migration:migrateThe task:
- Queries
public.usersforsigned_up_as: 'patient' - Creates matching
Identity::Userrecords (preservingpassword_digest) - Stores
legacy_user_idinsettingsJSONB for audit trail - Idempotent: skips users that already have Identity records
- Handles errors per-record (continues processing)
Duplicate Detection Guard
Registration validation (UserSignUpValidations) now checks both:
identity.users-- existing Identity userspublic.users-- legacy users
If a phone exists in the legacy table, the error message says: "is associated with an existing account. Please use the login page or reset your password."
ABDM and ABHA Integration
How ABHA IDs Relate to Patient Signup
ABHA (Ayushman Bharat Health Account) is India's national health identifier. It identifies a patient within the ABDM health data exchange network. ABHA is a clinical identifier, not an authentication credential -- it lives on Clinical::Patient, not Identity::User.
Where ABHA Data is Stored
Identity::User (global)
- phone, password, JWT auth
- NO ABHA data (authentication only)
Clinical::Patient (per-tenant)
- abha_id column (unique per tenant)
- identifiers JSONB array:
[
{ "system": "http://abdm.gov.in/healthid", "value": "john.doe@abdm" },
{ "system": "http://abdm.gov.in/healthid/number", "value": "1234567890" }
]abha_id: Direct column with unique index per tenant. Used for fast lookups and FHIR serialization.identifiers: JSONB array storing both ABHA address (healthid) and ABHA number (healthid/number).FindOrCreateServicepopulates this fromhealth_id/health_id_numberparams during patient creation.
Integration Flow
ABHA linking happens after patient signup, at the clinic level:
1. Patient registers via Identity API -> Identity::User created (no ABHA)
2. Patient visits a clinic -> Clinical::Patient created (no ABHA yet)
3. Patient links ABHA at the clinic -> abha_id + identifiers set on Clinical::Patient
4. Clinic registered as HIP in ABDM -> HipLinkRequest tracks the link
5. Prescriptions/records sent to ABDM -> AbdmCareContext created per recordABHA Linking Happens Per-Clinic (Not Per-User)
Each clinic is a separate HIP (Health Information Provider) in ABDM. When a patient links their ABHA at a clinic:
- The clinic's ABDM integration initiates the HIP link flow (
/abdm/hip/links/) - A
HipLinkRequestrecord tracks the linking status - The patient's
health_idandhealth_id_numberare stored on theirClinical::Patientrecord at that clinic - Subsequent clinical records (prescriptions, reports) are sent to ABDM as care contexts
How Patient Signup Flows Support ABHA
| Flow | ABHA Handling |
|---|---|
| Identity registration | No ABHA -- authentication only |
| FindOrCreateService (appointment booking) | Stores health_id / health_id_number in identifiers JSONB if provided in params |
| SelfRegistrationService (register at facility) | No ABHA initially -- patient can link later via clinic's ABDM flow |
| Queue check-in (walk-in) | Same as FindOrCreateService -- passes through any ABHA params |
| ABDM HIP linking (post-registration) | Sets abha_id column and updates identifiers on existing Clinical::Patient |
FHIR Serialization
The PatientBlueprint (view: :fhir) includes ABHA identifiers in the FHIR Patient resource:
# MRN identifier
{ use: 'official', type: { coding: [{ system: '...v2-0203', code: 'MR' }] }, value: patient.mrn }
# ABHA identifier
{ use: 'official', type: { coding: [{ system: 'https://ndhm.gov.in', code: 'ABHA' }] }, value: patient.abha_id }
# Additional identifiers from JSONB
patient.identifiers.each { |ident| { system: ident['system'], value: ident['value'] } }Why ABHA Lives on Clinical::Patient, Not Identity::User
- ABHA is a healthcare identifier, not an authentication credential. Mixing it into Identity conflates auth with clinical identification.
- It is tenant-scoped -- each clinic is a separate HIP. The ABHA link has clinical context (care contexts, consent artifacts) tied to that tenant.
- ABDM compliance requires per-clinic HIP registration -- the link flow, consent management, and data exchange all happen at the clinic level.
- Privacy separation -- the Identity layer knows nothing about a patient's health data or identifiers. Only the clinical layer (behind RLS) has access.
Future: Patient-Initiated ABHA Linking
If patients should be able to link their ABHA from the patient app before visiting a clinic:
- Store ABHA address as a convenience reference in
Identity::User.settingsJSONB - When
SelfRegistrationServicecreates aClinical::Patient, copy the ABHA from Identity settings to the clinical record - Add
PATCH /identity/patient/profilesupport forabha_address(stored in settings, not as a first-class field) - The actual ABDM HIP linking and data exchange would still happen per-clinic when the patient visits
Key ABDM Files
| File | Purpose |
|---|---|
parts/abdm/routes.rb | All ABDM API endpoints (ABHA auth, HIP linking, HIU consent, PHR) |
parts/abdm/models/hip_link_request.rb | Tracks HIP link status per patient per clinic |
parts/abdm/models/abdm_care_context.rb | Links clinical records to ABDM care contexts |
parts/abdm/models/abdm_patient_functions.rb | Mixin for patient ABDM helpers |
parts/doctor/interactors/prescriptions/send_to_abdm.rb | Sends prescriptions to ABDM |
domains/clinical/services/patients/find_or_create_service.rb | Stores health_id identifiers on patient creation |
domains/clinical/blueprints/patient_blueprint.rb | FHIR serialization including ABHA identifiers |
Security Considerations
- Patient JWTs contain only
user_id(no tenant data leakage) - Sessions are tracked in
identity.sessionswith device fingerprinting - OTP verification required before tokens are issued
- Password reset uses temporary access tokens (not verification codes)
- Rate limiting on all public endpoints
- Cross-system duplicate detection prevents account conflicts
Key Design Decisions
-
Tenant-agnostic JWTs for patients: Patients don't belong to a tenant, so their JWTs have no
tenant_id. Tenant context is resolved per-request when accessing clinical data. -
Two-record model:
Identity::User+Clinical::Patientallows patients to exist before visiting any clinic, while clinical data remains tenant-scoped with RLS. -
available_profilesreplacesuser_accounts: The Identity API returns profiles with linked facilities, replacing the legacy flatuser_accountsarray. -
password_digestpreserved during migration: Legacy bcrypt hashes are copied directly to Identity::User, so migrated patients can log in with their existing passwords. -
getPatientId()returns null for UUID IDs: Legacy endpoints expect numeric IDs. The utility returns null for UUID patient IDs, letting consumers use fallbacks until those features are migrated. -
Centralized Identity linking via FindOrCreateService: Rather than modifying every appointment flow individually,
FindOrCreateServiceauto-looks upIdentity::Userby phone and setsuser_id. All flows (public bookings, clinical appointments, queue check-in) automatically link patients to Identity accounts. Existing patients withoutuser_idare backfilled on lookup. -
SelfRegistrationService for patient-initiated registration: When patients explicitly register at a facility (QR code scan, app flow),
SelfRegistrationServicecreates theClinical::Patientwithuser_idfrom the JWT, generates an MRN, and creates the facility link. It is idempotent -- repeat calls return the existing patient.