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.
464 lines
18 KiB
Go
464 lines
18 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
)
|
|
|
|
// The entitlement-set forms (spec entitlement-set-management "Operator
|
|
// entitlement set creation", "Operator entitlement set editing", "Operator
|
|
// entitlement set rule management", "The Rules table is the rule form";
|
|
// spec form-library; design D1, D2, D7, D8 of staged-rule-changes). Two
|
|
// declarations: the set itself (create page and the composite's edit form,
|
|
// one declaration in two modes) and the Rules table, one batch form of the
|
|
// rows family whose editing rows carry the declaration's controls in the
|
|
// table's own columns.
|
|
//
|
|
// This file is the two forms' home; the registry is only the index
|
|
// (design D1). It sits beside operator_entitlement_sets.go, which parses
|
|
// through both declarations and never through r.FormValue.
|
|
|
|
const entitlementSetFormName = "operator.entitlement-set"
|
|
|
|
// entitlementSetForm is registered at package init, so the registry is
|
|
// complete for the invariants test, the route test and the
|
|
// registered-forms table whether or not a handler has been built (the
|
|
// same choice lane A made for operator.product).
|
|
var entitlementSetForm = forms.Register(forms.FormSpec{
|
|
Name: entitlementSetFormName,
|
|
Kind: forms.KindCreate,
|
|
Family: forms.Stacked,
|
|
Method: "POST",
|
|
Path: "/partials/operator/entitlement-sets",
|
|
// The record side is a different route: the create page posts to the
|
|
// collection, the edit form puts to the record.
|
|
EditMethod: "PUT",
|
|
EditPath: "/partials/operator/entitlement-sets/{setID}",
|
|
// Both sides re-render the operator body: saving a set changes its
|
|
// rules section as well as the form, and a refusal must swap into the
|
|
// same target the success does (design D9, lesson L§17).
|
|
Target: "#operator-body",
|
|
Swap: "innerHTML",
|
|
Fields: []forms.Field{
|
|
forms.RecordName(),
|
|
forms.RecordDescription(),
|
|
entitlementSetActiveField(),
|
|
},
|
|
Commit: "Create entitlement set",
|
|
CommitEdit: "Save changes",
|
|
WayOut: forms.LinkOut("Cancel", "/operator/entitlement-sets"),
|
|
})
|
|
|
|
// entitlementSetActiveField is the set's Active state, edit-only because a
|
|
// set is created active (finding FA-3). Unlike the product's edit-only
|
|
// fields, this one carries a Notice: entitlement-set-management's own
|
|
// scenario asks that the edit form say, at the field, why Active appears
|
|
// only there, since "why is there no toggle on the create page" is a
|
|
// question an operator would otherwise have to guess at (a set is always
|
|
// created active by design D21, and deactivating one is how a set that
|
|
// products or pools still reference gets retired without being deleted).
|
|
func entitlementSetActiveField() forms.Field {
|
|
f := forms.Field{
|
|
Name: "is_active",
|
|
Label: "Active",
|
|
Control: forms.Checkbox,
|
|
Value: "true",
|
|
Help: forms.Help("Active", "Inactive sets stay with the products that use them but cannot be chosen for a product."),
|
|
Notice: "A set is created active; deactivating here retires a set that products or pools still reference.",
|
|
}
|
|
return f.On(forms.EditOnly)
|
|
}
|
|
|
|
// entitlementSetCreateForm renders the create page's form: unbound on a
|
|
// first visit, submission-bound on a refusal.
|
|
func entitlementSetCreateForm(values forms.Values, errs *forms.Errors) forms.FormView {
|
|
mode := forms.ModeUnbound
|
|
if errs.Any() {
|
|
mode = forms.ModeSubmission
|
|
}
|
|
return forms.Render(entitlementSetForm, forms.Binding{
|
|
Mode: mode,
|
|
Side: forms.CreateOnly,
|
|
Values: values,
|
|
Errors: errs,
|
|
})
|
|
}
|
|
|
|
// entitlementSetEditForm renders the composite's Details section, bound to
|
|
// the set or to a refused submission of it.
|
|
func entitlementSetEditForm(setID string, values forms.Values, errs *forms.Errors) forms.FormView {
|
|
mode := forms.ModeRecord
|
|
if errs.Any() {
|
|
mode = forms.ModeSubmission
|
|
}
|
|
action, err := web.RouteURL(entitlementSetForm.EditPath, setID)
|
|
if err != nil {
|
|
action = entitlementSetForm.Path
|
|
}
|
|
return forms.Render(entitlementSetForm, forms.Binding{
|
|
Mode: mode,
|
|
Side: forms.EditOnly,
|
|
Action: action,
|
|
Values: values,
|
|
Errors: errs,
|
|
})
|
|
}
|
|
|
|
// entitlementSetEditValues binds a set record to the declaration's fields.
|
|
func entitlementSetEditValues(v EntitlementSetViewModel) forms.Values {
|
|
values := forms.NewValues()
|
|
values.Set("name", v.Name)
|
|
values.Set("description", v.Description)
|
|
if f, ok := entitlementSetForm.Field("is_active"); ok {
|
|
values.SetBool(f, v.IsActive)
|
|
}
|
|
return values
|
|
}
|
|
|
|
// The rules form. One declaration, operator.entitlement-set.rules, of the
|
|
// batch kind in the rows family (design D1 of staged-rule-changes): the
|
|
// Rules table is the form's body, its editing rows carry the declaration's
|
|
// controls in the table's own columns, and the staged batch rides in one
|
|
// tray under the table. The Limit and "Per unit" fields declare a ShowIf
|
|
// condition on resourceKeyKind, a Hidden marker the handler fills from the
|
|
// selected key's database-derived kind, never from user choice, so the
|
|
// fields a key reveals are the declaration's own render
|
|
// (entitlement-set-management "the kind-dependent fields SHALL NOT be
|
|
// swapped in by a hand-built partial").
|
|
|
|
const (
|
|
entitlementSetRulesFormName = "operator.entitlement-set.rules"
|
|
// entitlementSetRulesRoute is where every row action, Undo and Discard
|
|
// all post; entitlementSetRulesApplyRoute is the commit's own path
|
|
// (design D2: a row action re-renders the section, the commit applies
|
|
// the batch and re-renders the body).
|
|
entitlementSetRulesRoute = "/partials/operator/entitlement-sets/{setID}/rules"
|
|
entitlementSetRulesApplyRoute = "/partials/operator/entitlement-sets/{setID}/rules/apply"
|
|
// entitlementSetRulesTarget is the section every row action swaps.
|
|
entitlementSetRulesTarget = "#entitlement-set-rules"
|
|
// entitlementSetRulesNewRow is the instance of the one editing row that
|
|
// belongs to no stored rule: the add form Add rule opens above the table.
|
|
entitlementSetRulesNewRow = "new"
|
|
entitlementSetRuleNumericKind = "numeric"
|
|
// entitlementSetRuleAddKeyPrefix names a delta that adds a rule the set
|
|
// does not have yet, whose key cannot be a rule id.
|
|
entitlementSetRuleAddKeyPrefix = "new:"
|
|
)
|
|
|
|
var entitlementSetRulesForm = forms.Register(forms.FormSpec{
|
|
Name: entitlementSetRulesFormName,
|
|
Kind: forms.KindBatch,
|
|
Family: forms.Rows,
|
|
Method: "POST",
|
|
Path: entitlementSetRulesRoute,
|
|
CommitAction: entitlementSetRulesApplyRoute,
|
|
// A row action re-renders the Rules section whole, from the submitted
|
|
// state, so two open editors and a staged change survive every press
|
|
// (design D2). The commit carries its own target, the operator body.
|
|
Target: entitlementSetRulesTarget,
|
|
Swap: "outerHTML",
|
|
Fields: []forms.Field{
|
|
entitlementSetRuleKeyField(),
|
|
entitlementSetRuleKindField(),
|
|
entitlementSetRuleValueField(),
|
|
entitlementSetRulePerUnitField(),
|
|
entitlementSetRulePolicyField(),
|
|
entitlementSetRuleNoteField(),
|
|
},
|
|
Commit: "Apply changes",
|
|
WayOut: forms.WayOut{Label: "Discard all"},
|
|
})
|
|
|
|
// The three verbs a delta carries.
|
|
const (
|
|
entitlementSetRuleVerbAdd = "add"
|
|
entitlementSetRuleVerbEdit = "edit"
|
|
entitlementSetRuleVerbRemove = "remove"
|
|
)
|
|
|
|
// The acts a row action posts. An empty act is the plain re-render the add
|
|
// form's resource-key select fires on change: the handler re-reads the whole
|
|
// submitted state and renders it back, which is how choosing a key reveals
|
|
// that kind's fields beside the select.
|
|
const (
|
|
entitlementSetRuleActAdd = "add"
|
|
entitlementSetRuleActEdit = "edit"
|
|
entitlementSetRuleActCancel = "cancel"
|
|
entitlementSetRuleActStage = "stage"
|
|
entitlementSetRuleActRemove = "remove"
|
|
entitlementSetRuleActUndo = "undo"
|
|
entitlementSetRuleActDiscard = "discard"
|
|
)
|
|
|
|
// entitlementSetRuleKeyField is the key-first picker, the add form's first
|
|
// field: choosing a key derives the rule's shape (entitlement-set-management
|
|
// "The form SHALL NOT expose a rule-type selector"). Its on-change swap posts
|
|
// the whole form and re-renders the section, so the kind-dependent fields
|
|
// appear beside the select that is already open and nothing typed elsewhere
|
|
// is lost. The concrete path is filled in per render
|
|
// (entitlementSetRulesSpecFor), because Field.Attrs is fixed at declaration
|
|
// time and cannot embed the set's id. One rung narrower than a select's
|
|
// natural third, so the add form's five columns and its two buttons fit one
|
|
// dense row at the laptop width; the table's cells ignore the width.
|
|
func entitlementSetRuleKeyField() forms.Field {
|
|
return forms.Field{
|
|
Name: "resource_key",
|
|
Label: "Resource",
|
|
Control: forms.Select,
|
|
RuntimeOptions: true,
|
|
Width: forms.WidthNarrower,
|
|
}
|
|
}
|
|
|
|
// entitlementSetRuleKindField is the ShowIf marker: the selected key's kind,
|
|
// resolved server-side from the database on every request, never read back
|
|
// for the write itself (the rule's type is derived fresh at the commit;
|
|
// entitlement-set-management "Rule type cannot be chosen by the client").
|
|
func entitlementSetRuleKindField() forms.Field {
|
|
return forms.Field{Name: "resource_key_kind", Label: "Resource key kind", Control: forms.Hidden, Optional: true}
|
|
}
|
|
|
|
// entitlementSetRuleValueField is the Limit control of an editing row, in
|
|
// the table's own Limit column. No hint: the column header labels it at
|
|
// desktop width and its own label does below lg.
|
|
func entitlementSetRuleValueField() forms.Field {
|
|
return forms.Field{
|
|
Name: "resource_value",
|
|
Label: "Limit",
|
|
Control: forms.Number,
|
|
Min: "0",
|
|
ShowIf: forms.ShowIf{Field: "resource_key_kind", Equals: []string{entitlementSetRuleNumericKind}},
|
|
}
|
|
}
|
|
|
|
// entitlementSetRulePerUnitField is the Per unit control, in the Per unit
|
|
// column. It carries no hint and no help icon of its own: the column
|
|
// header's help icon states what the flag does.
|
|
func entitlementSetRulePerUnitField() forms.Field {
|
|
return forms.Field{
|
|
Name: "resource_per_unit",
|
|
Label: "Per unit",
|
|
Control: forms.Checkbox,
|
|
Value: "true",
|
|
ShowIf: forms.ShowIf{Field: "resource_key_kind", Equals: []string{entitlementSetRuleNumericKind}},
|
|
}
|
|
}
|
|
|
|
// entitlementSetRulePolicyField is the reduction policy, a field of the rule
|
|
// edited beside its limit in the Rules table's own column (design D7; the
|
|
// maintainer's decision 1 of 2026-09-19 moved it out of the tray). Its
|
|
// options are per row, because a rule whose stored value is dormant offers
|
|
// that value as its own option; a boolean row carries no policy control at
|
|
// all, because no reduction policy acts on a rule that grants or withholds a
|
|
// capability. One rung narrower in the add form's dense row, as the key
|
|
// select is, for the same reason.
|
|
func entitlementSetRulePolicyField() forms.Field {
|
|
return forms.Field{
|
|
Name: "tier_reduction_policy",
|
|
Label: "Reduction policy",
|
|
Control: forms.Select,
|
|
RuntimeOptions: true,
|
|
Width: forms.WidthNarrower,
|
|
ShowIf: forms.ShowIf{Field: "resource_key_kind", Equals: []string{entitlementSetRuleNumericKind}},
|
|
}
|
|
}
|
|
|
|
// entitlementSetRuleNoteField is the batch's own field, rendered once in the
|
|
// tray before the commit.
|
|
func entitlementSetRuleNoteField() forms.Field {
|
|
return forms.Field{
|
|
Name: "note",
|
|
Label: "Note",
|
|
Control: forms.Text,
|
|
Optional: true,
|
|
MaxLen: 500,
|
|
Placement: forms.PlaceTray,
|
|
}
|
|
}
|
|
|
|
// The reduction policy's four stored values. Only clamp and force_reduce act
|
|
// at a rule commit, so only those two are offered on a new staging; block and
|
|
// defer are offered back to the rule that already carries one, selected, so
|
|
// the console rewrites a stored promise only when the operator picks another
|
|
// (design D7).
|
|
const (
|
|
entitlementSetRulePolicyClamp = "clamp"
|
|
entitlementSetRulePolicyForceReduce = "force_reduce"
|
|
entitlementSetRulePolicyBlock = "block"
|
|
entitlementSetRulePolicyDefer = "defer"
|
|
)
|
|
|
|
// entitlementSetRulePolicyDefault is where a new rule's select opens: the
|
|
// console writes an acting value while the column default stays the model's.
|
|
const entitlementSetRulePolicyDefault = entitlementSetRulePolicyClamp
|
|
|
|
// entitlementSetRulePolicyOptions are one row's options: the two acting
|
|
// values, plus the stored value when it is one of the dormant two.
|
|
func entitlementSetRulePolicyOptions(stored string) []forms.Option {
|
|
out := []forms.Option{
|
|
{Value: entitlementSetRulePolicyClamp, Label: "Clamp"},
|
|
{Value: entitlementSetRulePolicyForceReduce, Label: "Force reduce"},
|
|
}
|
|
switch stored {
|
|
case entitlementSetRulePolicyBlock:
|
|
out = append(out, forms.Option{Value: entitlementSetRulePolicyBlock, Label: "Block"})
|
|
case entitlementSetRulePolicyDefer:
|
|
out = append(out, forms.Option{Value: entitlementSetRulePolicyDefer, Label: "Defer"})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// entitlementSetRulePolicyAccepted is what ParseBatchWith checks a submitted
|
|
// policy against: every value the page can render anywhere. Which of them one
|
|
// delta may actually carry is checked per delta against its own options,
|
|
// because a crafted submission can name a value no line offered.
|
|
func entitlementSetRulePolicyAccepted() []forms.Option {
|
|
return []forms.Option{
|
|
{Value: entitlementSetRulePolicyClamp, Label: "Clamp"},
|
|
{Value: entitlementSetRulePolicyForceReduce, Label: "Force reduce"},
|
|
{Value: entitlementSetRulePolicyBlock, Label: "Block"},
|
|
{Value: entitlementSetRulePolicyDefer, Label: "Defer"},
|
|
}
|
|
}
|
|
|
|
// entitlementSetRulePolicyOffered reports whether policy is one of the
|
|
// options a row whose stored value is stored would have offered.
|
|
func entitlementSetRulePolicyOffered(stored, policy string) bool {
|
|
for _, o := range entitlementSetRulePolicyOptions(stored) {
|
|
if o.Value == policy {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// entitlementSetRulePolicyLabel is a policy value as its select labels it
|
|
// ("Clamp", "Force reduce", "Block", "Defer"), which is how the table states
|
|
// it at rest; an unknown value reads as itself.
|
|
func entitlementSetRulePolicyLabel(value string) string {
|
|
for _, o := range entitlementSetRulePolicyAccepted() {
|
|
if o.Value == value {
|
|
return o.Label
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
// entitlementSetRulePolicyWord is the same label lowered for a sentence
|
|
// ("reduction policy clamp becomes force reduce").
|
|
func entitlementSetRulePolicyWord(value string) string {
|
|
return strings.ToLower(entitlementSetRulePolicyLabel(value))
|
|
}
|
|
|
|
// entitlementSetRulesSpecFor is the declaration with the resource-key
|
|
// select's escape-hatch Attrs filled in for one set: it fires a scoped POST to
|
|
// the row-action route on change, carrying the whole form and swapping the
|
|
// Rules section back, so choosing a key reveals that row's numeric fields and
|
|
// puts the row under its kind's group. The registered declaration carries no
|
|
// Attrs, because a set's route cannot be known until it renders; Parse never
|
|
// reads Attrs, so the registered value is enough for parsing (the org-type
|
|
// card's per-render copy is the precedent).
|
|
func entitlementSetRulesSpecFor(setID string) forms.FormSpec {
|
|
spec := entitlementSetRulesForm
|
|
action, err := web.RouteURL(entitlementSetRulesRoute, setID)
|
|
if err != nil {
|
|
action = entitlementSetRulesRoute
|
|
}
|
|
swap := map[string]string{
|
|
"hx-post": action,
|
|
"hx-trigger": "change",
|
|
"hx-sync": "this:replace",
|
|
"hx-include": "closest form",
|
|
"hx-target": entitlementSetRulesTarget,
|
|
"hx-swap": "outerHTML",
|
|
}
|
|
fields := make([]forms.Field, 0, len(spec.Fields))
|
|
for _, f := range spec.Fields {
|
|
if f.Name == "resource_key" {
|
|
f.Attrs = swap
|
|
}
|
|
fields = append(fields, f)
|
|
}
|
|
spec.Fields = fields
|
|
return spec
|
|
}
|
|
|
|
// entitlementSetRulesAction is the concrete row-action path for one set, and
|
|
// entitlementSetRulesApplyAction the commit's.
|
|
func entitlementSetRulesAction(setID string) string {
|
|
action, err := web.RouteURL(entitlementSetRulesRoute, setID)
|
|
if err != nil {
|
|
return entitlementSetRulesRoute
|
|
}
|
|
return action
|
|
}
|
|
|
|
func entitlementSetRulesApplyAction(setID string) string {
|
|
action, err := web.RouteURL(entitlementSetRulesApplyRoute, setID)
|
|
if err != nil {
|
|
return entitlementSetRulesApplyRoute
|
|
}
|
|
return action
|
|
}
|
|
|
|
// entitlementSetRuleKeyOptions builds the resource-key select's options: a
|
|
// "Choose a resource key" placeholder (spec form-conventions "A select that
|
|
// needs a choice opens with a placeholder option") followed by every key
|
|
// still available for a new rule on this set (finding #21: a key that already
|
|
// has an active rule on this set is excluded, since offering it could only
|
|
// 422).
|
|
func entitlementSetRuleKeyOptions(keys []ResourceKeyOption) []forms.Option {
|
|
out := make([]forms.Option, 0, len(keys)+1)
|
|
out = append(out, forms.ChooseOption("a resource key"))
|
|
for _, k := range keys {
|
|
out = append(out, forms.Option{Value: k.ResourceKey, Label: k.DisplayName + " (" + k.ResourceKey + ")"})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// entitlementSetRulesParseOptions is the option set ParseBatchWith reads a
|
|
// request through: every key the page could have offered, and every policy
|
|
// value any row could have offered.
|
|
func entitlementSetRulesParseOptions(keys []ResourceKeyOption) map[string][]forms.Option {
|
|
return map[string][]forms.Option{
|
|
"resource_key": entitlementSetRuleKeyOptions(keys),
|
|
"tier_reduction_policy": entitlementSetRulePolicyAccepted(),
|
|
}
|
|
}
|
|
|
|
// entitlementSetAvailableRuleKeys lists the resource keys usable for a new
|
|
// rule on setID: the catalog minus any key that already has an active rule on
|
|
// this set.
|
|
func (h *OperatorPartialsHandler) entitlementSetAvailableRuleKeys(ctx context.Context, setID string) []ResourceKeyOption {
|
|
ruled := map[string]bool{}
|
|
if rules, err := h.EntitlementsQ.GetActiveRulesBySetID(ctx, setID); err != nil {
|
|
h.Logger.Error("failed to list rules for key availability", slog.Any("error", err))
|
|
} else {
|
|
for _, rule := range rules {
|
|
if rule.ResourceKey.Valid {
|
|
ruled[rule.ResourceKey.String] = true
|
|
}
|
|
}
|
|
}
|
|
keys, err := h.EntitlementsQ.ListResourceKeys(ctx)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list resource keys", slog.Any("error", err))
|
|
return nil
|
|
}
|
|
out := make([]ResourceKeyOption, 0, len(keys))
|
|
for _, rk := range keys {
|
|
if ruled[rk.ResourceKey] {
|
|
continue
|
|
}
|
|
out = append(out, ResourceKeyOption{ResourceKey: rk.ResourceKey, DisplayName: rk.DisplayName})
|
|
}
|
|
return out
|
|
}
|