Kulcare Docs

Patient Self-Service Signup

Architecture, API contracts, and implementation details for independent patient registration via the Identity API.


Table of Contents

  1. Overview
  2. Architecture
  3. Database Changes
  4. Backend Services
  5. Patient-Clinic Linking
  6. API Endpoints
  7. Frontend (Patient Webapp)
  8. Mobile API Support
  9. Legacy Migration
  10. ABDM and ABHA Integration
  11. Security Considerations
  12. 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_profiles
  • Clinical::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 link

JWT 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) to clinical.patients
  • Foreign key to identity.users
  • Unique index on (tenant_id, user_id) -- one patient record per tenant per user
  • Index on user_id for reverse lookups

Backend Services

Clinical Domain

ServicePurposeLocation
FindOrCreateServiceCentral patient creation with automatic Identity user linkingbackend/domains/clinical/services/patients/
SelfRegistrationServiceCreate Clinical::Patient when patient visits a clinic (Identity-first)backend/domains/clinical/services/patients/
LinkedFacilitiesQueryFind all clinics where a patient has recordsbackend/domains/clinical/services/patients/
LongitudinalRecordServiceAggregate patient data across tenantsbackend/domains/clinical/services/patients/

Identity Domain

ServicePurposeLocation
ProfileOrchestrationServiceBuild available_profiles including patient profilesbackend/domains/identity/services/users/
GetAvailableProfilesServiceReturn available_profiles for login responsebackend/domains/identity/services/profiles/

Controllers

ControllerEndpointsAuth
PatientProfilesControllerGET/PATCH /identity/patient/profile, POST /identity/patient/register_at_facilityJWT (tenant-agnostic)
PatientDashboardControllerGET /identity/patient/dashboardJWT (tenant-agnostic)
DevicesControllerPOST /identity/devices/push_tokenJWT
QueueControllerPOST /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:

  1. Accepts optional identity_user: parameter
  2. Auto-looks up Identity::User by phone number if not provided
  3. Sets user_id on newly created patients
  4. Backfills user_id on 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 set

Flows 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:

  1. Creates Clinical::Patient linked to the Identity::User
  2. Creates PatientFacility link
  3. Generates an MRN (Medical Record Number)
  4. 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_id is nil
  • Handles ActiveRecord::RecordNotUnique for 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:

AspectLegacyIdentity
User typename, user_accounts[]first_name, last_name, available_profiles[]
Auth response{ success, auth_token, data: { user } }{ data: { ...user, access_token, refresh_token }, meta }
Registration keyuser_signup_formuser_sign_up_form
Country code paramcountry_codephone_country_code
Logout methodPOSTDELETE
Error format{ message }{ errors: [{ source, title }] }

Files Modified

FileChange
patient/src/types/auth.types.tsComplete rewrite for Identity types
patient/src/constants/api.tsAll auth endpoints remapped to /identity/*
patient/src/services/auth.service.tsRewritten for Identity API contracts
patient/src/stores/auth.store.tsNew extractUser(), extractErrorMessage()
patient/src/utils/patient.tsavailable_profiles-based utilities
patient/src/features/auth/components/*.tsxUpdated 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 profile

Mobile API Support

All endpoints work identically for mobile apps. Key considerations:

  • Device tracking: Send device_details with device_type: 'ios'|'android' during login/verify
  • Push tokens: Use POST /identity/devices/push_token to register/update tokens after login
  • Token refresh: Use POST /identity/sessions/refresh with refresh_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:migrate

The task:

  • Queries public.users for signed_up_as: 'patient'
  • Creates matching Identity::User records (preserving password_digest)
  • Stores legacy_user_id in settings JSONB 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:

  1. identity.users -- existing Identity users
  2. public.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). FindOrCreateService populates this from health_id / health_id_number params 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 record

ABHA 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:

  1. The clinic's ABDM integration initiates the HIP link flow (/abdm/hip/links/)
  2. A HipLinkRequest record tracks the linking status
  3. The patient's health_id and health_id_number are stored on their Clinical::Patient record at that clinic
  4. Subsequent clinical records (prescriptions, reports) are sent to ABDM as care contexts

How Patient Signup Flows Support ABHA

FlowABHA Handling
Identity registrationNo 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

  1. ABHA is a healthcare identifier, not an authentication credential. Mixing it into Identity conflates auth with clinical identification.
  2. It is tenant-scoped -- each clinic is a separate HIP. The ABHA link has clinical context (care contexts, consent artifacts) tied to that tenant.
  3. ABDM compliance requires per-clinic HIP registration -- the link flow, consent management, and data exchange all happen at the clinic level.
  4. 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:

  1. Store ABHA address as a convenience reference in Identity::User.settings JSONB
  2. When SelfRegistrationService creates a Clinical::Patient, copy the ABHA from Identity settings to the clinical record
  3. Add PATCH /identity/patient/profile support for abha_address (stored in settings, not as a first-class field)
  4. The actual ABDM HIP linking and data exchange would still happen per-clinic when the patient visits

Key ABDM Files

FilePurpose
parts/abdm/routes.rbAll ABDM API endpoints (ABHA auth, HIP linking, HIU consent, PHR)
parts/abdm/models/hip_link_request.rbTracks HIP link status per patient per clinic
parts/abdm/models/abdm_care_context.rbLinks clinical records to ABDM care contexts
parts/abdm/models/abdm_patient_functions.rbMixin for patient ABDM helpers
parts/doctor/interactors/prescriptions/send_to_abdm.rbSends prescriptions to ABDM
domains/clinical/services/patients/find_or_create_service.rbStores health_id identifiers on patient creation
domains/clinical/blueprints/patient_blueprint.rbFHIR serialization including ABHA identifiers

Security Considerations

  • Patient JWTs contain only user_id (no tenant data leakage)
  • Sessions are tracked in identity.sessions with 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

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

  2. Two-record model: Identity::User + Clinical::Patient allows patients to exist before visiting any clinic, while clinical data remains tenant-scoped with RLS.

  3. available_profiles replaces user_accounts: The Identity API returns profiles with linked facilities, replacing the legacy flat user_accounts array.

  4. password_digest preserved during migration: Legacy bcrypt hashes are copied directly to Identity::User, so migrated patients can log in with their existing passwords.

  5. 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.

  6. Centralized Identity linking via FindOrCreateService: Rather than modifying every appointment flow individually, FindOrCreateService auto-looks up Identity::User by phone and sets user_id. All flows (public bookings, clinical appointments, queue check-in) automatically link patients to Identity accounts. Existing patients without user_id are backfilled on lookup.

  7. SelfRegistrationService for patient-initiated registration: When patients explicitly register at a facility (QR code scan, app flow), SelfRegistrationService creates the Clinical::Patient with user_id from the JWT, generates an MRN, and creates the facility link. It is idempotent -- repeat calls return the existing patient.

On this page