OnboardingTours
The OnboardingTours model — fields, relations, and API.
Fields on: admin · Auth: AdminAuthGuard (auth-only) · Spec: Section 5 · Architecture: guide
PRD
Business problem
New users land in an empty workspace and drop off before their first "aha". A guided onboarding tour — triggered right after they pick a trial/build path — walks them to their first record; separately, one-off feature tours (Kanban, PDF editor, …) teach new surfaces without a manual. The backend must own this state so it survives reloads and devices, feeds the activation comm sequence, and never shows the tour retroactively to a pre-existing user.
User stories
- As a new workspace user, after I pick how to build my workspace I want a short guided tour that ends when I create my first record, so I learn by doing.
- As a returning user, I want each feature tour (Kanban, PDF editor) to show once and never nag me again, across devices.
- As the growth team, I want reliable
tour_started/tour_completed/tour_skippedand a server-authoritative "first record created" signal to drive the activation comm sequence.
Success criteria
- The onboarding tour is eligible exactly when
onboarding_state = trial_chosenandtour_state != legacy; it is never eligible for pre-existing users (backfilled tolegacy). tour_statetransitions are durable and echoed back so the client refreshes its cache in one round-trip; completing stampstour_completed_at.first_record_createdbecomes true exactly once, server-side, no matter which channel (UI, AI, import, lead-distribution) creates the record.- A feature tour marked seen never re-shows (idempotent
seen_tours).
How it works
High-level overview — enough to understand the feature without reading the code.
Summary
Six flags on the admin row model two coupled state machines — a lifecycle
(onboarding_state) and raw tour progress (tour_state) — plus an activation
flag, a build-path choice, and a generic feature-tour registry (seen_tours).
The frontend reads all six from the me / login payloads and writes
transitions through three auth-only PATCH endpoints; the lifecycle is advanced
server-side in lockstep with the tour so the two machines can never drift.
first_record_created is owned entirely by the server.
Flow
Steps
- User picks a trial/build path → frontend
PATCH /admin/onboarding { onboarding_state: 'trial_chosen', build_path }. - On the next read the frontend sees
trial_chosen(+tour_state != legacy), starts the tour, and callsPATCH /admin/tour-state { in_progress }— the server setsonboarding_state = tour_in_progress. - User creates their first record → the server flips
first_record_createdAND retires the object/table tour (appendsobject-tabletoseen_tours), in one guarded update, independent of the client; runs exactly once. - Tour ends →
PATCH /admin/tour-state { completed | skipped }→ server setsonboarding_state = completedand stampstour_completed_atoncompleted. - Later, a feature tour finishes/dismisses →
PATCH /admin/tours/seen { tour_key }appends the key unique toseen_tours.
Key components
- Controller —
admin.controller.ts:PATCH /admin/tour-state,/admin/onboarding,/admin/tours/seen— each@SkipPermissions()underAdminAuthGuard(auth-only), mirroringPATCH /admin/is-new-user. - Service —
admin.service.ts:updateTourState,updateOnboarding,markTourSeen,markFirstRecordCreated— the only writers of these columns. - Read surface —
AdminResponseDto(/admin/me) +AdminLoginResponseDto(login); the six columns are added to the service's partial-select. - Enums —
admin/enums/{onboarding-state,tour-state,build-path}.enum.ts. - Activation hook —
RecordCreateService.create()callsmarkFirstRecordCreatedpost-commit, fail-soft. - Migration —
1800000000021-AddOnboardingTourFieldsToAdmin(main + tenant), with the legacy backfill.
FRD
Per-field functional requirements (the six fields are this capability's "fields"):
| # | Field | Type | Written by | Notes |
|---|---|---|---|---|
| 1 | onboarding_state | enum not_started | trial_chosen | tour_in_progress | completed | PATCH /admin/onboarding (→ trial_chosen) + tour endpoint (lockstep) | tour eligible at trial_chosen; default not_started |
| 2 | tour_state | enum not_started | in_progress | completed | skipped | legacy | PATCH /admin/tour-state (client writes in_progress/completed/skipped only) | legacy = pre-existing, never shown |
| 3 | tour_completed_at | timestamptz | null | server, on completed | UTC |
| 4 | first_record_created | boolean | server only — RecordCreateService.create() | idempotent, once |
| 5 | build_path | enum template | ai | null | PATCH /admin/onboarding | selects Step-1 copy |
| 6 | seen_tours | string[] | PATCH /admin/tours/seen | append-unique; any key |
HLD
Frontend
The frontend is the tour driver; the backend is the durable store + the
lockstep/activation rules. It reads the six fields from GET /admin/me and
the admin login response and derives:
- Show onboarding tour? →
onboarding_state ∈ { trial_chosen, tour_in_progress }andtour_state !== 'legacy'(i.e. not a pre-existing user) and the tour isn't alreadycompleted/skipped. - Step-1 copy variant →
build_path(templatevsai). - Activation done? →
first_record_created. The client may advance Step 1 optimistically from its local create mutation, but the server flag is the source of truth. - Show feature tour
X? →!seen_tours.includes('X').
It writes exactly three transitions, each returning the onboarding subset (or
{ seen_tours }) so the client refreshes its cache without re-fetching me:
| When | Call |
|---|---|
| user picks a trial/build path | PATCH /admin/onboarding { onboarding_state: 'trial_chosen', build_path } |
| tour starts / ends | PATCH /admin/tour-state { in_progress | completed | skipped } |
| a feature tour is finished/dismissed | PATCH /admin/tours/seen { tour_key } |
Until these shipped, the frontend drove the onboarding tour via a local
TOUR_OVERRIDE switch and feature tours via localStorage flags — these endpoints
replace both (see the seen_tours cutover note in the guide).
API
Three auth-only PATCH endpoints under /admin plus the two read surfaces
(GET /admin/me, admin login). The lifecycle is advanced server-side; the client
never writes onboarding_state = tour_in_progress/completed directly. Full wire
contract, state diagrams, and design rationale: the
Onboarding & Feature Tours guide.
Database changes
Six additive, idempotent columns on admin — no new table (the state is 1-to-1
with a workspace user, and not mirrored to the main-DB user). Shipped on
both crm_hyper and every tenant DB. Pre-existing rows are backfilled to
tour_state = legacy, onboarding_state = completed; new users get not_started.
LLD
Tables
The six columns on admin:
| Field | TS type | DB type | Nullable | Default |
|---|---|---|---|---|
onboarding_state | OnboardingState | enum | no | not_started |
tour_state | TourState | enum | no | not_started |
tour_completed_at | Date | null | timestamptz | yes | — |
first_record_created | boolean | — | no | false |
build_path | BuildPath | null | enum | yes | — |
seen_tours | string[] | jsonb | no | [] |
Services
updateTourState(adminId, state)— writestour_stateand advancesonboarding_statein the same update (in_progress→tour_in_progress,completed|skipped→completed); stampstour_completed_atoncompleted.updateOnboarding(adminId, { onboarding_state?, build_path? })— partial update; rejects an empty body (400).markTourSeen(adminId, tourKey)— unique append; idempotent (no write when already present). Benign read-modify-write race (worst case: one re-show).markFirstRecordCreated(adminId)— one conditionalUPDATE … WHERE first_record_created = false(no read) that flips the flag AND appendsobject-tabletoseen_tours(the object tour is retired server-side, so the client never PATCHes it); fail-soft.
All writes go through repoProvider (RLS-pinned on hyper via the admin
login-aware policy).
Validators
UpdateTourStateDto—@IsInrestricted to the writable states (in_progress | completed | skipped);not_started/legacyrejected.MarkTourSeenDto—@IsString @IsNotEmpty @MaxLength(100).UpdateOnboardingDto—@IsEnumon both optional fields; the service enforces "at least one".
API endpoints
PATCH /api/v1/admin/tour-statePATCH /api/v1/admin/onboardingPATCH /api/v1/admin/tours/seenGET /api/v1/admin/me(read — exposes the six fields)POST /api/v1/admin/auth/login(read — exposes the six fields)
API
The write endpoints are auth-only (AdminAuthGuard + @SkipPermissions()) and
each echo the onboarding subset. The read fields ride on GET /admin/me and the
admin login response. Full payloads and the state machine live in the
Onboarding & Feature Tours guide; the
endpoints also appear in the API Explorer under the
admin tag.
ERD
The state has no table of its own — it lives on admin and is fed by one
cross-module writer:
- The onboarding/tour fields are columns on
admin, not a related table. first_record_createdis written byRecordCreateService(records → admin); every other field by the three admin endpoints.- All six are read back through the admin
me/ login response DTOs.