Files
member-console/internal/server/operator_enrollment_forms.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

233 lines
9.1 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"html/template"
"strconv"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// The organization composite's two grant forms (spec
// plan-enrollment-administration "The composite's grant forms use
// imperative copy and help icons"; spec form-library, form-conventions;
// design D3, D8, D10). Both are dense sub-record forms on the organization
// composite: Issue grant opens from the Plan and grants card's header, one
// per page; Extend opens from a pool's delivery row, once per grant-backed
// delivery, so its render carries a Binding.Instance and a per-delivery
// WayOut (findings FA-28, FA-29, FA-32, FA-36).
//
// This file is the forms' home; the registry is only the index (design
// D1). It sits beside operator_enrollment.go, which parses through both
// declarations and never through r.FormValue.
const (
issueGrantFormName = "operator.enrollment.grant.issue"
extendGrantFormName = "operator.enrollment.grant.extend"
)
// issueGrantForm is the declaration for Issue grant: any published product,
// a quantity, an optional valid_until, a reason from the operator domain,
// and a free-text description (plan-enrollment-administration "Operator
// grant issuance"). 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 pattern operator_product_
// forms.go set).
var issueGrantForm = forms.Register(forms.FormSpec{
Name: issueGrantFormName,
Kind: forms.KindSubRecord,
Family: forms.Dense,
Method: "POST",
Path: "/partials/operator/organizations/{orgID}/grant/create",
// The whole composite re-renders: issuing a grant can change the
// pools' delivery listing, the ledger and the tier-changes history as
// well as the form, and a refusal must swap into the same target the
// success does (design D9, lesson L§17). Single instance per page, so
// no Binding.Target override is needed.
Target: "#operator-body",
Swap: "innerHTML",
Fields: []forms.Field{
issueGrantProductField(),
{
Name: "quantity",
Label: "Quantity",
Control: forms.Number,
Optional: true,
Min: "1",
Max: strconv.Itoa(maxGrantQuantity),
Help: forms.Help("Quantity", "Seats, for per-seat products."),
// A ghost example rather than a pre-filled value: the handler
// defaults a blank submission to 1 (plan-enrollment-administration,
// "a positive integer, defaulting to 1"), so leaving the field
// empty is itself a valid, common choice.
Placeholder: "1",
},
forms.GrantValidUntil(),
{
Name: "reason",
Label: "Reason",
Control: forms.Select,
Options: issueGrantReasonOptions(),
// An enum of one or two words: a select's third is wider than
// any option needs.
Width: forms.WidthNarrower,
},
forms.GrantNote(),
},
Commit: "Issue grant",
WayOut: forms.ClosePanel("Cancel", "issueGrantPanel"),
})
// extendGrantForm is the declaration for Extend: the shared description and
// valid_until fields the issuance form carries, named and labelled alike
// (finding FA-28: Extend keeps no "reason" field; the handler records
// grant_reason "manual"; finding FA-32: one "Valid until" label on both
// forms). provision_id is hidden, because the route names only the
// organization and its pool, not the specific delivery the button rides
// (design D2's Hidden control: a parent id the route cannot itself carry).
// This declaration renders once per grant-backed delivery, so every render
// site supplies Binding.Instance and a per-delivery WayOut; the WayOut
// below is the placeholder invariants need at registration and is never
// what actually renders.
var extendGrantForm = forms.Register(forms.FormSpec{
Name: extendGrantFormName,
Kind: forms.KindSubRecord,
Family: forms.Dense,
Method: "POST",
Path: "/partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend",
Target: "#operator-body",
Swap: "innerHTML",
Fields: []forms.Field{
forms.GrantNote(),
forms.GrantValidUntil(),
{
Name: "provision_id",
Label: "Delivery",
Control: forms.Hidden,
Optional: true,
},
},
Commit: "Extend tier",
WayOut: forms.ClosePanel("Cancel", "extendGrantPanelTemplate"),
})
// issueGrantReasonOptions is the grant-reason enum select's options: a
// disabled "Choose a reason" placeholder (spec form-conventions "A select
// that needs a choice opens with a placeholder option"), then the
// operator-selectable domain, never including 'default' (system-authored
// only) or 'trial' (superseded by 'evaluation' plus valid_until;
// plan-enrollment-administration "Operator cannot select grant_reason
// 'default'", "Time-boxed grant uses 'evaluation', never 'trial'").
func issueGrantReasonOptions() []forms.Option {
opts := make([]forms.Option, 0, len(grantReasonDomain)+1)
opts = append(opts, forms.ChooseOption("a reason"))
for _, v := range grantReasonDomain {
opts = append(opts, forms.Option{Value: v, Label: v})
}
return opts
}
// issuanceProductOptions builds the Product select's runtime options: a
// disabled "Choose a product" placeholder, then every issuable product,
// carrying forward the internal and supersession markers the composite
// already showed (finding FA-33 is unaddressed here; the marker's text
// equivalent is a tracked, not this lane's, fix).
func issuanceProductOptions(products []IssuanceProductOption) []forms.Option {
opts := make([]forms.Option, 0, len(products)+1)
opts = append(opts, forms.ChooseOption("a product"))
for _, p := range products {
label := p.Name
if p.IsInternal {
label += " [internal]"
}
if p.SupersedesSubscription {
label += " ⚠"
}
opts = append(opts, forms.Option{Value: p.ProductID, Label: label})
}
return opts
}
// issueGrantProductField is the grant's product picker plus the rule-less
// warning's live region (design.md M1; plan-enrollment-administration "Issue
// grant names a product that provides nothing"), on the duplicate-name
// warning's precedent. The picker stays unfiltered and unpartitioned and the
// commit stays enabled: a set with no active rule is a state an operator may
// legitimately grant from.
func issueGrantProductField() forms.Field {
return forms.Field{
Name: "product_id",
Label: "Product",
Control: forms.Select,
RuntimeOptions: true,
Slot: "warning",
Attrs: map[string]string{
"hx-get": "/partials/operator/products/rule-check",
"hx-trigger": "change",
"hx-target": `#form-operator\.enrollment\.grant\.issue-product_id-warning`,
"hx-swap": "innerHTML",
},
}
}
// issueGrantFormOptions is the option map both the render and the parse
// read, so what the control offered and what the handler accepts are one
// list (the pattern operator_product_forms.go set for entitlement_set_id).
func issueGrantFormOptions(products []IssuanceProductOption) map[string][]forms.Option {
return map[string][]forms.Option{"product_id": issuanceProductOptions(products)}
}
// issueGrantFormView renders the Issue grant panel: mode is ModeRecord at
// rest (bound to empty values, so a fresh open shows nothing chosen and no
// error) and ModeSubmission on a refused submission, with every submitted
// value carried back (design D9).
func issueGrantFormView(orgID string, products []IssuanceProductOption, mode forms.Mode, values forms.Values, errs *forms.Errors, warning template.HTML) forms.FormView {
action, err := web.RouteURL(issueGrantForm.Path, orgID)
if err != nil {
action = issueGrantForm.Path
}
var slots map[string]template.HTML
if warning != "" {
slots = map[string]template.HTML{"product_id": warning}
}
return forms.Render(issueGrantForm, forms.Binding{
Mode: mode,
Action: action,
Values: values,
Errors: errs,
Options: issueGrantFormOptions(products),
Slots: slots,
})
}
// extendGrantFormView renders one delivery's Extend panel. Instance is the
// provision id, so a page with several open panels carries no duplicate DOM
// id; the way out closes exactly the panel the button that opened it named
// (design D10; findings FA-19, FA-28, FA-29, FA-32).
func extendGrantFormView(orgID, poolID, provisionID string, mode forms.Mode, values forms.Values, errs *forms.Errors) forms.FormView {
action, err := web.RouteURL(extendGrantForm.Path, orgID, poolID)
if err != nil {
action = extendGrantForm.Path
}
return forms.Render(extendGrantForm, forms.Binding{
Mode: mode,
Action: action,
Instance: provisionID,
WayOut: &forms.WayOut{Kind: forms.WayOutClosePanel, Label: "Cancel", PanelID: "extendPanel-" + provisionID},
Values: values,
Errors: errs,
})
}
// extendGrantRestValues binds an at-rest Extend panel's one carried value:
// the delivery it extends, so the hidden field always names the right
// provision even before any submission (design D2's Hidden control).
func extendGrantRestValues(provisionID string) forms.Values {
values := forms.NewValues()
values.Set("provision_id", provisionID)
return values
}