Files
member-console/internal/server/operator_topology.go
T
cgalo5758 71818de0bd Add setup checklist and empty-state guidance
Implement the ux-first-run change: a state-derived setup checklist on
/operator/setup with a landing region that recedes once required steps
are done, and empty states that distinguish blocked from empty across
operator and member surfaces. Also add production deployment and
environment reference docs, plus a config-key completeness test.
2026-08-23 03:06:11 -05:00

367 lines
13 KiB
Go

package server
import (
"log/slog"
"net/http"
"sort"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
)
// PlanTopologyData is the body data for operator_plan_topology.html — the
// operator-facing mirror of the member catalog. It conveys the whole plan
// topology at once: ladders as columns (ordered by sort_order), ranks as
// rows, the off-ladder add-on strip, the shared-product (M:N) reverse index,
// the per-org-type provisioning summary, and inline structural-validation
// health. It is read-only; every drill-in links into existing CRUD.
type PlanTopologyData struct {
Ladders []TopologyLadderColumn
Rows []TopologyRow
Addons []TopologyAddonViewModel
SharedProducts []SharedProductViewModel
OrgTypes []OrgTypeProvisionViewModel
Health PlanLadderValidationData
// NothingToValidate reports that no active pool-to-ladder attachments
// exist anywhere (AnyActiveLadderAttachment), so the structural checks
// in Health had nothing to range over — a fresh deployment, most often.
// The strip renders a neutral "nothing to validate yet" state instead of
// claiming healthy, since healthy is only honest when something was
// actually checked (ux-first-run spec: topology health strip).
NothingToValidate bool
Error string
}
// TopologyLadderColumn is one ladder rendered as a grid column. The header is a
// drag-to-reorder handle (SortableJS) plus a hidden ladder-ID input the reorder
// POST serializes in the dropped order.
type TopologyLadderColumn struct {
PlanLadderID string
LadderKey string
Name string
IsActive bool
ActiveAttachmentCount int64
}
// TopologyRow is one rank rendered as a grid row. Cells are aligned 1:1 with
// PlanTopologyData.Ladders order.
type TopologyRow struct {
Rank int32
Cells []TopologyCell
}
// TopologyCell is the (ladder, rank) intersection. Present=false renders as an
// empty/muted cell so ranks stay aligned across axes of differing depth.
type TopologyCell struct {
Present bool
ProductID string
ProductName string
Shared bool
LifecycleStatus string
LadderID string
}
// TopologyAddonViewModel is an off-ladder add-on (product_kind = 'addon').
type TopologyAddonViewModel struct {
ProductID string
Name string
Category string // display_category, "uncategorized" when blank (presentation only)
LifecycleStatus string
}
// SharedProductMembership is one (ladder, rank) membership of a shared product.
type SharedProductMembership struct {
LadderKey string
LadderName string
Rank int32
}
// SharedProductViewModel is a product that is a tier in more than one ladder,
// with all of its memberships — the reverse index of the M:N relationship.
type SharedProductViewModel struct {
ProductID string
ProductName string
Memberships []SharedProductMembership
}
// OrgTypeProvisionViewModel summarizes what a newly created org of this type is
// provisioned: the configured default resolved to its ladder + rank-0 product.
type OrgTypeProvisionViewModel struct {
OrgType string
DisplayName string
HasDefault bool
LadderName string
RankZeroProduct string
}
// GetPlanTopologyPage handles GET /operator/plan-topology — the read-only
// cross-ladder topology overview. Catalog-group MPA page rendered inside the
// operator.html shell.
func (h *OperatorPartialsHandler) GetPlanTopologyPage(w http.ResponseWriter, r *http.Request) {
bodyData := h.loadPlanTopologyData(r)
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:plan-topology"
page.ActiveCapability = "plan-topology"
page.BodyTemplate = "operator_plan_topology.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// loadPlanTopologyData hydrates the topology overview. All reads are read-only
// over existing schema; no mutation occurs here.
func (h *OperatorPartialsHandler) loadPlanTopologyData(r *http.Request) PlanTopologyData {
ctx := r.Context()
var data PlanTopologyData
ladders, err := h.BillingQ.ListPlanLadders(ctx)
if err != nil {
h.Logger.Error("failed to list plan ladders", slog.Any("error", err))
data.Error = "Failed to load plan topology"
return data
}
// Shared products: the set of product IDs that are tiers in >1 ladder, plus
// the reverse index grouping each shared product's memberships. Rows arrive
// ordered by product name then ladder, so grouping preserves stable order.
sharedRows, err := h.BillingQ.ListSharedTierProducts(ctx)
if err != nil {
h.Logger.Error("failed to list shared tier products", slog.Any("error", err))
}
sharedSet := make(map[string]bool, len(sharedRows))
sharedIndex := make(map[string]int) // product_id -> index into data.SharedProducts
for _, sr := range sharedRows {
sharedSet[sr.ProductID] = true
idx, ok := sharedIndex[sr.ProductID]
if !ok {
idx = len(data.SharedProducts)
sharedIndex[sr.ProductID] = idx
data.SharedProducts = append(data.SharedProducts, SharedProductViewModel{
ProductID: sr.ProductID,
ProductName: sr.ProductName,
})
}
data.SharedProducts[idx].Memberships = append(data.SharedProducts[idx].Memberships, SharedProductMembership{
LadderKey: sr.LadderKey,
LadderName: sr.LadderName,
Rank: sr.Rank,
})
}
// Columns (ladders) + per-ladder tier lookup keyed by rank. ListPlanLadders
// already orders by sort_order ASC, name ASC, so column order is the member
// catalog's axis order.
data.Ladders = make([]TopologyLadderColumn, len(ladders))
tiersByLadder := make([]map[int32]TopologyCell, len(ladders))
rankSet := make(map[int32]bool)
tieredProducts := make(map[string]bool)
for i, l := range ladders {
count, _ := h.EntitlementsQ.CountActiveAttachmentsByLadder(ctx, l.PlanLadderID)
data.Ladders[i] = TopologyLadderColumn{
PlanLadderID: l.PlanLadderID,
LadderKey: l.LadderKey,
Name: l.Name,
IsActive: l.IsActive,
ActiveAttachmentCount: count,
}
cells := make(map[int32]TopologyCell)
tiers, _ := h.BillingQ.ListTiersByLadderWithProducts(ctx, l.PlanLadderID)
for _, t := range tiers {
cells[t.Rank] = TopologyCell{
Present: true,
ProductID: t.ProductID,
ProductName: t.ProductName,
Shared: sharedSet[t.ProductID],
LifecycleStatus: t.LifecycleStatus,
LadderID: l.PlanLadderID,
}
rankSet[t.Rank] = true
tieredProducts[t.ProductID] = true
}
tiersByLadder[i] = cells
}
// Rows span the union of ranks present across all ladders, rendered with the
// highest rank on top and rank 0 at the bottom (member-catalog mirror:
// upgrades move up). Absent (ladder, rank) intersections render as empty
// cells so ranks stay aligned across axes of differing depth.
ranks := make([]int32, 0, len(rankSet))
for rank := range rankSet {
ranks = append(ranks, rank)
}
sort.Slice(ranks, func(a, b int) bool { return ranks[a] > ranks[b] })
data.Rows = make([]TopologyRow, len(ranks))
for ri, rank := range ranks {
cells := make([]TopologyCell, len(data.Ladders))
for ci := range data.Ladders {
if cell, ok := tiersByLadder[ci][rank]; ok {
cells[ci] = cell
}
}
data.Rows[ri] = TopologyRow{Rank: rank, Cells: cells}
}
// Off-ladder products, bucketed by display_category (presentation-only, Doc
// 41 Decision 136; a blank category lists as "uncategorized"). Every product
// not tiered on a ladder is shown with a lifecycle badge.
products, err := h.BillingQ.ListAllProducts(ctx)
if err != nil {
h.Logger.Error("failed to list products", slog.Any("error", err))
} else {
for _, p := range products {
if tieredProducts[p.ProductID] {
continue
}
category := "uncategorized"
if p.DisplayCategory.Valid && p.DisplayCategory.String != "" {
category = p.DisplayCategory.String
}
data.Addons = append(data.Addons, TopologyAddonViewModel{
ProductID: p.ProductID,
Name: p.Name,
Category: category,
LifecycleStatus: p.LifecycleStatus,
})
}
}
// Per-org-type provisioning summary: resolve each org type's configured
// default ladder to its rank-0 product ("new orgs get → ladder @ product").
rankZero, err := h.BillingQ.ListPlanLaddersWithRankZeroProduct(ctx)
if err != nil {
h.Logger.Error("failed to list rank-zero products", slog.Any("error", err))
}
rankZeroByLadder := make(map[string]struct{ ladderName, productName string }, len(rankZero))
for _, rz := range rankZero {
// The query LEFT JOINs so rank-0-less ladders surface elsewhere; here
// a default that can't resolve to a product renders as no-default.
if !rz.ProductID.Valid {
continue
}
rankZeroByLadder[rz.PlanLadderID] = struct{ ladderName, productName string }{rz.LadderName, rz.RankZeroProductName.String}
}
if orgTypes, otErr := h.OrgQ.ListOrgTypes(ctx); otErr != nil {
h.Logger.Error("failed to list org types", slog.Any("error", otErr))
} else {
for _, ot := range orgTypes {
vm := OrgTypeProvisionViewModel{
OrgType: ot.OrgType,
DisplayName: ot.DisplayName,
}
if ot.DefaultPlanLadderID.Valid {
if rz, ok := rankZeroByLadder[ot.DefaultPlanLadderID.UUID.String()]; ok {
vm.HasDefault = true
vm.LadderName = rz.ladderName
vm.RankZeroProduct = rz.productName
}
}
data.OrgTypes = append(data.OrgTypes, vm)
}
}
// Structural-validation health — shared computation with the validation page.
data.Health = h.computePlanLadderValidation(ctx)
// Empty-validation-domain gate: claim healthy only when at least one
// active pool-to-ladder attachment existed for the checks above to
// cover. A query failure falls through to the normal healthy/issue
// branches (NothingToValidate stays false) rather than hiding the strip.
if anyActive, err := h.EntitlementsQ.AnyActiveLadderAttachment(ctx); err != nil {
h.Logger.Error("failed to check active ladder attachments", slog.Any("error", err))
} else {
data.NothingToValidate = !anyActive
}
return data
}
// ReorderPlanLadders handles POST /partials/operator/plan-ladders/reorder — the
// one mutation permitted on the otherwise read-only topology overview. It sets
// the ladder display order from a drag-and-drop on the column headers, then
// re-renders the grid in place.
//
// The request carries the full ladder-ID order as repeated `ladder` form values
// (the hidden inputs in the header row, serialized by htmx in their new DOM
// order after the SortableJS drop). The server assigns each submitted ladder a
// contiguous sort_order by position (0..N-1) — so the result is deterministic
// even when ladders previously shared the default sort_order = 0 — validating
// every ID against the live ladder set and writing the changed rows in one
// transaction. Submitted IDs that don't exist are ignored.
func (h *OperatorPartialsHandler) ReorderPlanLadders(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderPlanTopologyBody(w, r, "", "Invalid reorder request.")
return
}
ordered := r.Form["ladder"] // ladder IDs in the new left-to-right order
if len(ordered) == 0 {
// Nothing submitted (e.g. a drag that changed nothing on a single-column
// grid): re-render unchanged, no writes, no toast.
h.renderPlanTopologyBody(w, r, "", "")
return
}
ctx := r.Context()
existing, err := h.BillingQ.ListPlanLadders(ctx)
if err != nil {
h.Logger.Error("failed to list plan ladders for reorder", slog.Any("error", err))
h.renderPlanTopologyBody(w, r, "", "Failed to reorder ladders.")
return
}
// Map each known ladder to its current sort_order so we can skip no-op writes.
current := make(map[string]int32, len(existing))
for _, l := range existing {
current[l.PlanLadderID] = l.SortOrder
}
tx, err := h.Database.BeginTx(ctx, nil)
if err != nil {
h.Logger.Error("failed to begin reorder tx", slog.Any("error", err))
h.renderPlanTopologyBody(w, r, "", "Failed to reorder ladders.")
return
}
defer tx.Rollback()
qtx := billing.New(tx)
// Assign a contiguous sort_order by submitted position, skipping unknown IDs
// (defensive against a stale page) and rows already at the right value.
pos := int32(0)
for _, id := range ordered {
old, known := current[id]
if !known {
continue
}
if old != pos {
if err := qtx.SetPlanLadderSortOrder(ctx, billing.SetPlanLadderSortOrderParams{
PlanLadderID: id,
SortOrder: pos,
}); err != nil {
h.Logger.Error("failed to set ladder sort_order", slog.Any("error", err))
h.renderPlanTopologyBody(w, r, "", "Failed to reorder ladders.")
return
}
}
pos++
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit reorder tx", slog.Any("error", err))
h.renderPlanTopologyBody(w, r, "", "Failed to reorder ladders.")
return
}
h.renderPlanTopologyBody(w, r, "Ladder order updated.", "")
}
// renderPlanTopologyBody re-renders the topology overview body into
// #operator-body after a reorder, mirroring the detail-body re-render helpers.
// Best-effort: a load error surfaces inline rather than aborting.
func (h *OperatorPartialsHandler) renderPlanTopologyBody(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
fireSuccessToast(w, success)
data := h.loadPlanTopologyData(r)
if errMsg != "" {
data.Error = errMsg
}
h.Templates.Render(w, "operator_plan_topology.html", data)
}