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.
347 lines
15 KiB
Go
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, ""
|
|
}
|