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

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
}