// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "context" "log/slog" "strings" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" "git.coopcloud.tech/wiki-cafe/member-console/internal/instance" ) // This file backs the operator setup checklist (design D12 / // operator-setup-checklist): 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: // // configure integrations -> entitlement set -> published product -> // price+Stripe sync -> plan ladder (rank-0 tier) -> org-type default plan // // Every step is equal (design D12, round 2: the required/conditional // classes are gone — they encoded one deployment's business model and the // two-tone badges they produced read as inconsistency). A step whose // relevance depends on the deployment's model (does it charge money? does // it deliver through a connected service? does signup confer a plan?) says // so in its own Description rather than through a separate class the // system cannot actually judge from data. 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, including — // for a step whose relevance depends on the deployment's model — the // plain-language condition under which it applies. Description 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. Empty on the chain's last step, which has no // downstream step to name. Unlocks string // Note is an optional secondary status line. Populated on the // integrations step (naming the first unconfigured provider an active // entitlement set's rules reference) and the price-sync step (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 // (a provider's integration settings page). NoteHref string // NoteLinkText labels the NoteHref link. NoteLinkText string } // SetupState is the checklist's full view model: every step, in chain // order, derived from one consistent set of live reads, plus whether the // operator overview's banner has been dismissed. GetSetupPage // (operator_setup.go) is the checklist's only renderer now — the overview // carries a dismissible summary banner instead of embedding the step list // (design D12, round 2) — so the two never need to reconcile two // presentations of the same state, only the banner's summary against this // state's DoneCount/Total. type SetupState struct { Steps []SetupStep // Dismissed reports whether the operator overview's setup banner has // been closed (the instance setting instance.SetupBannerDismissed). // Deployment-wide, not per-operator or per-browser. Dismissed bool } // ToneBadge renders a step's completion in the one tone per state design // D12 (round 2) requires: every complete step reads "Done" in the success // tone, every incomplete step reads "Incomplete" in the neutral tone — no // step is singled out as more of a problem than another. func (s SetupStep) ToneBadge() Badge { if s.Complete { return Badge{Label: "Done", Tone: "success"} } return Badge{Label: "Incomplete", Tone: "secondary"} } // DoneCount reports how many steps are complete. The overview banner no // longer states a count (design D12 round 2: "Getting started", no step // count); DoneCount and Total remain for ShowBanner's completeness check // and for the checklist page. func (s SetupState) DoneCount() int { n := 0 for _, step := range s.Steps { if step.Complete { n++ } } return n } // Total reports the checklist's step count. func (s SetupState) Total() int { return len(s.Steps) } // ShowBanner reports whether the operator overview should render the setup // banner (design D12 / operator-setup-checklist "The overview carries a // dismissible setup banner"): not dismissed, and at least one step is // still incomplete. A finished checklist withdraws the banner regardless of // dismissal — once every step is done the banner has nothing left to say. func (s SetupState) ShowBanner() bool { if s.Dismissed { return false } return s.DoneCount() < s.Total() } // 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 for step completion — plus the // banner's dismissal, which IS stored (the first instance setting, design // D28) because "has an operator closed this" is not something any table's // existence answers. Every completion 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" } // The integrations step derives from the same provider-configuration leg // product readiness uses (product-management "Readiness includes the // delivering provider's configuration", ACC-3): incomplete while any // resource key an active entitlement set's rules reference belongs to an // unconfigured provider, or while no integration is configured at all — // so the checklist and the readiness panel can never disagree about // whether a sold entitlement can be delivered. integrationsComplete, unconfiguredProvider := providerConfigurationLeg(ctx, h.EntitlementsQ, h.IntegrationConfigs) integrationsNote, integrationsNoteHref, integrationsNoteLinkText := "", "", "" if unconfiguredProvider != "" { name := providerDisplayName(h.IntegrationConfigs, unconfiguredProvider) integrationsNote = name + " is not configured yet." integrationsNoteHref = "/operator/integrations/" + unconfiguredProvider + "/settings" integrationsNoteLinkText = "Go to " + name + " settings" } // The entitlement-set step's second route, shown only while the step // is incomplete: once a set exists there is nothing left to offer. setNote, setNoteHref, setNoteLinkText := "", "", "" if !anyEntitlementSet { setNote = "Or create one with your first product." setNoteHref = "/operator/products/new" setNoteLinkText = "Go to the product create page" } steps := []SetupStep{ { Key: "integrations", Label: "Configure integrations", Description: "Integrations deliver what a product confers through a connected service. Needed when a product delivers through a connected service.", Complete: integrationsComplete, Href: "/operator/integrations", LinkText: "Go to integrations", Unlocks: "Selling or granting a product that delivers through that service.", Note: integrationsNote, NoteHref: integrationsNoteHref, NoteLinkText: integrationsNoteLinkText, }, { // The step names both routes to a set and blocks nothing // (operator-setup-checklist, design D14): the product create // page's entitlement-set select opens on "New set named after // this product" and creates the set with the product, so the // Products step is actionable while this one is incomplete // and neither step's copy tells the operator to do the other // first. Key: "entitlement-set", Label: "Create an entitlement set", Description: "Entitlement sets define what a product grants; every product needs one.", Complete: anyEntitlementSet, Href: "/operator/entitlement-sets", LinkText: "Go to entitlement sets", Unlocks: "Reusing one definition of what a product delivers across several products.", Note: setNote, NoteHref: setNoteHref, NoteLinkText: setNoteLinkText, }, { // 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 product uses an entitlement set and is published as soon as it is created.", 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. 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, NoteLinkText: "Go to Stripe settings", }, { Key: "ladder", Label: "Build a plan ladder with this product as a tier", Description: "Plan ladders group the products members move between; the rank-0 tier is the deployment's base plan and is what completes this step.", 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. Needed only when signup should confer a plan automatically.", Complete: anyOrgTypeDefault, Href: "/operator/org-types", LinkText: "Go to org types", }, } dismissed := false if h.InstanceSettings != nil { var err error dismissed, err = h.InstanceSettings.GetBool(ctx, instance.SetupBannerDismissed) if err != nil { h.Logger.Warn("setup checklist: failed to read banner dismissal", slog.Any("error", err)) dismissed = false } } return SetupState{Steps: steps, Dismissed: dismissed} } // providerConfigurationLeg is the setup checklist's integrations-step // predicate (design D12 / operator-setup-checklist "The integrations step // follows the readiness leg"): the same provider-configuration leg product // readiness uses (resolveProviderReadinessRows in product_readiness.go), // widened to every active entitlement set rather than one product's. complete // is false while any resource key an active entitlement set's rules // reference belongs to a provider whose required configuration is // unresolved, or while no integration is configured at all — a fresh // deployment with zero configured integrations starts with this step // incomplete even before any entitlement set references one. // unconfiguredProvider names the first unresolved provider found (by // entitlement-set then rule order); "" when there is none to name (either // complete, or incomplete only via the "no integration configured" clause). func providerConfigurationLeg(ctx context.Context, entitlementsQ entitlements.Querier, configs []IntegrationConfigInfo) (complete bool, unconfiguredProvider string) { anyConfigured := false for _, c := range configs { if ok, _ := configurationReadiness(configs, c.Key); ok { anyConfigured = true break } } if !anyConfigured { return false, "" } sets, err := entitlementsQ.ListActiveEntitlementSets(ctx) if err != nil { return false, "" } keys, err := entitlementsQ.ListResourceKeys(ctx) if err != nil { return false, "" } byKey := make(map[string]entitlements.ResourceKey, len(keys)) for _, k := range keys { byKey[k.ResourceKey] = k } for _, set := range sets { rules, err := entitlementsQ.GetActiveRulesBySetID(ctx, set.SetID) if err != nil { continue } for _, rule := range rules { if !rule.ResourceKey.Valid { continue } rk, ok := byKey[rule.ResourceKey.String] if !ok || !rk.Provider.Valid || rk.Provider.String == "" { continue } if configured, _ := configurationReadiness(configs, rk.Provider.String); !configured { return false, rk.Provider.String } } } return true, "" }