Skip to main content

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.

RoutePurpose
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_settingsNow 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 version column. 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. TourDefinition has no version field for exactly this reason.
  • Items are server-registered. A tour's state can only be persisted if a product_guidance_items row exists with a matching key. 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:

keySurface
portal-welcomeDashboard, first login
portal-plannerPlanner
portal-scheduleSchedule drawer (chained from Planner)
portal-usersUsers
portal-user-createUser drawer, create mode (chained from Users)
portal-tagsTags
portal-tag-createTag drawer, create mode (chained from Tags)
portal-liveLive Hub
portal-live-createLive-session drawer (chained from Live Hub)
portal-insightsInsights
portal-chart-createChart builder (chained from Insights)
portal-profilesProfiles
portal-devicesDevices

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 shared preloadAfterAuth() cascade, because the platform differs per app.
  • Auto-start is gated on the load having settled. TourAutoStart fires 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 PUT is fire-and-forget and a failure is logged rather than shown; the state simply reads as unseen again on the next load.
  • whats_new items 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.