Files
cgalo5758 71818de0bd Add setup checklist and empty-state guidance
Implement the ux-first-run change: a state-derived setup checklist on
/operator/setup with a landing region that recedes once required steps
are done, and empty states that distinguish blocked from empty across
operator and member surfaces. Also add production deployment and
environment reference docs, plus a config-key completeness test.
2026-08-23 03:06:11 -05:00

198 lines
8.5 KiB
Go

package server
import (
"context"
"log/slog"
"strings"
)
// This file backs the operator setup checklist (openspec change
// ux-first-run): a persistent, revisitable answer to "is this deployment
// ready?" Every step's copy — label, description, link target, and what it
// unlocks — lives here rather than in the template, so a later rename sweep
// touches one place. Completion is DERIVED fresh from live database state on
// every render; nothing here is a stored progress flag, so deleting (say)
// the last entitlement set un-completes that step on the very next render.
// SetupStep is one row of the checklist, in chain order:
//
// entitlement set -> published product -> price+Stripe sync -> plan
// ladder (rank-0 tier) -> org-type default plan
//
// The first two and the ladder step are REQUIRED (they gate the landing
// region); the price-sync and org-type-default steps are CONDITIONAL,
// because whether they apply depends on the deployment's business model
// (does it charge money? does signup confer a plan?) which the system
// cannot read off the data — so each conditional step states its own
// condition instead of pretending to judge it.
type SetupStep struct {
// Key is a stable identifier for the step, used by tests and callers;
// never persisted.
Key string
// Label names the action the operator takes.
Label string
// Description states what the step is and why it exists.
Description string
// Required distinguishes the four required steps from the two
// conditional ones. Conditional steps set Condition instead of being
// unconditionally expected.
Required bool
// Condition is set only on a conditional step: the plain-language
// reason it may or may not apply to this deployment.
Condition string
// Complete is this render's live derivation result.
Complete bool
// Href is the surface where the operator completes this step.
Href string
// LinkText labels the link to Href.
LinkText string
// Unlocks names the downstream step this one feeds. The template shows
// it only while the step itself is incomplete — a completed step has
// nothing left to unlock.
Unlocks string
// Note is an optional secondary status line. Populated only on the
// price-sync step to surface whether the Stripe integration itself is
// configured, independent of whether a price has been synced yet.
Note string
// NoteHref, when set, links Note's text at the surface that resolves it
// (the Stripe integration settings page).
NoteHref string
}
// SetupState is the checklist's full view model: every step, in chain
// order, derived from one consistent set of live reads. The landing
// region and the /operator/setup page both render from the same SetupState
// (via the same template partial) so the two surfaces can never disagree.
//
// FullPage parameterizes the shared partial's presentation only (heading
// level, intro copy, wrapper landmark) — never which steps appear or their
// completion. It defaults false (the landing-region rendering); the full
// checklist page handler sets it true after deriving state.
type SetupState struct {
Steps []SetupStep
FullPage bool
}
// RequiredIncomplete reports whether any REQUIRED step is still incomplete.
// This is the landing region's visibility predicate (ux-first-run): the
// region renders ahead of the metric tiles while this is true, and stops
// rendering once every required step is complete, even if conditional steps
// remain outstanding.
func (s SetupState) RequiredIncomplete() bool {
for _, step := range s.Steps {
if step.Required && !step.Complete {
return true
}
}
return false
}
// probeSetupStep runs one derivation predicate. A failed probe is logged and
// treated as incomplete: a checklist that could not confirm a step is done
// must never claim that it is.
func probeSetupStep(ctx context.Context, logger *slog.Logger, what string, fn func(context.Context) (bool, error)) bool {
ok, err := fn(ctx)
if err != nil {
logger.Warn("setup checklist: probe failed", slog.String("check", what), slog.Any("error", err))
return false
}
return ok
}
// DeriveSetupState assembles the checklist from live state — one read per
// predicate, no caching, no stored flags. Every predicate here is a direct
// sqlc-generated existence probe (entitlements/billing/organization/Stripe
// store queriers); see each step below for which one backs it.
func (h *OperatorHandler) DeriveSetupState(ctx context.Context) SetupState {
anyEntitlementSet := probeSetupStep(ctx, h.Logger, "any active entitlement set", h.EntitlementsQ.AnyActiveEntitlementSet)
anyPublishedProduct := probeSetupStep(ctx, h.Logger, "any published product", h.BillingQ.AnyPublishedProduct)
anyActivePrice := probeSetupStep(ctx, h.Logger, "any active price", h.BillingQ.AnyActivePrice)
anyMappedPrice := probeSetupStep(ctx, h.Logger, "any mapped Stripe price", h.StripeQ.AnyMappedPrice)
anyRankZeroTier := probeSetupStep(ctx, h.Logger, "any rank-0 ladder tier", h.BillingQ.AnyRankZeroTier)
anyOrgTypeDefault := probeSetupStep(ctx, h.Logger, "any org type with a default plan", h.OrgQ.AnyOrgTypeWithDefaultPlan)
// The price step's completion is a composed proxy: AnyActivePrice alone
// only shows a price was defined, and AnyMappedPrice alone can't tell
// which product it belongs to. In practice a Stripe mapping is only ever
// created FROM a price (SyncProductToStripe), so requiring both together
// is the honest signal without a schema join this checklist doesn't need.
priceStepComplete := anyActivePrice && anyMappedPrice
// Reuses the same required-key resolution the Integrations list and the
// landing System panel already read (operator_integration_settings.go),
// so "Stripe not configured" means the same thing everywhere it renders.
stripeConfigured, stripeMissing := configurationReadiness(h.IntegrationConfigs, "stripe")
priceNote, priceNoteHref := "", ""
if !stripeConfigured {
priceNote = "Stripe integration is not configured yet"
if len(stripeMissing) > 0 {
priceNote += "; missing " + strings.Join(stripeMissing, ", ")
}
priceNote += "."
priceNoteHref = "/operator/integrations/stripe/settings"
}
steps := []SetupStep{
{
Key: "entitlement-set",
Label: "Create an entitlement set",
Description: "Entitlement sets define what a product grants; every product needs one.",
Required: true,
Complete: anyEntitlementSet,
Href: "/operator/entitlement-sets",
LinkText: "Go to entitlement sets",
Unlocks: "Creating a product that uses it.",
},
{
// Keyed on a PUBLISHED product, not mere existence: the operator
// panel's create path publishes at creation and offers no draft
// state or publish control (operator_products.go), so a separate
// "publish" step could never be independently acted on — and a
// draft-or-retired-only catalog honestly reads incomplete.
Key: "product",
Label: "Create a product",
Description: "Products are what this deployment grants or sells; each one uses an entitlement set and is published as soon as it is created.",
Required: true,
Complete: anyPublishedProduct,
Href: "/operator/products",
LinkText: "Go to products",
Unlocks: "Adding the product to a plan ladder, and selling it.",
},
{
Key: "price-sync",
Label: "Add an active price and sync it to Stripe",
Description: "A price makes a product chargeable; syncing it creates the matching Stripe price.",
Required: false,
Condition: "Needed only when products are sold for money.",
Complete: priceStepComplete,
Href: "/operator/products",
LinkText: "Go to products",
Unlocks: "Charging members for this product.",
Note: priceNote,
NoteHref: priceNoteHref,
},
{
Key: "ladder",
Label: "Build a plan ladder with this product as a tier",
Description: "Plan ladders group products members move between; the rank-0 tier is the deployment's base plan and is what completes this step.",
Required: true,
Complete: anyRankZeroTier,
Href: "/operator/plan-ladders",
LinkText: "Go to plan ladders",
Unlocks: "Choosing an org-type default plan.",
},
{
Key: "org-type-default",
Label: "Choose an org-type default plan",
Description: "An org-type default plan is conferred automatically on signup.",
Required: false,
Condition: "Needed only when signup should confer a plan automatically.",
Complete: anyOrgTypeDefault,
Href: "/operator/org-types",
LinkText: "Go to org types",
},
}
return SetupState{Steps: steps}
}