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
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 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 }