Files
member-console/internal/server/operator_plan_ladder_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

310 lines
12 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"html/template"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// The plan-ladder forms (spec plan-ladder-management; spec form-library;
// design D2, D5, D8, D10). Four declarations: the ladder itself (create
// page and the composite's edit form, one declaration in two modes), the
// tier-add dense sub-record form, and the two preview-then-commit forms
// (reorder, removal) that share the preview idiom (design D10; finding
// FA-13): one alert tone, disposition radios at the default size, a
// filled "Apply change" commit and a "Discard" way out.
//
// This file is the four forms' home; the registry is only the index
// (design D1). It sits beside operator_plan_ladders.go, which parses
// through every declaration and never through r.FormValue.
const planLadderFormName = "operator.plan-ladder"
var planLadderForm = forms.Register(forms.FormSpec{
Name: planLadderFormName,
Kind: forms.KindCreate,
Family: forms.Stacked,
Method: "POST",
Path: "/partials/operator/plan-ladders",
EditMethod: "PUT",
EditPath: "/partials/operator/plan-ladders/{ladderID}",
Target: "#operator-body",
Swap: "innerHTML",
Fields: []forms.Field{
forms.RecordName(),
forms.RecordDescription(),
// No Active field on either side: ladder retirement is not wired
// up (an inactive ladder still sells and provisions), so the edit
// form preserves the stored value verbatim (UpdatePlanLadder) and
// no control is offered for it at all.
},
Commit: "Create plan ladder",
CommitEdit: "Save changes",
WayOut: forms.LinkOut("Cancel", "/operator/plan-ladders"),
})
func planLadderCreateForm(values forms.Values, errs *forms.Errors) forms.FormView {
mode := forms.ModeUnbound
if errs.Any() {
mode = forms.ModeSubmission
}
return forms.Render(planLadderForm, forms.Binding{Mode: mode, Side: forms.CreateOnly, Values: values, Errors: errs})
}
func planLadderEditForm(ladderID string, values forms.Values, errs *forms.Errors) forms.FormView {
mode := forms.ModeRecord
if errs.Any() {
mode = forms.ModeSubmission
}
action, err := web.RouteURL(planLadderForm.EditPath, ladderID)
if err != nil {
action = planLadderForm.Path
}
return forms.Render(planLadderForm, forms.Binding{Mode: mode, Side: forms.EditOnly, Action: action, Values: values, Errors: errs})
}
func planLadderEditValues(v PlanLadderViewModel) forms.Values {
values := forms.NewValues()
values.Set("name", v.Name)
values.Set("description", v.Description)
return values
}
// The tier-add form.
const planLadderTierAddFormName = "operator.plan-ladder.tier.add"
const planLadderTierAddPanelID = "tierAddPanel"
var planLadderTierAddForm = forms.Register(forms.FormSpec{
Name: planLadderTierAddFormName,
Kind: forms.KindSubRecord,
Family: forms.Dense,
Method: "POST",
Path: "/partials/operator/plan-ladders/{ladderID}/tiers",
// The whole composite re-renders: adding a tier changes the tiers
// table, so 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{
planLadderTierProductField(),
},
Commit: "Add tier",
WayOut: forms.ClosePanel("Cancel", planLadderTierAddPanelID),
})
// planLadderTierProductField is the tier's product picker plus the
// rule-less warning's live region (design.md M1): choosing a product whose
// entitlement set carries no active rule states one sentence under the
// control, on the duplicate-name warning's precedent
// (operator_product_forms.go's productNameField). The slot id is escaped for
// a CSS selector because the form's name carries dots. The sentence warns
// and never blocks: the commit stays enabled.
func planLadderTierProductField() 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\.plan-ladder\.tier\.add-product_id-warning`,
"hx-swap": "innerHTML",
},
}
}
func planLadderTierAddOptions(products []ProductOption) []forms.Option {
out := make([]forms.Option, 0, len(products)+1)
out = append(out, forms.ChooseOption("a product"))
for _, p := range products {
out = append(out, forms.Option{Value: p.ProductID, Label: p.Name})
}
return out
}
func planLadderTierAddFormOptions(products []ProductOption) map[string][]forms.Option {
return map[string][]forms.Option{"product_id": planLadderTierAddOptions(products)}
}
func planLadderTierAddFormView(ladderID string, values forms.Values, errs *forms.Errors, products []ProductOption, warning template.HTML) forms.FormView {
mode := forms.ModeRecord
if errs.Any() {
mode = forms.ModeSubmission
}
action, err := web.RouteURL(planLadderTierAddForm.Path, ladderID)
if err != nil {
action = planLadderTierAddForm.Path
}
var slots map[string]template.HTML
if warning != "" {
slots = map[string]template.HTML{"product_id": warning}
}
return forms.Render(planLadderTierAddForm, forms.Binding{
Mode: mode, Action: action, Values: values, Errors: errs,
Options: planLadderTierAddFormOptions(products),
Slots: slots,
})
}
// The two preview-then-commit forms. Neither carries a way out with a
// static destination: Discard re-fetches the specific ladder that
// produced the preview, which the declaration's own WayOut is a
// placeholder for; the render overrides it with the concrete URL through
// Binding.WayOut (finding FA-13's "one alert tone... a filled 'Apply
// change' commit... a 'Discard' beside it").
const planLadderTierReorderCommitFormName = "operator.plan-ladder.tier.reorder.commit"
const planLadderTierRemoveCommitFormName = "operator.plan-ladder.tier.remove.commit"
// planLadderTierNeedsDispositionField is the ShowIf marker both preview
// commit forms share: whether the pending change requires a disposition
// choice, set from the server-computed preview (PendingTierReorder.
// NeedsDisposition, PendingTierRemoval.NeedsDisposition), never from user
// choice.
func planLadderTierNeedsDispositionField() forms.Field {
return forms.Field{Name: "needs_disposition", Label: "Disposition needed", Control: forms.Hidden, Optional: true}
}
var planLadderTierReorderForm = forms.Register(forms.FormSpec{
Name: planLadderTierReorderCommitFormName,
Kind: forms.KindPreview,
Family: forms.Stacked,
Method: "POST",
Path: "/partials/operator/plan-ladders/{ladderID}/tiers/reorder",
// The whole composite re-renders: committing a reorder changes ranks
// across the tiers table (and, when rank 0 changes, org dispositions),
// so 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{
{Name: "order", Label: "Tier order", Control: forms.Hidden, Optional: true},
planLadderTierNeedsDispositionField(),
{
Name: "bucket2_disposition",
Label: "Disposition for organizations on the outgoing default",
Control: forms.Radio,
Options: []forms.Option{
{Value: "grandfather", Label: "Grandfather: keep their plan as legacy grants"},
{Value: "migrate", Label: "Migrate: move them to the new rank-0 tier"},
},
// No empty-valued option: Parse requires a choice whenever
// the field is shown (ShowIf), and skips it entirely when
// hidden (design D2's ShowIf; no separate required check
// needed).
ShowIf: forms.ShowIf{Field: "needs_disposition", Equals: []string{"true"}},
},
},
// The declared URL is a fallback the invariants require of a Discard
// way out; every render overrides it with the specific ladder's page
// (Binding.WayOut).
Commit: "Apply change",
WayOut: forms.Discard("Discard", "/operator/plan-ladders"),
})
var planLadderTierRemoveForm = forms.Register(forms.FormSpec{
Name: planLadderTierRemoveCommitFormName,
Kind: forms.KindPreview,
Family: forms.Stacked,
Method: "POST",
Path: "/partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove",
// The whole composite re-renders: committing a removal changes the
// tiers table (and org dispositions), so 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{
planLadderTierNeedsDispositionField(),
{
Name: "default_disposition",
Label: "Disposition for organizations on the org type default",
Control: forms.Radio,
Options: []forms.Option{
{Value: "keep", Label: "Keep: they keep these entitlements off-ladder; the current type default will still apply at their next restoration"},
{Value: "migrate", Label: "Migrate: end this position and apply the current default now"},
},
ShowIf: forms.ShowIf{Field: "needs_disposition", Equals: []string{"true"}},
},
},
Commit: "Apply change",
WayOut: forms.Discard("Discard", "/operator/plan-ladders"),
})
// boolMarker renders the ShowIf marker's raw value: "true" or "".
func boolMarker(on bool) string {
if on {
return "true"
}
return ""
}
// planLadderTierReorderValues binds a pending reorder (or a refused
// resubmission of one) to the reorder-commit declaration's fields.
func planLadderTierReorderValues(orderIDs []string, needsDisposition bool, disposition string) forms.Values {
values := forms.NewValues()
values.Set("order", strings.Join(orderIDs, ","))
values.Set("needs_disposition", boolMarker(needsDisposition))
if disposition != "" {
values.Set("bucket2_disposition", disposition)
}
return values
}
// planLadderTierReorderPreview renders the reorder preview's commit form,
// bound to the pending order or to a refused submission of it. discardURL
// is the specific ladder page Discard returns to; message is the
// narrative rendered into the form's own box, above its fields (design
// D10's preview idiom, Binding.Message).
func planLadderTierReorderPreview(ladderID, discardURL string, values forms.Values, errs *forms.Errors, message template.HTML) forms.FormView {
mode := forms.ModeRecord
if errs.Any() {
mode = forms.ModeSubmission
}
action, err := web.RouteURL(planLadderTierReorderForm.Path, ladderID)
if err != nil {
action = planLadderTierReorderForm.Path
}
discard := forms.Discard("Discard", discardURL)
return forms.Render(planLadderTierReorderForm, forms.Binding{
Mode: mode, Action: action, Values: values, Errors: errs, WayOut: &discard, Message: message,
})
}
// planLadderTierRemovalValues binds a pending removal (or a refused
// resubmission of one) to the removal-commit declaration's fields.
func planLadderTierRemovalValues(needsDisposition bool, disposition string) forms.Values {
values := forms.NewValues()
values.Set("needs_disposition", boolMarker(needsDisposition))
if disposition != "" {
values.Set("default_disposition", disposition)
}
return values
}
// planLadderTierRemovalPreview renders the removal preview's commit form.
func planLadderTierRemovalPreview(ladderID, productID, discardURL string, values forms.Values, errs *forms.Errors, message template.HTML) forms.FormView {
mode := forms.ModeRecord
if errs.Any() {
mode = forms.ModeSubmission
}
action, err := web.RouteURL(planLadderTierRemoveForm.Path, ladderID, productID)
if err != nil {
action = planLadderTierRemoveForm.Path
}
discard := forms.Discard("Discard", discardURL)
return forms.Render(planLadderTierRemoveForm, forms.Binding{
Mode: mode, Action: action, Values: values, Errors: errs, WayOut: &discard, Message: message,
})
}