// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "context" "database/sql" "errors" "log/slog" "net/http" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" "git.coopcloud.tech/wiki-cafe/member-console/internal/forms" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" ) // EntitlementSetViewModel represents an entitlement set for operator template rendering. type EntitlementSetViewModel struct { SetID string Name string Description string IsActive bool // Key is the set's declarative address (entity-keys §5), shown read-only // on the detail page when set and omitted entirely when NULL. The create // form does not collect one; a set that has a key was named by a seed or // a configuration file. Key string RuleCount int // FailedPools is how many of this set's recompute obligations have // exhausted their attempts (CountFailedObligationsBySets, one query for // the page rather than one per row). The list states it only when // non-zero, so a set nobody is looking at still declares its dead // letter (design.md "The drain", monitoring). FailedPools int64 CreatedAt string } // FailedLabel is the sets list's attention cell, empty when nothing failed. func (v EntitlementSetViewModel) FailedLabel() string { if v.FailedPools == 0 { return "" } return formatCount(v.FailedPools) + " failed" } // EntitlementSetsData holds data for the entitlement sets list partial. It // carries no FieldErrors: creation moved to its own page (design D20, round // 4; EntitlementSetNewData), so no form on this page can fail validation. type EntitlementSetsData struct { EntitlementSets []EntitlementSetViewModel Success string Error string // Empty drives the list's empty state via the shared "emptyState" // define (ux-first-run 3.4). Entitlement sets are never blocked (they // have no prerequisite of their own), so this is always the not-blocked // shape; the copy carries the two-ended dependency purpose (rules feed // in, products consume it) so it survives past a bare "No X found." Empty EmptyStateParams } // EntitlementSetEditData holds data for the entitlement set edit form. type EntitlementSetEditData struct { EntitlementSet EntitlementSetViewModel // Form is the set declaration rendered on its edit side, bound to this // record or to a refused submission of it (form-library). The create // page renders the same declaration unbound, so the two forms cannot // expose different field sets. Form forms.FormView Success string Error string } // RuleViewModel is one row of the Rules table, in whichever of its three // shapes this render draws: at rest, open for editing, or staged. type RuleViewModel struct { RuleID string RuleType string ResourceKey string // DisplayName is the resource key's friendly name (from the same // resource-key catalog the rules form's picker uses), so the rules table // leads with a human-readable name instead of the raw key (ui-vocabulary // 4.3). Empty when the key can no longer be resolved; the template falls // back to the raw key alone. DisplayName string ResourceValue int64 ResourcePerUnit bool StackingPolicy string IsActive bool // Policy is a numeric rule's reduction policy as its select labels it // ("Clamp", "Force reduce", or a dormant "Block" or "Defer"), stated in // the Rules table's own column at rest (design D7). Empty on a boolean // rule, which carries none. Policy string // Instance is the row's key in the rules form: the rule's id, or "new" // for the row Add rule appends. Editing rows render their controls under // it (FormView.RowField). Instance string // Editing renders the row's Limit and Per unit cells as controls, with // Stage change and Cancel as its actions. Numeric says the selected key // is numeric, which is what reveals those two controls. Editing bool Numeric bool // Staged renders the row tinted with Undo alone. Verb is the delta's // (add, edit or remove) and DeltaKey what Undo drops; Removed strikes the // resource name; IsNew marks a row no stored rule backs yet. Staged bool Verb string DeltaKey string Removed bool IsNew bool // OldLimit, OldPerUnit and OldPolicy are the struck values beside a // staged edit's new ones, empty where the staging did not move them. OldLimit string OldPerUnit string OldPolicy string NewLimit string NewPerUnit string NewPolicy string } // EntitlementSetRulesData holds data for the rules detail partial. type EntitlementSetRulesData struct { EntitlementSet EntitlementSetViewModel Rules []RuleViewModel ResourceKeys []ResourceKeyOption // Form is the rules declaration (operator.entitlement-set.rules) bound to // this render's state, with the table as its body and the staged batch in // its tray (design D1 of staged-rule-changes). Form forms.FormView // PendingPools and FailedPools are the obligations this set's changes // still owe and the ones whose attempts are exhausted // (CountUnsettledObligationsBySet). The section states the first as a // transient line and the second in the attention tone beside a Retry // control (design.md "The drain", monitoring). PendingPools int64 FailedPools int64 Success string Error string } // DrainingLine is the transient line the Rules section carries while a // change still owes pools; empty when it owes none. func (d EntitlementSetRulesData) DrainingLine() string { if d.PendingPools == 0 { return "" } return formatCount(d.PendingPools) + " " + pluralize(d.PendingPools, "pool", "pools") + " not recomputed." } // FailedLine is the attention line, rendered only when a pool's attempts // are exhausted; the Retry control renders beside it. func (d EntitlementSetRulesData) FailedLine() string { if d.FailedPools == 0 { return "" } return formatCount(d.FailedPools) + " " + pluralize(d.FailedPools, "pool", "pools") + " failed to recompute." } // ResourceKeyOption represents a resource key for dropdown selection. type ResourceKeyOption struct { ResourceKey string DisplayName string } // EntitlementSetDetailData is the body data for the addressable per-entitlement-set // composite page (operator_entitlement_set_detail.html). It co-locates the edit // form and the rules manager on one URL; the composite template renders each // sub-section via its existing partial. type EntitlementSetDetailData struct { Edit EntitlementSetEditData Rules EntitlementSetRulesData // History is the set's ledger of committed rule changes, below Details // and Rules (design.md A15). The commit re-renders this whole body, so // an applied change lands on its own first page of History. History EntitlementSetHistoryData Error string } // entitlementSetNameConstraints maps the name-uniqueness constraint (finding // #40; baked into the 00001_init.sql baseline, not a later migration) to a // friendly field error so a duplicate name renders on the "name" input // instead of leaking a 23505 into the banner. var entitlementSetNameConstraints = web.ConstraintMessages{ "uq_entitlement_sets_name": {Field: "name", Message: "That name is already in use. Choose a different name."}, } // CreateEntitlementSet handles POST /partials/operator/entitlement-sets, // reading the body through the set declaration and never through // r.FormValue (form-library "The handler parses through the // declaration"). func (h *OperatorPartialsHandler) CreateEntitlementSet(w http.ResponseWriter, r *http.Request) { values, errs := entitlementSetForm.ParseSide(r, forms.CreateOnly, nil) if errs.Any() { h.renderEntitlementSetNewRefusal(w, r, values, errs) return } name := values.String("name") description := values.String("description") // design D21 (the declaration's is_active field is edit-only): the // create form has no Active control; a new set is always active, and // deactivating is an edit-form-only action, since a set cannot be // deleted (only retired for products and pools that already reference // it). set, err := h.EntitlementsQ.CreateEntitlementSet(r.Context(), entitlements.CreateEntitlementSetParams{ Name: name, Description: sql.NullString{String: description, Valid: description != ""}, IsActive: true, }) if err != nil { if fe, ok := web.FieldErrorsFromDB(err, entitlementSetNameConstraints); ok { for field, msg := range fe { errs.Field(field, msg) } h.renderEntitlementSetNewRefusal(w, r, values, errs) return } h.Logger.Error("failed to create entitlement set", slog.Any("error", err)) errs.Form("Failed to create the entitlement set. Details are in the server logs.") h.renderEntitlementSetNewRefusal(w, r, values, errs) return } // Create-then-land (design D20): the create page, then land on the // record. The new set's own page carries the next step (the empty // rules section with its Add rule opener). redirectToRecord(w, r, "/operator/entitlement-sets/"+set.SetID+"?flash=created") } // UpdateEntitlementSet handles PUT /partials/operator/entitlement-sets/{setID}, // reading the body through the same declaration the create page posts // through, on its edit side. func (h *OperatorPartialsHandler) UpdateEntitlementSet(w http.ResponseWriter, r *http.Request) { setID := r.PathValue("setID") values, errs := entitlementSetForm.ParseSide(r, forms.EditOnly, nil) if errs.Any() { h.renderEntitlementSetEditRefusal(w, r, setID, values, errs) return } name := values.String("name") description := values.String("description") isActive := values.Bool("is_active") _, err := h.EntitlementsQ.UpdateEntitlementSet(r.Context(), entitlements.UpdateEntitlementSetParams{ SetID: setID, Name: name, Description: sql.NullString{String: description, Valid: description != ""}, IsActive: isActive, }) if err != nil { if fe, ok := web.FieldErrorsFromDB(err, entitlementSetNameConstraints); ok { for field, msg := range fe { errs.Field(field, msg) } h.renderEntitlementSetEditRefusal(w, r, setID, values, errs) return } h.Logger.Error("failed to update entitlement set", slog.Any("error", err)) errs.Form("Failed to update the entitlement set. Details are in the server logs.") h.renderEntitlementSetEditRefusal(w, r, setID, values, errs) return } // Stay on the set's composite detail page (the boosted nav already set the // URL); re-render in place reflecting the saved changes. h.renderEntitlementSetDetailBody(w, r, setID, "Entitlement set updated successfully.", "") } // Rendering helpers func (h *OperatorPartialsHandler) renderEntitlementSetsPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) { fireSuccessToast(w, success) data := h.loadEntitlementSetsPageData(r, "", errMsg) h.Templates.Render(w, "operator_entitlement_sets.html", data) } // loadEntitlementSetsPageData hydrates the entitlement-sets listing. Shared // by the legacy partial endpoint (renderEntitlementSetsPage) and the new MPA // page handler (GetEntitlementSetsPage). func (h *OperatorPartialsHandler) loadEntitlementSetsPageData(r *http.Request, success string, errMsg string) EntitlementSetsData { data := EntitlementSetsData{ Success: success, Error: errMsg, // Entitlement sets are never blocked (ux-first-run 3.4). Set // unconditionally, including on the error-return path below, so a // transient DB error never leaves the empty-state block rendering // with blank copy. design D20: the list section header's own "New // entitlement set" action opens the create panel, so the empty // state does not repeat it. Empty: EmptyStateParams{ Headline: "No entitlement sets yet.", Note: "Rules feed into a set, and products consume it. Creating a set is the first step toward a sellable product.", }, } // A stale-detail GET redirects here with ?flash=missing; surface it as the // page banner (finding #53) without clobbering an explicit mutation-guard // errMsg. if data.Error == "" && r.URL.Query().Get("flash") == "missing" { data.Error = "That entitlement set no longer exists; it may have been deleted." } // Finding #54: always hydrate the table and create form, even when a banner // is set, so an unrecognized error never wipes the whole management surface. sets, err := h.EntitlementsQ.ListEntitlementSets(r.Context()) if err != nil { h.Logger.Error("failed to list entitlement sets", slog.Any("error", err)) if data.Error == "" { data.Error = "Failed to load entitlement sets" } return data } // One query for the whole page's failed counts rather than one per row. setIDs := make([]string, len(sets)) for i, s := range sets { setIDs[i] = s.SetID } failedBySet := make(map[string]int64, len(setIDs)) if len(setIDs) > 0 { rows, err := h.EntitlementsQ.CountFailedObligationsBySets(r.Context(), setIDs) if err != nil { h.Logger.Error("failed to count failed obligations for the sets list", slog.Any("error", err)) } for _, row := range rows { failedBySet[row.SetID] = row.FailedCount } } data.EntitlementSets = make([]EntitlementSetViewModel, len(sets)) for i, s := range sets { ruleCount := 0 if rules, err := h.EntitlementsQ.GetActiveRulesBySetID(r.Context(), s.SetID); err == nil { ruleCount = len(rules) } desc := "" if s.Description.Valid { desc = s.Description.String } data.EntitlementSets[i] = EntitlementSetViewModel{ SetID: s.SetID, Name: s.Name, Description: desc, IsActive: s.IsActive, RuleCount: ruleCount, FailedPools: failedBySet[s.SetID], CreatedAt: s.CreatedAt.Format("Jan 2, 2006"), } } return data } // renderEntitlementSetEditRefusal re-renders the composite page at 422 // with the set declaration in submission mode (design D9). func (h *OperatorPartialsHandler) renderEntitlementSetEditRefusal(w http.ResponseWriter, r *http.Request, setID string, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) edit, ok := h.loadEntitlementSetEditData(r, setID) if !ok { h.renderEntitlementSetsPage(w, r, "", "Entitlement set not found") return } edit.Form = entitlementSetEditForm(setID, values, errs) h.Templates.Render(w, "operator_entitlement_set_detail.html", EntitlementSetDetailData{ Edit: edit, Rules: h.loadEntitlementSetRulesData(r, setID), History: h.loadEntitlementSetHistory(r, setID), }) } // loadEntitlementSetEditData hydrates the edit-form view model for one set. // ok=false means the set does not exist. func (h *OperatorPartialsHandler) loadEntitlementSetEditData(r *http.Request, setID string) (EntitlementSetEditData, bool) { data, found, err := h.loadEntitlementSetEditDataResult(r, setID) return data, found && err == nil } // loadEntitlementSetEditDataResult is loadEntitlementSetEditData with the // not-found vs. transient-DB-error distinction preserved: found=false with // err=nil means the set does not exist; err != nil means the load itself // failed. The full-page GET (GetEntitlementSetDetailPage) uses this to redirect // on the former and return a full-shell 500 on the latter, rather than // conflating the two (finding #53). func (h *OperatorPartialsHandler) loadEntitlementSetEditDataResult(r *http.Request, setID string) (EntitlementSetEditData, bool, error) { es, err := h.EntitlementsQ.GetEntitlementSetByID(r.Context(), setID) if errors.Is(err, sql.ErrNoRows) { return EntitlementSetEditData{}, false, nil } if err != nil { h.Logger.Error("failed to get entitlement set", slog.Any("error", err)) return EntitlementSetEditData{}, false, err } desc := "" if es.Description.Valid { desc = es.Description.String } vm := EntitlementSetViewModel{ SetID: es.SetID, Name: es.Name, Description: desc, IsActive: es.IsActive, Key: es.Key.String, CreatedAt: es.CreatedAt.Format("Jan 2, 2006"), } return EntitlementSetEditData{ EntitlementSet: vm, // The edit form is the set declaration bound to this record: the // same field list the create page renders unbound, with the // edit-only Active field and its notice (design D5, D8). Form: entitlementSetEditForm(setID, entitlementSetEditValues(vm), nil), }, true, nil } // GetEntitlementSetDetailPage handles GET /operator/entitlement-sets/{setID} — // the addressable, bookmarkable per-set composite. Renders the operator.html // shell with the edit form and rules manager co-located on one URL. Unknown // setID degrades to the entitlement-sets browse page with an error. func (h *OperatorPartialsHandler) GetEntitlementSetDetailPage(w http.ResponseWriter, r *http.Request) { setID, ok := h.pathUUID(w, r, "setID") if !ok { return } edit, found, err := h.loadEntitlementSetEditDataResult(r, setID) if err != nil { // Transient load failure — keep the operator inside the shell (nav, CSS) // with a 500 instead of mislabeling it "not found" (finding #53). errPage := h.buildOperatorPageData(r) errPage.IAPosition = "catalog:entitlement-sets" errPage.ActiveCapability = "entitlement-sets" errPage.BodyTemplate = "operator_entitlement_sets.html" errPage.BodyData = h.loadEntitlementSetsPageData(r, "", "Could not load this entitlement set right now. Try again.") w.WriteHeader(http.StatusInternalServerError) h.Templates.Render(w, "operator.html", errPage) return } if !found { // Stale/unknown ID — normalize the URL back to the browse route and carry // a flash so the operator understands the bounce (finding #53). http.Redirect(w, r, "/operator/entitlement-sets?flash=missing", http.StatusSeeOther) return } page := h.buildOperatorPageData(r) // design D20, round 2 ("a success toast, not a banner"): a fresh // ?flash=created landing shows a success toast. if r.URL.Query().Get("flash") == "created" { page.FlashSuccess = "Entitlement set created." } page.IAPosition = "catalog:entitlement-sets:" + setID page.ActiveCapability = "entitlement-sets" page.BodyTemplate = "operator_entitlement_set_detail.html" page.BodyData = EntitlementSetDetailData{ Edit: edit, Rules: h.loadEntitlementSetRulesData(r, setID), History: h.loadEntitlementSetHistory(r, setID), } h.Templates.Render(w, "operator.html", page) } // renderEntitlementSetDetailBody re-renders the composite set detail body into // #operator-body after an in-page mutation. The operator stays on // /operator/entitlement-sets/{setID}; unknown setID degrades to the browse page. func (h *OperatorPartialsHandler) renderEntitlementSetDetailBody(w http.ResponseWriter, r *http.Request, setID string, success string, errMsg string) { fireSuccessToast(w, success) edit, ok := h.loadEntitlementSetEditData(r, setID) if !ok { h.renderEntitlementSetsPage(w, r, "", "Entitlement set not found") return } // Finding #41: set only the composite-level Error so the alert renders once // at page top; setting edit.Error too stacked an identical banner inside the // Edit card, visually blaming the wrong form. h.Templates.Render(w, "operator_entitlement_set_detail.html", EntitlementSetDetailData{ Edit: edit, Rules: h.loadEntitlementSetRulesData(r, setID), History: h.loadEntitlementSetHistory(r, setID), Error: errMsg, }) } // renderEntitlementSetEditPage re-renders the set composite in place. Retained as // a thin alias so the mutation call sites (UpdateEntitlementSet) keep working; // the edit form now lives on the composite detail page. func (h *OperatorPartialsHandler) renderEntitlementSetEditPage(w http.ResponseWriter, r *http.Request, setID string, success string, errMsg string) { h.renderEntitlementSetDetailBody(w, r, setID, success, errMsg) } // renderEntitlementSetRulesPage re-renders the set composite in place. Retained // as a thin alias so the rule mutation call sites (add/delete) keep working; the // rules manager now lives on the composite detail page. func (h *OperatorPartialsHandler) renderEntitlementSetRulesPage(w http.ResponseWriter, r *http.Request, setID string, success string, errMsg string) { h.renderEntitlementSetDetailBody(w, r, setID, success, errMsg) } // ruleResourceKeyIsLiveBacking reports whether resourceKey currently backs an // entitlement some pool actively holds: a numeric contribution from a live // (non-ended) provision, or a granted boolean key. It drives the Delete // control's inline consequence copy (ux-honest-surfaces UX-10) — deleting a // rule never re-materializes pools, so this is read-only and best-effort; a // query failure renders as "not live-backing" rather than blocking the page. func (h *OperatorPartialsHandler) ruleResourceKeyIsLiveBacking(ctx context.Context, ruleType, resourceKey string) bool { if resourceKey == "" || h.Database == nil { return false } var live bool var err error if ruleType == "boolean" { err = h.Database.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM core.boolean_entitlements WHERE resource_key = $1 AND granted = TRUE)`, resourceKey, ).Scan(&live) } else { // Contributions are recreated from each pool's currently active // provisions on every materialization (materialize.go) and deleted // outright once a provision ends, so a surviving row means some pool's // materialized limit is presently backed by this resource key. err = h.Database.QueryRowContext(ctx, `SELECT EXISTS ( SELECT 1 FROM core.numeric_entitlement_contributions c JOIN core.numeric_entitlements ne ON ne.entitlement_id = c.entitlement_id WHERE ne.resource_key = $1 )`, resourceKey, ).Scan(&live) } if err != nil { h.Logger.Error("failed to check live-backing for resource key", slog.Any("error", err), slog.String("resource_key", resourceKey)) return false } return live } // loadEntitlementSetRulesData hydrates the Rules section at rest: every active // rule as a row, no editor open and nothing staged. Best-effort — a query // failure yields a partial result rather than aborting, since this feeds the // composite detail page where the set's existence is already established. func (h *OperatorPartialsHandler) loadEntitlementSetRulesData(r *http.Request, setID string) EntitlementSetRulesData { ctx := r.Context() return h.entitlementSetRulesData(entitlementSetRulesRender{ ctx: ctx, state: h.loadEntitlementSetRulesState(ctx, setID), open: map[string]forms.RowBinding{}, errs: forms.NewErrors(), }) }