Files
member-console/internal/server/operator_partials.go
T
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.

People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.

Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.

Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.

Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
2026-08-24 03:58:18 -05:00

731 lines
33 KiB
Go

package server
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"unicode/utf16"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"go.temporal.io/sdk/client"
)
// OperatorRole is the OIDC role required to access operator pages
const OperatorRole = "operator-member"
// fireSuccessToast sets the HX-Trigger response header so the operator's
// browser fires a `showSuccessToast` event with the given message. The
// `success-toast.js` driver listens for that event and renders the toast.
// Must be called BEFORE any w.Write — once the body starts streaming the
// header is locked. Empty msg is a no-op so callers can pass success
// strings through render helpers without conditional checks.
//
// This is the convention per docs/operator-ux-conventions.md §3: mutation
// success fires a toast, not a re-rendered alert banner.
func fireSuccessToast(w http.ResponseWriter, msg string) {
if msg == "" {
return
}
payload, err := asciiTriggerJSON(map[string]string{"showSuccessToast": msg})
if err != nil {
return
}
w.Header().Set("HX-Trigger", payload)
}
// fireErrorToast surfaces a mutation error as a visible toast in addition to
// the in-body .alert-danger banner. The banner alone is invisible when the
// operator triggered the action from a scrolled-down section of the page
// (e.g. revoking a grant from the per-org composite); the toast lives in
// the page-shell container outside the swap target and is always visible.
// Empty msg is a no-op.
func fireErrorToast(w http.ResponseWriter, msg string) {
if msg == "" {
return
}
payload, err := asciiTriggerJSON(map[string]string{"showErrorToast": msg})
if err != nil {
return
}
w.Header().Set("HX-Trigger", payload)
}
// asciiTriggerJSON marshals v to JSON and escapes every non-ASCII rune as a
// \uXXXX sequence, so the result is safe to place in an HX-Trigger response
// header. HTTP header values are transported as ISO-8859-1, so raw multi-byte
// UTF-8 (e.g. an em-dash in a toast message) is otherwise mangled by the browser;
// the \uXXXX escapes keep the header ASCII while htmx's JSON.parse restores the
// original text client-side.
func asciiTriggerJSON(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
var sb strings.Builder
for _, r := range string(b) {
switch {
case r < 0x80:
sb.WriteRune(r)
case r > 0xFFFF:
r1, r2 := utf16.EncodeRune(r)
fmt.Fprintf(&sb, `\u%04x\u%04x`, r1, r2)
default:
fmt.Fprintf(&sb, `\u%04x`, r)
}
}
return sb.String(), nil
}
// OperatorPartialsHandler handles HTMX partial requests for operator pages
type OperatorPartialsHandler struct {
EntitlementsQ entitlements.Querier
BillingQ billing.Querier
StripeQ stripedb.Querier
Database *sql.DB
IdentityQ identity.Querier
OrgQ organization.Querier
Logger *slog.Logger
AuthConfig *auth.Config
Templates *SafeTemplates
StripeDashboardURL string
StripeConfigured bool
TemporalClient client.Client
// Registry is the domains allocation API, for the operator Domains
// surface (operator_domains.go). Nil disables that page's data rather
// than the page: it renders the "registry unavailable" banner, the same
// way every other read failure here degrades.
Registry *domains.Registry
// IntegrationConfigs: declared config per installed integration, for
// the generic settings surface (see Config.IntegrationConfigs).
IntegrationConfigs []IntegrationConfigInfo
}
// OperatorPartialsConfig holds configuration for the operator partials handler
type OperatorPartialsConfig struct {
EntitlementsQ entitlements.Querier
BillingQ billing.Querier
StripeQ stripedb.Querier
Database *sql.DB
IdentityQ identity.Querier
OrgQ organization.Querier
Logger *slog.Logger
AuthConfig *auth.Config
StripeDashboardURL string
StripeConfigured bool
TemporalClient client.Client
Registry *domains.Registry
IntegrationConfigs []IntegrationConfigInfo
}
// NewOperatorPartialsHandler creates a new OperatorPartialsHandler
func NewOperatorPartialsHandler(cfg OperatorPartialsConfig) (*OperatorPartialsHandler, error) {
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
return nil, err
}
shellSubFS, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
return nil, err
}
// html/template's `{{ template "literal" . }}` action requires a string
// literal — there is no dynamic-name form. operator.html's <main> needs
// to dispatch to a body partial chosen at request time, so we expose
// `renderBody` as a template function that looks up the named template
// inside the same set and returns its rendered HTML.
//
// The closure captures `tmpl` (assigned below). Funcs must be installed
// before parsing so the parser accepts references to "renderBody"; we
// pre-construct the empty template, install the func, then ParseFS.
var tmpl *template.Template
tmpl = template.New("operator").Funcs(template.FuncMap{
"renderBody": func(name string, data any) (template.HTML, error) {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
},
// fieldErr returns the per-field error message ONLY when both the
// active form name and a per-form scope key (e.g. poolID) match —
// pages with multiple forms (the per-org composite has Issue,
// Create, and per-pool Extend forms) need to scope errors to one
// form. Pass empty string for `scope` if the form has no per-row
// disambiguation. Returns the empty string when no error applies,
// which the template uses with `{{ if }}` / `{{ with }}` to skip
// rendering the is-invalid class + invalid-feedback markup.
"fieldErr": func(activeForm, expectedForm, activeScope, expectedScope string, errs web.FieldErrors, field string) string {
if activeForm != expectedForm {
return ""
}
if activeScope != expectedScope {
return ""
}
return errs.Get(field)
},
"routeURL": web.RouteURL,
// stripeEntityURL renders a Stripe dashboard deep-link for a Stripe
// entity ID, or "" when the dashboard URL is unconfigured — templates
// gate the <a> wrapper on the returned value (finding #39). Captures the
// deployment's dashboard base URL from config.
"stripeEntityURL": func(entityType, stripeID string) string {
return stripeEntityURL(cfg.StripeDashboardURL, entityType, stripeID)
},
"deploymentName": config.DeploymentName,
})
if tmpl, err = tmpl.ParseFS(templateSubFS, "operator_*.html"); err != nil {
return nil, err
}
if tmpl, err = tmpl.ParseFS(shellSubFS, "operator.html"); err != nil {
return nil, err
}
return &OperatorPartialsHandler{
EntitlementsQ: cfg.EntitlementsQ,
BillingQ: cfg.BillingQ,
StripeQ: cfg.StripeQ,
Database: cfg.Database,
IdentityQ: cfg.IdentityQ,
OrgQ: cfg.OrgQ,
Logger: cfg.Logger,
AuthConfig: cfg.AuthConfig,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
IntegrationConfigs: cfg.IntegrationConfigs,
StripeDashboardURL: cfg.StripeDashboardURL,
StripeConfigured: cfg.StripeConfigured,
TemporalClient: cfg.TemporalClient,
Registry: cfg.Registry,
}, nil
}
// RegisterRoutes registers all operator HTMX partial routes.
//
// Note: GET /operator/fedwiki-sites is NOT registered here. It used to be
// a hardcoded entry in this method (and the read-only page handler lived
// here too), but per design.md Decision 8 an integration's own operator
// surface is registered by the integration itself, at the path its
// provider manifest declares (OperatorSurfacePath) — see
// internal/integrations/fedwiki.Adapter.RegisterRoutes and
// internal/integrations/fedwiki/web's FedWikiOperatorHandler
// (openspec/changes/integration-extraction task 2.7).
func (h *OperatorPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
// Top-level MPA pages (operator_pages.go).
mux.HandleFunc("GET /operator/persons", h.requireOperatorRole(h.GetPersonsPage))
mux.HandleFunc("GET /operator/organizations", h.requireOperatorRole(h.GetOrganizationsPage))
mux.HandleFunc("GET /operator/organizations/{orgID}", h.requireOperatorRole(h.GetOrganizationDetailPage))
mux.HandleFunc("GET /operator/grants", h.requireOperatorRole(h.GetGrantsPage))
mux.HandleFunc("GET /operator/billing/accounts", h.requireOperatorRole(h.GetBillingAccountsPage))
mux.HandleFunc("GET /operator/billing/subscriptions", h.requireOperatorRole(h.GetSubscriptionsPage))
mux.HandleFunc("GET /operator/billing/invoices", h.requireOperatorRole(h.GetInvoicesPage))
mux.HandleFunc("GET /operator/billing/invoices/{invoiceID}", h.requireOperatorRole(h.GetOperatorInvoiceDetailPage))
mux.HandleFunc("GET /operator/billing/payments", h.requireOperatorRole(h.GetPaymentsPage))
mux.HandleFunc("GET /operator/org-types", h.requireOperatorRole(h.GetOrgTypesPage))
mux.HandleFunc("GET /operator/products", h.requireOperatorRole(h.GetProductsPage))
mux.HandleFunc("GET /operator/entitlement-sets", h.requireOperatorRole(h.GetEntitlementSetsPage))
mux.HandleFunc("GET /operator/plan-ladders", h.requireOperatorRole(h.GetPlanLaddersPage))
// /operator/plan-topology has no route: the topology overview folded
// into /operator/plan-ladders as its map-first view (D4), and with no
// production deployments there is no old URL to stay compatible with —
// the subtree fallback answers it with the panel's 404.
mux.HandleFunc("GET /operator/domains", h.requireOperatorRole(h.GetDomainsPage))
mux.HandleFunc("GET /operator/integrations", h.requireOperatorRole(h.GetIntegrationsPage))
// Subtree fallback: any /operator/* path no specific route claims gets
// an in-shell 404 instead of falling through to the "/" catch-all
// (which renders the member dashboard). Method-less so stray POSTs to
// unknown operator paths land here too; every registered pattern above
// and in the integrations' own RegisterRoutes wins by specificity.
mux.HandleFunc("/operator/", h.requireOperatorRole(h.GetOperatorNotFound))
// Literal /config-orphans is preferred over the {slug} wildcard for its
// exact path (same ServeMux specificity note as /plan-ladders/validation).
mux.HandleFunc("DELETE /operator/integrations/config-orphans/{key}", h.requireOperatorRole(h.DeleteOrphanOverride))
mux.HandleFunc("GET /operator/integrations/{slug}/settings", h.requireOperatorRole(h.GetIntegrationSettingsPage))
mux.HandleFunc("POST /operator/integrations/{slug}/settings", h.requireOperatorRole(h.PostIntegrationSetting))
mux.HandleFunc("GET /operator/persons/{personID}", h.requireOperatorRole(h.GetPersonPage))
mux.HandleFunc("GET /operator/products/{productID}", h.requireOperatorRole(h.GetProductDetailPage))
// Literal /validation is registered alongside the {ladderID} wildcard; Go's
// ServeMux prefers the more specific literal for that exact path, so the
// full-page structural-validation view (audit finding #43) never shadows a
// real ladder detail.
mux.HandleFunc("GET /operator/plan-ladders/validation", h.requireOperatorRole(h.GetPlanLadderValidationPage))
mux.HandleFunc("GET /operator/plan-ladders/{ladderID}", h.requireOperatorRole(h.GetPlanLadderDetailPage))
mux.HandleFunc("GET /operator/entitlement-sets/{setID}", h.requireOperatorRole(h.GetEntitlementSetDetailPage))
// Legacy pane-loader GETs (/partials/operator/{organizations,sites,
// grants,products,entitlement-sets,org-types,plan-ladders,billing/*})
// were retired in slice 8 — they only ever served the legacy tabstrip
// panes, which were demolished alongside operator-tabs.js. The
// POST/PUT/DELETE handlers below are kept because they back in-page
// forms, row edits, and modal submissions on the new MPA pages.
//
// Grant management. The grant-action surface is the per-org composite
// (operator-panel-navigation requirement: composite is the SOLE UI entry
// point for grant actions). The legacy POST /partials/operator/grants
// route was retired alongside the global Grants page going read-only —
// the new home for non-plan CreateGrant is on the composite at
// /partials/operator/organizations/{orgID}/grant/create. The simple
// RevokeGrant (non-composite) endpoint is also retired; revocations go
// through RevokeGrantAndTransition exclusively.
mux.HandleFunc("POST /partials/operator/organizations/{orgID}/grant/create", h.requireOperatorRole(h.IssueGrant))
// Product management
mux.HandleFunc("POST /partials/operator/products", h.requireOperatorRole(h.CreateProduct))
mux.HandleFunc("PUT /partials/operator/products/{productID}", h.requireOperatorRole(h.UpdateProduct))
mux.HandleFunc("POST /partials/operator/products/{productID}/sync-stripe", h.requireOperatorRole(h.SyncProductToStripe))
mux.HandleFunc("GET /partials/operator/products/{productID}/readiness", h.requireOperatorRole(h.GetProductReadiness))
// Entitlement set management. The edit form and rules manager live on the
// set composite page (GET /operator/entitlement-sets/{setID}); the routes
// below back the in-page forms.
mux.HandleFunc("POST /partials/operator/entitlement-sets", h.requireOperatorRole(h.CreateEntitlementSet))
mux.HandleFunc("PUT /partials/operator/entitlement-sets/{setID}", h.requireOperatorRole(h.UpdateEntitlementSet))
mux.HandleFunc("POST /partials/operator/entitlement-sets/{setID}/rules", h.requireOperatorRole(h.CreateEntitlementSetRule))
mux.HandleFunc("GET /partials/operator/entitlement-sets/{setID}/rules/fields", h.requireOperatorRole(h.GetEntitlementSetRuleFields))
mux.HandleFunc("DELETE /partials/operator/entitlement-sets/{setID}/rules/{ruleID}", h.requireOperatorRole(h.DeleteEntitlementSetRule))
// Organization type management: selecting a candidate default posts to
// the preview (read-only classification, per-card swap); the commit
// enacts the change with per-bucket dispositions.
mux.HandleFunc("POST /partials/operator/org-types/{orgType}/default-change/preview", h.requireOperatorRole(h.PreviewOrgTypeDefaultChange))
mux.HandleFunc("POST /partials/operator/org-types/{orgType}/default-change", h.requireOperatorRole(h.CommitOrgTypeDefaultChange))
// Enrollment & transition tools
mux.HandleFunc("GET /partials/operator/organizations/{orgID}/enrollment", h.requireOperatorRole(h.GetOrgEnrollment))
mux.HandleFunc("POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend", h.requireOperatorRole(h.ExtendGrant))
mux.HandleFunc("POST /partials/operator/grants/{grantID}/revoke-and-transition", h.requireOperatorRole(h.RevokeGrantAndTransition))
// Product prices (the prices view itself lives on the product composite page;
// these POSTs back the in-page add-price form and the per-row price
// affordances: promote a price to the product's default, retire a
// non-default price. Per-price Stripe sync reuses the sync-stripe route
// above with an explicit price_id form value.
mux.HandleFunc("POST /partials/operator/products/{productID}/prices", h.requireOperatorRole(h.CreatePrice))
mux.HandleFunc("POST /partials/operator/products/{productID}/prices/{priceID}/make-default", h.requireOperatorRole(h.MakeDefaultPrice))
mux.HandleFunc("POST /partials/operator/products/{productID}/prices/{priceID}/deactivate", h.requireOperatorRole(h.DeactivatePrice))
// Plan ladder management. The edit form and tier manager live on the ladder
// composite page (GET /operator/plan-ladders/{ladderID}); the routes below
// back the in-page forms.
mux.HandleFunc("POST /partials/operator/plan-ladders", h.requireOperatorRole(h.CreatePlanLadder))
mux.HandleFunc("PUT /partials/operator/plan-ladders/{ladderID}", h.requireOperatorRole(h.UpdatePlanLadder))
mux.HandleFunc("DELETE /partials/operator/plan-ladders/{ladderID}", h.requireOperatorRole(h.DeletePlanLadder))
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers", h.requireOperatorRole(h.CreatePlanLadderTier))
// Tier reordering within a ladder is a two-step flow: dropping a dragged row
// POSTs the full pending product order to the read-only preview (pending
// ranks + consequences of the pending rank 0), and the commit re-sequences
// ranks (two-phase, collision-free) and enacts any required outgoing-default
// dispositions before re-rendering the ladder detail body.
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder/preview", h.requireOperatorRole(h.PreviewPlanLadderTiersReorder))
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder", h.requireOperatorRole(h.ReorderPlanLadderTiers))
// Removing a tier with live holders is also two-step: Remove renders a
// read-only preview (holders classified by position source), and the
// commit deletes + renumbers + reconciles every holder in one atomic
// transaction. The plain DELETE below remains the holder-less modal path.
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview", h.requireOperatorRole(h.PreviewPlanLadderTierRemoval))
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove", h.requireOperatorRole(h.CommitPlanLadderTierRemoval))
mux.HandleFunc("DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}", h.requireOperatorRole(h.DeletePlanLadderTier))
mux.HandleFunc("GET /partials/operator/plan-ladders/validation", h.requireOperatorRole(h.GetPlanLadderValidation))
// Domain moderation. The only write on the operator Domains surface:
// force-release frees a name whatever workspace holds it, through the
// registry's own lock and placement guard (operator_domains.go).
mux.HandleFunc("POST /partials/operator/domains/{claimID}/force-release", h.requireOperatorRole(h.ForceReleaseClaim))
// Ladder display reordering lives on the topology overview (the one surface
// where column order is visible): a drag-and-drop on the column headers POSTs
// the full new order, which re-sequences sort_order and re-renders the grid.
mux.HandleFunc("POST /partials/operator/plan-ladders/reorder", h.requireOperatorRole(h.ReorderPlanLadders))
}
// requireOperatorRole is middleware that checks for the operator role
func (h *OperatorPartialsHandler) requireOperatorRole(next http.HandlerFunc) http.HandlerFunc {
return RequireOperatorRole(h.AuthConfig, h.Logger, next)
}
// PersonViewModel represents a person for operator template rendering
type PersonViewModel struct {
PersonID string
DisplayName string
Email string
Status string
CreatedAt string
}
// PersonDetailData holds data for the operator_person_detail.html template
// (singular — per design D4 persons is lookup-only, no list view).
type PersonDetailData struct {
Person PersonViewModel
Memberships []PersonMembershipViewModel
Error string
}
// PersonMembershipViewModel represents one org the person belongs to, with
// the role they hold there. Each row links to /operator/organizations/{orgID}
// on the person-detail page (closes the deferred 3.6 memberships gap from
// operator-mpa-conversion).
type PersonMembershipViewModel struct {
OrgID string
OrgName string
OrgSlug string
RoleName string
JoinedAt string
}
// loadPersonDetailData hydrates a single person by ID. Used by the MPA
// page handler (GetPersonPage). Returns an error-shaped data struct when
// the person is missing so the template renders the alert banner.
func (h *OperatorPartialsHandler) loadPersonDetailData(r *http.Request, personID string) PersonDetailData {
p, err := h.IdentityQ.GetPersonByID(r.Context(), personID)
if err != nil {
h.Logger.Warn("person not found", slog.String("person_id", personID), slog.Any("error", err))
return PersonDetailData{Error: "Person not found"}
}
data := PersonDetailData{
Person: PersonViewModel{
PersonID: p.PersonID,
DisplayName: p.DisplayName,
Email: p.PrimaryEmail,
Status: p.Status,
CreatedAt: p.CreatedAt.Format("Jan 2, 2006"),
},
}
if memberships, err := h.OrgQ.ListActiveMembershipsByPersonID(r.Context(), personID); err == nil {
data.Memberships = make([]PersonMembershipViewModel, len(memberships))
for i, m := range memberships {
data.Memberships[i] = PersonMembershipViewModel{
OrgID: m.OrgID,
OrgName: m.OrgName,
OrgSlug: m.OrgSlug,
RoleName: m.RoleName,
JoinedAt: m.JoinedAt.Format("Jan 2, 2006"),
}
}
}
return data
}
// OrganizationViewModel represents an organization for operator template rendering
type OrganizationViewModel struct {
OrgID string
Name string
Slug string
OrgType string
Status string
MemberCount int
CreatedAt string
// OwnerName is the guaranteed owner's display name (organizations.owner_person_id
// is NOT NULL), so a row never reads as unowned (ux-honest-surfaces UX-16).
OwnerName string
// IsSystemOrg marks the reserved System tenant row (org_type = systemtenant.OrgType)
// so it carries a visible synthetic marker instead of inviting ordinary
// member-org actions.
IsSystemOrg bool
}
// OrganizationsData holds data for the organizations list partial
type OrganizationsData struct {
Organizations []OrganizationViewModel
Error string
// Nav is the list-controls view model (operator-list-scale): search,
// the org-type facet, and the true-total pager. Its Filtered() decides
// whether an empty Organizations slice renders the no-match state or
// the page's true-empty state.
Nav ListNav
}
// Helper functions
func (h *OperatorPartialsHandler) renderError(w http.ResponseWriter, tmplName string, message string) {
data := struct{ Error string }{Error: message}
h.Templates.Render(w, tmplName, data)
}
// GrantViewModel represents a grant for operator template rendering.
//
// DeliveryState is the operationally-meaningful classification derived by
// joining the grant to its pool_provisions / pool_provision_ladders (the
// shared ListGrantsWithDelivery query — ux-honest-surfaces design decision
// 1, "one query, two consumers"):
//
// "live" -> at least one linked provision is status='active'
// "superseded" -> not live, and a later grant is on record as its
// replacement (ReplacedByGrantID is set)
// "inactive" -> not live and not superseded: revoked/expired with no
// recorded successor, or a grant that never delivered
//
// This is the single vocabulary and derivation both the grants index
// (/operator/grants) and the org-detail composite render from, so the two
// surfaces can never disagree about what these words mean. Grants are an
// issuance ledger (grants.status, Status below); DeliveryState is the
// operational fact and is intentionally kept separate — see
// docs/models/entitlements.md.
type GrantViewModel struct {
GrantID string
OrgName string
ProductName string
EntitlementSetName string
Quantity int32
GrantReason string
Status string
CreatedAt string
DeliveryState string
ActivatedAt string
EndedAt string
// ReplacedByGrantID / ReplacedByLabel identify the grant that
// superseded this one via extends_grant_id, set only when
// DeliveryState is "superseded" — the lineage a superseded row must
// show so it never reads as an unexplained non-live grant.
ReplacedByGrantID string
ReplacedByLabel string
// ExtendsGrantID / ExtendsLabel name this grant's own ancestor, set
// when this grant was itself issued as a replacement for an earlier
// one — the other end of the same lineage link.
ExtendsGrantID string
ExtendsLabel string
}
// GrantsData holds data for the grants management partial.
type GrantsData struct {
Grants []GrantViewModel
Organizations []OrganizationViewModel
Products []ProductViewModel
EntitlementSets []EntitlementSetOption
Success string
Error string
// Nav is the shared list-controls view model (operator-list-scale).
// loadGrantsListData stashes the query's true total on Nav.Total so
// the FetchPage caller (GetGrantsPage, operator_pages.go) can read it
// back off the returned GrantsData; GetGrantsPage then fills in the
// rest of Nav (BasePath, facet vocabulary, current Q/Facet/Page).
Nav ListNav
}
// ProductViewModel represents a product for the grant creation form.
type ProductViewModel struct {
ProductID string
Name string
DisplayCategory string
}
// Removed in M7e Slice D: legacy CreateGrant + RevokeGrant + renderGrantsPage
// handlers backed the global Grants page's mutation forms. The composite is
// now the sole UI entry point for grant actions (operator-panel-navigation
// spec); the composite-side handlers CreateNonPlanGrant + IssueGrant +
// RevokeGrantAndTransition cover every UI flow. The e2e test was rewritten
// to exercise those instead. loadGrantsListData (below) is retained — the
// read-only MPA page GetGrantsPage still uses it.
// loadGrantsListData hydrates the read-only, paginated grants list for the
// MPA page handler (GetGrantsPage). Errors are recorded on data.Error so
// the template renders a single banner.
//
// params carries the current search/facet/page state (operator-list-scale).
// The query itself is ListGrantsWithDeliveryPage (design D4): a paginated,
// searchable, filterable sibling of the shared ListGrantsWithDelivery
// query the org-detail composite still uses unpaged. Search matches the
// granted-to organization's name in SQL; product-name search cannot join
// into the billing schema from the entitlements module, so matchingProductIDs
// pre-resolves candidate product IDs in Go and passes them as the query's
// product_ids criterion. Delivery-state filtering happens in the query's
// HAVING clause on the identical CASE expression ListGrantsWithDelivery
// uses, so the SQL filter can never disagree with the Go derivation below.
//
// The rows are adapted (toDeliveryRows) to the shape buildGrantViewModels
// already consumes so the paged query and ListGrantsWithDelivery render
// through the exact same view-model derivation (design decision 1, "one
// query, two consumers" — now "one derivation, two queries").
func (h *OperatorPartialsHandler) loadGrantsListData(r *http.Request, params ListParams, success string, errMsg string) GrantsData {
data := GrantsData{
Success: success,
Error: errMsg,
}
if errMsg != "" {
return data
}
queryParams := entitlements.ListGrantsWithDeliveryPageParams{
PageLimit: params.Limit(),
PageOffset: params.Offset(),
}
if params.Q != "" {
queryParams.Q = sql.NullString{String: params.Q, Valid: true}
queryParams.ProductIds = h.matchingProductIDs(r, params.Q)
}
if params.Facet != "" {
queryParams.DeliveryState = sql.NullString{String: params.Facet, Valid: true}
}
rows, err := h.EntitlementsQ.ListGrantsWithDeliveryPage(r.Context(), queryParams)
if err != nil {
h.Logger.Error("failed to list grants", slog.Any("error", err))
data.Error = "Failed to load grants"
return data
}
if len(rows) > 0 {
data.Nav.Total = rows[0].TotalCount
}
data.Grants = h.buildGrantViewModels(r, toDeliveryRows(rows), true)
return data
}
// matchingProductIDs resolves the product IDs whose name case-insensitively
// contains q. The grants module (internal/entitlements) cannot query the
// billing schema directly, so ListGrantsWithDeliveryPage's product-name
// search criterion rides on IDs resolved here rather than a SQL join into
// core.products. ListAllProducts (not just published/active products) is
// used because a grant issued while its product was published must stay
// searchable by name after the product is later unpublished. Returns nil
// (not an error) on a query failure — search degrades to org-name-only
// rather than failing the whole page.
func (h *OperatorPartialsHandler) matchingProductIDs(r *http.Request, q string) []string {
products, err := h.BillingQ.ListAllProducts(r.Context())
if err != nil {
h.Logger.Error("failed to list products for grants search", slog.Any("error", err))
return nil
}
needle := strings.ToLower(q)
var ids []string
for _, p := range products {
if strings.Contains(strings.ToLower(p.Name), needle) {
ids = append(ids, p.ProductID)
}
}
return ids
}
// toDeliveryRows adapts ListGrantsWithDeliveryPage's rows (which carry the
// extra TotalCount window-function column) to entitlements.ListGrantsWithDeliveryRow,
// the shape buildGrantViewModels already consumes — the two queries share
// every column except TotalCount, by construction (design D4: the paged
// query must never let its derivation drift from the shared one).
func toDeliveryRows(rows []entitlements.ListGrantsWithDeliveryPageRow) []entitlements.ListGrantsWithDeliveryRow {
out := make([]entitlements.ListGrantsWithDeliveryRow, len(rows))
for i, row := range rows {
out[i] = entitlements.ListGrantsWithDeliveryRow{
GrantID: row.GrantID,
GrantedToOrgID: row.GrantedToOrgID,
ProductID: row.ProductID,
GrantReason: row.GrantReason,
Quantity: row.Quantity,
GrantStatus: row.GrantStatus,
CreatedAt: row.CreatedAt,
ExtendsGrantID: row.ExtendsGrantID,
DeliveryState: row.DeliveryState,
ReplacedByGrantID: row.ReplacedByGrantID,
ActivatedAt: row.ActivatedAt,
EndedAt: row.EndedAt,
}
}
return out
}
// buildGrantViewModels converts rows from the shared ListGrantsWithDelivery
// query into GrantViewModels, resolving organization/product names and the
// supersession-lineage labels (ReplacedBy / Extends). Shared by the grants
// index (rows for every org, resolveOrgName=true) and the org-detail
// composite (rows scoped to one org, resolveOrgName=false — the org is
// already known and named once on the page) so both surfaces render the
// exact same vocabulary from the exact same derivation (design decision 1,
// "one query, two consumers": two hand-rolled derivations would drift).
//
// Lineage labels resolve from the same result set (a map keyed by
// grant_id) rather than issuing another query per lineage link — the
// replacing/extended grant is almost always present in the same rows
// already loaded for this surface.
func (h *OperatorPartialsHandler) buildGrantViewModels(r *http.Request, rows []entitlements.ListGrantsWithDeliveryRow, resolveOrgName bool) []GrantViewModel {
byID := make(map[string]entitlements.ListGrantsWithDeliveryRow, len(rows))
for _, g := range rows {
byID[g.GrantID] = g
}
productNames := map[string]string{}
productName := func(productID string) string {
if productID == "" {
return ""
}
if name, ok := productNames[productID]; ok {
return name
}
name := ""
if p, err := h.BillingQ.GetProductByID(r.Context(), productID); err == nil {
name = p.Name
}
productNames[productID] = name
return name
}
// lineageLabel names the grant at the other end of a lineage link for
// display (falls back to a short grant-id fragment when its product
// can't be resolved, e.g. it fell outside the loaded row set).
lineageLabel := func(grantID string) string {
if g, ok := byID[grantID]; ok {
if name := productName(g.ProductID); name != "" {
return name
}
}
if len(grantID) >= 8 {
return "grant " + grantID[:8]
}
return "grant " + grantID
}
out := make([]GrantViewModel, 0, len(rows))
for _, g := range rows {
orgName := ""
if resolveOrgName && g.GrantedToOrgID.Valid {
if org, err := h.OrgQ.GetOrganizationByID(r.Context(), g.GrantedToOrgID.UUID.String()); err == nil {
orgName = org.Name
}
}
// sqlc emits interface{} for the MAX(...) aggregate columns since
// nullability is lost through the aggregate/GROUP BY (pq/pgx return
// time.Time for timestamps, string for the text-cast lineage id).
activatedAt := ""
if t, ok := g.ActivatedAt.(time.Time); ok {
activatedAt = t.Format("Jan 2, 2006 15:04")
}
endedAt := ""
if t, ok := g.EndedAt.(time.Time); ok {
endedAt = t.Format("Jan 2, 2006 15:04")
}
replacedByID := ""
if v, ok := g.ReplacedByGrantID.(string); ok {
replacedByID = v
}
extendsID := ""
if g.ExtendsGrantID.Valid {
extendsID = g.ExtendsGrantID.UUID.String()
}
vm := GrantViewModel{
GrantID: g.GrantID,
OrgName: orgName,
ProductName: productName(g.ProductID),
Quantity: g.Quantity,
GrantReason: g.GrantReason,
Status: g.GrantStatus,
CreatedAt: g.CreatedAt.Format("Jan 2, 2006"),
DeliveryState: g.DeliveryState,
ActivatedAt: activatedAt,
EndedAt: endedAt,
}
if replacedByID != "" {
vm.ReplacedByGrantID = replacedByID
vm.ReplacedByLabel = lineageLabel(replacedByID)
}
if extendsID != "" {
vm.ExtendsGrantID = extendsID
vm.ExtendsLabel = lineageLabel(extendsID)
}
out = append(out, vm)
}
return out
}