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 }