Product guidance: backend persistence
Which spotlight tours a user has already seen is stored per account on the backend, so a tour finished on one device stays finished everywhere. This page records the delivered contract, how the client consumes it, and the one operational task the backend team owns (seeding).
For the frontend model — authoring tours, targets, chaining — see walkthroughs.md.
The contract
Two resources plus two flags on the existing settings record. Every route is bearer-authenticated and scoped to the JWT user; identity is never sent in a body or query.
| Route | Purpose |
|---|---|
GET /product_guidance_items?platform={portal|app} | Active items for one app, each annotated with this user's status (null = untreated) |
PUT /user_product_guidance_states/{productGuidanceId} | Body { status: "completed" | "dismissed" }. Idempotent upsert |
DELETE /user_product_guidance_states/{productGuidanceId} | Forget one item's state (204, idempotent) |
DELETE /user_product_guidance_states?type={tour|whats_new} | Forget every state of one kind |
GET / PUT /user_app_settings | Now also carries autoToursEnabled / autoWhatsNewEnabled (both default true) |
Client wrappers: packages/core/src/api/product-guidance.ts
(getProductGuidance, setGuidanceState, resetGuidanceState,
resetGuidanceStatesOfType). Note the apiRequest base URL already carries
the /api prefix, so paths are written without it.
Two properties of the design worth internalising:
- No versioning. There is deliberately no
versioncolumn. A tour is shown once and then never again — so materially new content means a new tour id (and a new seeded key), not a bumped number.TourDefinitionhas noversionfield for exactly this reason. - Items are server-registered. A tour's state can only be persisted if a
product_guidance_itemsrow exists with a matchingkey. There is no admin API; rows are added by migration.
Seeding — required for cross-device persistence
The portal ships 13 tours. Each needs a row with type = tour,
platform = portal, is_active = true:
key | Surface |
|---|---|
portal-welcome | Dashboard, first login |
portal-planner | Planner |
portal-schedule | Schedule drawer (chained from Planner) |
portal-users | Users |
portal-user-create | User drawer, create mode (chained from Users) |
portal-tags | Tags |
portal-tag-create | Tag drawer, create mode (chained from Tags) |
portal-live | Live Hub |
portal-live-create | Live-session drawer (chained from Live Hub) |
portal-insights | Insights |
portal-chart-create | Chart builder (chained from Insights) |
portal-profiles | Profiles |
portal-devices | Devices |
The keys are the id values in apps/portal/src/tours/*.ts — that file set is
the source of truth. Adding a tour means adding a seed row, otherwise its
state stays per-browser (see the fallback below).
How the client uses it
State lives in packages/core/src/tours/store.ts; no
component talks to the API directly.
- Hydration —
<ProductGuidanceLoader platform="portal" />is mounted in the portal's auth-gated dashboard layout, covering both a reload while signed in and the navigation after a fresh login. It is not part of the sharedpreloadAfterAuth()cascade, because the platform differs per app. - Auto-start is gated on the load having settled.
TourAutoStartfires on a short timer, so without this an in-flight request would let a tour the user already dismissed on another device flash up. - Nothing is persisted client-side. The backend is the only store, so there is no second copy to drift out of sync. The one exception is an in-memory, per-session set of tours whose key isn't seeded yet — without it such a tour would restart on every navigation. It is dropped on reload, which is the honest consequence of an unseeded key.
- Tours never wait longer than 3s for the load. Auto-start is gated on the guidance load having settled, but the HTTP layer allows 15s — without a bound of its own an unreachable endpoint would silently suppress every tour for that whole window.
- Writes never block or surface errors. Tours are non-critical, so the
PUTis fire-and-forget and a failure is logged rather than shown; the state simply reads as unseen again on the next load. whats_newitems are ignored — the type exists in the API but the client has no renderer for it yet.
The settings trap
PUT /user_app_settings replaces the whole record and the server falls
back to true for absent flags. So autoToursEnabled / autoWhatsNewEnabled
must be carried on every settings save — otherwise toggling something
unrelated (Metric System, say) silently switches tours back on. They are
therefore present in three places: both DTOs and the field-by-field body in
putUserAppSettings. Single-field writes from outside the settings drawer go
through patchUserAppSettings(), which merges into the cached record and PUTs
the lot.
Out of scope
Tour content, per-step progress, drop-off analytics, versioning, admin CRUD,
and any cross-user state access. Retire an item with is_active = false rather
than deleting it — deletion cascades to every user's state for that item.