Onboarding System Technical Reference
Comprehensive technical documentation for the custom onboarding by user type feature (PR #1111).
Quick Reference
| Aspect | Details |
|---|---|
| User Types | practitioner (3 steps), facility (1 step), staff/vendor (no wizard) |
| Frontend Location | frontend/apps/shell/src/components/identity/OnboardingModal.tsx |
| Backend Controller | backend/domains/identity/controllers/my_practitioner_controller.rb |
| API Routes | GET/PATCH /identity/my/practitioner, POST /identity/users/:id/onboarding |
| Key Hook | useMyPractitioner() 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 # PlaceholderBackend 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
endResponse: 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.
)
endPOST /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
endRoutes 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'
endSaveOnboardingService
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
endDatabase Changes
Migration: external_profile_url
add_column 'identity.practitioners', :external_profile_url, :string, limit: 500PractitionerBlueprint
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
endUser 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: trueFrontend 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'sderived_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 nullDismiss logic uses sessionStorage (survives refresh, not session restart).
State Management
State Types
| Type | Implementation | Purpose |
|---|---|---|
| Server State | React Query (useMyPractitioner) | Practitioner data caching |
| Auth State | Zustand (useAuthStore) | User session, onboarding_completed flag |
| UI State | Local useState | Step 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
| Error | Response |
|---|---|
| Practitioner not found | 404 ResourceNotFoundError |
| Validation failure | render_validation_errors |
| Data too large | failure('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
- Authorization:
MyPractitionerPolicyensures users can only modify own record - URL Validation:
avatar_urlvalidated with HTTP(S) URL regex (XSS prevention) - Input Size Limit: 64KB max for onboarding data payload
- No Tenant Context:
/identity/my/practitionerbypasses tenant scoping
Performance Optimizations
- Lazy Loading: Step components use
React.lazy()- not in initial bundle - React Query: Caching, deduplication, automatic retry
- Parallel Saves:
Promise.all()for concurrent API calls - Blob URL Cleanup:
URL.revokeObjectURL()on unmount - 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
| File | Purpose |
|---|---|
controllers/my_practitioner_controller.rb | GET/PATCH /identity/my/practitioner |
policies/my_practitioner_policy.rb | Own-record authorization |
services/save_onboarding_service.rb | Onboarding data persistence |
routes.rb | Route registration |
models/practitioner.rb | external_profile_url field |
models/user.rb | avatar_url validation |
blueprints/practitioner_blueprint.rb | Extended serialization |
blueprints/user_blueprint.rb | onboarding_completed field |
Frontend Files
| File | Purpose |
|---|---|
OnboardingModal.tsx | Main orchestrator |
ProfileBasicsStep.tsx | Step 1: Photo, bio, email |
ProfessionalIdentityStep.tsx | Step 2: Experience, registration number |
ProfileImportStep.tsx | Step 3: External profile URL |
FacilitySetupCTA.tsx | Facility owner CTA |
FacilitySetupPage.tsx | Placeholder page |
api.ts | API functions |
hooks.ts | React Query hooks |
keys.ts | Query key factory |
User Scenarios
Practitioner (3 Steps)
- Sign up → OTP → Redirect to /connect
- OnboardingModal appears
- Step 1: Upload photo, add bio, verify email → Continue
- Step 2: Enter experience, registration number → Continue
- Step 3: Paste URL or skip → Complete
- Land on Connect with IdentityProfileCompletionBanner
Facility Owner (1 Step)
- Sign up → OTP → Draft tenant created (free plan)
- OnboardingModal appears (1 step)
- Step 1: Upload photo, add bio → Complete
- Land on Connect with FacilitySetupCTA
Skip & Resume
- Click "Skip -- I'll add this later" on Step 3
- Onboarding marked complete
- Banner shows on Connect to remind later
Edge Cases
| Scenario | Behavior |
|---|---|
| No practitioner record | Returns null, forms show empty |
| Partial data | Forms pre-fill with existing values |
| Upload fails | Error toast, preview reverts |
| Network error | Error toast, retry available |