Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.
People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.
Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.
Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.
Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
1602 lines
66 KiB
Go
1602 lines
66 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
)
|
|
|
|
// PlanLadderViewModel represents a plan ladder for operator template rendering.
|
|
type PlanLadderViewModel struct {
|
|
PlanLadderID string
|
|
LadderKey string
|
|
Name string
|
|
Description string
|
|
IsActive bool
|
|
TierCount int
|
|
ActiveAttachmentCount int64
|
|
}
|
|
|
|
// PlanLadderTierViewModel represents a tier within a ladder.
|
|
type PlanLadderTierViewModel struct {
|
|
PlanLadderID string
|
|
ProductID string
|
|
ProductName string
|
|
Rank int32
|
|
HasActiveAttachments bool
|
|
// ActiveAttachmentCount is the live-holder count backing
|
|
// HasActiveAttachments, rendered inline beside the Remove control so its
|
|
// consequence is stated before any confirmation step.
|
|
ActiveAttachmentCount int64
|
|
}
|
|
|
|
// PlanLaddersData holds data for the plan ladders list partial.
|
|
type PlanLaddersData struct {
|
|
Ladders []PlanLadderViewModel
|
|
FieldErrors web.FieldErrors
|
|
Success string
|
|
Error string
|
|
// Topology is the cross-ladder map (grid + structural-health strip)
|
|
// rendered at the top of the page (ux-ia-naming 2.2: plan ladders opens
|
|
// map-first). Hydrated by the same loader the formerly-standalone
|
|
// /operator/plan-topology page used (loadPlanTopologyData) so every
|
|
// state — issues / healthy / nothing-to-validate — behaves unchanged at
|
|
// its new home. See operator_plan_ladders.html, which includes
|
|
// operator_plan_topology.html as a partial against this field.
|
|
Topology PlanTopologyData
|
|
}
|
|
|
|
// PlanLadderEditData holds data for the plan ladder edit form.
|
|
type PlanLadderEditData struct {
|
|
Ladder PlanLadderViewModel
|
|
FieldErrors web.FieldErrors
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// ProductOption represents a product for dropdown selection on the plan ladders page.
|
|
type ProductOption struct {
|
|
ProductID string
|
|
Name string
|
|
}
|
|
|
|
// PlanLadderTiersData holds data for the tier management partial.
|
|
type PlanLadderTiersData struct {
|
|
Ladder PlanLadderViewModel
|
|
Tiers []PlanLadderTierViewModel
|
|
AvailableProducts []ProductOption
|
|
FieldErrors web.FieldErrors
|
|
Success string
|
|
Error string
|
|
// DefaultForOrgTypeNames is the display names of org types whose configured
|
|
// default plan ladder is this one. When non-empty, new organizations of
|
|
// those types are provisioned onto this ladder's rank-0 tier — so the
|
|
// rank-0 row is badged accordingly. Empty means this ladder is no org
|
|
// type's default, and rank 0 is only the ladder's base tier (provisions
|
|
// no one). See orgTypesDefaultingToLadder.
|
|
DefaultForOrgTypeNames []string
|
|
// Pending is the server-rendered preview of a dropped-but-uncommitted tier
|
|
// order; nil when no reorder is in flight. See PendingTierReorder.
|
|
Pending *PendingTierReorder
|
|
// RemovalPending is the server-rendered preview of removing a tier that
|
|
// has live holders; nil when no removal is in flight. The card renders one
|
|
// pending state at a time. See PendingTierRemoval.
|
|
RemovalPending *PendingTierRemoval
|
|
}
|
|
|
|
// PendingTierRemoval is the server-rendered preview of removing a tier with
|
|
// live holders — nothing is written until the operator commits. Other-source
|
|
// positions always align-shrink (junction ends, delivery continues);
|
|
// default-sourced holders require a disposition (keep | migrate), because a
|
|
// kept off-ladder default cannot block the floor and would later stack with
|
|
// the current type default — the preview says so instead of letting the
|
|
// operator discover it months later.
|
|
type PendingTierRemoval struct {
|
|
ProductID string
|
|
ProductName string
|
|
DefaultSourced []OrgRef
|
|
OtherSource []OrgSourceRef
|
|
NeedsDisposition bool
|
|
// PromotedName is set when the removed tier is rank 0 with a successor:
|
|
// the product promoted to the new rank 0. PromotedForTypes names the org
|
|
// types whose new-org default that promotion changes (empty when the
|
|
// ladder is no type's default).
|
|
PromotedName string
|
|
PromotedForTypes []string
|
|
}
|
|
|
|
// orgTypeRef names an org type (key + display name) whose default plan ladder
|
|
// is the ladder under edit: the tier view badges rank 0 with the display
|
|
// names, and the reorder preview/commit classify each type's population by
|
|
// key.
|
|
type orgTypeRef struct {
|
|
OrgType string
|
|
DisplayName string
|
|
}
|
|
|
|
// TierReorderTypePreview pairs one org type whose default is this ladder with
|
|
// its default-change classification against the pending rank-0 product.
|
|
type TierReorderTypePreview struct {
|
|
OrgType string
|
|
DisplayName string
|
|
Preview *OrgTypeChangePreview
|
|
}
|
|
|
|
// PendingTierReorder is the server-rendered preview of a dropped tier order —
|
|
// the drop itself writes nothing. Rank 0 is what an org type default resolves
|
|
// to at use-time, so when the pending order changes rank 0 of a ladder that is
|
|
// some org type's default, TypePreviews classifies each type's population
|
|
// (same classifier as the org-types card) and the commit requires a bucket-2
|
|
// disposition (NeedsDisposition). Pending state lives in the rendered card
|
|
// (the commit form re-submits OrderIDs), never browser-only.
|
|
type PendingTierReorder struct {
|
|
OrderIDs []string // pending product order, rank 0 first
|
|
Order []PlanLadderTierViewModel // OrderIDs hydrated with names/flags and pending ranks
|
|
Stale bool // submission reconciled against a changed ladder
|
|
RankZeroChanged bool
|
|
OldRankZeroName string
|
|
NewRankZeroName string
|
|
TypePreviews []TierReorderTypePreview
|
|
NeedsDisposition bool // some type's outgoing-default bucket is non-empty
|
|
}
|
|
|
|
// PlanLadderDetailData is the body data for the addressable per-ladder composite
|
|
// page (operator_plan_ladder_detail.html). It co-locates the edit form, the
|
|
// tier/rank manager, and this ladder's structural validation on one URL; the
|
|
// composite template renders the edit and tiers sub-sections via their existing
|
|
// partials.
|
|
type PlanLadderDetailData struct {
|
|
Edit PlanLadderEditData
|
|
Tiers PlanLadderTiersData
|
|
Validation PlanLadderValidationData
|
|
Error string
|
|
}
|
|
|
|
// PlanLadderValidationData holds data for the structural invariant validation view.
|
|
type PlanLadderValidationData struct {
|
|
OrphanProducts []ProductOption
|
|
MultiActivePools []MultiActivePoolViewModel
|
|
MalformedRankLadders []MalformedRankLadderViewModel
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// MalformedRankLadderViewModel is a ladder whose tier ranks are not
|
|
// 0-based contiguous (0..N-1). The schema only enforces per-ladder rank
|
|
// uniqueness, but rank 0 is load-bearing — org-type default resolution
|
|
// joins on rank = 0 and the member catalog branches on it — and every UI
|
|
// authoring path (append at MAX+1, reorder/removal renumber) maintains
|
|
// 0..N-1, so a gap or nonzero base means data written outside the UI.
|
|
type MalformedRankLadderViewModel struct {
|
|
LadderID string
|
|
Name string
|
|
MinRank int64
|
|
MaxRank int64
|
|
TierCount int64
|
|
}
|
|
|
|
// MultiActivePoolViewModel represents a pool with multiple active attachments on the same ladder.
|
|
type MultiActivePoolViewModel struct {
|
|
PoolID string
|
|
LadderID string
|
|
LadderKey string
|
|
ActiveCount int64
|
|
}
|
|
|
|
// CreatePlanLadder handles POST /partials/operator/plan-ladders
|
|
func (h *OperatorPartialsHandler) CreatePlanLadder(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderPlanLaddersPage(w, r, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
ladderKey := r.FormValue("ladder_key")
|
|
name := r.FormValue("name")
|
|
description := r.FormValue("description")
|
|
|
|
errs := web.New()
|
|
if ladderKey == "" {
|
|
errs.Set("ladder_key", "Slug is required.")
|
|
}
|
|
if name == "" {
|
|
errs.Set("name", "Display name is required.")
|
|
}
|
|
if errs.Any() {
|
|
h.renderPlanLaddersFormErrors(w, r, errs)
|
|
return
|
|
}
|
|
|
|
_, err := h.BillingQ.CreatePlanLadder(r.Context(), billing.CreatePlanLadderParams{
|
|
LadderKey: ladderKey,
|
|
Name: name,
|
|
Description: sql.NullString{String: description, Valid: description != ""},
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
|
|
"plan_ladders_ladder_key_key": {Field: "ladder_key", Message: "A ladder with this key already exists — pick a different key."},
|
|
}); ok {
|
|
h.renderPlanLaddersFormErrors(w, r, fe)
|
|
return
|
|
}
|
|
h.Logger.Error("failed to create plan ladder", slog.Any("error", err))
|
|
h.renderPlanLaddersPage(w, r, "", "Failed to create plan ladder — see server logs.")
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
|
|
h.renderPlanLaddersPage(w, r, "Plan ladder created successfully.", "")
|
|
}
|
|
|
|
// UpdatePlanLadder handles PUT /partials/operator/plan-ladders/{ladderID}
|
|
func (h *OperatorPartialsHandler) UpdatePlanLadder(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderPlanLadderEditPage(w, r, ladderID, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
name := r.FormValue("name")
|
|
description := r.FormValue("description")
|
|
|
|
errs := web.New()
|
|
if name == "" {
|
|
errs.Set("name", "Display name is required.")
|
|
}
|
|
if errs.Any() {
|
|
h.renderPlanLadderEditFormErrors(w, r, ladderID, errs)
|
|
return
|
|
}
|
|
|
|
// The Active flag is not operator-editable: ladder retirement isn't wired
|
|
// up (an inactive ladder still sells and provisions), so the edit form no
|
|
// longer renders the toggle and the stored value is preserved verbatim.
|
|
ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to load plan ladder", slog.Any("error", err))
|
|
h.renderPlanLadderEditPage(w, r, ladderID, "", "Plan ladder not found.")
|
|
return
|
|
}
|
|
|
|
// Ladder display order (sort_order) is deliberately not editable here — it is
|
|
// adjusted via the move-left/move-right controls on the topology overview,
|
|
// where sibling order is visible. UpdatePlanLadder no longer touches it.
|
|
_, err = h.BillingQ.UpdatePlanLadder(r.Context(), billing.UpdatePlanLadderParams{
|
|
PlanLadderID: ladderID,
|
|
Name: name,
|
|
Description: sql.NullString{String: description, Valid: description != ""},
|
|
IsActive: ladder.IsActive,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to update plan ladder", slog.Any("error", err))
|
|
h.renderPlanLadderEditPage(w, r, ladderID, "", "Failed to update plan ladder — see server logs.")
|
|
return
|
|
}
|
|
|
|
// Stay on the ladder's composite detail page (the boosted nav already set
|
|
// the URL); re-render in place reflecting the saved changes.
|
|
h.renderPlanLadderDetailBody(w, r, ladderID, "Plan ladder updated successfully.", "")
|
|
}
|
|
|
|
// DeletePlanLadder handles DELETE /partials/operator/plan-ladders/{ladderID}
|
|
func (h *OperatorPartialsHandler) DeletePlanLadder(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
|
|
// Guard: reject deletion if any active pool_provision_ladders exist
|
|
count, err := h.EntitlementsQ.CountActiveAttachmentsByLadder(r.Context(), ladderID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to count active attachments", slog.Any("error", err))
|
|
h.renderPlanLaddersPage(w, r, "", "Failed to check active positions")
|
|
return
|
|
}
|
|
if count > 0 {
|
|
h.renderPlanLaddersPage(w, r, "", "Cannot delete ladder: "+strconv.FormatInt(count, 10)+" active position(s) exist")
|
|
return
|
|
}
|
|
|
|
err = h.BillingQ.DeletePlanLadder(r.Context(), ladderID)
|
|
if err != nil {
|
|
if _, ok := web.FieldErrorsFromDB(err, nil); ok {
|
|
h.renderPlanLaddersPage(w, r, "", "Cannot delete this ladder: something still references it (a tier, or an org type using it as its default). Remove those references first.")
|
|
return
|
|
}
|
|
h.Logger.Error("failed to delete plan ladder", slog.Any("error", err))
|
|
h.renderPlanLaddersPage(w, r, "", "Failed to delete plan ladder — see server logs.")
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
|
|
h.renderPlanLaddersPage(w, r, "Plan ladder deleted successfully.", "")
|
|
}
|
|
|
|
// CreatePlanLadderTier handles POST /partials/operator/plan-ladders/{ladderID}/tiers
|
|
func (h *OperatorPartialsHandler) CreatePlanLadderTier(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Unauthorized")
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
productID := r.FormValue("product_id")
|
|
|
|
errs := web.New()
|
|
if productID == "" {
|
|
errs.Set("product_id", "Product is required.")
|
|
}
|
|
if errs.Any() {
|
|
h.renderPlanLadderTiersFormErrors(w, r, ladderID, errs)
|
|
return
|
|
}
|
|
|
|
// Any published product may be a tier (Doc 41: display_category is
|
|
// presentation-only and never gates ladder membership).
|
|
product, err := h.BillingQ.GetProductByID(r.Context(), productID)
|
|
if err != nil {
|
|
errs.Set("product_id", "Product not found.")
|
|
h.renderPlanLadderTiersFormErrors(w, r, ladderID, errs)
|
|
return
|
|
}
|
|
if product.LifecycleStatus != "published" {
|
|
errs.Set("product_id", "Only published products can be tiers. This product is "+product.LifecycleStatus+".")
|
|
h.renderPlanLadderTiersFormErrors(w, r, ladderID, errs)
|
|
return
|
|
}
|
|
|
|
// Tier creation and the shape-alignment of every live provision of this
|
|
// product run in one tx: adding the tier grew the product's conferral shape,
|
|
// so existing deliveries must gain the new rung atomically.
|
|
ctx := r.Context()
|
|
tx, err := h.Database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
h.Logger.Error("failed to begin add-tier tx", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier — see server logs.")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
bq := billing.New(tx)
|
|
entQ := entitlements.New(tx)
|
|
|
|
// Rank self-assigns to MAX+1 inside the INSERT — new tiers append at the
|
|
// bottom; the operator drags them into position (that is also the only way
|
|
// the rank-0 provisioning default changes, which is the right affordance).
|
|
if _, err = bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
}); err != nil {
|
|
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
|
|
"plan_ladder_tiers_pkey": {Field: "product_id", Message: "This product is already a tier in this ladder."},
|
|
"plan_ladder_tiers_plan_ladder_id_rank_key": {Field: "product_id", Message: "Another change collided with this one — try again."},
|
|
}); ok {
|
|
h.renderPlanLadderTiersFormErrors(w, r, ladderID, fe)
|
|
return
|
|
}
|
|
h.Logger.Error("failed to create plan ladder tier", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier — see server logs.")
|
|
return
|
|
}
|
|
|
|
// Align every live provision of the product to the grown shape; CF003 names
|
|
// the provision already holding the new rung and blocks the add.
|
|
provisions, err := entQ.GetLivePoolProvisionsByProductID(ctx, productID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list live provisions for tier product", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier — see server logs.")
|
|
return
|
|
}
|
|
for _, p := range provisions {
|
|
// The operator-actor transition CHECK requires actor_id; omitting it
|
|
// made every provision-bearing induction roll back (found 2026-07-12
|
|
// by the tier-reorder walkthrough).
|
|
if _, err := entQ.AlignConferralShape(ctx, entitlements.AlignConferralShapeParams{
|
|
ProvisionID: p.ProvisionID,
|
|
ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
Reason: sql.NullString{String: "tier added to ladder " + ladderID, Valid: true},
|
|
}); err != nil {
|
|
if errors.Is(err, entitlements.ErrConferralShapeCollision) {
|
|
errs.Set("product_id", "Cannot add this tier — a rung is already held: "+err.Error())
|
|
h.renderPlanLadderTiersFormErrors(w, r, ladderID, errs)
|
|
return
|
|
}
|
|
h.Logger.Error("failed to align provision to new tier shape", slog.Any("error", err), slog.String("provision_id", p.ProvisionID))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier — see server logs.")
|
|
return
|
|
}
|
|
if err := entitlements.MaterializePoolEntitlements(ctx, entQ, p.PoolID); err != nil {
|
|
h.Logger.Error("failed to materialize after tier align", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier — see server logs.")
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
h.Logger.Error("failed to commit add-tier tx", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier — see server logs.")
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "Tier added at the bottom — drag it into position.", "")
|
|
}
|
|
|
|
// reconcileTierOrder merges a submitted product order with the ladder's
|
|
// current tiers: submitted products that are no longer tiers are dropped, and
|
|
// tiers absent from the submission are appended in their current relative
|
|
// order (tiers arrive rank ASC), so the result always covers the whole ladder
|
|
// and renumbering can never collide with an untouched tier that still holds a
|
|
// target rank (audit finding #42). Returns the merged order, the product
|
|
// currently at rank 0, and whether the submission was stale (something was
|
|
// dropped or appended). Shared by the reorder preview and commit so the order
|
|
// the operator saw is the order the commit gates on.
|
|
func reconcileTierOrder(tiers []billing.PlanLadderTier, submitted []string) (final []string, prevTop string, stale bool) {
|
|
valid := make(map[string]bool, len(tiers))
|
|
for _, t := range tiers {
|
|
valid[t.ProductID] = true
|
|
if t.Rank == 0 {
|
|
prevTop = t.ProductID
|
|
}
|
|
}
|
|
taken := make(map[string]bool, len(submitted))
|
|
final = make([]string, 0, len(tiers))
|
|
for _, pid := range submitted {
|
|
if valid[pid] && !taken[pid] {
|
|
taken[pid] = true
|
|
final = append(final, pid)
|
|
}
|
|
}
|
|
stale = len(final) != len(submitted) // a submitted product was not a valid tier
|
|
for _, t := range tiers {
|
|
if !taken[t.ProductID] {
|
|
final = append(final, t.ProductID)
|
|
stale = true
|
|
}
|
|
}
|
|
return final, prevTop, stale
|
|
}
|
|
|
|
// buildPendingTierReorder reconciles a submitted product order into a fully
|
|
// hydrated preview: pending rows with pending ranks, whether rank 0 changes,
|
|
// and — when it does on a ladder that is some org type's default — each
|
|
// affected type's default-change classification with the pending rank-0
|
|
// product as candidate. Read-only. A (nil, "") return means there was nothing
|
|
// to preview (empty submission or a fully-stale one); callers re-render the
|
|
// persisted order.
|
|
func (h *OperatorPartialsHandler) buildPendingTierReorder(ctx context.Context, ladderID string, submitted []string) (*PendingTierReorder, string) {
|
|
if len(submitted) == 0 {
|
|
return nil, ""
|
|
}
|
|
tiers, err := h.BillingQ.ListTiersByLadder(ctx, ladderID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list tiers for reorder preview", slog.Any("error", err))
|
|
return nil, "Failed to load tiers for the reorder preview."
|
|
}
|
|
final, prevTop, stale := reconcileTierOrder(tiers, submitted)
|
|
if len(final) == 0 {
|
|
return nil, ""
|
|
}
|
|
|
|
withProducts, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to load tier products for reorder preview", slog.Any("error", err))
|
|
return nil, "Failed to load tiers for the reorder preview."
|
|
}
|
|
names := make(map[string]string, len(withProducts))
|
|
for _, t := range withProducts {
|
|
names[t.ProductID] = t.ProductName
|
|
}
|
|
|
|
pending := &PendingTierReorder{
|
|
OrderIDs: final,
|
|
Stale: stale,
|
|
RankZeroChanged: final[0] != prevTop,
|
|
OldRankZeroName: names[prevTop],
|
|
NewRankZeroName: names[final[0]],
|
|
}
|
|
pending.Order = make([]PlanLadderTierViewModel, len(final))
|
|
for i, pid := range final {
|
|
count, _ := h.EntitlementsQ.CountActiveAttachmentsByTier(ctx, entitlements.CountActiveAttachmentsByTierParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: pid,
|
|
})
|
|
pending.Order[i] = PlanLadderTierViewModel{
|
|
PlanLadderID: ladderID,
|
|
ProductID: pid,
|
|
ProductName: names[pid],
|
|
Rank: int32(i),
|
|
HasActiveAttachments: count > 0,
|
|
ActiveAttachmentCount: count,
|
|
}
|
|
}
|
|
|
|
if pending.RankZeroChanged {
|
|
for _, ref := range h.orgTypesDefaultingToLadder(ctx, ladderID) {
|
|
preview, err := h.buildOrgTypeChangePreview(ctx, ref.OrgType, ladderID, final[0])
|
|
if err != nil {
|
|
h.Logger.Error("failed to classify for reorder preview", slog.Any("error", err), slog.String("org_type", ref.OrgType))
|
|
return nil, "Failed to classify affected organizations. Details are in the server logs."
|
|
}
|
|
pending.TypePreviews = append(pending.TypePreviews, TierReorderTypePreview{
|
|
OrgType: ref.OrgType,
|
|
DisplayName: ref.DisplayName,
|
|
Preview: preview,
|
|
})
|
|
if len(preview.OutgoingDefault) > 0 {
|
|
pending.NeedsDisposition = true
|
|
}
|
|
}
|
|
}
|
|
return pending, ""
|
|
}
|
|
|
|
// PreviewPlanLadderTiersReorder handles
|
|
// POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder/preview — the
|
|
// drop half of the two-step reorder. It writes nothing: the submitted order is
|
|
// reconciled and re-rendered as pending state (server-rendered, so no swap can
|
|
// silently discard it) together with the consequences of the pending rank 0,
|
|
// and the card offers Commit/Discard. A second drag on the previewed card
|
|
// simply re-previews.
|
|
func (h *OperatorPartialsHandler) PreviewPlanLadderTiersReorder(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid reorder request.")
|
|
return
|
|
}
|
|
pending, errMsg := h.buildPendingTierReorder(r.Context(), ladderID, r.Form["product"])
|
|
if errMsg != "" {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg)
|
|
return
|
|
}
|
|
h.renderPlanLadderTiersPending(w, r, ladderID, pending, "")
|
|
}
|
|
|
|
// ReorderPlanLadderTiers handles POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder
|
|
// — the commit half of the two-step reorder (the drop itself only previews;
|
|
// see PreviewPlanLadderTiersReorder). The request carries the pending
|
|
// product-ID order as repeated `product` form values plus, when the pending
|
|
// rank 0 differs on a ladder that is some org type's default and organizations
|
|
// hold the outgoing rank-0 through default-sourced positions, a required
|
|
// `bucket2_disposition` (`grandfather` | `migrate`). Enactment mirrors the
|
|
// org-type default-change commit: validation gate (nothing written, not even
|
|
// ranks), then renumber (the moment the effective default changes), then
|
|
// per-pool dispositions with in-tx re-derivation. A tier's rank is display
|
|
// order plus the rank-0 provisioning default — member provisions bind by
|
|
// product_id (pool_provision_ladders), so reordering never re-plans a member.
|
|
func (h *OperatorPartialsHandler) ReorderPlanLadderTiers(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Unauthorized")
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid reorder request.")
|
|
return
|
|
}
|
|
disposition := r.FormValue("bucket2_disposition")
|
|
if disposition != "" && disposition != "grandfather" && disposition != "migrate" {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid disposition for organizations on the outgoing default.")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
pending, errMsg := h.buildPendingTierReorder(ctx, ladderID, r.Form["product"])
|
|
if errMsg != "" {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg)
|
|
return
|
|
}
|
|
if pending == nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "")
|
|
return
|
|
}
|
|
|
|
// Validation gate: an outgoing-default population anywhere requires an
|
|
// explicit disposition. Rejecting here writes nothing — not even ranks.
|
|
if pending.NeedsDisposition && disposition == "" {
|
|
outgoing := 0
|
|
for _, tp := range pending.TypePreviews {
|
|
outgoing += len(tp.Preview.OutgoingDefault)
|
|
}
|
|
h.renderPlanLadderTiersPending(w, r, ladderID, pending,
|
|
fmt.Sprintf("%d organization(s) hold the outgoing rank-0 default. Choose whether to grandfather or migrate them before committing.", outgoing))
|
|
return
|
|
}
|
|
|
|
tx, err := h.Database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
h.Logger.Error("failed to begin tier reorder tx", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
qtx := billing.New(tx)
|
|
|
|
if err := renumberTiers(ctx, qtx, ladderID, pending.OrderIDs); err != nil {
|
|
h.Logger.Error("failed to renumber tiers", slog.Any("error", err))
|
|
// Renumbering the whole ladder removes the stale-collision failure mode;
|
|
// a residual error here rolls back and re-renders the true current order,
|
|
// so tell the operator their page was out of date instead of emitting an
|
|
// opaque generic failure (audit finding #42).
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "This page was out of date — showing the current tier order. Please try again.")
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
h.Logger.Error("failed to commit tier reorder tx", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.")
|
|
return
|
|
}
|
|
|
|
msg := "Tier order updated."
|
|
if pending.Stale {
|
|
msg = "Tier order saved. The page was out of date, so this reflects the current order."
|
|
}
|
|
if pending.RankZeroChanged && len(pending.TypePreviews) > 0 {
|
|
// The effective default changed: enact per-pool dispositions for every
|
|
// affected type's orgs, exactly like the org-type default-change commit
|
|
// (shared enactOrgDisposition — per-pool serializable transactions,
|
|
// in-tx bucket re-derivation, per-org failure isolation).
|
|
ladderKey := ladderID
|
|
if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil {
|
|
ladderKey = ladder.LadderKey
|
|
}
|
|
actor := entitlements.Actor{
|
|
ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
Reason: fmt.Sprintf("plan-ladder tier reorder (%s)", ladderKey),
|
|
}
|
|
result := &OrgTypeChangeResult{}
|
|
var typeNames []string
|
|
for _, tp := range pending.TypePreviews {
|
|
typeNames = append(typeNames, tp.DisplayName)
|
|
orgs, err := h.OrgQ.ListOrganizationsByType(ctx, tp.OrgType)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list orgs for reorder enactment", slog.Any("error", err), slog.String("org_type", tp.OrgType))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Tier order saved, but affected organizations could not be listed for enactment. Re-drop the same order and commit again.")
|
|
return
|
|
}
|
|
for _, org := range orgs {
|
|
h.enactOrgDisposition(ctx, org, disposition, pending.OrderIDs[0], session.PersonID, actor, result)
|
|
}
|
|
}
|
|
msg = fmt.Sprintf("%s is now the default plan for new %s organizations: %s.", pending.NewRankZeroName, strings.Join(typeNames, ", "), result.Summary())
|
|
if len(result.Failures) > 0 {
|
|
parts := make([]string, 0, len(result.Failures))
|
|
for _, f := range result.Failures {
|
|
parts = append(parts, fmt.Sprintf("%s (%s) — %s", f.OrgName, f.OrgSlug, f.Reason))
|
|
}
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", msg+" Failed organizations were skipped — fix the cause, re-drop the same order, and commit again: "+strings.Join(parts, "; "))
|
|
return
|
|
}
|
|
} else if pending.RankZeroChanged {
|
|
msg = fmt.Sprintf("%s is now this ladder's rank-0 tier. The ladder is not any org type's default, so no organizations were affected.", pending.NewRankZeroName)
|
|
}
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, msg, "")
|
|
}
|
|
|
|
// renumberTiers assigns each product in `ordered` a contiguous rank 0..N-1 by
|
|
// position, in two phases inside the caller's transaction: UNIQUE
|
|
// (plan_ladder_id, rank) is non-deferrable, so a single-pass assignment can
|
|
// transiently collide (e.g. setting one tier to 0 while another still holds 0).
|
|
// Phase 1 parks every tier at a unique negative rank (disjoint from any
|
|
// non-negative existing value); phase 2 assigns the final contiguous ranks.
|
|
// Shared by drag-and-drop reorder and renumber-on-delete.
|
|
func renumberTiers(ctx context.Context, qtx *billing.Queries, ladderID string, ordered []string) error {
|
|
for i, pid := range ordered {
|
|
if _, err := qtx.UpdateTierRank(ctx, billing.UpdateTierRankParams{
|
|
PlanLadderID: ladderID, ProductID: pid, Rank: int32(-(i + 1)),
|
|
}); err != nil {
|
|
return fmt.Errorf("park tier rank: %w", err)
|
|
}
|
|
}
|
|
for i, pid := range ordered {
|
|
if _, err := qtx.UpdateTierRank(ctx, billing.UpdateTierRankParams{
|
|
PlanLadderID: ladderID, ProductID: pid, Rank: int32(i),
|
|
}); err != nil {
|
|
return fmt.Errorf("set tier rank: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeletePlanLadderTier handles DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}
|
|
func (h *OperatorPartialsHandler) DeletePlanLadderTier(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
productID := r.PathValue("productID")
|
|
|
|
// A tier with live holders is removed via the preview → commit flow
|
|
// (CommitPlanLadderTierRemoval), never this plain delete — this DELETE is
|
|
// only reachable with holders from a stale page, so re-render with the
|
|
// current state (whose Remove button now posts the preview).
|
|
count, err := h.EntitlementsQ.CountActiveAttachmentsByTier(r.Context(), entitlements.CountActiveAttachmentsByTierParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to count active tier attachments", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to check active positions")
|
|
return
|
|
}
|
|
if count > 0 {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "This tier has "+strconv.FormatInt(count, 10)+" live holder(s) — Remove now previews the reconciled removal before committing.")
|
|
return
|
|
}
|
|
|
|
// Refuse deleting a live default ladder's only remaining tier: new
|
|
// signups land on this ladder's rank-0 tier, so emptying it would leave
|
|
// every org type that defaults to it without a starting plan. Emptying a
|
|
// ladder no org type defaults to stays legal — the 2026-07-03
|
|
// remediation explicitly rejected a blanket last-tier guard.
|
|
tiers, err := h.BillingQ.ListTiersByLadder(r.Context(), ladderID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list tiers for delete guard", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to check the ladder's tiers")
|
|
return
|
|
}
|
|
if len(tiers) == 1 && tiers[0].ProductID == productID {
|
|
if refs := h.orgTypesDefaultingToLadder(r.Context(), ladderID); len(refs) > 0 {
|
|
names := make([]string, len(refs))
|
|
for i, ref := range refs {
|
|
names[i] = ref.DisplayName
|
|
}
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "",
|
|
"This is the ladder's only remaining tier, and "+strings.Join(names, ", ")+" defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused.")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Delete + renumber the remaining tiers to contiguous 0..N-1 in one tx, so
|
|
// ranks never gap and the ladder always has a rank-0 provisioning default
|
|
// (deleting the base tier promotes the next tier — the confirm dialog says so).
|
|
ctx := r.Context()
|
|
tx, err := h.Database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
h.Logger.Error("failed to begin tier delete tx", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier — see server logs.")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
qtx := billing.New(tx)
|
|
|
|
if err := qtx.DeleteTier(ctx, billing.DeleteTierParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
}); err != nil {
|
|
h.Logger.Error("failed to delete tier", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier — see server logs.")
|
|
return
|
|
}
|
|
remaining, err := qtx.ListTiersByLadder(ctx, ladderID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list remaining tiers", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier — see server logs.")
|
|
return
|
|
}
|
|
ordered := make([]string, len(remaining))
|
|
for i, t := range remaining {
|
|
ordered[i] = t.ProductID
|
|
}
|
|
if err := renumberTiers(ctx, qtx, ladderID, ordered); err != nil {
|
|
h.Logger.Error("failed to renumber tiers after delete", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier — see server logs.")
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
h.Logger.Error("failed to commit tier delete tx", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier — see server logs.")
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "Tier removed — remaining tiers renumbered.", "")
|
|
}
|
|
|
|
// buildTierRemovalPreview classifies the live holders of (ladder, product)
|
|
// for the removal preview: default-sourced positions (disposition required)
|
|
// vs other-source positions (align-shrunk, named, never force-ended), plus
|
|
// the promoted-rank-0 note when removing the top tier. Read-only. A (nil, "")
|
|
// return means the tier has no live holders — callers fall back to the plain
|
|
// modal-confirmed delete.
|
|
func (h *OperatorPartialsHandler) buildTierRemovalPreview(ctx context.Context, ladderID, productID string) (*PendingTierRemoval, string) {
|
|
rows, err := h.EntitlementsQ.ListLiveTierHoldersByLadderProduct(ctx, entitlements.ListLiveTierHoldersByLadderProductParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to list tier holders for removal preview", slog.Any("error", err))
|
|
return nil, "Failed to load the tier's holders. Details are in the server logs."
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, ""
|
|
}
|
|
|
|
pending := &PendingTierRemoval{ProductID: productID, ProductName: productID}
|
|
if prod, err := h.BillingQ.GetProductByID(ctx, productID); err == nil {
|
|
pending.ProductName = prod.Name
|
|
}
|
|
seenDefault := make(map[string]bool)
|
|
seenOther := make(map[string]bool)
|
|
for _, row := range rows {
|
|
ref := OrgRef{OrgID: row.OrgID, Name: row.Name, Slug: row.Slug}
|
|
if row.GrantReason.Valid && row.GrantReason.String == "default" {
|
|
if !seenDefault[row.OrgID] {
|
|
seenDefault[row.OrgID] = true
|
|
pending.DefaultSourced = append(pending.DefaultSourced, ref)
|
|
}
|
|
continue
|
|
}
|
|
if !seenOther[row.OrgID] {
|
|
seenOther[row.OrgID] = true
|
|
pending.OtherSource = append(pending.OtherSource, OrgSourceRef{
|
|
OrgRef: ref,
|
|
Source: otherSourceLabel([]entitlements.GetLivePlanAttachmentsWithSourceByPoolRow{{
|
|
ProductID: row.ProductID,
|
|
GrantID: row.GrantID,
|
|
SubscriptionID: row.SubscriptionID,
|
|
PurchaseID: row.PurchaseID,
|
|
GrantReason: row.GrantReason,
|
|
}}),
|
|
})
|
|
}
|
|
}
|
|
pending.NeedsDisposition = len(pending.DefaultSourced) > 0
|
|
|
|
// Promoted-rank-0 note: removing the top tier of a ladder makes the next
|
|
// tier the resolution target for new-org provisioning.
|
|
if tiers, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID); err == nil && len(tiers) > 1 &&
|
|
tiers[0].ProductID == productID && tiers[0].Rank == 0 {
|
|
pending.PromotedName = tiers[1].ProductName
|
|
for _, ref := range h.orgTypesDefaultingToLadder(ctx, ladderID) {
|
|
pending.PromotedForTypes = append(pending.PromotedForTypes, ref.DisplayName)
|
|
}
|
|
}
|
|
return pending, ""
|
|
}
|
|
|
|
// renderPlanLadderTiersRemoval re-renders the ladder composite with a pending
|
|
// tier removal attached to the tier card. No toast — nothing has happened yet.
|
|
func (h *OperatorPartialsHandler) renderPlanLadderTiersRemoval(w http.ResponseWriter, r *http.Request, ladderID string, pending *PendingTierRemoval, errMsg string) {
|
|
edit, ok := h.loadPlanLadderEditData(r, ladderID)
|
|
if !ok {
|
|
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
|
|
return
|
|
}
|
|
tiersData := h.loadPlanLadderTiersData(r, ladderID)
|
|
tiersData.RemovalPending = pending
|
|
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
|
|
Edit: edit,
|
|
Tiers: tiersData,
|
|
Validation: h.loadLadderScopedValidation(r.Context(), ladderID),
|
|
Error: errMsg,
|
|
})
|
|
}
|
|
|
|
// PreviewPlanLadderTierRemoval handles
|
|
// POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview
|
|
// — the first half of removing a tier with live holders. Writes nothing: the
|
|
// tier card re-renders with the affected orgs classified by position source,
|
|
// disposition radios when default-sourced holders exist, and Commit / Discard.
|
|
func (h *OperatorPartialsHandler) PreviewPlanLadderTierRemoval(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
productID := r.PathValue("productID")
|
|
pending, errMsg := h.buildTierRemovalPreview(r.Context(), ladderID, productID)
|
|
if errMsg != "" {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg)
|
|
return
|
|
}
|
|
if pending == nil {
|
|
// The holders vanished since the page rendered: the plain delete flow
|
|
// applies again.
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "This tier no longer has live holders — use Remove directly.")
|
|
return
|
|
}
|
|
h.renderPlanLadderTiersRemoval(w, r, ladderID, pending, "")
|
|
}
|
|
|
|
// CommitPlanLadderTierRemoval handles
|
|
// POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove —
|
|
// the commit half of removing a tier with live holders. Atomic by design
|
|
// (design D3): tier deletion, renumbering, and every holder reconciliation
|
|
// run in ONE transaction with affected pools locked in deterministic order —
|
|
// once the tier row is gone there is no entry point to re-run a
|
|
// half-reconciled removal, and a shape-mismatched provision would block
|
|
// future same-product confers, so a failure rolls back everything including
|
|
// the deletion. Other-source positions align-shrink (junction ends, delivery
|
|
// continues). Default-sourced positions follow the required disposition:
|
|
// keep = align-shrink (the preview carried the floor caveat); migrate =
|
|
// end + floor-guarded restoration, resolved after renumbering, so removing a
|
|
// default ladder's rank 0 migrates holders onto the promoted tier.
|
|
func (h *OperatorPartialsHandler) CommitPlanLadderTierRemoval(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
productID := r.PathValue("productID")
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Unauthorized")
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid removal request.")
|
|
return
|
|
}
|
|
disposition := r.FormValue("default_disposition")
|
|
if disposition != "" && disposition != "keep" && disposition != "migrate" {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid disposition for default-sourced holders.")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
productName := productID
|
|
if prod, err := h.BillingQ.GetProductByID(ctx, productID); err == nil {
|
|
productName = prod.Name
|
|
}
|
|
ladderKey := ladderID
|
|
if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil {
|
|
ladderKey = ladder.LadderKey
|
|
}
|
|
actor := entitlements.Actor{
|
|
ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
Reason: fmt.Sprintf("tier removal (%s from %s)", productName, ladderKey),
|
|
}
|
|
|
|
tx, err := h.Database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
h.Logger.Error("failed to begin tier removal tx", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
qtx := entitlements.New(tx)
|
|
bq := billing.New(tx)
|
|
|
|
// Lock affected pools in deterministic order (concurrent per-pool commits
|
|
// of the default-change flows lock single pools; sorted acquisition avoids
|
|
// deadlocking against them), then re-derive the population from the locked
|
|
// state — the preview was a projection.
|
|
rows, err := qtx.ListLiveTierHoldersByLadderProduct(ctx, entitlements.ListLiveTierHoldersByLadderProductParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to list tier holders for removal", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.")
|
|
return
|
|
}
|
|
var pools []string
|
|
seenPool := make(map[string]bool)
|
|
for _, row := range rows {
|
|
if !seenPool[row.PoolID] {
|
|
seenPool[row.PoolID] = true
|
|
pools = append(pools, row.PoolID)
|
|
}
|
|
}
|
|
sort.Strings(pools)
|
|
for _, poolID := range pools {
|
|
if _, err := tx.ExecContext(ctx, "SELECT 1 FROM core.resource_pools WHERE pool_id = $1 FOR UPDATE", poolID); err != nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.")
|
|
return
|
|
}
|
|
}
|
|
rows, err = qtx.ListLiveTierHoldersByLadderProduct(ctx, entitlements.ListLiveTierHoldersByLadderProductParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
})
|
|
if err != nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.")
|
|
return
|
|
}
|
|
|
|
type poolWork struct {
|
|
incumbents []defaultIncumbent // default-sourced positions (disposition applies)
|
|
aligns []string // provision IDs to align-shrink
|
|
}
|
|
work := make(map[string]*poolWork)
|
|
defaultHolders := 0
|
|
for _, row := range rows {
|
|
pw := work[row.PoolID]
|
|
if pw == nil {
|
|
pw = &poolWork{}
|
|
work[row.PoolID] = pw
|
|
}
|
|
if row.GrantReason.Valid && row.GrantReason.String == "default" {
|
|
defaultHolders++
|
|
pw.incumbents = append(pw.incumbents, defaultIncumbent{
|
|
ProvisionID: row.ProvisionID,
|
|
GrantID: row.GrantID.UUID.String(),
|
|
ProductID: row.ProductID,
|
|
})
|
|
continue
|
|
}
|
|
pw.aligns = append(pw.aligns, row.ProvisionID)
|
|
}
|
|
|
|
// Validation gate: default-sourced holders require an explicit
|
|
// disposition. Rejecting here writes nothing — the tier stays.
|
|
if defaultHolders > 0 && disposition == "" {
|
|
pending, _ := h.buildTierRemovalPreview(ctx, ladderID, productID)
|
|
if pending == nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.")
|
|
return
|
|
}
|
|
h.renderPlanLadderTiersRemoval(w, r, ladderID, pending, fmt.Sprintf(
|
|
"%d organization(s) hold this tier through the org type default. Choose whether to keep or migrate them before committing.", defaultHolders))
|
|
return
|
|
}
|
|
|
|
// Delete + renumber first: alignment matches the product's CURRENT shape,
|
|
// so the shape must already be shrunk, and the floor restoration below
|
|
// must resolve rank 0 against the promoted order.
|
|
if err := bq.DeleteTier(ctx, billing.DeleteTierParams{PlanLadderID: ladderID, ProductID: productID}); err != nil {
|
|
h.Logger.Error("failed to delete tier", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.")
|
|
return
|
|
}
|
|
remaining, err := bq.ListTiersByLadder(ctx, ladderID)
|
|
if err != nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.")
|
|
return
|
|
}
|
|
if len(remaining) > 0 {
|
|
ordered := make([]string, len(remaining))
|
|
for i, t := range remaining {
|
|
ordered[i] = t.ProductID
|
|
}
|
|
if err := renumberTiers(ctx, bq, ladderID, ordered); err != nil {
|
|
h.Logger.Error("failed to renumber tiers after removal", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Enact per pool: end incumbents first (migrate), then align every
|
|
// remaining position of the product, then restore — so a pool that still
|
|
// holds another live position after alignment is correctly not re-floored.
|
|
ended, migrated := 0, 0
|
|
for _, poolID := range pools {
|
|
pw := work[poolID]
|
|
if pw == nil {
|
|
continue
|
|
}
|
|
aligns := pw.aligns
|
|
if len(pw.incumbents) > 0 && disposition == "migrate" {
|
|
if err := endDefaultIncumbents(ctx, qtx, pw.incumbents, actor); err != nil {
|
|
h.Logger.Error("tier removal: migrate end failed", slog.String("pool_id", poolID), slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Removal failed and nothing was changed: could not end a default-sourced position. Details are in the server logs.")
|
|
return
|
|
}
|
|
migrated++
|
|
} else {
|
|
for _, inc := range pw.incumbents {
|
|
aligns = append(aligns, inc.ProvisionID) // keep: align-shrink like any other holder
|
|
}
|
|
}
|
|
for _, provisionID := range aligns {
|
|
if _, err := qtx.AlignConferralShape(ctx, entitlements.AlignConferralShapeParams{
|
|
ProvisionID: provisionID,
|
|
ActorType: "operator",
|
|
ActorID: actor.ActorID,
|
|
Reason: sql.NullString{String: actor.Reason, Valid: true},
|
|
}); err != nil {
|
|
h.Logger.Error("tier removal: align failed", slog.String("provision_id", provisionID), slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Removal failed and nothing was changed: could not reconcile a holder's position. Details are in the server logs.")
|
|
return
|
|
}
|
|
ended++
|
|
}
|
|
if len(pw.incumbents) > 0 && disposition == "migrate" {
|
|
if _, err := entitlements.ReapplyDefaultsIfVacant(ctx, tx, poolID, actor); err != nil {
|
|
h.Logger.Error("tier removal: migrate restore failed", slog.String("pool_id", poolID), slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Removal failed and nothing was changed: could not apply the current default. Details are in the server logs.")
|
|
return
|
|
}
|
|
}
|
|
if err := entitlements.MaterializePoolEntitlements(ctx, qtx, poolID); err != nil {
|
|
h.Logger.Error("tier removal: materialize failed", slog.String("pool_id", poolID), slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Removal failed and nothing was changed: could not materialize entitlements. Details are in the server logs.")
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
h.Logger.Error("failed to commit tier removal tx", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.")
|
|
return
|
|
}
|
|
|
|
parts := []string{}
|
|
if ended > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d position(s) on this ladder ended — delivery continues", ended))
|
|
}
|
|
if migrated > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d organization(s) migrated to the current default", migrated))
|
|
}
|
|
msg := fmt.Sprintf("%s removed from the ladder: %s.", productName, strings.Join(parts, "; "))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, msg, "")
|
|
}
|
|
|
|
// GetPlanLadderValidation handles GET /partials/operator/plan-ladders/validation
|
|
func (h *OperatorPartialsHandler) GetPlanLadderValidation(w http.ResponseWriter, r *http.Request) {
|
|
h.renderPlanLadderValidationPage(w, r, "", "")
|
|
}
|
|
|
|
// GetPlanLadderValidationPage handles GET /operator/plan-ladders/validation —
|
|
// the addressable, bookmarkable full-page structural-validation view, rendered
|
|
// inside the operator.html shell. The ladders-list "Validate Structure" and
|
|
// topology "View detail" links point here (a boosted full-page nav) so a new
|
|
// tab or refresh lands on a styled, addressable page instead of the shell-less
|
|
// fragment the bare partial endpoint returns (audit finding #43).
|
|
func (h *OperatorPartialsHandler) GetPlanLadderValidationPage(w http.ResponseWriter, r *http.Request) {
|
|
page := h.buildOperatorPageData(r)
|
|
page.IAPosition = "catalog:plan-ladders:validation"
|
|
page.ActiveCapability = "plan-ladders"
|
|
page.BodyTemplate = "operator_plan_ladder_validation.html"
|
|
page.BodyData = h.computePlanLadderValidation(r.Context())
|
|
|
|
h.Templates.Render(w, "operator.html", page)
|
|
}
|
|
|
|
func (h *OperatorPartialsHandler) renderPlanLaddersPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
|
|
fireSuccessToast(w, success)
|
|
// The in-body banner alone is invisible when the mutation was triggered
|
|
// from a scrolled-down row (maintainer 2026-08-23: a ladder delete gave
|
|
// "no feedback"); the toast is the visible half.
|
|
fireErrorToast(w, errMsg)
|
|
data := h.loadPlanLaddersPageData(r, "", errMsg)
|
|
h.Templates.Render(w, "operator_plan_ladders.html", data)
|
|
}
|
|
|
|
// loadPlanLaddersPageData hydrates the plan-ladders listing. Shared by the
|
|
// legacy partial endpoint (renderPlanLaddersPage) and the new MPA page
|
|
// handler (GetPlanLaddersPage).
|
|
func (h *OperatorPartialsHandler) loadPlanLaddersPageData(r *http.Request, success string, errMsg string) PlanLaddersData {
|
|
data := PlanLaddersData{
|
|
Success: success,
|
|
Error: errMsg,
|
|
}
|
|
// A stale-detail GET redirects here with ?flash=missing; surface it as the
|
|
// page banner (finding #53) without clobbering an explicit mutation-guard
|
|
// errMsg.
|
|
if data.Error == "" && r.URL.Query().Get("flash") == "missing" {
|
|
data.Error = "That plan ladder no longer exists — it may have been deleted."
|
|
}
|
|
|
|
// ux-ia-naming 2.2: plan ladders opens map-first. Loaded independently of
|
|
// (and before) the list below, mirroring finding #54's always-hydrate
|
|
// rule — a failure in one section never blanks the other; each renders
|
|
// its own error state.
|
|
data.Topology = h.loadPlanTopologyData(r)
|
|
|
|
// Finding #54: always hydrate the table and create form, even when a banner
|
|
// is set, so an unrecognized error never wipes the whole management surface.
|
|
ladders, err := h.BillingQ.ListPlanLadders(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list plan ladders", slog.Any("error", err))
|
|
if data.Error == "" {
|
|
data.Error = "Failed to load plan ladders"
|
|
}
|
|
return data
|
|
}
|
|
|
|
data.Ladders = make([]PlanLadderViewModel, len(ladders))
|
|
for i, l := range ladders {
|
|
tiers, _ := h.BillingQ.ListTiersByLadder(r.Context(), l.PlanLadderID)
|
|
attachments, _ := h.EntitlementsQ.CountActiveAttachmentsByLadder(r.Context(), l.PlanLadderID)
|
|
desc := ""
|
|
if l.Description.Valid {
|
|
desc = l.Description.String
|
|
}
|
|
data.Ladders[i] = PlanLadderViewModel{
|
|
PlanLadderID: l.PlanLadderID,
|
|
LadderKey: l.LadderKey,
|
|
Name: l.Name,
|
|
Description: desc,
|
|
IsActive: l.IsActive,
|
|
TierCount: len(tiers),
|
|
ActiveAttachmentCount: attachments,
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
// renderPlanLaddersFormErrors re-renders the ladders list page (with inline
|
|
// create-ladder form) with 422 + FieldErrors populated.
|
|
func (h *OperatorPartialsHandler) renderPlanLaddersFormErrors(w http.ResponseWriter, r *http.Request, errs web.FieldErrors) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
data := h.loadPlanLaddersPageData(r, "", "")
|
|
data.FieldErrors = errs
|
|
h.Templates.Render(w, "operator_plan_ladders.html", data)
|
|
}
|
|
|
|
// renderPlanLadderTiersFormErrors re-renders the tiers page (with inline
|
|
// add-tier + reorder-rank forms) with 422 + FieldErrors populated. The
|
|
// load logic mirrors renderPlanLadderTiersPage's hydration since we need
|
|
// to write the 422 header before any body bytes.
|
|
func (h *OperatorPartialsHandler) renderPlanLadderTiersFormErrors(w http.ResponseWriter, r *http.Request, ladderID string, errs web.FieldErrors) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
edit, ok := h.loadPlanLadderEditData(r, ladderID)
|
|
if !ok {
|
|
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
|
|
return
|
|
}
|
|
tiers := h.loadPlanLadderTiersData(r, ladderID)
|
|
tiers.FieldErrors = errs
|
|
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
|
|
Edit: edit,
|
|
Tiers: tiers,
|
|
Validation: h.loadLadderScopedValidation(r.Context(), ladderID),
|
|
})
|
|
}
|
|
|
|
// renderPlanLadderEditFormErrors re-renders the edit form with 422 +
|
|
// FieldErrors populated. Convention per docs/operator-ux-conventions.md §6+§8.
|
|
func (h *OperatorPartialsHandler) renderPlanLadderEditFormErrors(w http.ResponseWriter, r *http.Request, ladderID string, errs web.FieldErrors) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
edit, ok := h.loadPlanLadderEditData(r, ladderID)
|
|
if !ok {
|
|
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
|
|
return
|
|
}
|
|
edit.FieldErrors = errs
|
|
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
|
|
Edit: edit,
|
|
Tiers: h.loadPlanLadderTiersData(r, ladderID),
|
|
Validation: h.loadLadderScopedValidation(r.Context(), ladderID),
|
|
})
|
|
}
|
|
|
|
// loadPlanLadderEditData hydrates the edit-form view model for one ladder.
|
|
// ok=false means the ladder does not exist.
|
|
func (h *OperatorPartialsHandler) loadPlanLadderEditData(r *http.Request, ladderID string) (PlanLadderEditData, bool) {
|
|
data, found, err := h.loadPlanLadderEditDataResult(r, ladderID)
|
|
return data, found && err == nil
|
|
}
|
|
|
|
// loadPlanLadderEditDataResult is loadPlanLadderEditData with the not-found vs.
|
|
// transient-DB-error distinction preserved: found=false with err=nil means the
|
|
// ladder does not exist; err != nil means the load itself failed. The full-page
|
|
// GET (GetPlanLadderDetailPage) uses this to redirect on the former and return a
|
|
// full-shell 500 on the latter, rather than conflating the two (finding #53).
|
|
func (h *OperatorPartialsHandler) loadPlanLadderEditDataResult(r *http.Request, ladderID string) (PlanLadderEditData, bool, error) {
|
|
ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return PlanLadderEditData{}, false, nil
|
|
}
|
|
if err != nil {
|
|
h.Logger.Error("failed to get plan ladder", slog.Any("error", err))
|
|
return PlanLadderEditData{}, false, err
|
|
}
|
|
|
|
desc := ""
|
|
if ladder.Description.Valid {
|
|
desc = ladder.Description.String
|
|
}
|
|
return PlanLadderEditData{
|
|
Ladder: PlanLadderViewModel{
|
|
PlanLadderID: ladder.PlanLadderID,
|
|
LadderKey: ladder.LadderKey,
|
|
Name: ladder.Name,
|
|
Description: desc,
|
|
IsActive: ladder.IsActive,
|
|
},
|
|
}, true, nil
|
|
}
|
|
|
|
// GetPlanLadderDetailPage handles GET /operator/plan-ladders/{ladderID} — the
|
|
// addressable, bookmarkable per-ladder composite. Renders the operator.html
|
|
// shell with the edit form, tier/rank manager, and this ladder's structural
|
|
// validation co-located on one URL. Unknown ladderID degrades to the ladders
|
|
// browse page with an error.
|
|
func (h *OperatorPartialsHandler) GetPlanLadderDetailPage(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
edit, found, err := h.loadPlanLadderEditDataResult(r, ladderID)
|
|
if err != nil {
|
|
// Transient load failure — keep the operator inside the shell (nav, CSS)
|
|
// with a 500 instead of mislabeling it "not found" (finding #53).
|
|
errPage := h.buildOperatorPageData(r)
|
|
errPage.IAPosition = "catalog:plan-ladders"
|
|
errPage.ActiveCapability = "plan-ladders"
|
|
errPage.BodyTemplate = "operator_plan_ladders.html"
|
|
errPage.BodyData = h.loadPlanLaddersPageData(r, "", "Could not load this plan ladder right now. Please try again.")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
h.Templates.Render(w, "operator.html", errPage)
|
|
return
|
|
}
|
|
if !found {
|
|
// Stale/unknown ID — normalize the URL back to the browse route and carry
|
|
// a flash so the operator understands the bounce (finding #53).
|
|
http.Redirect(w, r, "/operator/plan-ladders?flash=missing", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
page := h.buildOperatorPageData(r)
|
|
page.IAPosition = "catalog:plan-ladders:" + ladderID
|
|
page.ActiveCapability = "plan-ladders"
|
|
page.BodyTemplate = "operator_plan_ladder_detail.html"
|
|
page.BodyData = PlanLadderDetailData{
|
|
Edit: edit,
|
|
Tiers: h.loadPlanLadderTiersData(r, ladderID),
|
|
Validation: h.loadLadderScopedValidation(r.Context(), ladderID),
|
|
}
|
|
|
|
h.Templates.Render(w, "operator.html", page)
|
|
}
|
|
|
|
// renderPlanLadderDetailBody re-renders the composite ladder detail body into
|
|
// #operator-body after an in-page mutation. The operator stays on
|
|
// /operator/plan-ladders/{ladderID}; unknown ladderID degrades to the browse page.
|
|
func (h *OperatorPartialsHandler) renderPlanLadderDetailBody(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) {
|
|
fireSuccessToast(w, success)
|
|
fireErrorToast(w, errMsg)
|
|
edit, ok := h.loadPlanLadderEditData(r, ladderID)
|
|
if !ok {
|
|
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
|
|
return
|
|
}
|
|
// Finding #41: set only the composite-level Error so the alert renders once
|
|
// at page top; setting edit.Error too stacked an identical banner inside the
|
|
// Edit card, visually blaming the wrong form.
|
|
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
|
|
Edit: edit,
|
|
Tiers: h.loadPlanLadderTiersData(r, ladderID),
|
|
Validation: h.loadLadderScopedValidation(r.Context(), ladderID),
|
|
Error: errMsg,
|
|
})
|
|
}
|
|
|
|
// renderPlanLadderEditPage re-renders the ladder composite in place. Retained as
|
|
// a thin alias so the mutation call sites (UpdatePlanLadder) keep working; the
|
|
// edit form now lives on the composite detail page.
|
|
func (h *OperatorPartialsHandler) renderPlanLadderEditPage(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) {
|
|
h.renderPlanLadderDetailBody(w, r, ladderID, success, errMsg)
|
|
}
|
|
|
|
// loadLadderScopedValidation filters the global structural-invariant checks to
|
|
// the violations that concern this one ladder (non-plan tiers attached here, and
|
|
// any multi-active pool attachments on this ladder). Orphan products are global
|
|
// (they belong to no ladder), so they are intentionally excluded from a ladder
|
|
// page. Reuses computePlanLadderValidation so the checks never diverge.
|
|
func (h *OperatorPartialsHandler) loadLadderScopedValidation(ctx context.Context, ladderID string) PlanLadderValidationData {
|
|
all := h.computePlanLadderValidation(ctx)
|
|
scoped := PlanLadderValidationData{}
|
|
for _, p := range all.MultiActivePools {
|
|
if p.LadderID == ladderID {
|
|
scoped.MultiActivePools = append(scoped.MultiActivePools, p)
|
|
}
|
|
}
|
|
return scoped
|
|
}
|
|
|
|
// renderPlanLadderTiersPage re-renders the ladder composite in place. Retained
|
|
// as a thin alias so the tier mutation call sites (add/reorder/remove) keep
|
|
// working; the tier manager now lives on the composite detail page.
|
|
func (h *OperatorPartialsHandler) renderPlanLadderTiersPage(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) {
|
|
h.renderPlanLadderDetailBody(w, r, ladderID, success, errMsg)
|
|
}
|
|
|
|
// renderPlanLadderTiersPending re-renders the ladder composite with a pending
|
|
// (uncommitted) tier reorder attached to the tier card: pending rows and ranks,
|
|
// consequence panel, Commit/Discard. No toast — nothing has happened yet. A nil
|
|
// pending falls back to the persisted order.
|
|
func (h *OperatorPartialsHandler) renderPlanLadderTiersPending(w http.ResponseWriter, r *http.Request, ladderID string, pending *PendingTierReorder, errMsg string) {
|
|
edit, ok := h.loadPlanLadderEditData(r, ladderID)
|
|
if !ok {
|
|
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
|
|
return
|
|
}
|
|
tiersData := h.loadPlanLadderTiersData(r, ladderID)
|
|
tiersData.Pending = pending
|
|
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
|
|
Edit: edit,
|
|
Tiers: tiersData,
|
|
Validation: h.loadLadderScopedValidation(r.Context(), ladderID),
|
|
Error: errMsg,
|
|
})
|
|
}
|
|
|
|
// loadPlanLadderTiersData hydrates the tier-management view model for one ladder:
|
|
// the existing tiers (with active-attachment flags) and the products still
|
|
// available to add as tiers. Best-effort — a query failure yields a partial
|
|
// result rather than aborting, since this feeds the composite detail page where
|
|
// the ladder's existence is already established.
|
|
// orgTypesDefaultingToLadder returns the org types (key + display name) whose
|
|
// configured default plan ladder is ladderID. New organizations of those types
|
|
// are auto-provisioned onto this ladder's rank-0 tier; for any ladder that is
|
|
// no org type's default, rank 0 is merely the base tier and provisions no one.
|
|
// Best-effort: a query failure logs and returns nil (the badge simply doesn't
|
|
// render; the reorder preview treats the ladder as no type's default) rather
|
|
// than failing the whole tier view.
|
|
func (h *OperatorPartialsHandler) orgTypesDefaultingToLadder(ctx context.Context, ladderID string) []orgTypeRef {
|
|
lid, err := uuid.Parse(ladderID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
orgTypes, err := h.OrgQ.ListOrgTypes(ctx)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list org types for default-ladder badge", slog.Any("error", err))
|
|
return nil
|
|
}
|
|
var refs []orgTypeRef
|
|
for _, ot := range orgTypes {
|
|
if ot.DefaultPlanLadderID.Valid && ot.DefaultPlanLadderID.UUID == lid {
|
|
refs = append(refs, orgTypeRef{OrgType: ot.OrgType, DisplayName: ot.DisplayName})
|
|
}
|
|
}
|
|
return refs
|
|
}
|
|
|
|
func (h *OperatorPartialsHandler) loadPlanLadderTiersData(r *http.Request, ladderID string) PlanLadderTiersData {
|
|
data := PlanLadderTiersData{}
|
|
if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID); err == nil {
|
|
data.Ladder = PlanLadderViewModel{
|
|
PlanLadderID: ladder.PlanLadderID,
|
|
LadderKey: ladder.LadderKey,
|
|
Name: ladder.Name,
|
|
IsActive: ladder.IsActive,
|
|
}
|
|
}
|
|
for _, ref := range h.orgTypesDefaultingToLadder(r.Context(), ladderID) {
|
|
data.DefaultForOrgTypeNames = append(data.DefaultForOrgTypeNames, ref.DisplayName)
|
|
}
|
|
|
|
// Load tiers with product info
|
|
tiers, err := h.BillingQ.ListTiersByLadderWithProducts(r.Context(), ladderID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list tiers", slog.Any("error", err))
|
|
} else {
|
|
data.Tiers = make([]PlanLadderTierViewModel, len(tiers))
|
|
for i, t := range tiers {
|
|
count, _ := h.EntitlementsQ.CountActiveAttachmentsByTier(r.Context(), entitlements.CountActiveAttachmentsByTierParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: t.ProductID,
|
|
})
|
|
data.Tiers[i] = PlanLadderTierViewModel{
|
|
PlanLadderID: ladderID,
|
|
ProductID: t.ProductID,
|
|
ProductName: t.ProductName,
|
|
Rank: t.Rank,
|
|
HasActiveAttachments: count > 0,
|
|
ActiveAttachmentCount: count,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load available products: any active, published product not already in this
|
|
// ladder (Doc 41: display_category never gates tier membership).
|
|
products, err := h.BillingQ.ListActiveProducts(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list products", slog.Any("error", err))
|
|
} else {
|
|
existingTiers, _ := h.BillingQ.ListTiersByLadder(r.Context(), ladderID)
|
|
existingProductIDs := make(map[string]bool)
|
|
for _, t := range existingTiers {
|
|
existingProductIDs[t.ProductID] = true
|
|
}
|
|
|
|
for _, p := range products {
|
|
if p.LifecycleStatus != "published" {
|
|
continue // skip non-published products
|
|
}
|
|
if existingProductIDs[p.ProductID] {
|
|
continue // skip already-attached products
|
|
}
|
|
data.AvailableProducts = append(data.AvailableProducts, ProductOption{
|
|
ProductID: p.ProductID,
|
|
Name: p.Name,
|
|
})
|
|
}
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
func (h *OperatorPartialsHandler) renderPlanLadderValidationPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
|
|
fireSuccessToast(w, success)
|
|
fireErrorToast(w, errMsg)
|
|
if errMsg != "" {
|
|
h.Templates.Render(w, "operator_plan_ladder_validation.html", PlanLadderValidationData{Error: errMsg})
|
|
return
|
|
}
|
|
h.Templates.Render(w, "operator_plan_ladder_validation.html", h.computePlanLadderValidation(r.Context()))
|
|
}
|
|
|
|
// computePlanLadderValidation runs the three structural-invariant checks
|
|
// (orphan plan products, non-plan tiers, multi-active attachments). It is the
|
|
// single source of truth shared by the validation page
|
|
// (renderPlanLadderValidationPage) and the topology overview's health strip
|
|
// (loadPlanTopologyData), so the two never disagree.
|
|
func (h *OperatorPartialsHandler) computePlanLadderValidation(ctx context.Context) PlanLadderValidationData {
|
|
var data PlanLadderValidationData
|
|
|
|
// Orphan products: published products on no ladder. Doc 41 dissolved the
|
|
// plan/non-plan distinction (display_category is presentation-only), so this
|
|
// is now purely informational — an off-ladder product is legitimate.
|
|
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 p.LifecycleStatus == "published" {
|
|
ladders, _ := h.BillingQ.ListLaddersByProduct(ctx, p.ProductID)
|
|
if len(ladders) == 0 {
|
|
data.OrphanProducts = append(data.OrphanProducts, ProductOption{
|
|
ProductID: p.ProductID,
|
|
Name: p.Name,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Multi-active attachments: pools with multiple active attachments on the
|
|
// same ladder. Impossible under the GiST exclusion, checked defensively.
|
|
rows, err := h.Database.QueryContext(ctx, `
|
|
SELECT pool_id, plan_ladder_id, COUNT(*) as cnt
|
|
FROM core.pool_provision_ladders
|
|
WHERE status = 'active'
|
|
GROUP BY pool_id, plan_ladder_id
|
|
HAVING COUNT(*) > 1
|
|
`)
|
|
if err != nil {
|
|
h.Logger.Error("failed to query multi-active attachments", slog.Any("error", err))
|
|
} else {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var poolID, planLadderID string
|
|
var cnt int64
|
|
if err := rows.Scan(&poolID, &planLadderID, &cnt); err == nil {
|
|
ladder, _ := h.BillingQ.GetPlanLadderByID(ctx, planLadderID)
|
|
data.MultiActivePools = append(data.MultiActivePools, MultiActivePoolViewModel{
|
|
PoolID: poolID,
|
|
LadderID: planLadderID,
|
|
LadderKey: ladder.LadderKey,
|
|
ActiveCount: cnt,
|
|
})
|
|
}
|
|
}
|
|
rows.Close()
|
|
}
|
|
|
|
// Malformed rank sequences: ladders whose tier ranks are not 0-based
|
|
// contiguous. Impossible via the UI (append at MAX+1, reorder/removal
|
|
// renumber); rank 0 is load-bearing (org-type default resolution joins
|
|
// on rank = 0, member catalog branches on it), so surface any drift
|
|
// here instead of letting a defaultless ladder read as mysteriously
|
|
// broken elsewhere (2026-07-23 finding).
|
|
rankRows, err := h.Database.QueryContext(ctx, `
|
|
SELECT l.plan_ladder_id, l.name, MIN(t.rank), MAX(t.rank), COUNT(*)
|
|
FROM core.plan_ladder_tiers t
|
|
JOIN core.plan_ladders l ON l.plan_ladder_id = t.plan_ladder_id
|
|
GROUP BY l.plan_ladder_id, l.name
|
|
HAVING MIN(t.rank) <> 0 OR MAX(t.rank) <> COUNT(*) - 1
|
|
ORDER BY l.name, l.plan_ladder_id
|
|
`)
|
|
if err != nil {
|
|
h.Logger.Error("failed to query malformed rank ladders", slog.Any("error", err))
|
|
} else {
|
|
defer rankRows.Close()
|
|
for rankRows.Next() {
|
|
var vm MalformedRankLadderViewModel
|
|
if err := rankRows.Scan(&vm.LadderID, &vm.Name, &vm.MinRank, &vm.MaxRank, &vm.TierCount); err == nil {
|
|
data.MalformedRankLadders = append(data.MalformedRankLadders, vm)
|
|
}
|
|
}
|
|
rankRows.Close()
|
|
}
|
|
|
|
return data
|
|
}
|