- 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
355 lines
13 KiB
Go
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)
|
|
}
|