Onboarding and facility setup
Audience: engineers touching signup, Connect, or the identity backend; product/sales who need to know which paths are supported.
TL;DR: a practitioner gets a facility in one of three ways. The Facility track of Connect onboarding ends with
POST /identity/my/facility_setup, which makes a new practice or adds a facility to the user's own practice. Sales can also provision a practice by hand from a product lead. An owner or admin can add a facility from the dashboard. Virtual OPD isservice_level: 'opd_only' + is_virtual_only: true, not aservice_levelenum value.
Supported paths
| Path | Who | Outcome |
|---|---|---|
| Self-serve practice (Connect onboarding, Facility track) | A user who may start a practice (owner, admin or practitioner of any practice, or a new Facility Owner) | A new active practice, its first facility, a trial on the picked plan, and the team invites (section 5) |
| Self-serve facility add | Owner or admin of an active practice, with manage_facilities | A new facility under that practice. The onboarding add-facility track uses POST /identity/my/facility_setup with existing_tenant (section 5). The dashboard form uses POST /identity/facilities (section 6). |
| Sales-led | A practitioner who leaves a product lead | Sales provisions the practice by hand after the call back (section 4) |
The ProductInterestForm captures interest, and sales follows up out-of-band.
Full journey
Stage-by-stage
1. Signup
- Endpoint:
POST /identity/registrations, thenPOST /identity/registrations/verifywith the OTP. - Frontend:
frontend/apps/shell/src/pages/SignUpPage.tsx - Backend:
backend/domains/identity/forms/user_sign_up_form.rbline 190 lists the allowedsigned_up_asvalues:practitioner | facility | patient | staff | vendor. - After the OTP check,
VerificationWithProfileServicecallsProfileOrchestrationService, which acts onsigned_up_as:practitioner: it makes theIdentity::Practitionerrecord, and turns each pending practitioner invite into a practitioner role at the invited facility. It makes no practice.facility: a Facility Owner signup makes the user only. No tenant, organization or facility is made at signup (plan 2026-10-07, R1). The practice is made at the end of the Facility track (section 5). A user who signs up from a Facility Admin invite (a pending staff invite withstaff_role: 'admin') goes the staff way instead and lands at the invited facility.staff: it processes the pending staff invites.vendor: it makes the vendor record.patient: it makes the patient record.
2. Connect onboarding tracks
The old shell OnboardingModal (3 steps) is gone. #2613 removed it. Connect onboarding now runs as full-page
steps, one track for each role. Connect onboarding by role has every step.
- Where the user lands. Sign-in ends on
nextwhen it is a safe path, else on/connect(signInTarget,frontend/apps/shell/src/services/identity/postAuth.ts)./connect(ConnectIndexRoute,frontend/apps/connect/src/pages/onboarding/OnboardingPages.tsx) sends a user who has not finished to their next open step, and a finished user to the dashboard. - The role.
onboardingRoleFor(user.signed_up_as)(frontend/packages/shared-services/src/domains/connect/onboarding.types.ts:308):practitionerordoctoris Practitioner,facilityorowneris Owner,stafforfacility_staffis Staff,vendororlamazeis Vendor. Any other value opens Welcome (/connect/welcome), where the user picks a role. - The answers. They go to one onboarding document on the user (
user.onboarding_data.step_data.connect,GETandPOST /identity/users/:id/onboarding) and save as the user types. Only a few steps also write to a real record (see the guide by role). - The Facility track (Owner). About, Facility, Practice and Verify (only when the owner sees patients),
Address (not for a virtual-only facility), Team, Operations, Plan and Review. Nothing is made while the owner
walks the steps. Review's "Create my practice" calls
POST /identity/my/facility_setupwithnew_tenant(frontend/packages/shared-services/src/domains/connect/facilitySetup.ts), which makes the practice, its facility, the owner's role, the plan's trial and the team invites (section 5). - The add-facility track. Facility, Address, Team, Operations, Plan and Review. Review's button is "Add my
facility" for
existing_tenant(a user who owns or administers an active practice), else "Create my practice" (new_tenant). - "Not now" on Review makes nothing.
- Finished. The user's own flow is finished when each of its steps is
completedorskipped. The first save that sees this recordsonboarding.completed_at.
3. Connect Dashboard — state branching
- File:
frontend/apps/connect/src/pages/DashboardPage.tsx - Gate (lines 49 + 77-79):
const hasMemberships = availableProfiles.length > 0 // ... {!isAuthLoading && !hasMemberships && user.is_practitioner && ( <ProductInterestForm /> )} - If
hasMemberships, the standard dashboard renders (stats grid + upcoming appointments + recent referrals).
4. State A: product lead capture
- Component:
frontend/apps/connect/src/components/identity/ProductInterestForm.tsx - Products offered:
opd_module,opd_ipd_lite,hospital,rev_share(mirrorsIdentity::ProductLead::PRODUCTS). - Submit flow:
POST /identity/product_leads→ backend upserts oneIdentity::ProductLeadrow per product ((user_id, product)is unique-indexed) → enqueuesIdentity::NotifySalesLeadJobper row (idempotent on(lead_id, day)). - Backend:
- Controller:
backend/domains/identity/controllers/product_leads_controller.rb - Model:
backend/domains/identity/models/product_lead.rb - Job:
backend/domains/identity/jobs/notify_sales_lead_job.rb— v1 logs toRails.logger; Slack/email integration is a TODO.
- Controller:
- After submit: form collapses to a "Pending contact" confirmation pill. User can re-open and submit additional products. No tenant is created.
5. The end of the Facility track: POST /identity/my/facility_setup
- Controller:
backend/domains/identity/controllers/my_facility_setup_controller.rb - Services:
OnboardingSetupServicemakes the records.OnboardingBillingstarts the trial or puts the facility on the plan.OnboardingAnswersvalidates the answers and maps them onto the facility. - Plan:
docs/plans/2026-10-07-feat-connect-owner-facility-integration-plan.md, B6. - Body:
{ mode, tenant_id?, plan_id, expected_new_recurring?, idempotency_key, answers, allow_second_practice? }.answersis the frontendFacilityAnswersshape. - No session tenant: the endpoint finds the practice through the user's own memberships. No profile switch comes first.
Two modes:
| Mode | Who | What it makes |
|---|---|---|
new_tenant | A user who may start a practice (PracticeStart) and owns no active practice | An active tenant, organization, facility and owner membership, through SetupService.call(draft: false). plan_id is required. It must be a plan for sale (SubscriptionPlan.for_sale) offered in the facility's country. |
existing_tenant | Owner or admin of the active practice tenant_id, with the manage_facilities permission | A facility under that practice. The facility country must be the practice's country. |
In one transaction, under an advisory lock on the user:
- The records, with the answers mapped onto the facility and its primary location.
- A physician role for an owner who sees patients (
CreateOrLinkWithRoleService, safe to call twice). The owner sees patients whenanswers.owner_sees_patientsis true, or when the team has their own row (is_self: true) with thepractitionerrole. A practitioner who adds a facility of their own is not asked; the Facility steps put them on the team instead. - The result, stored against
idempotency_keyinusers.settingsand marked incomplete.
After the commit:
- Billing.
new_tenantstarts the plan's trial (Billing::Subscriptions::CreateService, no payment). An owner who owns another practice that lapsed for non-payment gets no trial (TrialPolicy.no_new_trial?): no subscription is made, andsubscription_errorsays to pay for the plan from billing. Checkout on the new practice then starts it with no trial. The rule applies to a new practice only, one with no live subscription in its history (TrialPolicy.owner_lapsed_elsewhere?). It reads the owners of the practice and the user who pays. A practice with a history keeps its own record, and a co-owner's lapse elsewhere does not change it.existing_tenantputs the facility on the practice's subscription throughPreviewServiceandChangeService, the path the subscription screens use. It does this only when the membership hasmanage_subscriptionand the quote's new monthly total equalsexpected_new_recurring. The client gets that number, in minor units, fromPOST /billing/subscriptions/changes/preview. Connect's Plan step sends the target{ kind: 'facility', facilityTypeId }, which prices the same as the target B6 builds after it makes the facility. The step keeps the total asnew_recurringin theplananswers, and Review sends it. The step asks only when the session is in that practice and the member hasmanage_subscription. Otherwise the facility stays off the plan, and someone with the permission adds it from billing. - Invites. Each team row, less the owner's own row, gets a practitioner or staff invite in the new practice's context.
- The final result replaces the stored one and is marked complete.
A billing or invite failure goes into the response and does not undo the practice. A billing failure is also logged and reported to error tracking.
Response:
{ "data": { "tenant_id": "…", "facility_id": "…", "status": "active",
"subscription": { "status": "trial", "trial_end_date": "…" },
"subscription_error": null,
"subscription_error_code": null,
"invites": { "sent": 2, "failed": [] } } }subscription_error_code is no_trial when the practice gets no free trial (TrialPolicy), and null for every other error. Connect's Review shows the server's subscription_error line as it is only for no_trial. It branches on the code, never on the wording.
Repeats:
- Same key, complete result: 200 with the stored result. Nothing is made or sent.
- Same key, incomplete result (the process stopped after the commit): the billing and the invites run again, and the result is completed. A trial or plan item that exists is kept, and an invite that went out is counted as sent.
- A name race: two requests that pick the same name at the same moment are retried once. A second clash gets 409
FACILITY_SETUP_CONFLICT.
Refusals: 403 AUTHZ_INSUFFICIENT_ROLE for a user who may not start a practice, or who is not an owner or admin with manage_facilities of the active practice. 422 PRACTICE_ALREADY_OWNED for a new_tenant from a user who already owns an active practice, unless the body sets allow_second_practice: true. No screen sends that flag. 422 with field errors for a missing name or type, a missing, foreign or not-for-sale plan, a bad team row, or a bad expected_new_recurring. 422 FACILITY_SETUP_MODE_REQUIRED for an unknown mode.
The result is stored under a row lock on the user, and the write sets only the facility_setup_results key of users.settings (jsonb_set). A write to another key of users.settings is kept.
Known limit: other writers of users.settings (reauth codes, identifier changes, ABHA) still read and rewrite the whole column without the row lock. One of them can drop facility_setup_results. A retry with that idempotency key then gets 422 PRACTICE_ALREADY_OWNED, not the stored result.
POST /billing/subscriptions and POST /billing/subscriptions/initiate also take only a plan for sale. A legacy plan that live subscriptions keep active, such as a free one, is 404.
An owner who already owns a practice
A Facility Owner from before #2613 owns an active practice and has never done Connect onboarding. The owner flow does not offer them a second practice:
- The onboarding repository reads the active practices the user owns from the profile list (
activeOwnedPracticeIds). On every read it marks such an owner on Facility details (owned_practice_id,withOwnedPractice). A save stores the mark. stepsForthen drops Facility details, Address, Team, Operations and Plan. The flow is About, Practice and Verify when the owner says on About that they see patients, and Review. Until the owner answers on About, an answer they gave earlier on Facility details stands (ownerSeesPatientsIn).- Review makes nothing (
makesFacilityis false). The owner adds facilities from the dashboard. - Practice and Verify make only the practitioner record. They do not give the owner a physician role at their existing facility, so patients cannot book them there yet. That is pending work.
The rest of the Connect side
- "Add a facility" (
/connect/facility/new) does not reset the user's own flow while it is not complete, for any role. It opens the furthest step of that flow. The reset would writeadd_facilityover their role, and nothing brings that role back. An owner who already owns a practice is the exception: their own flow has no Facility steps, so the reset runs. onboarding.completed_atis recorded only from the user's own flow, never from theadd_facilityflow. A finishedadd_facilityflow also does not make the user complete (onboardingCompleted). Onlycompleted_atdoes.- A changed facility type on Facility details clears
plan.new_recurringand the Plan step's status, so the owner confirms the price again before Review.
Sales-led provisioning from a product lead is still manual. Sales provisions those practices by hand, not through this endpoint.
6. Self-serve facility addition (post-onboarding)
The onboarding add-facility track uses POST /identity/my/facility_setup with existing_tenant (section 5). The dashboard form below uses POST /identity/facilities. Both need the manage_facilities permission.
- File:
frontend/apps/connect/src/pages/FacilityNewPage.tsx - Gate (line 80):
selectedProfile?.role === 'owner' || selectedProfile?.role === 'admin'. Other roles see an access-denied guidance card. - Practice type radio (form field
practice_type, default'in_person'):in_person: collectsaddress_line_1,city(required),state,postal_code, etc.virtual_only: hides the address section entirely.
- Submit payload for both branches:
{ "facility": { "name": "...", "display_name": "...", "slug": "auto-derived-from-name-and-city", "service_level": "opd_only", "is_virtual_only": true, "locations_attributes": [/* only for in_person */] } } - Backend:
backend/domains/identity/controllers/facilities_controller.rb—createstrips client-suppliedorganization_idand derives it fromcurrent_tenant.organizations.order(:created_at).first(single-org tenants today; multi-org gets the oldest org with a Rails.logger warning). - Post-create flow:
GET /identity/profiles→useAuthStore.setAvailableProfiles(fresh)— refreshes the store before the JWT swap soselectedProfileresolves cleanly.queryClient.invalidateQueries({ queryKey: identityKeys.facilities.all() }).identityAuthService.switchFacility({ tenantId, facilityId })— flips the bearer.- Toast "{facility name} is ready." → navigate to
/connect/dashboard.
Virtual OPD — the explicit answer
What "virtual OPD" means in the schema:
service_level: 'opd_only'+is_virtual_only: true.The DB enum
identity.service_leveldoes not include'virtual_only'. Virtual is signaled by the dedicated boolean column. If you find yourself trying to add'virtual_only'to the enum union, stop — read this section.
Path A — sales-led (new clinic)
- Practitioner signs up → 3-step OnboardingModal → State A on Connect Dashboard.
- Selects "OPD Module" in
ProductInterestForm, optionally adds notes ("telemedicine-only practice"), submits. - Sales calls back, provisions tenant + facility via the manual SOP. They set
is_virtual_only: trueif the practitioner asked for telemedicine-only. - Practitioner logs back in to Connect and sees their tenant active in the facility switcher.
Path B — self-serve (existing tenant)
- Owner or admin already has a tenant. They navigate to
/connect/facility/new(link from the "Add facility" QuickAction on Dashboard, gated on owner/admin role). - Pick "Virtual-only practice" radio. Address fields disappear.
- Submit. Backend persists
service_level: 'opd_only', is_virtual_only: true. - Frontend refreshes profiles → switches JWT to the new facility → toast → land on Dashboard with the new virtual OPD selected.
Permissions matrix
| Role | View Connect Dashboard | Use ProductInterestForm | Create new facility |
|---|---|---|---|
| Owner | ✓ | only if no membership | ✓ |
| Admin | ✓ | only if no membership | ✓ |
| Staff / nurse / coordinator | ✓ | only if no membership | ✗ (access-denied card on /connect/facility/new) |
| State A practitioner | ✓ | ✓ (the only path forward) | ✗ (no tenant) |
Test accounts
Full list in backend/db/seeds/identity/TEST_ACCOUNTS.md. Quick reference for testing this flow:
| Password | Tests | |
|---|---|---|
state.a.practitioner@example.com | StateA123! | State A → ProductInterestForm |
dr.james.wilson@smith.com | Wilson123! | Owner → /connect/facility/new (in-person + virtual-only) |
sarah.johnson@cityhospital.com | Admin123! | Admin → /connect/facility/new (TOTP required) |
rani.nurse@cityhospital.com | Nurse123! | Staff → access-denied guidance card |
To get the State A account, run bin/rails db:drop db:create db:migrate db:seed (the State A user is in 04_users.rb + 06_practitioners.rb with no membership rows).
Test scenarios
- State A flow: log in as
state.a.practitioner@example.com. Confirm theProductInterestFormcard renders. Submit OPD Module + Hospital. In Rails console:Identity::ProductLead.where(user_id: SeedUUIDs::STATE_A_PRACTITIONER).pluck(:product)should return both. Sidekiq web should show twoNotifySalesLeadJobenqueues (or checkRails.loggerfor two[sales.product_lead]lines). - Self-serve in-person: log in as
dr.james.wilson@smith.com. Click "Add facility" → fill name, city, full address. Submit. Confirm: toast appears, facility switcher shows new facility selected, DB row hasis_virtual_only: falseandservice_level: 'opd_only'. - Self-serve virtual-only: same account. Pick "Virtual-only practice". Submit. Confirm: address fields didn't render, DB row has
is_virtual_only: trueandservice_level: 'opd_only'. - Staff access-denied: log in as
rani.nurse@cityhospital.com. Navigate directly to/connect/facility/new. Confirm: access-denied guidance card; "Add facility" QuickAction is hidden on Dashboard.
Open documentation debt
- The sales SOP: how a captured lead becomes a provisioned tenant is not in this repo. Document the manual SOP in an ops-owned doc and link to it from step 4 above, or give sales an admin tool.
Identity::Facilities::SetupService.call(draft: false)is whatPOST /identity/my/facility_setupwraps for a new practice (section 5). An admin tool for sales can wrap it the same way.
Cross-references
- Plan that shipped this work:
docs/plans/2026-04-30-feat-connect-state-a-onboarding-wizard-plan.md - PR: #1286 (merged 2026-04-30)
- Issue: #1268 (closed)
- Connect onboarding by role: the steps of each role, what each step saves, and where a user lands after they stop part way.
- Profile completeness: what each profile needs to be found, and where each item is fixed.