Signal dependent tabs to re-fetch when plan ladders change. operator_plan_ladders handlers set HX-Trigger: planLadderMutation on create/update/delete and tier operations so Org Types' plan dropdowns refresh. operator.html adds explanatory comments and hx-trigger attrs so Org Types, Grants, and Products panes listen for productMutation, planLadderMutation, and entitlementMutation
538 lines
18 KiB
Go
538 lines
18 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// PlanLaddersData holds data for the plan ladders list partial.
|
|
type PlanLaddersData struct {
|
|
Ladders []PlanLadderViewModel
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// PlanLadderEditData holds data for the plan ladder edit form.
|
|
type PlanLadderEditData struct {
|
|
Ladder PlanLadderViewModel
|
|
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
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// PlanLadderValidationData holds data for the structural invariant validation view.
|
|
type PlanLadderValidationData struct {
|
|
OrphanProducts []ProductOption
|
|
NonPlanTiers []NonPlanTierViewModel
|
|
MultiActivePools []MultiActivePoolViewModel
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// NonPlanTierViewModel represents a tier violation where a non-plan product is attached.
|
|
type NonPlanTierViewModel struct {
|
|
ProductID string
|
|
ProductName string
|
|
ProductType string
|
|
LadderKey string
|
|
LadderID string
|
|
Rank int32
|
|
}
|
|
|
|
// MultiActivePoolViewModel represents a pool with multiple active attachments on the same ladder.
|
|
type MultiActivePoolViewModel struct {
|
|
PoolID string
|
|
LadderID string
|
|
LadderKey string
|
|
ActiveCount int64
|
|
}
|
|
|
|
// GetPlanLadders handles GET /partials/operator/plan-ladders
|
|
func (h *OperatorPartialsHandler) GetPlanLadders(w http.ResponseWriter, r *http.Request) {
|
|
h.renderPlanLaddersPage(w, r, "", "")
|
|
}
|
|
|
|
// 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")
|
|
|
|
if ladderKey == "" || name == "" {
|
|
h.renderPlanLaddersPage(w, r, "", "Slug and display name are required")
|
|
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 {
|
|
h.Logger.Error("failed to create plan ladder", slog.Any("error", err))
|
|
h.renderPlanLaddersPage(w, r, "", "Failed to create plan ladder: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
|
|
w.Header().Set("HX-Trigger", "planLadderMutation")
|
|
h.renderPlanLaddersPage(w, r, "Plan ladder created successfully.", "")
|
|
}
|
|
|
|
// GetPlanLadderEdit handles GET /partials/operator/plan-ladders/{ladderID}/edit
|
|
func (h *OperatorPartialsHandler) GetPlanLadderEdit(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
h.renderPlanLadderEditPage(w, r, ladderID, "", "")
|
|
}
|
|
|
|
// 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")
|
|
isActive := r.FormValue("is_active") == "true"
|
|
|
|
if name == "" {
|
|
h.renderPlanLadderEditPage(w, r, ladderID, "", "Display name is required")
|
|
return
|
|
}
|
|
|
|
_, err := h.BillingQ.UpdatePlanLadder(r.Context(), billing.UpdatePlanLadderParams{
|
|
PlanLadderID: ladderID,
|
|
Name: name,
|
|
Description: sql.NullString{String: description, Valid: description != ""},
|
|
IsActive: 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: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
|
|
w.Header().Set("HX-Trigger", "planLadderMutation")
|
|
h.renderPlanLaddersPage(w, r, "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 attachments")
|
|
return
|
|
}
|
|
if count > 0 {
|
|
h.renderPlanLaddersPage(w, r, "", "Cannot delete ladder: "+strconv.FormatInt(count, 10)+" active attachment(s) exist")
|
|
return
|
|
}
|
|
|
|
err = h.BillingQ.DeletePlanLadder(r.Context(), ladderID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to delete plan ladder", slog.Any("error", err))
|
|
h.renderPlanLaddersPage(w, r, "", "Failed to delete plan ladder: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
|
|
w.Header().Set("HX-Trigger", "planLadderMutation")
|
|
h.renderPlanLaddersPage(w, r, "Plan ladder deleted successfully.", "")
|
|
}
|
|
|
|
// GetPlanLadderTiers handles GET /partials/operator/plan-ladders/{ladderID}/tiers
|
|
func (h *OperatorPartialsHandler) GetPlanLadderTiers(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "")
|
|
}
|
|
|
|
// CreatePlanLadderTier handles POST /partials/operator/plan-ladders/{ladderID}/tiers
|
|
func (h *OperatorPartialsHandler) CreatePlanLadderTier(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
productID := r.FormValue("product_id")
|
|
rankStr := r.FormValue("rank")
|
|
|
|
if productID == "" || rankStr == "" {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Product and rank are required")
|
|
return
|
|
}
|
|
|
|
rank, err := strconv.Atoi(rankStr)
|
|
if err != nil || rank < 0 {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Rank must be a non-negative integer")
|
|
return
|
|
}
|
|
|
|
// Enforce plan-type-only: reject products with a non-NULL product_type or non-published status
|
|
product, err := h.BillingQ.GetProductByID(r.Context(), productID)
|
|
if err != nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Product not found")
|
|
return
|
|
}
|
|
if product.ProductType.Valid && product.ProductType.String != "" {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Only plan-type products can be added as ladder tiers. This product is type: "+product.ProductType.String)
|
|
return
|
|
}
|
|
if product.LifecycleStatus != "published" {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Only published products can be added as ladder tiers. This product is status: "+product.LifecycleStatus)
|
|
return
|
|
}
|
|
|
|
_, err = h.BillingQ.CreatePlanLadderTier(r.Context(), billing.CreatePlanLadderTierParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
Rank: int32(rank),
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to create plan ladder tier", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
|
|
w.Header().Set("HX-Trigger", "planLadderMutation")
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "Tier added successfully.", "")
|
|
}
|
|
|
|
// UpdatePlanLadderTierRank handles PUT /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/rank
|
|
func (h *OperatorPartialsHandler) UpdatePlanLadderTierRank(w http.ResponseWriter, r *http.Request) {
|
|
ladderID := r.PathValue("ladderID")
|
|
productID := r.PathValue("productID")
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
rankStr := r.FormValue("rank")
|
|
rank, err := strconv.Atoi(rankStr)
|
|
if err != nil || rank < 0 {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Rank must be a non-negative integer")
|
|
return
|
|
}
|
|
|
|
_, err = h.BillingQ.UpdateTierRank(r.Context(), billing.UpdateTierRankParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
Rank: int32(rank),
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to update tier rank", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tier: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
|
|
w.Header().Set("HX-Trigger", "planLadderMutation")
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "Tier reordered successfully.", "")
|
|
}
|
|
|
|
// 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")
|
|
|
|
// Guard: reject removal when active attachments exist for this tier
|
|
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 attachments")
|
|
return
|
|
}
|
|
if count > 0 {
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Cannot remove tier: "+strconv.FormatInt(count, 10)+" active attachment(s) exist")
|
|
return
|
|
}
|
|
|
|
err = h.BillingQ.DeleteTier(r.Context(), billing.DeleteTierParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to delete tier", slog.Any("error", err))
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
|
|
w.Header().Set("HX-Trigger", "planLadderMutation")
|
|
h.renderPlanLadderTiersPage(w, r, ladderID, "Tier removed successfully.", "")
|
|
}
|
|
|
|
// GetPlanLadderValidation handles GET /partials/operator/plan-ladders/validation
|
|
func (h *OperatorPartialsHandler) GetPlanLadderValidation(w http.ResponseWriter, r *http.Request) {
|
|
h.renderPlanLadderValidationPage(w, r, "", "")
|
|
}
|
|
|
|
func (h *OperatorPartialsHandler) renderPlanLaddersPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
|
|
data := PlanLaddersData{
|
|
Success: success,
|
|
Error: errMsg,
|
|
}
|
|
|
|
if errMsg == "" {
|
|
ladders, err := h.BillingQ.ListPlanLadders(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list plan ladders", slog.Any("error", err))
|
|
data.Error = "Failed to load plan ladders"
|
|
} else {
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
h.Templates.Render(w, "operator_plan_ladders.html", data)
|
|
}
|
|
|
|
func (h *OperatorPartialsHandler) renderPlanLadderEditPage(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) {
|
|
data := PlanLadderEditData{
|
|
Success: success,
|
|
Error: errMsg,
|
|
}
|
|
|
|
ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get plan ladder", slog.Any("error", err))
|
|
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
|
|
return
|
|
}
|
|
|
|
desc := ""
|
|
if ladder.Description.Valid {
|
|
desc = ladder.Description.String
|
|
}
|
|
data.Ladder = PlanLadderViewModel{
|
|
PlanLadderID: ladder.PlanLadderID,
|
|
LadderKey: ladder.LadderKey,
|
|
Name: ladder.Name,
|
|
Description: desc,
|
|
IsActive: ladder.IsActive,
|
|
}
|
|
|
|
h.Templates.Render(w, "operator_plan_ladder_edit.html", data)
|
|
}
|
|
|
|
func (h *OperatorPartialsHandler) renderPlanLadderTiersPage(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) {
|
|
data := PlanLadderTiersData{
|
|
Success: success,
|
|
Error: errMsg,
|
|
}
|
|
|
|
ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get plan ladder", slog.Any("error", err))
|
|
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
|
|
return
|
|
}
|
|
|
|
data.Ladder = PlanLadderViewModel{
|
|
PlanLadderID: ladder.PlanLadderID,
|
|
LadderKey: ladder.LadderKey,
|
|
Name: ladder.Name,
|
|
IsActive: ladder.IsActive,
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load available products: active, published, product_type IS NULL, not already in this ladder
|
|
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.ProductType.Valid && p.ProductType.String != "" {
|
|
continue // skip non-plan 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
h.Templates.Render(w, "operator_plan_ladder_tiers.html", data)
|
|
}
|
|
|
|
func (h *OperatorPartialsHandler) renderPlanLadderValidationPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
|
|
data := PlanLadderValidationData{
|
|
Success: success,
|
|
Error: errMsg,
|
|
}
|
|
|
|
if errMsg == "" {
|
|
// Orphan plan products: published, product_type IS NULL, not in any ladder
|
|
products, err := h.BillingQ.ListAllProducts(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list products", slog.Any("error", err))
|
|
} else {
|
|
for _, p := range products {
|
|
if p.LifecycleStatus == "published" && (!p.ProductType.Valid || p.ProductType.String == "") {
|
|
ladders, _ := h.BillingQ.ListLaddersByProduct(r.Context(), p.ProductID)
|
|
if len(ladders) == 0 {
|
|
data.OrphanProducts = append(data.OrphanProducts, ProductOption{
|
|
ProductID: p.ProductID,
|
|
Name: p.Name,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-plan tiers: products with product_type IS NOT NULL that are in a ladder
|
|
ladders, _ := h.BillingQ.ListPlanLadders(r.Context())
|
|
for _, l := range ladders {
|
|
tiers, _ := h.BillingQ.ListTiersByLadderWithProducts(r.Context(), l.PlanLadderID)
|
|
for _, t := range tiers {
|
|
if t.ProductType.Valid && t.ProductType.String != "" {
|
|
data.NonPlanTiers = append(data.NonPlanTiers, NonPlanTierViewModel{
|
|
ProductID: t.ProductID,
|
|
ProductName: t.ProductName,
|
|
ProductType: t.ProductType.String,
|
|
LadderKey: l.LadderKey,
|
|
LadderID: l.PlanLadderID,
|
|
Rank: t.Rank,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Multi-active attachments: pools with multiple active attachments on the same ladder
|
|
// This should be impossible under GiST exclusion, but we check defensively
|
|
// We need raw SQL since there's no simple query for this
|
|
rows, err := h.Database.QueryContext(r.Context(), `
|
|
SELECT pool_id, plan_ladder_id, COUNT(*) as cnt
|
|
FROM entitlements.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(r.Context(), planLadderID)
|
|
data.MultiActivePools = append(data.MultiActivePools, MultiActivePoolViewModel{
|
|
PoolID: poolID,
|
|
LadderID: planLadderID,
|
|
LadderKey: ladder.LadderKey,
|
|
ActiveCount: cnt,
|
|
})
|
|
}
|
|
}
|
|
rows.Close()
|
|
}
|
|
}
|
|
|
|
h.Templates.Render(w, "operator_plan_ladder_validation.html", data)
|
|
}
|