- 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
713 lines
28 KiB
Go
713 lines
28 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
)
|
|
|
|
// EntitlementSetViewModel represents an entitlement set for operator template rendering.
|
|
type EntitlementSetViewModel struct {
|
|
SetID string
|
|
Name string
|
|
Description string
|
|
IsActive bool
|
|
RuleCount int
|
|
CreatedAt string
|
|
}
|
|
|
|
// EntitlementSetsData holds data for the entitlement sets list partial.
|
|
type EntitlementSetsData struct {
|
|
EntitlementSets []EntitlementSetViewModel
|
|
FieldErrors web.FieldErrors
|
|
Success string
|
|
Error string
|
|
// Empty drives the list's empty state via the shared "emptyState"
|
|
// define (ux-first-run 3.4). Entitlement sets are never blocked (they
|
|
// have no prerequisite of their own), so this is always the not-blocked
|
|
// shape; the copy carries the two-ended dependency purpose (rules feed
|
|
// in, products consume it) so it survives past a bare "No X found."
|
|
Empty EmptyStateParams
|
|
}
|
|
|
|
// EntitlementSetEditData holds data for the entitlement set edit form.
|
|
type EntitlementSetEditData struct {
|
|
EntitlementSet EntitlementSetViewModel
|
|
FieldErrors web.FieldErrors
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// RuleViewModel represents an entitlement set rule for template rendering.
|
|
type RuleViewModel struct {
|
|
RuleID string
|
|
RuleType string
|
|
ResourceKey string
|
|
// DisplayName is the resource key's friendly name (from the same
|
|
// resource-key catalog the add-rule picker uses), so the rules table
|
|
// leads with a human-readable name instead of the raw key (ui-vocabulary
|
|
// 4.3). Empty when the key can no longer be resolved; the template falls
|
|
// back to the raw key alone.
|
|
DisplayName string
|
|
ResourceValue int64
|
|
ResourcePerUnit bool
|
|
StackingPolicy string
|
|
IsActive bool
|
|
// IsLiveBacking is true when this rule's resource key currently backs a
|
|
// materialized entitlement some pool actively holds (a numeric
|
|
// contribution from a live provision, or a granted boolean key) — the
|
|
// Delete control's inline consequence copy only renders for these.
|
|
IsLiveBacking bool
|
|
}
|
|
|
|
// EntitlementSetRulesData holds data for the rules detail partial.
|
|
type EntitlementSetRulesData struct {
|
|
EntitlementSet EntitlementSetViewModel
|
|
Rules []RuleViewModel
|
|
ResourceKeys []ResourceKeyOption
|
|
RuleFields RuleFieldsData
|
|
FieldErrors web.FieldErrors
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// RuleFieldsData drives the kind-branched fields region of the add-rule form
|
|
// (operator_entitlement_set_rule_fields.html). SelectedKind is "" (no key
|
|
// chosen yet — hint state), "boolean", or "numeric".
|
|
type RuleFieldsData struct {
|
|
SelectedKey string
|
|
SelectedKind string
|
|
FieldErrors web.FieldErrors
|
|
}
|
|
|
|
// ResourceKeyOption represents a resource key for dropdown selection.
|
|
type ResourceKeyOption struct {
|
|
ResourceKey string
|
|
DisplayName string
|
|
}
|
|
|
|
// EntitlementSetDetailData is the body data for the addressable per-entitlement-set
|
|
// composite page (operator_entitlement_set_detail.html). It co-locates the edit
|
|
// form and the rules manager on one URL; the composite template renders each
|
|
// sub-section via its existing partial.
|
|
type EntitlementSetDetailData struct {
|
|
Edit EntitlementSetEditData
|
|
Rules EntitlementSetRulesData
|
|
Error string
|
|
}
|
|
|
|
// entitlementSetNameConstraints maps the name-uniqueness constraint (finding
|
|
// #40; baked into the 00001_init.sql baseline, not a later migration) to a
|
|
// friendly field error so a duplicate name renders on the "name" input
|
|
// instead of leaking a 23505 into the banner.
|
|
var entitlementSetNameConstraints = web.ConstraintMessages{
|
|
"uq_entitlement_sets_name": {Field: "name", Message: "That name is already in use. Choose a different name."},
|
|
}
|
|
|
|
// allowedStackingPolicies is the set of stacking policies rule authoring may
|
|
// write. "replace" was de-advertised in finding #2 (2026-07-03 remediation)
|
|
// — it wrote a contribution that materialized to a 0 limit — and removed from
|
|
// the add-rule dropdown and rejected server-side.
|
|
//
|
|
// Narrowed to additive-only on 2026-08-22 (maintainer decision, schema-hardening
|
|
// change, design D9): the additive/maximum selector confused operators, and
|
|
// mixing policies on one resource made limits depend on purchase order. This
|
|
// is authoring-only — the materializer, existing rows, and the DB column are
|
|
// untouched; rows already carrying "maximum" (or "replace") keep computing
|
|
// and render read-only in the rules table. Exploring what stacking policies
|
|
// are for, whether the rule form is the right home, and the eventual
|
|
// mixed-policy semantics is tracked as its own issue in status/issues.md
|
|
// rather than built now.
|
|
var allowedStackingPolicies = map[string]bool{
|
|
"additive": true,
|
|
}
|
|
|
|
// CreateEntitlementSet handles POST /partials/operator/entitlement-sets
|
|
func (h *OperatorPartialsHandler) CreateEntitlementSet(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderEntitlementSetsPage(w, r, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
name := r.FormValue("name")
|
|
description := r.FormValue("description")
|
|
|
|
errs := web.New()
|
|
if name == "" {
|
|
errs.Set("name", "Name is required.")
|
|
}
|
|
if errs.Any() {
|
|
h.renderEntitlementSetsFormErrors(w, r, errs)
|
|
return
|
|
}
|
|
|
|
_, err := h.EntitlementsQ.CreateEntitlementSet(r.Context(), entitlements.CreateEntitlementSetParams{
|
|
Name: name,
|
|
Description: sql.NullString{String: description, Valid: description != ""},
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
if fe, ok := web.FieldErrorsFromDB(err, entitlementSetNameConstraints); ok {
|
|
h.renderEntitlementSetsFormErrors(w, r, fe)
|
|
return
|
|
}
|
|
h.Logger.Error("failed to create entitlement set", slog.Any("error", err))
|
|
h.renderEntitlementSetsPage(w, r, "", "Failed to create the entitlement set. Details are in the server logs.")
|
|
return
|
|
}
|
|
|
|
// Signal dependent tabs (Products, Grants) to re-fetch — they show entitlement set dropdowns
|
|
h.renderEntitlementSetsPage(w, r, "Entitlement set created successfully.", "")
|
|
}
|
|
|
|
// UpdateEntitlementSet handles PUT /partials/operator/entitlement-sets/{setID}
|
|
func (h *OperatorPartialsHandler) UpdateEntitlementSet(w http.ResponseWriter, r *http.Request) {
|
|
setID := r.PathValue("setID")
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderEntitlementSetEditPage(w, r, setID, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
name := r.FormValue("name")
|
|
description := r.FormValue("description")
|
|
|
|
errs := web.New()
|
|
if name == "" {
|
|
errs.Set("name", "Name is required.")
|
|
}
|
|
if errs.Any() {
|
|
h.renderEntitlementSetEditFormErrors(w, r, setID, errs)
|
|
return
|
|
}
|
|
|
|
isActive := r.FormValue("is_active") == "true"
|
|
|
|
_, err := h.EntitlementsQ.UpdateEntitlementSet(r.Context(), entitlements.UpdateEntitlementSetParams{
|
|
SetID: setID,
|
|
Name: name,
|
|
Description: sql.NullString{String: description, Valid: description != ""},
|
|
IsActive: isActive,
|
|
})
|
|
if err != nil {
|
|
if fe, ok := web.FieldErrorsFromDB(err, entitlementSetNameConstraints); ok {
|
|
h.renderEntitlementSetEditFormErrors(w, r, setID, fe)
|
|
return
|
|
}
|
|
h.Logger.Error("failed to update entitlement set", slog.Any("error", err))
|
|
h.renderEntitlementSetEditPage(w, r, setID, "", "Failed to update the entitlement set. Details are in the server logs.")
|
|
return
|
|
}
|
|
|
|
// Stay on the set's composite detail page (the boosted nav already set the
|
|
// URL); re-render in place reflecting the saved changes.
|
|
h.renderEntitlementSetDetailBody(w, r, setID, "Entitlement set updated successfully.", "")
|
|
}
|
|
|
|
// CreateEntitlementSetRule handles POST /partials/operator/entitlement-sets/{setID}/rules
|
|
func (h *OperatorPartialsHandler) CreateEntitlementSetRule(w http.ResponseWriter, r *http.Request) {
|
|
setID := r.PathValue("setID")
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderEntitlementSetRulesPage(w, r, setID, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
resourceKey := r.FormValue("resource_key")
|
|
|
|
errs := web.New()
|
|
if resourceKey == "" {
|
|
errs.Set("resource_key", "Resource key is required.")
|
|
h.renderEntitlementSetRulesFormErrors(w, r, setID, errs)
|
|
return
|
|
}
|
|
|
|
// The rule type is derived from the key's kind, never from client input —
|
|
// the schema CHECK makes a wrong-shape rule invalid, and deriving here
|
|
// makes it unreachable (a stale form or crafted POST cannot choose).
|
|
rk, err := h.EntitlementsQ.GetResourceKey(r.Context(), resourceKey)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
errs.Set("resource_key", "That resource key no longer exists. Refresh the page and choose another.")
|
|
h.renderEntitlementSetRulesFormErrors(w, r, setID, errs)
|
|
return
|
|
}
|
|
h.Logger.Error("failed to look up resource key", slog.Any("error", err))
|
|
h.renderEntitlementSetRulesPage(w, r, setID, "", "Failed to create the rule. Details are in the server logs.")
|
|
return
|
|
}
|
|
|
|
params := entitlements.CreateEntitlementSetRuleParams{
|
|
SetID: setID,
|
|
ResourceKey: sql.NullString{String: resourceKey, Valid: true},
|
|
}
|
|
if rk.Kind == "boolean" {
|
|
// chk_entitlement_set_rules_type requires NULL resource_value,
|
|
// stacking_policy, and resource_per_unit on boolean rules (the
|
|
// stacking column default 'additive' would violate it, so the
|
|
// zero-valued Valid:false params are load-bearing). Numeric inputs a
|
|
// stale form may still submit are ignored.
|
|
params.RuleType = "boolean"
|
|
} else {
|
|
params.RuleType = "limit"
|
|
resourceValueStr := r.FormValue("resource_value")
|
|
resourcePerUnit := r.FormValue("resource_per_unit") == "true"
|
|
|
|
// The form no longer renders a stacking-policy control (2026-08-22,
|
|
// design D9): rule authoring is additive-only. A blank submission
|
|
// (the normal case) defaults to "additive"; a non-empty value other
|
|
// than "additive" — a smuggled "maximum" or de-advertised "replace"
|
|
// from a stale tab or crafted POST — is rejected server-side and
|
|
// nothing is written.
|
|
stackingPolicy := r.FormValue("stacking_policy")
|
|
if stackingPolicy == "" {
|
|
stackingPolicy = "additive"
|
|
} else if !allowedStackingPolicies[stackingPolicy] {
|
|
errs.Set("stacking_policy", "Stacking policy must be additive.")
|
|
}
|
|
var resourceValue int64
|
|
if resourceValueStr == "" {
|
|
errs.Set("resource_value", "Resource value is required.")
|
|
} else {
|
|
v, err := strconv.ParseInt(resourceValueStr, 10, 64)
|
|
if err != nil {
|
|
errs.Set("resource_value", "Resource value must be an integer.")
|
|
} else {
|
|
resourceValue = v
|
|
}
|
|
}
|
|
if errs.Any() {
|
|
h.renderEntitlementSetRulesFormErrors(w, r, setID, errs)
|
|
return
|
|
}
|
|
params.ResourceValue = sql.NullInt64{Int64: resourceValue, Valid: true}
|
|
params.ResourcePerUnit = sql.NullBool{Bool: resourcePerUnit, Valid: true}
|
|
params.StackingPolicy = sql.NullString{String: stackingPolicy, Valid: true}
|
|
}
|
|
|
|
_, err = h.EntitlementsQ.CreateEntitlementSetRule(r.Context(), params)
|
|
if err != nil {
|
|
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
|
|
"entitlement_set_rules_resource_key_fkey": {Field: "resource_key", Message: "That resource key no longer exists. Refresh the page and choose another."},
|
|
"chk_entitlement_set_rules_type": {Field: "resource_value", Message: "These values do not form a valid rule for this resource key."},
|
|
"uq_entitlement_set_rules_active_resource_key": {Field: "resource_key", Message: "This set already has a rule for that resource key. Delete the existing rule before adding another."},
|
|
}); ok {
|
|
h.renderEntitlementSetRulesFormErrors(w, r, setID, fe)
|
|
return
|
|
}
|
|
h.Logger.Error("failed to create rule", slog.Any("error", err))
|
|
h.renderEntitlementSetRulesPage(w, r, setID, "", "Failed to create the rule. Details are in the server logs.")
|
|
return
|
|
}
|
|
|
|
h.renderEntitlementSetRulesPage(w, r, setID, "Rule added successfully.", "")
|
|
}
|
|
|
|
// GetEntitlementSetRuleFields handles GET /partials/operator/entitlement-sets/{setID}/rules/fields.
|
|
// The add-rule form's key select swaps this partial into #ruleFields on change,
|
|
// so the fields region always matches the selected key's kind.
|
|
func (h *OperatorPartialsHandler) GetEntitlementSetRuleFields(w http.ResponseWriter, r *http.Request) {
|
|
h.Templates.Render(w, "operator_entitlement_set_rule_fields.html", h.ruleFieldsForKey(r, r.URL.Query().Get("resource_key"), nil))
|
|
}
|
|
|
|
// ruleFieldsForKey resolves the fields-region view state for a selected key.
|
|
// Empty or unknown keys yield the hint state (SelectedKind "").
|
|
func (h *OperatorPartialsHandler) ruleFieldsForKey(r *http.Request, resourceKey string, errs web.FieldErrors) RuleFieldsData {
|
|
data := RuleFieldsData{SelectedKey: resourceKey, FieldErrors: errs}
|
|
if resourceKey == "" {
|
|
return data
|
|
}
|
|
rk, err := h.EntitlementsQ.GetResourceKey(r.Context(), resourceKey)
|
|
if err != nil {
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
h.Logger.Error("failed to look up resource key", slog.Any("error", err))
|
|
}
|
|
return data
|
|
}
|
|
data.SelectedKind = rk.Kind
|
|
return data
|
|
}
|
|
|
|
// DeleteEntitlementSetRule handles DELETE /partials/operator/entitlement-sets/{setID}/rules/{ruleID}
|
|
func (h *OperatorPartialsHandler) DeleteEntitlementSetRule(w http.ResponseWriter, r *http.Request) {
|
|
setID := r.PathValue("setID")
|
|
ruleID := r.PathValue("ruleID")
|
|
|
|
err := h.EntitlementsQ.DeleteEntitlementSetRule(r.Context(), ruleID)
|
|
if err != nil {
|
|
// The delete button has no form to attach 422 FieldErrors to —
|
|
// surface translated constraint text through the banner shape.
|
|
if fe, ok := web.FieldErrorsFromDB(err, nil); ok {
|
|
h.renderEntitlementSetRulesPage(w, r, setID, "", fieldErrorsBanner(fe))
|
|
return
|
|
}
|
|
h.Logger.Error("failed to delete rule", slog.Any("error", err))
|
|
h.renderEntitlementSetRulesPage(w, r, setID, "", "Failed to delete the rule. Details are in the server logs.")
|
|
return
|
|
}
|
|
|
|
h.renderEntitlementSetRulesPage(w, r, setID, "Rule deleted.", "")
|
|
}
|
|
|
|
// Rendering helpers
|
|
|
|
func (h *OperatorPartialsHandler) renderEntitlementSetsPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
|
|
fireSuccessToast(w, success)
|
|
data := h.loadEntitlementSetsPageData(r, "", errMsg)
|
|
h.Templates.Render(w, "operator_entitlement_sets.html", data)
|
|
}
|
|
|
|
// loadEntitlementSetsPageData hydrates the entitlement-sets listing. Shared
|
|
// by the legacy partial endpoint (renderEntitlementSetsPage) and the new MPA
|
|
// page handler (GetEntitlementSetsPage).
|
|
func (h *OperatorPartialsHandler) loadEntitlementSetsPageData(r *http.Request, success string, errMsg string) EntitlementSetsData {
|
|
data := EntitlementSetsData{
|
|
Success: success,
|
|
Error: errMsg,
|
|
// Entitlement sets are never blocked; the empty state is always
|
|
// actionable (ux-first-run 3.4). Set unconditionally, including on
|
|
// the error-return path below, so a transient DB error never leaves
|
|
// the empty-state block rendering with blank copy.
|
|
Empty: EmptyStateParams{
|
|
Headline: "No entitlement sets yet.",
|
|
Note: "Rules feed into a set, and products consume it. Creating a set is the first step toward a sellable product.",
|
|
ActionURL: "#createEntitlementSetForm",
|
|
ActionLabel: "Create Entitlement Set",
|
|
},
|
|
}
|
|
// 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 entitlement set no longer exists — it may have been deleted."
|
|
}
|
|
|
|
// 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.
|
|
sets, err := h.EntitlementsQ.ListEntitlementSets(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list entitlement sets", slog.Any("error", err))
|
|
if data.Error == "" {
|
|
data.Error = "Failed to load entitlement sets"
|
|
}
|
|
return data
|
|
}
|
|
|
|
data.EntitlementSets = make([]EntitlementSetViewModel, len(sets))
|
|
for i, s := range sets {
|
|
ruleCount := 0
|
|
if rules, err := h.EntitlementsQ.GetActiveRulesBySetID(r.Context(), s.SetID); err == nil {
|
|
ruleCount = len(rules)
|
|
}
|
|
desc := ""
|
|
if s.Description.Valid {
|
|
desc = s.Description.String
|
|
}
|
|
data.EntitlementSets[i] = EntitlementSetViewModel{
|
|
SetID: s.SetID,
|
|
Name: s.Name,
|
|
Description: desc,
|
|
IsActive: s.IsActive,
|
|
RuleCount: ruleCount,
|
|
CreatedAt: s.CreatedAt.Format("Jan 2, 2006"),
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
// renderEntitlementSetsFormErrors re-renders the entitlement-sets page
|
|
// (the inline create-set form lives on it) with 422 + FieldErrors populated.
|
|
// Convention per docs/operator-ux-conventions.md §6+§8.
|
|
func (h *OperatorPartialsHandler) renderEntitlementSetsFormErrors(w http.ResponseWriter, r *http.Request, errs web.FieldErrors) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
data := h.loadEntitlementSetsPageData(r, "", "")
|
|
data.FieldErrors = errs
|
|
h.Templates.Render(w, "operator_entitlement_sets.html", data)
|
|
}
|
|
|
|
// renderEntitlementSetRulesFormErrors re-renders the rules page (with its
|
|
// inline add-rule form) with 422 + FieldErrors populated. Convention per
|
|
// docs/operator-ux-conventions.md §6+§8.
|
|
//
|
|
// The header MUST be written before any body bytes, so the data loading
|
|
// is inlined here rather than calling renderEntitlementSetRulesPage (which
|
|
// would write the body before we could 422-stamp the header). The load
|
|
// logic mirrors renderEntitlementSetRulesPage's hydration steps.
|
|
func (h *OperatorPartialsHandler) renderEntitlementSetRulesFormErrors(w http.ResponseWriter, r *http.Request, setID string, errs web.FieldErrors) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
edit, ok := h.loadEntitlementSetEditData(r, setID)
|
|
if !ok {
|
|
h.renderEntitlementSetsPage(w, r, "", "Entitlement set not found")
|
|
return
|
|
}
|
|
rules := h.loadEntitlementSetRulesData(r, setID)
|
|
rules.FieldErrors = errs
|
|
// Restore the fields region to the submitted key's branch so value/stacking
|
|
// errors render on the fields they belong to.
|
|
rules.RuleFields = h.ruleFieldsForKey(r, r.FormValue("resource_key"), errs)
|
|
h.Templates.Render(w, "operator_entitlement_set_detail.html", EntitlementSetDetailData{
|
|
Edit: edit,
|
|
Rules: rules,
|
|
})
|
|
}
|
|
|
|
// renderEntitlementSetEditFormErrors re-renders the edit form with 422 +
|
|
// FieldErrors populated. Convention per docs/operator-ux-conventions.md §6+§8.
|
|
func (h *OperatorPartialsHandler) renderEntitlementSetEditFormErrors(w http.ResponseWriter, r *http.Request, setID string, errs web.FieldErrors) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
edit, ok := h.loadEntitlementSetEditData(r, setID)
|
|
if !ok {
|
|
h.renderEntitlementSetsPage(w, r, "", "Entitlement set not found")
|
|
return
|
|
}
|
|
edit.FieldErrors = errs
|
|
h.Templates.Render(w, "operator_entitlement_set_detail.html", EntitlementSetDetailData{
|
|
Edit: edit,
|
|
Rules: h.loadEntitlementSetRulesData(r, setID),
|
|
})
|
|
}
|
|
|
|
// loadEntitlementSetEditData hydrates the edit-form view model for one set.
|
|
// ok=false means the set does not exist.
|
|
func (h *OperatorPartialsHandler) loadEntitlementSetEditData(r *http.Request, setID string) (EntitlementSetEditData, bool) {
|
|
data, found, err := h.loadEntitlementSetEditDataResult(r, setID)
|
|
return data, found && err == nil
|
|
}
|
|
|
|
// loadEntitlementSetEditDataResult is loadEntitlementSetEditData with the
|
|
// not-found vs. transient-DB-error distinction preserved: found=false with
|
|
// err=nil means the set does not exist; err != nil means the load itself
|
|
// failed. The full-page GET (GetEntitlementSetDetailPage) 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) loadEntitlementSetEditDataResult(r *http.Request, setID string) (EntitlementSetEditData, bool, error) {
|
|
es, err := h.EntitlementsQ.GetEntitlementSetByID(r.Context(), setID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return EntitlementSetEditData{}, false, nil
|
|
}
|
|
if err != nil {
|
|
h.Logger.Error("failed to get entitlement set", slog.Any("error", err))
|
|
return EntitlementSetEditData{}, false, err
|
|
}
|
|
|
|
desc := ""
|
|
if es.Description.Valid {
|
|
desc = es.Description.String
|
|
}
|
|
return EntitlementSetEditData{
|
|
EntitlementSet: EntitlementSetViewModel{
|
|
SetID: es.SetID,
|
|
Name: es.Name,
|
|
Description: desc,
|
|
IsActive: es.IsActive,
|
|
CreatedAt: es.CreatedAt.Format("Jan 2, 2006"),
|
|
},
|
|
}, true, nil
|
|
}
|
|
|
|
// GetEntitlementSetDetailPage handles GET /operator/entitlement-sets/{setID} —
|
|
// the addressable, bookmarkable per-set composite. Renders the operator.html
|
|
// shell with the edit form and rules manager co-located on one URL. Unknown
|
|
// setID degrades to the entitlement-sets browse page with an error.
|
|
func (h *OperatorPartialsHandler) GetEntitlementSetDetailPage(w http.ResponseWriter, r *http.Request) {
|
|
setID := r.PathValue("setID")
|
|
edit, found, err := h.loadEntitlementSetEditDataResult(r, setID)
|
|
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:entitlement-sets"
|
|
errPage.ActiveCapability = "entitlement-sets"
|
|
errPage.BodyTemplate = "operator_entitlement_sets.html"
|
|
errPage.BodyData = h.loadEntitlementSetsPageData(r, "", "Could not load this entitlement set 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/entitlement-sets?flash=missing", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
page := h.buildOperatorPageData(r)
|
|
page.IAPosition = "catalog:entitlement-sets:" + setID
|
|
page.ActiveCapability = "entitlement-sets"
|
|
page.BodyTemplate = "operator_entitlement_set_detail.html"
|
|
page.BodyData = EntitlementSetDetailData{
|
|
Edit: edit,
|
|
Rules: h.loadEntitlementSetRulesData(r, setID),
|
|
}
|
|
|
|
h.Templates.Render(w, "operator.html", page)
|
|
}
|
|
|
|
// renderEntitlementSetDetailBody re-renders the composite set detail body into
|
|
// #operator-body after an in-page mutation. The operator stays on
|
|
// /operator/entitlement-sets/{setID}; unknown setID degrades to the browse page.
|
|
func (h *OperatorPartialsHandler) renderEntitlementSetDetailBody(w http.ResponseWriter, r *http.Request, setID string, success string, errMsg string) {
|
|
fireSuccessToast(w, success)
|
|
edit, ok := h.loadEntitlementSetEditData(r, setID)
|
|
if !ok {
|
|
h.renderEntitlementSetsPage(w, r, "", "Entitlement set 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_entitlement_set_detail.html", EntitlementSetDetailData{
|
|
Edit: edit,
|
|
Rules: h.loadEntitlementSetRulesData(r, setID),
|
|
Error: errMsg,
|
|
})
|
|
}
|
|
|
|
// renderEntitlementSetEditPage re-renders the set composite in place. Retained as
|
|
// a thin alias so the mutation call sites (UpdateEntitlementSet) keep working;
|
|
// the edit form now lives on the composite detail page.
|
|
func (h *OperatorPartialsHandler) renderEntitlementSetEditPage(w http.ResponseWriter, r *http.Request, setID string, success string, errMsg string) {
|
|
h.renderEntitlementSetDetailBody(w, r, setID, success, errMsg)
|
|
}
|
|
|
|
// renderEntitlementSetRulesPage re-renders the set composite in place. Retained
|
|
// as a thin alias so the rule mutation call sites (add/delete) keep working; the
|
|
// rules manager now lives on the composite detail page.
|
|
func (h *OperatorPartialsHandler) renderEntitlementSetRulesPage(w http.ResponseWriter, r *http.Request, setID string, success string, errMsg string) {
|
|
h.renderEntitlementSetDetailBody(w, r, setID, success, errMsg)
|
|
}
|
|
|
|
// ruleResourceKeyIsLiveBacking reports whether resourceKey currently backs an
|
|
// entitlement some pool actively holds: a numeric contribution from a live
|
|
// (non-ended) provision, or a granted boolean key. It drives the Delete
|
|
// control's inline consequence copy (ux-honest-surfaces UX-10) — deleting a
|
|
// rule never re-materializes pools, so this is read-only and best-effort; a
|
|
// query failure renders as "not live-backing" rather than blocking the page.
|
|
func (h *OperatorPartialsHandler) ruleResourceKeyIsLiveBacking(ctx context.Context, ruleType, resourceKey string) bool {
|
|
if resourceKey == "" || h.Database == nil {
|
|
return false
|
|
}
|
|
var live bool
|
|
var err error
|
|
if ruleType == "boolean" {
|
|
err = h.Database.QueryRowContext(ctx,
|
|
`SELECT EXISTS (SELECT 1 FROM core.boolean_entitlements WHERE resource_key = $1 AND granted = TRUE)`,
|
|
resourceKey,
|
|
).Scan(&live)
|
|
} else {
|
|
// Contributions are recreated from each pool's currently active
|
|
// provisions on every materialization (materialize.go) and deleted
|
|
// outright once a provision ends, so a surviving row means some pool's
|
|
// materialized limit is presently backed by this resource key.
|
|
err = h.Database.QueryRowContext(ctx,
|
|
`SELECT EXISTS (
|
|
SELECT 1 FROM core.numeric_entitlement_contributions c
|
|
JOIN core.numeric_entitlements ne ON ne.entitlement_id = c.entitlement_id
|
|
WHERE ne.resource_key = $1
|
|
)`,
|
|
resourceKey,
|
|
).Scan(&live)
|
|
}
|
|
if err != nil {
|
|
h.Logger.Error("failed to check live-backing for resource key", slog.Any("error", err), slog.String("resource_key", resourceKey))
|
|
return false
|
|
}
|
|
return live
|
|
}
|
|
|
|
// loadEntitlementSetRulesData hydrates the rules-manager view model for one set:
|
|
// the active rules and the resource keys available for the add-rule dropdown.
|
|
// Best-effort — a query failure yields a partial result rather than aborting,
|
|
// since this feeds the composite detail page where the set's existence is
|
|
// already established.
|
|
func (h *OperatorPartialsHandler) loadEntitlementSetRulesData(r *http.Request, setID string) EntitlementSetRulesData {
|
|
data := EntitlementSetRulesData{}
|
|
if es, err := h.EntitlementsQ.GetEntitlementSetByID(r.Context(), setID); err == nil {
|
|
desc := ""
|
|
if es.Description.Valid {
|
|
desc = es.Description.String
|
|
}
|
|
data.EntitlementSet = EntitlementSetViewModel{
|
|
SetID: es.SetID,
|
|
Name: es.Name,
|
|
Description: desc,
|
|
IsActive: es.IsActive,
|
|
}
|
|
}
|
|
|
|
rules, err := h.EntitlementsQ.GetActiveRulesBySetID(r.Context(), setID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list rules", slog.Any("error", err))
|
|
} else {
|
|
data.Rules = make([]RuleViewModel, len(rules))
|
|
for i, rule := range rules {
|
|
rk := ""
|
|
if rule.ResourceKey.Valid {
|
|
rk = rule.ResourceKey.String
|
|
}
|
|
var rv int64
|
|
if rule.ResourceValue.Valid {
|
|
rv = rule.ResourceValue.Int64
|
|
}
|
|
rpu := false
|
|
if rule.ResourcePerUnit.Valid {
|
|
rpu = rule.ResourcePerUnit.Bool
|
|
}
|
|
sp := ""
|
|
if rule.StackingPolicy.Valid {
|
|
sp = rule.StackingPolicy.String
|
|
}
|
|
data.Rules[i] = RuleViewModel{
|
|
RuleID: rule.RuleID,
|
|
RuleType: rule.RuleType,
|
|
ResourceKey: rk,
|
|
ResourceValue: rv,
|
|
ResourcePerUnit: rpu,
|
|
StackingPolicy: sp,
|
|
IsActive: rule.IsActive,
|
|
IsLiveBacking: h.ruleResourceKeyIsLiveBacking(r.Context(), rule.RuleType, rk),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load resource keys for the add-rule dropdown, excluding keys that already
|
|
// have an active rule on this set (finding #21). One active rule per
|
|
// (set_id, resource_key) is enforced by a unique index; hiding ruled keys
|
|
// keeps the dropdown from offering a choice that can only 422.
|
|
ruledKeys := make(map[string]bool, len(data.Rules))
|
|
for _, rule := range data.Rules {
|
|
if rule.ResourceKey != "" {
|
|
ruledKeys[rule.ResourceKey] = true
|
|
}
|
|
}
|
|
resourceKeys, err := h.EntitlementsQ.ListResourceKeys(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list resource keys", slog.Any("error", err))
|
|
} else {
|
|
// Same catalog backs both the picker's friendly names and the rules
|
|
// table's leading display name (ui-vocabulary 4.3) — one lookup, two
|
|
// consumers, so they can never disagree.
|
|
displayNames := make(map[string]string, len(resourceKeys))
|
|
data.ResourceKeys = make([]ResourceKeyOption, 0, len(resourceKeys))
|
|
for _, rk := range resourceKeys {
|
|
displayNames[rk.ResourceKey] = rk.DisplayName
|
|
if ruledKeys[rk.ResourceKey] {
|
|
continue
|
|
}
|
|
data.ResourceKeys = append(data.ResourceKeys, ResourceKeyOption{
|
|
ResourceKey: rk.ResourceKey,
|
|
DisplayName: rk.DisplayName,
|
|
})
|
|
}
|
|
for i := range data.Rules {
|
|
data.Rules[i].DisplayName = displayNames[data.Rules[i].ResourceKey]
|
|
}
|
|
}
|
|
|
|
return data
|
|
}
|