The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
550 lines
22 KiB
Go
550 lines
22 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
|
"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
|
|
// Key is the set's declarative address (entity-keys §5), shown read-only
|
|
// on the detail page when set and omitted entirely when NULL. The create
|
|
// form does not collect one; a set that has a key was named by a seed or
|
|
// a configuration file.
|
|
Key string
|
|
RuleCount int
|
|
// FailedPools is how many of this set's recompute obligations have
|
|
// exhausted their attempts (CountFailedObligationsBySets, one query for
|
|
// the page rather than one per row). The list states it only when
|
|
// non-zero, so a set nobody is looking at still declares its dead
|
|
// letter (design.md "The drain", monitoring).
|
|
FailedPools int64
|
|
CreatedAt string
|
|
}
|
|
|
|
// FailedLabel is the sets list's attention cell, empty when nothing failed.
|
|
func (v EntitlementSetViewModel) FailedLabel() string {
|
|
if v.FailedPools == 0 {
|
|
return ""
|
|
}
|
|
return formatCount(v.FailedPools) + " failed"
|
|
}
|
|
|
|
// EntitlementSetsData holds data for the entitlement sets list partial. It
|
|
// carries no FieldErrors: creation moved to its own page (design D20, round
|
|
// 4; EntitlementSetNewData), so no form on this page can fail validation.
|
|
type EntitlementSetsData struct {
|
|
EntitlementSets []EntitlementSetViewModel
|
|
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
|
|
// Form is the set declaration rendered on its edit side, bound to this
|
|
// record or to a refused submission of it (form-library). The create
|
|
// page renders the same declaration unbound, so the two forms cannot
|
|
// expose different field sets.
|
|
Form forms.FormView
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// RuleViewModel is one row of the Rules table, in whichever of its three
|
|
// shapes this render draws: at rest, open for editing, or staged.
|
|
type RuleViewModel struct {
|
|
RuleID string
|
|
RuleType string
|
|
ResourceKey string
|
|
// DisplayName is the resource key's friendly name (from the same
|
|
// resource-key catalog the rules form's 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
|
|
// Policy is a numeric rule's reduction policy as its select labels it
|
|
// ("Clamp", "Force reduce", or a dormant "Block" or "Defer"), stated in
|
|
// the Rules table's own column at rest (design D7). Empty on a boolean
|
|
// rule, which carries none.
|
|
Policy string
|
|
|
|
// Instance is the row's key in the rules form: the rule's id, or "new"
|
|
// for the row Add rule appends. Editing rows render their controls under
|
|
// it (FormView.RowField).
|
|
Instance string
|
|
// Editing renders the row's Limit and Per unit cells as controls, with
|
|
// Stage change and Cancel as its actions. Numeric says the selected key
|
|
// is numeric, which is what reveals those two controls.
|
|
Editing bool
|
|
Numeric bool
|
|
// Staged renders the row tinted with Undo alone. Verb is the delta's
|
|
// (add, edit or remove) and DeltaKey what Undo drops; Removed strikes the
|
|
// resource name; IsNew marks a row no stored rule backs yet.
|
|
Staged bool
|
|
Verb string
|
|
DeltaKey string
|
|
Removed bool
|
|
IsNew bool
|
|
// OldLimit, OldPerUnit and OldPolicy are the struck values beside a
|
|
// staged edit's new ones, empty where the staging did not move them.
|
|
OldLimit string
|
|
OldPerUnit string
|
|
OldPolicy string
|
|
NewLimit string
|
|
NewPerUnit string
|
|
NewPolicy string
|
|
}
|
|
|
|
// EntitlementSetRulesData holds data for the rules detail partial.
|
|
type EntitlementSetRulesData struct {
|
|
EntitlementSet EntitlementSetViewModel
|
|
Rules []RuleViewModel
|
|
ResourceKeys []ResourceKeyOption
|
|
// Form is the rules declaration (operator.entitlement-set.rules) bound to
|
|
// this render's state, with the table as its body and the staged batch in
|
|
// its tray (design D1 of staged-rule-changes).
|
|
Form forms.FormView
|
|
// PendingPools and FailedPools are the obligations this set's changes
|
|
// still owe and the ones whose attempts are exhausted
|
|
// (CountUnsettledObligationsBySet). The section states the first as a
|
|
// transient line and the second in the attention tone beside a Retry
|
|
// control (design.md "The drain", monitoring).
|
|
PendingPools int64
|
|
FailedPools int64
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// DrainingLine is the transient line the Rules section carries while a
|
|
// change still owes pools; empty when it owes none.
|
|
func (d EntitlementSetRulesData) DrainingLine() string {
|
|
if d.PendingPools == 0 {
|
|
return ""
|
|
}
|
|
return formatCount(d.PendingPools) + " " + pluralize(d.PendingPools, "pool", "pools") + " not recomputed."
|
|
}
|
|
|
|
// FailedLine is the attention line, rendered only when a pool's attempts
|
|
// are exhausted; the Retry control renders beside it.
|
|
func (d EntitlementSetRulesData) FailedLine() string {
|
|
if d.FailedPools == 0 {
|
|
return ""
|
|
}
|
|
return formatCount(d.FailedPools) + " " + pluralize(d.FailedPools, "pool", "pools") + " failed to recompute."
|
|
}
|
|
|
|
// 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
|
|
// History is the set's ledger of committed rule changes, below Details
|
|
// and Rules (design.md A15). The commit re-renders this whole body, so
|
|
// an applied change lands on its own first page of History.
|
|
History EntitlementSetHistoryData
|
|
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."},
|
|
}
|
|
|
|
// CreateEntitlementSet handles POST /partials/operator/entitlement-sets,
|
|
// reading the body through the set declaration and never through
|
|
// r.FormValue (form-library "The handler parses through the
|
|
// declaration").
|
|
func (h *OperatorPartialsHandler) CreateEntitlementSet(w http.ResponseWriter, r *http.Request) {
|
|
values, errs := entitlementSetForm.ParseSide(r, forms.CreateOnly, nil)
|
|
if errs.Any() {
|
|
h.renderEntitlementSetNewRefusal(w, r, values, errs)
|
|
return
|
|
}
|
|
|
|
name := values.String("name")
|
|
description := values.String("description")
|
|
|
|
// design D21 (the declaration's is_active field is edit-only): the
|
|
// create form has no Active control; a new set is always active, and
|
|
// deactivating is an edit-form-only action, since a set cannot be
|
|
// deleted (only retired for products and pools that already reference
|
|
// it).
|
|
set, 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 {
|
|
for field, msg := range fe {
|
|
errs.Field(field, msg)
|
|
}
|
|
h.renderEntitlementSetNewRefusal(w, r, values, errs)
|
|
return
|
|
}
|
|
h.Logger.Error("failed to create entitlement set", slog.Any("error", err))
|
|
errs.Form("Failed to create the entitlement set. Details are in the server logs.")
|
|
h.renderEntitlementSetNewRefusal(w, r, values, errs)
|
|
return
|
|
}
|
|
|
|
// Create-then-land (design D20): the create page, then land on the
|
|
// record. The new set's own page carries the next step (the empty
|
|
// rules section with its Add rule opener).
|
|
redirectToRecord(w, r, "/operator/entitlement-sets/"+set.SetID+"?flash=created")
|
|
}
|
|
|
|
// UpdateEntitlementSet handles PUT /partials/operator/entitlement-sets/{setID},
|
|
// reading the body through the same declaration the create page posts
|
|
// through, on its edit side.
|
|
func (h *OperatorPartialsHandler) UpdateEntitlementSet(w http.ResponseWriter, r *http.Request) {
|
|
setID := r.PathValue("setID")
|
|
values, errs := entitlementSetForm.ParseSide(r, forms.EditOnly, nil)
|
|
if errs.Any() {
|
|
h.renderEntitlementSetEditRefusal(w, r, setID, values, errs)
|
|
return
|
|
}
|
|
|
|
name := values.String("name")
|
|
description := values.String("description")
|
|
isActive := values.Bool("is_active")
|
|
|
|
_, 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 {
|
|
for field, msg := range fe {
|
|
errs.Field(field, msg)
|
|
}
|
|
h.renderEntitlementSetEditRefusal(w, r, setID, values, errs)
|
|
return
|
|
}
|
|
h.Logger.Error("failed to update entitlement set", slog.Any("error", err))
|
|
errs.Form("Failed to update the entitlement set. Details are in the server logs.")
|
|
h.renderEntitlementSetEditRefusal(w, r, setID, values, errs)
|
|
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.", "")
|
|
}
|
|
|
|
// 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 (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. design D20: the list section header's own "New
|
|
// entitlement set" action opens the create panel, so the empty
|
|
// state does not repeat it.
|
|
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.",
|
|
},
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// One query for the whole page's failed counts rather than one per row.
|
|
setIDs := make([]string, len(sets))
|
|
for i, s := range sets {
|
|
setIDs[i] = s.SetID
|
|
}
|
|
failedBySet := make(map[string]int64, len(setIDs))
|
|
if len(setIDs) > 0 {
|
|
rows, err := h.EntitlementsQ.CountFailedObligationsBySets(r.Context(), setIDs)
|
|
if err != nil {
|
|
h.Logger.Error("failed to count failed obligations for the sets list", slog.Any("error", err))
|
|
}
|
|
for _, row := range rows {
|
|
failedBySet[row.SetID] = row.FailedCount
|
|
}
|
|
}
|
|
|
|
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,
|
|
FailedPools: failedBySet[s.SetID],
|
|
CreatedAt: s.CreatedAt.Format("Jan 2, 2006"),
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
// renderEntitlementSetEditRefusal re-renders the composite page at 422
|
|
// with the set declaration in submission mode (design D9).
|
|
func (h *OperatorPartialsHandler) renderEntitlementSetEditRefusal(w http.ResponseWriter, r *http.Request, setID string, values forms.Values, errs *forms.Errors) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
edit, ok := h.loadEntitlementSetEditData(r, setID)
|
|
if !ok {
|
|
h.renderEntitlementSetsPage(w, r, "", "Entitlement set not found")
|
|
return
|
|
}
|
|
edit.Form = entitlementSetEditForm(setID, values, errs)
|
|
h.Templates.Render(w, "operator_entitlement_set_detail.html", EntitlementSetDetailData{
|
|
Edit: edit,
|
|
Rules: h.loadEntitlementSetRulesData(r, setID),
|
|
History: h.loadEntitlementSetHistory(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
|
|
}
|
|
vm := EntitlementSetViewModel{
|
|
SetID: es.SetID,
|
|
Name: es.Name,
|
|
Description: desc,
|
|
IsActive: es.IsActive,
|
|
Key: es.Key.String,
|
|
CreatedAt: es.CreatedAt.Format("Jan 2, 2006"),
|
|
}
|
|
return EntitlementSetEditData{
|
|
EntitlementSet: vm,
|
|
// The edit form is the set declaration bound to this record: the
|
|
// same field list the create page renders unbound, with the
|
|
// edit-only Active field and its notice (design D5, D8).
|
|
Form: entitlementSetEditForm(setID, entitlementSetEditValues(vm), nil),
|
|
}, 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, ok := h.pathUUID(w, r, "setID")
|
|
if !ok {
|
|
return
|
|
}
|
|
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. 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)
|
|
// design D20, round 2 ("a success toast, not a banner"): a fresh
|
|
// ?flash=created landing shows a success toast.
|
|
if r.URL.Query().Get("flash") == "created" {
|
|
page.FlashSuccess = "Entitlement set created."
|
|
}
|
|
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),
|
|
History: h.loadEntitlementSetHistory(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),
|
|
History: h.loadEntitlementSetHistory(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 section at rest: every active
|
|
// rule as a row, no editor open and nothing staged. 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 {
|
|
ctx := r.Context()
|
|
return h.entitlementSetRulesData(entitlementSetRulesRender{
|
|
ctx: ctx,
|
|
state: h.loadEntitlementSetRulesState(ctx, setID),
|
|
open: map[string]forms.RowBinding{},
|
|
errs: forms.NewErrors(),
|
|
})
|
|
}
|