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} }