Files
member-console/internal/server/operator_topology.go
T
cgalo5758 9b96e9c9e9 Rework operator IA and unify UI vocabulary
- Restructure operator sidebar into a flat task list with indented
  children; fold plan topology into plan ladders
- Expand member catalog non-plan section to all published non-tier
  products; require recurring Stripe-mapped prices for purchase
- Add operator domains placements and terminal-claims ledger; redirect
  /domains to the FedWiki Sites Domains anchor
- Apply canonical vocabulary and chrome/form conventions; migrate seeded
  FedWiki Sites display name
2026-08-23 17:12:42 -05:00

355 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
}
// 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 plan-ladders page body into
// #operator-body after a topology reorder, mirroring the detail-body
// re-render helpers. ux-ia-naming 2.2 moved the sortable grid's home from the
// standalone topology page onto /operator/plan-ladders; its hx-target is
// still #operator-body (the whole page body), so a drop must re-render the
// full merged page — topology grid plus ladder list — not the topology
// fragment alone, or the ladder list below it would vanish from the DOM on
// every reorder. 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.loadPlanLaddersPageData(r, "", errMsg)
h.Templates.Render(w, "operator_plan_ladders.html", data)
}