Files
member-console/internal/server/operator_entitlement_sets.go
T
cgalo5758 f8a3478f2a Rebuild the entitlement set Rules surface as a staged batch
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.
2026-09-19 19:46:09 -05:00

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(),
})
}