Files
member-console/internal/server/setup_state.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

347 lines
15 KiB
Go

// 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, ""
}