// 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 }