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.
233 lines
9.1 KiB
Go
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
|
|
}
|