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.
310 lines
12 KiB
Go
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,
|
|
})
|
|
}
|