Corteksa

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 admin row, 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 three PATCH endpoints. It may advance a step optimistically, but the server row is the source of truth.
  • Endpoints — three thin PATCH handlers on AdminController (tour-state, onboarding, tours/seen), each delegating straight to AdminService. Symmetric with the existing PATCH /admin/is-new-user.
  • AuthAdminAuthGuard + @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 posture me and is-new-user use.
  • 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 admin row — the single store. No side table; the tour state is 1-to-1 with a workspace user, and a user is an admin row here.
  • Read surfaceAdminResponseDto (/admin/me) and AdminLoginResponseDto (login) expose all six fields; the service's partial-select list carries the columns so they actually hydrate.
  • Activation hookRecordCreateService.create() calls markFirstRecordCreated post-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 writtenonboarding_statetour_completed_at
in_progresstour_in_progress
completedcompletednow() (UTC)
skippedcompleted

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_progressin_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:

FieldTypeMeaning
onboarding_stateenum not_started | trial_chosen | tour_in_progress | completedLifecycle; the tour triggers at trial_chosen.
tour_stateenum not_started | in_progress | completed | skipped | legacyRaw tour progress. Pre-existing users are legacy.
tour_completed_attimestamptz | nullStamped (UTC) when the tour first completes.
first_record_createdbooleanActivation flag — server-set on first record.
build_pathenum template | ai | nullChosen build path (selects Step-1 copy).
seen_toursstring[]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 (hasColumn guards), shipped in both migrations/ and migrations/tenant/ because the admin table lives on crm_hyper and every dedicated tenant DB. Run by migration:run and migration:run-tenants.
  • Backfill — on the initial add only, every existing row → tour_state = legacy, onboarding_state = completed (§5). New users get the not_started defaults.
  • seen_tours cutover — 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 starts seen_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.

On this page