Corteksa

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_skipped and 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_chosen and tour_state != legacy; it is never eligible for pre-existing users (backfilled to legacy).
  • tour_state transitions are durable and echoed back so the client refreshes its cache in one round-trip; completing stamps tour_completed_at.
  • first_record_created becomes 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

  1. User picks a trial/build path → frontend PATCH /admin/onboarding { onboarding_state: 'trial_chosen', build_path }.
  2. On the next read the frontend sees trial_chosen (+ tour_state != legacy), starts the tour, and calls PATCH /admin/tour-state { in_progress } — the server sets onboarding_state = tour_in_progress.
  3. User creates their first record → the server flips first_record_created AND retires the object/table tour (appends object-table to seen_tours), in one guarded update, independent of the client; runs exactly once.
  4. Tour ends → PATCH /admin/tour-state { completed | skipped } → server sets onboarding_state = completed and stamps tour_completed_at on completed.
  5. Later, a feature tour finishes/dismisses → PATCH /admin/tours/seen { tour_key } appends the key unique to seen_tours.

Key components

  • Controlleradmin.controller.ts: PATCH /admin/tour-state, /admin/onboarding, /admin/tours/seen — each @SkipPermissions() under AdminAuthGuard (auth-only), mirroring PATCH /admin/is-new-user.
  • Serviceadmin.service.ts: updateTourState, updateOnboarding, markTourSeen, markFirstRecordCreated — the only writers of these columns.
  • Read surfaceAdminResponseDto (/admin/me) + AdminLoginResponseDto (login); the six columns are added to the service's partial-select.
  • Enumsadmin/enums/{onboarding-state,tour-state,build-path}.enum.ts.
  • Activation hookRecordCreateService.create() calls markFirstRecordCreated post-commit, fail-soft.
  • Migration1800000000021-AddOnboardingTourFieldsToAdmin (main + tenant), with the legacy backfill.

FRD

Per-field functional requirements (the six fields are this capability's "fields"):

#FieldTypeWritten byNotes
1onboarding_stateenum not_started | trial_chosen | tour_in_progress | completedPATCH /admin/onboarding (→ trial_chosen) + tour endpoint (lockstep)tour eligible at trial_chosen; default not_started
2tour_stateenum not_started | in_progress | completed | skipped | legacyPATCH /admin/tour-state (client writes in_progress/completed/skipped only)legacy = pre-existing, never shown
3tour_completed_attimestamptz | nullserver, on completedUTC
4first_record_createdbooleanserver onlyRecordCreateService.create()idempotent, once
5build_pathenum template | ai | nullPATCH /admin/onboardingselects Step-1 copy
6seen_toursstring[]PATCH /admin/tours/seenappend-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 } and tour_state !== 'legacy' (i.e. not a pre-existing user) and the tour isn't already completed/skipped.
  • Step-1 copy variantbuild_path (template vs ai).
  • 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:

WhenCall
user picks a trial/build pathPATCH /admin/onboarding { onboarding_state: 'trial_chosen', build_path }
tour starts / endsPATCH /admin/tour-state { in_progress | completed | skipped }
a feature tour is finished/dismissedPATCH /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:

FieldTS typeDB typeNullableDefault
onboarding_stateOnboardingStateenumnonot_started
tour_stateTourStateenumnonot_started
tour_completed_atDate | nulltimestamptzyes
first_record_createdbooleannofalse
build_pathBuildPath | nullenumyes
seen_toursstring[]jsonbno[]

Services

  • updateTourState(adminId, state) — writes tour_state and advances onboarding_state in the same update (in_progress→tour_in_progress, completed|skipped→completed); stamps tour_completed_at on completed.
  • 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 conditional UPDATE … WHERE first_record_created = false (no read) that flips the flag AND appends object-table to seen_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@IsIn restricted to the writable states (in_progress | completed | skipped); not_started/legacy rejected.
  • MarkTourSeenDto@IsString @IsNotEmpty @MaxLength(100).
  • UpdateOnboardingDto@IsEnum on both optional fields; the service enforces "at least one".

API endpoints

  • PATCH /api/v1/admin/tour-state
  • PATCH /api/v1/admin/onboarding
  • PATCH /api/v1/admin/tours/seen
  • GET /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_created is written by RecordCreateService (records → admin); every other field by the three admin endpoints.
  • All six are read back through the admin me / login response DTOs.

On this page