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 fail-closed. A tour starts only once the GET has succeeded and user_app_settings (which carries the opt-out) has loaded. TourAutoStart fires 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. ProductGuidanceLoader retries 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 PUT when the item's id isn't known yet, and outranks the server row so an arriving status: null can't shadow it. It is dropped on reload.
  • 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.
  • 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.warn naming the key rather than failing silently — that log is the first thing to check if a tour keeps re-appearing.
  • 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.