The entitlement sets list counted each set's active rules with one read per set, the setup step read each set's rules in its provider leg the same way, and a product's prices read one Stripe mapping per price. Each page now reads once over the ids it already holds: activeRulesBySet over the existing ListEntitlementSetRulesBySetIDs (filtered to active rules and ordered by set, then resource key, as GetActiveRulesBySetID orders them), and priceMappings over ListPriceMappingsByPriceIDs, which reads nothing when the Stripe querier is absent. A failed batch read gives every set no rules and every price no mapping, which is what a failed per-row read gave each row. GetActiveRulesBySetID and GetPriceMappingByPriceID keep other callers. No output changes, shown by: - The three read-count rows (entitlement-sets, setup, product-prices) go from 1 read per added row to 0. - The failing queriers of the catalog read tests now fail the batch reads and return no rows, as generated code does on a failed query; test and subtest names are unchanged and every assertion keeps its scope (same on all rows of the assertion map). - internal/server and internal/entitlements give the same 1578 test and subtest results at the parent and at this commit (1577 pass, 1 skip, 0 fail), compared by name. - 21 break records: 11 run on the old side and 11 on the new side, each caught by the old and the new test (10 only on the old side, 10 only on the new, 1 on both). - The 22 operator walkthroughs from the demo snapshot match the step baseline at the parent and at this commit. - The screens differ from the baseline only in the Stripe integration page, whose size varies on every run. - Lint snapshot: removed 0, added 0; sqlc-unused reports no unused query. Unprobed: - A rule or mapping read that fails for one set or one price alone. One statement fails for every row or for none, so neither side can produce that case. - The order of rules within one set in the setup step. Each fixture set has one rule, and the batch query orders by set, then resource key, which is the order GetActiveRulesBySetID uses within a set. - The empty-ids short circuit in both batch readers: a page without sets or prices reads nothing either way and shows the same rows.
361 lines
16 KiB
Go
361 lines
16 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
|
|
}
|
|
|
|
setIDs := idsOf(sets, func(s entitlements.EntitlementSet) string { return s.SetID })
|
|
rulesBySet := activeRulesBySet(ctx, entitlementsQ, setIDs)
|
|
for _, set := range sets {
|
|
for _, rule := range rulesBySet[set.SetID] {
|
|
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, ""
|
|
}
|
|
|
|
// activeRulesBySet reads the active rules of each set in one read, grouped by
|
|
// set in resource-key order, the order GetActiveRulesBySetID returns them. A
|
|
// set without rules is absent, as is every set when the read fails; the error
|
|
// is discarded, as the per-set callers discarded it.
|
|
func activeRulesBySet(ctx context.Context, q entitlements.Querier, setIDs []string) map[string][]entitlements.EntitlementSetRule {
|
|
bySet := make(map[string][]entitlements.EntitlementSetRule, len(setIDs))
|
|
if len(setIDs) == 0 {
|
|
return bySet
|
|
}
|
|
rules, _ := q.ListEntitlementSetRulesBySetIDs(ctx, setIDs)
|
|
for _, rule := range rules {
|
|
bySet[rule.SetID] = append(bySet[rule.SetID], rule)
|
|
}
|
|
return bySet
|
|
}
|