Kulcare Docs
DeveloperAuthentication Documentation04 featuresOnboarding

Onboarding System Technical Reference

Comprehensive technical documentation for the custom onboarding by user type feature (PR #1111).

Quick Reference

AspectDetails
User Typespractitioner (3 steps), facility (1 step), staff/vendor (no wizard)
Frontend Locationfrontend/apps/shell/src/components/identity/OnboardingModal.tsx
Backend Controllerbackend/domains/identity/controllers/my_practitioner_controller.rb
API RoutesGET/PATCH /identity/my/practitioner, POST /identity/users/:id/onboarding
Key HookuseMyPractitioner() from @kulcare/shared-services

System Architecture

High-Level Flow

User Signup
    ↓
OTP Verification
    ↓
ProfileOrchestrationService (auto-creates Practitioner/Tenant)
    ↓
Redirect to /connect
    ↓
OnboardingModal (Dialog overlay)
    ↓
Role-based step wizard
    ↓
POST /identity/users/:id/onboarding
    ↓
Connect Dashboard (with CTA banners)

Component Structure

frontend/apps/shell/src/
├── components/identity/
│   └── OnboardingModal.tsx          # Main orchestrator
└── pages/identity/
    ├── OnboardingPage.tsx          # Redirect only
    └── onboarding/
        ├── ProfileBasicsStep.tsx   # Step 1
        ├── ProfessionalIdentityStep.tsx  # Step 2
        └── ProfileImportStep.tsx  # Step 3

frontend/apps/connect/src/
├── components/identity/
│   ├── FacilitySetupCTA.tsx       # Facility owner CTA
│   └── IdentityProfileCompletionBanner.tsx  # Profile prompts
└── pages/
    └── FacilitySetupPage.tsx      # Placeholder

Backend Implementation

API Endpoints

GET /identity/my/practitioner

Retrieves the current user's practitioner profile. No tenant context required.

File: backend/domains/identity/controllers/my_practitioner_controller.rb

def show
  practitioner = current_user.practitioner
  if practitioner
    authorize practitioner, policy_class: MyPractitionerPolicy
    render_blueprint(practitioner, PractitionerBlueprint, view: :detailed)
  else
    render_success(data: nil)
  end
end

Response: PractitionerBlueprint with :detailed view, or { data: null } if no practitioner exists.

PATCH /identity/my/practitioner

Updates the current user's practitioner profile. No tenant context required.

def update
  authorize @practitioner, policy_class: MyPractitionerPolicy
  if @practitioner.update(practitioner_params)
    render_blueprint(@practitioner, PractitionerBlueprint, view: :detailed)
  else
    render_validation_errors(@practitioner.errors.full_messages)
  end
end

def practitioner_params
  params.require(:practitioner).permit(
    :first_name, :last_name, :display_name, :gender, :birth_date,
    :avatar_url, :bio, :experience_years, :external_profile_url,
    languages: [],
    qualifications: [:code, :issuer, :year, :specialty],
    identifiers: [:system, :value, :state]
    # `specialties` is intentionally NOT permitted (see #2005). The column was
    # retired by #1853; specialties are facility-scoped (practitioner_role_specialties)
    # and set via the role, not this self-serve update.
  )
end

POST /identity/users/:user_id/onboarding

Marks onboarding as complete. Existing endpoint reused.

# Triggers SaveOnboardingService which:
# 1. Validates input (64KB limit)
# 2. Saves onboarding_data to user record
# 3. If current_step == total_steps, calls user.complete_onboarding!

Authorization Policy

File: backend/domains/identity/policies/my_practitioner_policy.rb

class MyPractitionerPolicy < Shared::BasePolicy
  def show?
    own_practitioner?
  end

  def update?
    own_practitioner?
  end

  private

  def own_practitioner?
    record.user_id == user.id
  end
end

Routes Registration

File: backend/domains/identity/routes.rb (lines 165-175)

scope :my do
  resources :invites, only: %i[index], controller: 'my_invites' do
    member do
      post :accept
      post :decline
    end
  end

  resource :practitioner, only: %i[show update], controller: 'my_practitioner'
end

SaveOnboardingService

File: backend/domains/identity/services/save_onboarding_service.rb

class SaveOnboardingService < BaseService
  MAX_ONBOARDING_DATA_SIZE = 64.kilobytes

  def call
    validation_result = validate_input
    return validation_result if validation_result&.failure?

    save_onboarding_data
    check_completion_status
    success(user.reload)
  end

  private

  def validate_input
    return failure('Onboarding data is required') if params.blank?
    return failure('User is required') if user.blank?
    return failure('Onboarding data is too large') if params.to_json.bytesize > MAX_ONBOARDING_DATA_SIZE
  end

  def check_completion_status
    current_step = params[:current_step]&.to_i || 0
    total_steps = params[:total_steps]&.to_i || 0
    return unless current_step == total_steps && total_steps.positive?
    user.complete_onboarding!
  end
end

Database Changes

Migration: external_profile_url

add_column 'identity.practitioners', :external_profile_url, :string, limit: 500

PractitionerBlueprint

File: backend/domains/identity/blueprints/practitioner_blueprint.rb

view :detailed do
  include_view :normal
  fields :avatar_url, :bio, :languages, :qualifications, :identifiers,
         :birth_date, :user_id, :experience_years, :external_profile_url

  field :specialties do |practitioner|
    practitioner.specialty_names.presence || practitioner.specialties_from_qualifications
  end
end

User Model Validation

validates :avatar_url, format: {
  with: URI::DEFAULT_PARSER.make_regexp(%w[http https]),
  message: 'must be a valid HTTP(S) URL'
}, allow_blank: true

Frontend Implementation

OnboardingModal (Main Orchestrator)

File: frontend/apps/shell/src/components/identity/OnboardingModal.tsx

const STEPS_BY_ROLE: Record<string, OnboardingStep[]> = {
  practitioner: ['profile-basics', 'professional-identity', 'profile-import'],
  facility: ['profile-basics'],
}

const shouldShow = !isDismissed
  && !user?.onboarding_completed
  && (signedUpAs === 'practitioner' || signedUpAs === 'facility')

Key Features:

  • Dialog overlay - user sees Connect behind it
  • Prevents dismissal via onEscapeKeyDown, onInteractOutside
  • Lazy-loaded step components
  • React Query for practitioner data (caching, deduplication)
  • Progress indicator dots for multi-step flows

Step 1: ProfileBasicsStep

File: frontend/apps/shell/src/pages/identity/onboarding/ProfileBasicsStep.tsx

Collects: Photo (S3), bio (practitioners only), email (if not set)

S3 Upload Flow:

// 1. Get presigned URL
const urls = await generateUrls.mutateAsync([
  { bucket, file_path: `users/${user.id}/avatar.${ext}` },
])

// 2. Upload directly to S3
const uploadResponse = await fetch(urls[0], {
  method: 'PUT',
  body: file,
  headers: { 'Content-Type': file.type },
})

if (!uploadResponse.ok) {
  throw new Error(`Upload failed with status ${uploadResponse.status}`)
}

// 3. Store clean URL
setAvatarUrl(urls[0].split('?')[0])

Parallel Save:

const promises: Promise<unknown>[] = []

// Update user (photo + email)
if (avatarUrl || data.email) {
  promises.push(apiClient.patch(`/identity/users/${user.id}`, {
    user: { avatar_url: avatarUrl, email: data.email }
  }))
}

// Update practitioner (bio - practitioners only)
if (data.bio && user?.is_practitioner) {
  promises.push(apiClient.patch('/identity/my/practitioner', {
    practitioner: { bio: data.bio }
  }))
}

await Promise.all(promises)

Validation:

const profileBasicsSchema = z.object({
  bio: z.string().max(500).optional(),
  email: z.string().email().optional().or(z.literal('')),
})

Step 2: ProfessionalIdentityStep

File: frontend/apps/shell/src/pages/identity/onboarding/ProfessionalIdentityStep.tsx

Collects: Years of experience, registration number

Specialty is not collected here (#2005). It is a facility-scoped, billable attribute stored on practitioner_role_specialties (#1853) and can only be persisted once the practitioner holds a role at a facility. It is captured at facility-join and surfaced person-level via the Connect profile's derived_specialties. Collecting it at signup silently discarded it.

API Call:

await apiClient.patch('/identity/my/practitioner', {
  practitioner: {
    experience_years: experienceYears,
    identifiers: data.registration_number
      ? [{ system: 'registration', value: data.registration_number }]
      : undefined,
  }
})

Step 3: ProfileImportStep

File: frontend/apps/shell/src/pages/identity/onboarding/ProfileImportStep.tsx

Collects: External profile URL (Practo, Healthgrades, etc.) or skip

API Call:

await apiClient.patch('/identity/my/practitioner', {
  practitioner: { external_profile_url: data.external_profile_url }
})

React Query Hooks

File: frontend/packages/shared-services/src/domains/identity/hooks.ts

export function useMyPractitioner(enabled = true) {
  return useQuery({
    queryKey: identityKeys.myPractitioner.detail(),
    queryFn: () => getMyPractitioner(),
    enabled,
  })
}

FacilitySetupCTA

File: frontend/apps/connect/src/components/identity/FacilitySetupCTA.tsx

Shows for facility owners with draft tenants:

const draftProfile = availableProfiles.find(
  (p) => p.tenant?.status === 'draft'
)
if (!draftProfile) return null

Dismiss logic uses sessionStorage (survives refresh, not session restart).


State Management

State Types

TypeImplementationPurpose
Server StateReact Query (useMyPractitioner)Practitioner data caching
Auth StateZustand (useAuthStore)User session, onboarding_completed flag
UI StateLocal useStateStep index, loading, dismissed

Auth Store Update

useAuthStore.setState((state) => ({
  user: state.user
    ? { ...state.user, onboarding_completed: true }
    : state.user
}))

API Request/Response Examples

GET /identity/my/practitioner

Request:

GET /identity/my/practitioner
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response:

{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "first_name": "John",
    "last_name": "Doe",
    "display_name": "Dr. John Doe",
    "bio": "Experienced cardiologist...",
    "experience_years": 15,
    "external_profile_url": "https://www.practo.com/dr-john-doe",
    "specialties": [
      { "id": "uuid", "name": "Cardiology", "is_primary": true }
    ],
    "identifiers": [
      { "system": "registration", "value": "MCI-12345" }
    ]
  }
}

PATCH /identity/my/practitioner

Request:

PATCH /identity/my/practitioner
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json

{
  "practitioner": {
    "experience_years": 15,
    "identifiers": [{ "system": "registration", "value": "MCI-12345" }]
  }
}

POST /identity/users/:id/onboarding

Request:

POST /identity/users/550e8400-e29b-41d4-a716-446655440000/onboarding
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json

{
  "onboarding": {
    "current_step": 3,
    "total_steps": 3,
    "completed_steps": ["profile-basics", "professional-identity", "profile-import"],
    "user_type": "practitioner"
  }
}

Response:

{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "onboarding_completed": true,
    "onboarding_completed_at": "2026-03-23T12:00:00Z"
  }
}

Error Handling

Backend Errors

ErrorResponse
Practitioner not found404 ResourceNotFoundError
Validation failurerender_validation_errors
Data too largefailure('Onboarding data is too large')

Frontend Errors

// Photo upload
catch {
  setPreviewUrl(user?.avatar_url || null)
  toast.error('Failed to upload photo')
}

// Step submission
catch {
  toast.error('Failed to save. Please try again.')
}

// Onboarding completion
catch {
  toast.error('Something went wrong. Please try again.')
}

Security Measures

  1. Authorization: MyPractitionerPolicy ensures users can only modify own record
  2. URL Validation: avatar_url validated with HTTP(S) URL regex (XSS prevention)
  3. Input Size Limit: 64KB max for onboarding data payload
  4. No Tenant Context: /identity/my/practitioner bypasses tenant scoping

Performance Optimizations

  1. Lazy Loading: Step components use React.lazy() - not in initial bundle
  2. React Query: Caching, deduplication, automatic retry
  3. Parallel Saves: Promise.all() for concurrent API calls
  4. Blob URL Cleanup: URL.revokeObjectURL() on unmount
  5. S3 Direct Upload: Photos bypass backend

Dev Tools

In development mode, a "Reset Onboarding" button appears:

{import.meta.env.DEV && (
  <Button
    onClick={() => {
      const store = useAuthStore.getState()
      if (store.user) {
        store.setState({
          user: { ...store.user, onboarding_completed: false }
        })
      }
    }}
  >
    Reset Onboarding
  </Button>
)}

Note: Client-side only toggle. Does not reset backend flag.


File Reference

Backend Files

FilePurpose
controllers/my_practitioner_controller.rbGET/PATCH /identity/my/practitioner
policies/my_practitioner_policy.rbOwn-record authorization
services/save_onboarding_service.rbOnboarding data persistence
routes.rbRoute registration
models/practitioner.rbexternal_profile_url field
models/user.rbavatar_url validation
blueprints/practitioner_blueprint.rbExtended serialization
blueprints/user_blueprint.rbonboarding_completed field

Frontend Files

FilePurpose
OnboardingModal.tsxMain orchestrator
ProfileBasicsStep.tsxStep 1: Photo, bio, email
ProfessionalIdentityStep.tsxStep 2: Experience, registration number
ProfileImportStep.tsxStep 3: External profile URL
FacilitySetupCTA.tsxFacility owner CTA
FacilitySetupPage.tsxPlaceholder page
api.tsAPI functions
hooks.tsReact Query hooks
keys.tsQuery key factory

User Scenarios

Practitioner (3 Steps)

  1. Sign up → OTP → Redirect to /connect
  2. OnboardingModal appears
  3. Step 1: Upload photo, add bio, verify email → Continue
  4. Step 2: Enter experience, registration number → Continue
  5. Step 3: Paste URL or skip → Complete
  6. Land on Connect with IdentityProfileCompletionBanner

Facility Owner (1 Step)

  1. Sign up → OTP → Draft tenant created (free plan)
  2. OnboardingModal appears (1 step)
  3. Step 1: Upload photo, add bio → Complete
  4. Land on Connect with FacilitySetupCTA

Skip & Resume

  1. Click "Skip -- I'll add this later" on Step 3
  2. Onboarding marked complete
  3. Banner shows on Connect to remind later

Edge Cases

ScenarioBehavior
No practitioner recordReturns null, forms show empty
Partial dataForms pre-fill with existing values
Upload failsError toast, preview reverts
Network errorError toast, retry available

On this page