Onboarding & Feature Tours
Onboarding & Feature Tours
Where this sits. The general system architecture — module layout, tenancy, RLS — is Architecture. This doc zooms into the onboarding & feature-tour state: the flags that live on the
adminrow, the two state machines that drive them, and the endpoints the frontend calls. Reading order: general → this.Scope. This is the backend view (spec Section 5). The frontend tours are built and consume this contract; where code and this doc disagree, the code wins.
1. Shape in one line
Six flags on the admin row · two coupled state machines · three auth-only
PATCH endpoints · one server-authoritative activation signal. No new table,
no new guard — the tour rides the existing admin identity and the single-gate
auth model.
2. The layered picture
3. Layers, top to bottom
- Frontend — owns the tour UI. It reads the six fields from the
me/ login payloads and writes transitions through the threePATCHendpoints. It may advance a step optimistically, but the server row is the source of truth. - Endpoints — three thin
PATCHhandlers onAdminController(tour-state,onboarding,tours/seen), each delegating straight toAdminService. Symmetric with the existingPATCH /admin/is-new-user. - Auth —
AdminAuthGuard+@SkipPermissions(): auth-only. The acting admin is read from the JWT (@GetAdmin), so there is nothing to authorize beyond "are you signed in" — no@RouteName, no permission row. This is the same posturemeandis-new-useruse. AdminService— owns every write to the six columns. Reloads and returns the onboarding subset so the client can refresh its cache in one round-trip.- The
adminrow — the single store. No side table; the tour state is 1-to-1 with a workspace user, and a user is anadminrow here. - Read surface —
AdminResponseDto(/admin/me) andAdminLoginResponseDto(login) expose all six fields; the service's partial-selectlist carries the columns so they actually hydrate. - Activation hook —
RecordCreateService.create()callsmarkFirstRecordCreatedpost-commit, fail-soft. It is the only writer of that flag, so "first record created" means the same thing regardless of which channel (UI, AI, import, lead-distribution) created the record.
4. The two state models
Onboarding is modelled as two coupled machines on one row: a rich
lifecycle (onboarding_state) and the raw tour progress (tour_state).
The client only ever writes tour_state (+ picks a build path); the lifecycle is
advanced server-side in lockstep, so the two can never drift.
onboarding_state — the lifecycle (client writes only the trigger; the rest
is derived):
tour_state — raw tour progress (client-written, except legacy):
Lockstep coupling — a single PATCH /admin/tour-state writes tour_state
and advances onboarding_state in the same update:
tour_state written | ⇒ onboarding_state | tour_completed_at |
|---|---|---|
in_progress | tour_in_progress | — |
completed | completed | now() (UTC) |
skipped | completed | — |
not_started and legacy are server-managed and rejected on the write path
(400). trial_chosen (and build_path) come only from PATCH /admin/onboarding.
5. Design decisions — our architecture & why
Stated as decision → why → trade-off.
Store on the admin row — no new table, no user-mirror
Why: the state is 1-to-1 with a workspace user, and here a workspace user
is an admin row. A side table would add a join and a lifecycle to manage for
data that never outlives the admin. Unlike is_new_user, it is not mirrored
to the main-DB user, because it is only ever read through the admin me /
login payloads — a mirror would be a second writer for zero readers.
Trade-off: six more columns on a hot table; they are small (two enums, a
bool, a timestamp, a nullable enum, a small jsonb) and read on paths that already
load the row.
Reuse the single gate — auth-only, no new guard
Why: these endpoints act on your own row, identified by the JWT. There is
no cross-user object to scope, so a permission check would be theatre.
@SkipPermissions() under AdminAuthGuard is exactly the me / is-new-user
posture. Trade-off: none meaningful — adding a @RouteName here would create
a permission slug nobody could ever legitimately be denied.
Lifecycle advanced in lockstep by the tour endpoint
Why: onboarding_state and tour_state encode overlapping truth
(tour_in_progress ≈ in_progress). If the client wrote both, they could
diverge. Instead the client writes only the raw tour progress and the server
derives the lifecycle in the same update. Trade-off: the mapping lives in
one service method (updateTourState) rather than being data-driven — acceptable
for three transitions; revisit if the lifecycle grows.
first_record_created is server-authoritative
Why: this is the activation / "aha" event the comm flow keys off, so it must
be true exactly once and independent of the client. It is flipped at the single
record-create choke point (RecordCreateService.create()), so every creation
channel (UI, AI, import, lead-distribution) counts identically. Trade-off: a
write on the create path — made cheap and safe: a conditional
UPDATE … WHERE first_record_created = false (no read, self-idempotent) run
post-commit and fail-soft, so it can never break or slow the record create.
Legacy backfill for pre-existing users
Why: the tour must never appear retroactively for users who signed up before
it existed. The migration backfills every existing row to tour_state = legacy
(a terminal state the UI never shows) and onboarding_state = completed.
Trade-off: a one-time UPDATE in the migration, guarded so a re-run can't
clobber real users. New users created after the migration get the column default
(not_started) and see the tour.
seen_tours — a tolerated read-modify-write race
Why: feature tours (Kanban, PDF editor, …) need a generic "have I seen this"
registry, separate from the rich onboarding lifecycle. It is a jsonb string
array, appended unique. The append is read-modify-write, which carries a benign
race: two concurrent appends could drop one key. Trade-off: accepted — the
worst case is one feature tour re-showing once, the same tolerance the
localStorage cutover already accepts (below). Not worth a transaction or a
jsonb server-side concat for a per-user cosmetic flag.
6. Request flow & endpoints
All three live on AdminController under AdminAuthGuard, are auth-only
(@SkipPermissions()), and echo the onboarding subset so the client can
refresh its cache without re-fetching /admin/me.
PATCH /admin/tour-state
Records a tour transition and advances the lifecycle (§4).
body: { "tour_state": "in_progress" | "completed" | "skipped" }
→ 200 { data: <onboarding subset> }These three transitions are the tour_started / tour_completed / tour_skipped
signals the comm sequence reads (spec 5.1).
PATCH /admin/onboarding
Sets the lifecycle and/or the chosen build path — call this when the user picks a
trial/build path; onboarding_state: "trial_chosen" is what makes the tour
eligible to trigger.
body: { "onboarding_state"?: OnboardingState, "build_path"?: "template" | "ai" }
→ 200 { data: <onboarding subset> }At least one field is required (empty body → 400); only the provided fields apply.
PATCH /admin/tours/seen
Append-unique a feature-tour key. Accepts any string key (frontend ships
kanban, pdf-editor; more over time). Idempotent.
body: { "tour_key": "kanban" }
→ 200 { data: { seen_tours: [...] } }The read fields
Exposed on GET /admin/me and the admin login response:
| Field | Type | Meaning |
|---|---|---|
onboarding_state | enum not_started | trial_chosen | tour_in_progress | completed | Lifecycle; the tour triggers at trial_chosen. |
tour_state | enum not_started | in_progress | completed | skipped | legacy | Raw tour progress. Pre-existing users are legacy. |
tour_completed_at | timestamptz | null | Stamped (UTC) when the tour first completes. |
first_record_created | boolean | Activation flag — server-set on first record. |
build_path | enum template | ai | null | Chosen build path (selects Step-1 copy). |
seen_tours | string[] | Feature-tour keys the user has finished/dismissed. |
<onboarding subset> = all six fields above.
7. first_record_created — the activation signal
The flag is flipped after the record's transaction commits, keyed on the
acting admin. The same conditional UPDATE also retires the object/table tour
by appending object-table to seen_tours server-side — creating a record proves
the user has engaged the object grid, so its tour must not re-show, and the client
never PATCHes it. The UPDATE touches the row only while the flag is still false, so
it is idempotent with no prior read; and it is wrapped fail-soft, so a failure logs
and returns — it never affects the already-committed create. (The tour key lives in
admin/constants/onboarding-tour-keys.ts; the frontend object tour must gate on the
same object-table key.)
8. Isolation & tenancy
Every write goes through repoProvider.getRepository(Admin), never a raw
DataSource — so on the shared hyper DB it flows through the request/worker's
pinned RLS connection and the admin login-aware policy scopes it to the
caller's workspace automatically. The activation hook runs inside the record
create's already-pinned context (request or @TenantScoped worker), so it needs
no special handling. On dedicated tenants there is no RLS and the DB boundary is
the isolation. This follows the pinning contract in
ISOLATION.md rather than re-implementing filters.
9. Migration & rollout
- Migration
1800000000021-AddOnboardingTourFieldsToAdmin— additive, idempotent (hasColumnguards), shipped in bothmigrations/andmigrations/tenant/because theadmintable lives oncrm_hyperand every dedicated tenant DB. Run bymigration:runandmigration:run-tenants. - Backfill — on the initial add only, every existing row →
tour_state = legacy,onboarding_state = completed(§5). New users get thenot_starteddefaults. seen_tourscutover — the old Kanban/PDF tours lived in client localStorage (kanban-tour-completed,cortepdf-builder-tour-completed). The backend cannot read those, so they cannot be seeded server-side: every user startsseen_tours = []and sees each already-completed feature tour once more after cutover. This one re-show is the accepted trade-off.
10. Further reading
- Source spec:
onboarding-tour-backend (1).md(repo root). - Auth posture: Authorization — why acting-on-self is auth-only.
- Tenancy: Architecture — the RLS + pinning contract the writes follow.