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 fail-closed. A tour starts only once the
GEThas succeeded anduser_app_settings(which carries the opt-out) has loaded.TourAutoStartfires on a short timer, so anything weaker lets a cold start replay tours the user already finished: an in-flight or failed load leaves the client with no items, which is indistinguishable from "nothing seen". The asymmetry is intended — a first-timer meeting a tour one visit later is a much smaller cost than a dismissed tour coming back. - A failed load is retried, not absorbed.
ProductGuidanceLoaderretries twice with a short backoff, because fail-closed means one transient failure would otherwise silence every tour for the session. After that it logs and gives up; the next visit tries again. - Nothing is persisted client-side. The backend is the only store, so there
is no second copy to drift out of sync. The one local record is an in-memory,
per-session set of finished tours: it holds the finish while the write is in
flight, queues the
PUTwhen the item's id isn't known yet, and outranks the server row so an arrivingstatus: nullcan't shadow it. It is dropped on reload. - 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. - An unseeded key is logged. Finishing a tour with no matching row can only
be remembered for the session, so the store emits a
console.warnnaming the key rather than failing silently — that log is the first thing to check if a tour keeps re-appearing. 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.