Skip to main content

Onboarding architecture

This page explains the mechanics behind the three-step onboarding wizard: the single gate read that routes it, the write path behind each step, and how the gates leak into the rest of the portal. The guard stack, the sync-on-demand race bridge, and FB-2 error-screen behavior are covered in Authentication architecture; billing internals (checkout, webhooks, subscription activation) belong to the billing flow. Runtime probes: Org-creation flow and the Half-onboarded org fixture.

Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2 and optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea, 2026-10-07 (FLOW-002 harvest; B-017 re-verification pass). No live-browser re-audit of this flow has been recorded; the paid path through real Stripe/Paymob test mode is record-unverified (docs/FLOW-002.md §11).

The gate read drives everything​

One endpoint decides what the wizard renders: GET /portal/onboarding/state (src/portal/portal-onboarding.controller.ts) returns {hasAppConfig, plan, bypass}:

  • hasAppConfig — prisma.appConfig.count({ where: { organizationId } }) > 0 (step-2 gate)
  • plan — raw Organization.plan, null until selected (step-3 gate)
  • bypass — ops-org short-circuit ({hasAppConfig: true, plan: null, bypass: true}); the page navigates straight to /dashboard, which routes admins to /admin per FLOW-001

src/app/(auth)/onboarding/page.tsx renders whichever gate is tripped and advances by invalidating the ['onboarding-state'] query — there is no imperative step navigation. The query is enabled: hasOrg (src/hooks/use-onboarding.ts) because the endpoint needs an org-active session and 403s otherwise.

getState resolves the org itself, not via OrgResolutionPipe: no org claim → 403; a Clerk org with no DB row → clerkSync.ensureOrgFromClerk(clerkOrgId) (the webhook-race bridge, same mechanism as authentication); still missing → 403. This is what lets the wizard read state in the moment between createOrganization returning and the organization.created webhook landing.

Step 1: create-organization (Option E)​

POST /portal/onboarding/create-organization is the only orgless portal route: no org claim, no role check, just ClerkAuthGuard + RateLimitGuard + @Auditable. Body {name} (trimmed, 1–100 chars). Everything below happens synchronously before the 201 (src/portal/portal-onboarding.service.ts createOrganization):

  1. 409 if the local OrgMember table says the caller already belongs to any org. Local DB is the source of truth; this is the org-farming/double-submit guard.
  2. clerkBackend.createOrganization({ name, createdBy: clerkUserId }) — Clerk auto-adds the creator membership and fires organization.created + organizationMembership.created webhooks, which later confirm the same state idempotently.
  3. Local mirror in order: Organization (plan: NULL) → User → OrgMember(role: OWNER), via the shared ensure-patterns (src/clerk/clerk-sync.service.ts).
  4. clerkBackend.updateMemberRole(role: 'owner') stamps the membership's publicMetadata.role.

The FB-1 invariant: the owner role is stamped before any org-scoped token exists, so the caller's first org-scoped token already carries org_metadata.role = 'owner'. This eliminates the pre-fix failure mode by construction: before the fix, the first ~60 s of org-scoped tokens carried no role claim (measured: 403 at +38 s, first success at +96 s). Roles still come from Clerk session claims only; there is no RolesGuard DB fallback.

Name collisions: Organization.name is @unique. A P2002 on the local mirror triggers a best-effort clerkBackend.deleteOrganization rollback of the just-created Clerk org, then a 409; no orphaned Clerk orgs. This rollback covers only this endpoint's path: a duplicate name arriving via the webhook path still P2002s → 500 → Svix retry (left open, docs/FLOW-002.md).

Frontend completion order (name-workspace-step.tsx) is deliberate and load-bearing:

mutateAsync(create-organization)
→ clerk.setActive({ organization: clerkOrgId }) // mints the org-scoped token
→ clerk.user?.getOrganizationMemberships() // refreshes the cached membership list
→ invalidate ['onboarding-state'] // gate re-read advances to step 2

The memberships refresh exists because useAuth().role is derived from Clerk's cached membership list; skipping it leaves the fresh owner on the waiting-on-owner banner until a manual reload. The mutation hook itself has no onSuccess invalidation on purpose, so a failed setActive can be retried alone (this retry path is the "couldn't switch this session" screen; re-submitting the form would 409 on the already-in-org guard). Note: the fix-pass decision text names clerk.organization.switchTo, which does not exist in @clerk/react 6.12.2 — setActive({ organization }) is the actual (and equivalent) call.

Step 2: per-platform app-config upsert​

PATCH /portal/app-config/:platform (src/portal/portal-app-config.controller.ts, @Roles('developer')); the platform segment is uppercased server-side. The DTO (src/app-config/dto/upsert-app-config.dto.ts) is partial by design (FLOW-013 D2a): every field optional, with bundleId + storeUrl required only when the call creates the row (they back non-nullable columns). The wizard ignores that latitude and always sends a full payload via buildAppConfigPayload (src/lib/app-config.ts). Fields: bundleId/storeUrl, teamId? (iOS), sha256Fingerprints?: string[] (Android), uriScheme?, fallbackWebUrl?. The list read is GET /portal/app-config → bare AppConfig[] (no wrapper), query key ['app-config'].

While configs load, step 2 renders a structure-matching skeleton (picker row + 4 labeled input rows + button row), not a spinner. commitIfDirty in app-config-step.tsx guarantees unsaved edits are flushed before advancing or switching platform cards.

Step 3: two plan paths​

Free path — POST /portal/onboarding/select-plan (@Roles('owner'), @HttpCode(200)). The DTO accepts any known plan key (@IsIn over the plan-key list), but the service only ever writes 'starter':

  • paid key → UseBillingFlowException (422), body carries code: 'USE_BILLING_FLOW' + billingEndpoint: '/portal/billing/checkout-session' (extra fields forwarded by the GlobalExceptionFilter)
  • ops org → 400
  • re-selecting starter when plan is already 'starter' skips the write (idempotent)

Organization.plan is written directly — no Subscription row on this path.

Paid path — POST /portal/billing/checkout-session (src/portal/portal-billing.controller.ts L398–434, @Roles('owner'), @HttpCode(200) — note 200, the docs/TESTS-NOTES.md billing table's 201 is stale). Body {plan, currency}; STARTER → 400 ("STARTER is the default tier — no checkout needed"), ENTERPRISE → 400 (not self-serve). src/billing/subscriptions/checkout.service.ts creates an INACTIVE Subscription row up front so the activation webhook has a row to attach to, then returns {action: 'CHECKOUT', checkoutUrl, gateway, effectiveAt, scheduledPlan}. Gateway is picked server-side from currency: EGP → Paymob, USD → Stripe. A live subscription never checks out again (400 — change-plan/cancel/resume instead).

Frontend hand-off (select-package-step.tsx handlePaid): on a non-null checkoutUrl, stash the chosen plan in localStorage.subscriptionPlan (context for the success page while the webhook races the redirect), then window.location.href = checkoutUrl. Non-checkout actions surface a toast instead.

Completion is server-side. The plan gate clears only when Organization.plan becomes non-null, which for paid plans happens when the gateway webhook activates the subscription (entitlements granted only by gateway webhooks, idempotent on Invoice.gatewayInvoiceId — billing-v2 invariant, owned by FLOW-012). The /subscribe/:organizationId/success page (PORTAL-MAP.md PAGE-033) polls the active subscription; the wizard is never re-entered after a checkout redirect.

Guard wiring (portal)​

  • /onboarding registers outside AppLayout/RequireOrg with only RequireAuth (src/app/routes.tsx L61–67) — necessarily, since it is the pre-org route. /getting-started is a back-compat loader redirect to /dashboard (L94–97); fix-pass text saying the wizard "exits to /getting-started" is stale; the exit is /dashboard.
  • useNeedsOnboarding() gates its isLoading/isError on hasOrg, so a stale error from a previous enabled run can't fire for orgless users (they'd see a guard banner instead of being routed to the wizard).
  • RequireOrg redirects when !hasOrg || needsOnboarding; after sign-in, AuthRedirect routes to /onboarding before honoring ?redirect= or admin defaults. This is how half-onboarded users get caught from any app route.
  • State-read and guard-read failures render full-screen error banners with Retry (StateErrorScreen / GuardErrorScreen), never redirects; two failed gates must not bounce into each other (FB-2, detailed in Authentication architecture).
  • Step-level roles: step 2 needs developer+, step 3 needs owner (STEP_MIN_ROLE in page.tsx, checked client-side via hasMinimumRole on Clerk claims; backend decorators mirror it). Non-actors get WaitingOnOwner (step chrome + info banner; owner name resolved from GET /portal/team/members, fired only when hasOrg && role !== 'owner', falling back to "your organisation's owner" on read failure).

Downstream gates and audit​

The wizard's gates are enforced again outside it:

  • 428 APP_CONFIG_REQUIRED "Register your app before creating a link." — link creation on an org with zero AppConfig rows (src/portal/app-config-required.exception.ts, checked in portal-link.controller.ts); 428 fires before the 402 quota check (see link creation pipeline).
  • 402 PLAN_NOT_SELECTED "No plan selected for this organization." — quota/data endpoints reject plan = null orgs (src/entitlements/plan-not-selected.exception.ts).
  • Rate limit: portal preset, 30 req/s per key (rl:portal prefix) on every endpoint above.

Audit decorators: create-organization → @Auditable CREATE Organization; select-plan → @Auditable UPDATE Organization; app-config PATCH → @Auditable UPDATE AppConfig — all under AuditInterceptor.

Data model touched: Organization (plan, clerkOrgId, unique name, isOpsOrg), AppConfig (platform IOS|ANDROID + fields above), OrgMember (role enum), User (clerkUserId), Subscription (INACTIVE until webhook).

Testing gotchas​

Full recipes: docs/TESTS-NOTES.md §Onboarding + Recipes G/H. The traps:

  • FB-1 verification needs a fresh session — mint a new session after org creation before checking the role claim via raw Clerk API (Recipe G); a pre-existing token won't carry the role.
  • Pre-provisioned/mirrored orgs stay trapped in the wizard (plan = NULL until select-plan). Seed with UPDATE "Organization" SET plan='starter' WHERE "clerkOrgId"='$O'. The plan='free' variant at TESTS-NOTES L1480 is stale — free is not a plan key (ladder: starter/solo/growth/scale/enterprise).
  • select-plan needs the full local mirror (User + OrgMember) to exist — it runs behind OrgResolutionPipe; only state self-resolves via ensureOrgFromClerk (org row only).
  • Benign first-call 401 on the state read at every session start (token bridge returns null pre-session; React Query retry self-heals in ~1 s). Not a bug.
  • Fixture: [email protected] / Ux357Verify!2026: owner parked at wizard step 3 (org "UX Three Five Seven", one ANDROID config com.ux357.app, plan NULL); Recipe H builds a viewer invitee on top (OTP 424242 for +clerk_test emails).
  • Error states without touching the backend: init-script a fetch wrapper returning 500 for the target paths (the portal uses fetch, not axios) — pattern proven for the wizard state read.
  • Currency: wizard prices show EGP by default (currencyForCountry("EG") → EGP); don't mistake EGP amounts for a bug when testing from outside Egypt.

Known gaps​

  • Invitee role-stamp race (open, owned by FLOW-011): members accepting Clerk invitations wait up to ~60 s for the async organizationMembership.created role stamp → 403s on their first role-gated actions. Related: the webhook stamps role='owner' unconditionally — correct for the creator path, misleading for direct-API invitees.
  • No live re-audit: the four fix rounds were validated per-round (two independently, two implementer-verified); the wizard's end-to-end behavior with billing-v2 checkout (paid path through real Stripe/Paymob test mode) is record-unverified. A FLOW-012-adjacent live pass would close this.