Files
member-console/internal/server/operator_entitlement_sets.go
T
cgalo5758 71818de0bd Add setup checklist and empty-state guidance
Implement the ux-first-run change: a state-derived setup checklist on
/operator/setup with a landing region that recedes once required steps
are done, and empty states that distinguish blocked from empty across
operator and member surfaces. Also add production deployment and
environment reference docs, plus a config-key completeness test.
2026-08-23 03:06:11 -05:00

699 lines
27 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
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 {
data.ResourceKeys = make([]ResourceKeyOption, 0, len(resourceKeys))
for _, rk := range resourceKeys {
if ruledKeys[rk.ResourceKey] {
continue
}
data.ResourceKeys = append(data.ResourceKeys, ResourceKeyOption{
ResourceKey: rk.ResourceKey,
DisplayName: rk.DisplayName,
})
}
}
return data
}