// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "bytes" "context" "database/sql" "errors" "html/template" "io/fs" "log/slog" "net/http" "regexp" "sort" "strings" "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/embeds" "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/identity" "git.coopcloud.tech/wiki-cafe/member-console/internal/instance" "git.coopcloud.tech/wiki-cafe/member-console/internal/integration" 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" ) // OperatorHandler handles the main operator page type OperatorHandler struct { AuthConfig *auth.Config Logger *slog.Logger Templates *SafeTemplates Database *sql.DB // for the registry-driven Integration nav on the landing surface BillingQ billing.Querier EntitlementsQ entitlements.Querier OrgQ organization.Querier IdentityQ identity.Querier // IntegrationQ backs the landing surface's System panel (registered // providers + outbox health). Derived from Database in the constructor; // a separate field so tests can inject a fake without a live database. IntegrationQ integration.Querier // IntegrationConfigs backs the System panel's configuration-readiness // signal (configurationReadiness) — the same declared-config list the // Integrations list and the per-integration settings page read. IntegrationConfigs []IntegrationConfigInfo // StripeQ backs the setup checklist's Stripe-price-mapping predicate // (AnyMappedPrice), composed with BillingQ.AnyActivePrice to derive the // conditional "sync a price to Stripe" step (setup_state.go). Derived // from Database in the constructor, matching IntegrationQ. StripeQ stripedb.Querier // InstanceSettings backs the setup banner's dismissal (design D12/D28): // DeriveSetupState reads SetupBannerDismissed through it. Derived from // Database in the constructor, matching IntegrationQ and StripeQ; nil // degrades to "never dismissed" rather than panicking (tests that build // an OperatorHandler by hand, as most do here, don't need to wire it). InstanceSettings *instance.Store // StripeMode is the mode derived from the Stripe API key at boot // ("test", "live", or "" when no key is configured); the overview's // Stripe row labels it (see Config.StripeMode). StripeMode string } // OperatorHandlerConfig holds configuration for the operator handler type OperatorHandlerConfig struct { AuthConfig *auth.Config Logger *slog.Logger Database *sql.DB BillingQ billing.Querier EntitlementsQ entitlements.Querier OrgQ organization.Querier IdentityQ identity.Querier IntegrationConfigs []IntegrationConfigInfo StripeMode string } // NewOperatorHandler creates a new OperatorHandler func NewOperatorHandler(cfg OperatorHandlerConfig) (*OperatorHandler, error) { // Parse operator template templateSubFS, err := fs.Sub(embeds.Templates, "templates") if err != nil { return nil, err } // operator.html references the `renderBody` template func used by the // MPA page handlers' BodyTemplate dispatch. This handler now uses it too // (GetSetupPage, operator_setup.go), so — unlike the former no-op stub — // it must actually dispatch: look up the named template inside this same // set and render it. The closure captures `tmpl` (assigned below); funcs // must be installed before parsing so the parser accepts references to // "renderBody", so we pre-construct the empty template, install the // func, then ParseFS. Mirrors NewOperatorPartialsHandler's renderBody. // Also parses the lookup-result partial — operator.html `{{ template }}`s // it inline, and the OperatorLookup handler renders it standalone for // HTMX swap responses — and the setup-checklist partial, shared by the // landing region and GetSetupPage's BodyTemplate dispatch. 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 }, "deploymentName": config.DeploymentName, "supportURL": config.SupportURL, "helpIcon": helpIcon, "pageTitle": pageTitle, }) tmpl, err = tmpl.ParseFS(templateSubFS, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html") if err != nil { return nil, err } if tmpl, err = web.ParseUIPartials(tmpl); err != nil { return nil, err } // The operator surface's root crumb (design D18); overrides // web.ParseUIPartials's no-root-crumb default for this set. Must be // registered after ParseUIPartials returns to win over its default // registration of the same name (html/template resolves a function // call against the set's current function map at execution time). tmpl = tmpl.Funcs(template.FuncMap{"surfaceRoot": OperatorSurfaceRoot}) return &OperatorHandler{ AuthConfig: cfg.AuthConfig, Logger: cfg.Logger, Templates: NewSafeTemplates(tmpl, cfg.Logger), Database: cfg.Database, BillingQ: cfg.BillingQ, EntitlementsQ: cfg.EntitlementsQ, OrgQ: cfg.OrgQ, IdentityQ: cfg.IdentityQ, IntegrationQ: integration.New(cfg.Database), IntegrationConfigs: cfg.IntegrationConfigs, StripeQ: stripedb.New(cfg.Database), InstanceSettings: instance.NewStore(cfg.Database), StripeMode: cfg.StripeMode, }, nil } // RegisterRoutes registers operator routes func (h *OperatorHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /operator", h.requireOperatorRole(h.GetOperatorPage)) // The lookup is a search form (spec operator-panel-navigation): a // native GET with a true no-JS fallback, never a mutation route. mux.HandleFunc("GET /operator/lookup", h.requireOperatorRole(h.OperatorLookup)) mux.HandleFunc("GET /operator/setup", h.requireOperatorRole(h.GetSetupPage)) } // requireOperatorRole is middleware that checks for the operator role func (h *OperatorHandler) requireOperatorRole(next http.HandlerFunc) http.HandlerFunc { return RequireOperatorRole(h.AuthConfig, h.Logger, next) } // RequireOperatorRole wraps next so it only runs for a request whose session // holds the operator-member role; otherwise it responds 403 and logs the // denial. Exported (beyond the OperatorHandler/OperatorPartialsHandler // methods that delegate to it below) so an integration registering its own // operator surface — design.md Decision 8; e.g. // internal/integrations/fedwiki/web's read-only sites page — gates it with // the same check core's operator pages use, instead of duplicating the // role check inline. func RequireOperatorRole(authConfig *auth.Config, logger *slog.Logger, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !authConfig.HasRole(r, OperatorRole) { logger.Warn("operator access denied", slog.String("path", r.URL.Path), slog.String("reason", "missing operator-member role")) http.Error(w, "Forbidden", http.StatusForbidden) return } next(w, r) } } // OperatorPageData holds data for the operator page template. type OperatorPageData struct { Name string Username string Email string KeycloakAccountURL string // IAPosition is the positional tuple `:[:]` // declared by every operator page per the breadcrumb requirement. The // landing surface declares `runtime:landing` since it sits in the // runtime group as the curated hot-path entry point. IAPosition string // ActiveCapability is the sidebar-link key the current page belongs to // ("organizations", "grants", ...). Drives the sidebar `active` class. // Empty on the landing surface (Overview light up). ActiveCapability string // BodyTemplate names the partial template to render inside
. When // non-empty the operator.html shell dispatches via renderBody; when // empty it renders the curated landing surface. BodyTemplate string BodyData any // Activity is the unified-timeline rows for the landing surface's // "Recent activity" section (operator-composite-expansion D4), windowed // to ActivityNav's current page. Populated only on the landing branch; // nil/empty on MPA pages. Activity []ActivityEvent // ActivityNav pages the activity feed like the composite's Tier changes // (overview-consistency D5, round 2): loadRecentActivity returns the // whole merged window and the landing handler pages it with // pageSliceClamped, replacing the old ?limit parameter. ActivityNav ListNav // Overview holds the landing surface's "At a glance" counts and "System" // signals. Populated only on the landing branch; the zero value renders // the surface's empty states, which is what MPA pages get. Overview OverviewData // Setup is the setup checklist's derived state. The landing surface // reads it for the dismissible summary banner (rendered ahead of the // lookup, only while Setup.ShowBanner); GetSetupPage (operator_setup.go) // reads it for the full checklist page. Populated only on the landing // branch; the zero value's ShowBanner is false, so MPA pages render no // banner (they have none to report). Setup SetupState // Lookup state for the landing-surface lookup affordance. LookupTerm // retains the operator's input on re-render so they can edit and // re-submit; LookupCandidates / LookupOrgCandidates are populated on // ambiguous matches; LookupNoMatch is set on zero-match. Unique // matches don't end up here — they 303-redirect to the resolved // detail page. LookupTerm string LookupCandidates []LookupCandidateViewModel LookupOrgCandidates []LookupOrgCandidateViewModel LookupInvoiceCandidates []LookupInvoiceCandidateViewModel LookupNoMatch bool // LookupForm is the lookup declaration bound to LookupTerm // (form-library, spec operator-panel-navigation "The landing surface's // lookup is a search form"). LookupForm forms.FormView // FlashSuccess carries a create-then-land confirmation (design D20; ACC // round 2, review item 31/33: "a success toast, not a banner") into the // shell's persistent #successToast, server-rendered so it shows on this // page's very first load — a fresh top-level navigation from the // create panel's HX-Redirect, not an htmx-driven exchange the ordinary // HX-Trigger toast convention (fireSuccessToast) could reach. Set only // for a ?flash=created landing; empty otherwise, leaving the toast at // its default "Done." body and unshown. FlashSuccess string } // LookupCandidateViewModel represents one candidate person in the // disambiguation list rendered when a lookup term matches multiple // persons by name substring. type LookupCandidateViewModel struct { PersonID string DisplayName string Email string } // LookupOrgCandidateViewModel represents one candidate organization in the // disambiguation list rendered when a lookup term matches no person but // several organizations by name substring (design D2). type LookupOrgCandidateViewModel struct { OrgID string Name string OrgType string OwnerName string } // LookupInvoiceCandidateViewModel represents one candidate invoice in the // disambiguation list rendered when a lookup term matches exactly one // invoice number shared by several invoices across billing accounts — an // invoice number is unique only per billing account (operator-panel- // navigation design D4), so several organizations can carry the same one. type LookupInvoiceCandidateViewModel struct { InvoiceID string InvoiceNumber string OrgID string OrgName string IssuedAt string } // lookupClass names one record class the landing-surface lookup affordance // can resolve. It serves two purposes that must never drift apart (design // D4): the no-match sentence names every class the registry actually // resolves (lookupNoMatchClasses, generated from this slice so it can never // disagree with the code), and TestLookupRegistryCoversManifest asserts // every detail route in the capture manifest (test/e2e/screens/manifest.go) // has an entry here, so a record class joins the set the day it gains a // page. LinkPrefix matches that manifest's Instance.LinkPrefix for the // class's detail route. // // Order here is the sentence's reading order (People, Organizations, the // Products group in its own page-header order, then Billing) — a separate // concern from resolveLookupTerm's resolution PRECEDENCE (exact key -> // exact invoice number -> persons -> organizations, entity-keys §5 / // design D4), which decides which class wins when a term could plausibly // resolve more than one way. type lookupClass struct { ClassName string LinkPrefix string } // lookupRegistry is the ordered set of record classes the lookup affordance // resolves. See lookupClass's doc comment for what "ordered" means here. var lookupRegistry = []lookupClass{ {ClassName: "person", LinkPrefix: "/operator/persons/"}, {ClassName: "organization", LinkPrefix: "/operator/organizations/"}, {ClassName: "product", LinkPrefix: "/operator/products/"}, {ClassName: "entitlement set", LinkPrefix: "/operator/entitlement-sets/"}, {ClassName: "plan ladder", LinkPrefix: "/operator/plan-ladders/"}, {ClassName: "invoice", LinkPrefix: "/operator/billing/invoices/"}, } // lookupNoMatchClasses renders "person, organization, product, entitlement // set, plan ladder, or invoice" from lookupRegistry (design D4: "the // no-match sentence SHALL name the classes, generated from the resolver // registry so it cannot disagree with the code"). func lookupNoMatchClasses() string { names := make([]string, len(lookupRegistry)) for i, c := range lookupRegistry { names[i] = c.ClassName } return joinWithOr(names) } // joinWithOr renders a natural-language list with no Oxford-comma // ambiguity: "a", "a or b", "a, b, or c". func joinWithOr(items []string) string { switch len(items) { case 0: return "" case 1: return items[0] case 2: return items[0] + " or " + items[1] default: return strings.Join(items[:len(items)-1], ", ") + ", or " + items[len(items)-1] } } // ActivityEvent is one row in the landing-surface unified timeline. Each // row identifies its event type, the org (and person, when applicable), // a short human summary, and a precomputed link target. Per D4 the // template branches on EventType to render the right badge/icon — the // loader doesn't pick visual styles, it picks data. type ActivityEvent struct { EventType string // "grant_issued" | "transition" | "invoice_created" | "payment_received" Timestamp string // pre-formatted display string ("Jan 2, 2006 3:04 PM") SortKey int64 // unix nanos, used to sort the merged slice OrgID string OrgName string PersonID string // empty when no person attribution (system/webhook actor or billing event) PersonName string Summary string // human-readable description of what happened // Href is the entry's own addressable operator surface (D8/task 7.4: // "feed entries link to their subjects where addressable"): the org // detail page for a grant or transition, the billing view for an // invoice or payment. Empty when the entry has no addressable surface — // the template renders those entries as plain text, never a dead link. Href string } // GetOperatorPage handles GET /operator func (h *OperatorHandler) GetOperatorPage(w http.ResponseWriter, r *http.Request) { // Permanent redirect for legacy ?tab= bookmarks. Slated for // removal in M8 once operator analytics show no remaining hits. if tab := r.URL.Query().Get("tab"); tab != "" { if redirect, ok := legacyTabRedirects[tab]; ok { http.Redirect(w, r, redirect, http.StatusMovedPermanently) return } } h.renderLanding(w, r, "", nil, nil, nil, false) } // lookupOrgSearchLimit bounds the organization-name disambiguation list // (design D2): a short substring term can otherwise match a large slice of // the table. const lookupOrgSearchLimit = 20 // OperatorLookup handles POST /operator/lookup — resolves the operator's // typed lookup term to a person detail, an org composite, a catalog detail // page, an invoice detail, a disambiguation listing, or a no-match notice. // The resolvers form one ordered registry (design D4), tried in this order // so a typed identifier always wins over a substring: // 1. exact key match, when the term is shaped like a key at all // (entity-keys §5: "operator lookup matches keys exactly, after IDs and // before names") — organization, plan ladder, entitlement set, product // (lookupByKey). Keys are exact and unique, so a match is never // ambiguous. // 2. exact invoice number (GetInvoicesByNumber). A number is unique only // per billing account, so several invoices across organizations can // share one — one match → HX-Redirect to the invoice detail; several → // the disambiguation list (number, organization, issued date, each // linking to its detail). // 3. email-exact + name-substring against active persons (one query); if // one match → HX-Redirect to person detail; if many → render the // result partial with the disambiguation list (HTMX swap). // 4. only when no person matched: name-substring against active // organizations (SearchOrganizationsByName, bounded limit); one match // → HX-Redirect to the org composite; several → render the result // partial with organization candidates (name, type, owner, each // linking to its composite). // 5. otherwise → render the result partial with a no-match notice naming // every class lookupRegistry resolves (lookupNoMatchClasses). // // Persons resolve before organization names because every personal // organization's name contains its owner's display name, so a bare name // match must land on the person (design D2). Keys and invoice numbers // resolve before both: they are what a seed, an invoice, a configuration // file, or a script names a row by, so an operator typing one is naming // that row and not describing it. // // HTMX (hx-post on the form) handles the X-CSRF-Token header via the // body's hx-headers; this is the same CSRF path every other operator // form uses. Native form POST hit a brittle Origin-check path in // gorilla CSRF for some browsers (origin=null on certain submissions), // so we route through HTMX instead. func (h *OperatorHandler) OperatorLookup(w http.ResponseWriter, r *http.Request) { values, _ := operatorLookupForm.Parse(r) term := strings.TrimSpace(values.String("term")) // htmx issues the scoped GET declared on the form; a native submission // (no JavaScript) carries no such header and gets the full landing // surface back, the same destination the scripted path reaches (spec // operator-panel-navigation, "The lookup resolves without JavaScript"). htmxReq := r.Header.Get("HX-Request") == "true" if term == "" { if htmxReq { h.renderLookupResult(w, "", nil, nil, nil, false) return } h.renderLanding(w, r, "", nil, nil, nil, false) return } res := h.resolveLookup(r.Context(), term) if res.RedirectPath != "" { if htmxReq { w.Header().Set("HX-Redirect", res.RedirectPath) w.WriteHeader(http.StatusOK) return } http.Redirect(w, r, res.RedirectPath, http.StatusSeeOther) return } if htmxReq { h.renderLookupResult(w, term, res.Candidates, res.OrgCandidates, res.InvoiceCandidates, res.NoMatch) return } h.renderLanding(w, r, term, res.Candidates, res.OrgCandidates, res.InvoiceCandidates, res.NoMatch) } // lookupResult is what resolveLookup produces for one term: exactly one of // a redirect (an unambiguous match), a candidate list (several matches, of // one kind), or NoMatch. type lookupResult struct { RedirectPath string Candidates []LookupCandidateViewModel OrgCandidates []LookupOrgCandidateViewModel InvoiceCandidates []LookupInvoiceCandidateViewModel NoMatch bool } // resolveLookup runs the resolver registry (this function's doc lives on // OperatorLookup above) and returns the outcome without rendering it, so // the htmx-scoped fragment response and the native full-page response // (operator-panel-navigation's no-JS fallback) share one resolution. func (h *OperatorHandler) resolveLookup(ctx context.Context, term string) lookupResult { if path := h.lookupByKey(ctx, term); path != "" { return lookupResult{RedirectPath: path} } if h.BillingQ != nil { invoices, err := h.BillingQ.GetInvoicesByNumber(ctx, sql.NullString{String: term, Valid: true}) if err != nil { h.Logger.Warn("lookup: invoices query failed", slog.Any("error", err)) } switch len(invoices) { case 1: return lookupResult{RedirectPath: "/operator/billing/invoices/" + invoices[0].InvoiceID} case 0: // Fall through to the person check. default: candidates := make([]LookupInvoiceCandidateViewModel, len(invoices)) for i, inv := range invoices { orgName := inv.OrgID if org, err := h.OrgQ.GetOrganizationByID(ctx, inv.OrgID); err == nil { orgName = org.Name } candidates[i] = LookupInvoiceCandidateViewModel{ InvoiceID: inv.InvoiceID, InvoiceNumber: inv.InvoiceNumber.String, OrgID: inv.OrgID, OrgName: orgName, IssuedAt: inv.CreatedAt.Format("Jan 2, 2006"), } } return lookupResult{InvoiceCandidates: candidates} } } persons, err := h.IdentityQ.LookupPersons(ctx, term) if err != nil { h.Logger.Warn("lookup: persons query failed", slog.Any("error", err)) } switch len(persons) { case 1: return lookupResult{RedirectPath: "/operator/persons/" + persons[0].PersonID} case 0: // Fall through to the organization-name check. default: candidates := make([]LookupCandidateViewModel, len(persons)) for i, p := range persons { candidates[i] = LookupCandidateViewModel{ PersonID: p.PersonID, DisplayName: p.DisplayName, Email: p.PrimaryEmail, } } return lookupResult{Candidates: candidates} } orgs, err := h.OrgQ.SearchOrganizationsByName(ctx, organization.SearchOrganizationsByNameParams{ Name: term, RowLimit: lookupOrgSearchLimit, }) if err != nil { h.Logger.Warn("lookup: organizations query failed", slog.Any("error", err)) } switch len(orgs) { case 1: return lookupResult{RedirectPath: "/operator/organizations/" + orgs[0].OrgID} case 0: // Fall through to no-match. default: orgCandidates := make([]LookupOrgCandidateViewModel, len(orgs)) for i, o := range orgs { orgCandidates[i] = LookupOrgCandidateViewModel{ OrgID: o.OrgID, Name: o.Name, OrgType: o.OrgType, OwnerName: o.OwnerDisplayName.String, } } return lookupResult{OrgCandidates: orgCandidates} } return lookupResult{NoMatch: true} } // entityKeyGrammar is the ratified key grammar (entity-keys §4), the same // pattern every `chk_*_key_grammar` CHECK constraint carries. A term that // cannot be a key is not looked up as one, so an ordinary name search costs // no extra queries. var entityKeyGrammar = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) // entityKeyMaxLength is the length half of the same contract. const entityKeyMaxLength = 64 // lookupByKey resolves an exact key match across the four keyed entities an // operator can reach a detail page for, and returns the path to redirect to // (empty when nothing matches). A key is unique within its scope and all four // of these are root-scoped, so at most one row per entity can match; the // entities are searched in a fixed order and the first hit wins. A NULL key // never matches, because the comparison is `=`. // // Every query failure other than "no rows" is logged and treated as no match: // lookup degrades to the name search rather than showing the operator an // error for a term that was probably a name anyway. A nil querier (a handler // built without the catalog queries, as some tests do) skips its entities. func (h *OperatorHandler) lookupByKey(ctx context.Context, term string) string { if len(term) > entityKeyMaxLength || !entityKeyGrammar.MatchString(term) { return "" } key := sql.NullString{String: term, Valid: true} noteErr := func(what string, err error) bool { if err == nil { return false } if !errors.Is(err, sql.ErrNoRows) { h.Logger.Warn("lookup: key query failed", slog.String("entity", what), slog.Any("error", err)) } return true } if h.OrgQ != nil { org, err := h.OrgQ.GetOrganizationByKey(ctx, key) if !noteErr("organization", err) { return "/operator/organizations/" + org.OrgID } } if h.BillingQ != nil { ladder, err := h.BillingQ.GetPlanLadderByKey(ctx, key) if !noteErr("plan ladder", err) { return "/operator/plan-ladders/" + ladder.PlanLadderID } } if h.EntitlementsQ != nil { set, err := h.EntitlementsQ.GetEntitlementSetByKey(ctx, key) if !noteErr("entitlement set", err) { return "/operator/entitlement-sets/" + set.SetID } } if h.BillingQ != nil { product, err := h.BillingQ.GetProductByKey(ctx, key) if !noteErr("product", err) { return "/operator/products/" + product.ProductID } } return "" } // renderLookupResult renders the lookup-result partial for HTMX swap. // `term` populates the no-match alert / disambiguation header; // `candidates` drives the person picker; `orgCandidates` drives the // organization picker; `invoiceCandidates` drives the invoice picker (the // three pickers are mutually exclusive — the registry resolves key, then // invoice number, then persons, then organizations, so at most one // candidate list is ever non-empty for a given term); `noMatch` triggers // the warning alert, whose text names every class lookupRegistry resolves. func (h *OperatorHandler) renderLookupResult(w http.ResponseWriter, term string, candidates []LookupCandidateViewModel, orgCandidates []LookupOrgCandidateViewModel, invoiceCandidates []LookupInvoiceCandidateViewModel, noMatch bool) { data := struct { LookupTerm string LookupCandidates []LookupCandidateViewModel LookupOrgCandidates []LookupOrgCandidateViewModel LookupInvoiceCandidates []LookupInvoiceCandidateViewModel LookupNoMatch bool LookupNoMatchClasses string }{ LookupTerm: term, LookupCandidates: candidates, LookupOrgCandidates: orgCandidates, LookupInvoiceCandidates: invoiceCandidates, LookupNoMatch: noMatch, LookupNoMatchClasses: lookupNoMatchClasses(), } h.Templates.Render(w, "operator_lookup_result.html", data) } // renderLanding renders the operator landing surface with optional lookup // state. `lookupTerm` populates the form input on re-render so the // operator can edit and resubmit; `candidates` / `orgCandidates` / // `invoiceCandidates` trigger the disambiguation list; `noMatch` triggers // the no-match notice. All are off on a plain landing render. func (h *OperatorHandler) renderLanding(w http.ResponseWriter, r *http.Request, lookupTerm string, candidates []LookupCandidateViewModel, orgCandidates []LookupOrgCandidateViewModel, invoiceCandidates []LookupInvoiceCandidateViewModel, noMatch bool) { ctx := r.Context() // The feed pages like the composite's Tier changes (overview-consistency // D5, round 2): loadRecentActivity returns the whole merged window and // pageSliceClamped windows it to the requested page, namespaced "act_" // so it never collides with the lookup's own parameters (a different // route, /operator/lookup) or a record page's ?flash=created. activityParams := ParseListParamsNS(r, "act_", "") activityParams.PerPage = ParsePerPage(r, "act_", embeddedListDefaultPerPage) activityPage, activityTotal := pageSliceClamped(h.loadRecentActivity(ctx), &activityParams) data := OperatorPageData{ Name: h.AuthConfig.GetUserName(ctx), Username: h.AuthConfig.GetUsername(ctx), Email: h.AuthConfig.GetUserEmail(ctx), KeycloakAccountURL: config.IdPAccountURL(), IAPosition: "runtime:landing", Overview: h.loadOverview(ctx), Setup: h.DeriveSetupState(ctx), Activity: activityPage, ActivityNav: ListNav{ BasePath: "/operator", ParamPrefix: "act_", Page: activityParams.Page, Total: int64(activityTotal), PerPage: activityParams.PerPage, DefaultPerPage: embeddedListDefaultPerPage, PerPageOptions: perPageOptions, Target: "#activity-panel", }, LookupTerm: lookupTerm, LookupCandidates: candidates, LookupOrgCandidates: orgCandidates, LookupInvoiceCandidates: invoiceCandidates, LookupForm: lookupForm(lookupTerm), LookupNoMatch: noMatch, } h.Templates.Render(w, "operator.html", data) } // activityFeedSourceLimit is how many rows loadRecentActivity fetches from // each event source before merging (overview-consistency D5, round 2): the // old ?limit ceiling, now fixed rather than caller-supplied — the landing // handler pages the merged result with the list scaffold's pager // (ActivityNav) instead. const activityFeedSourceLimit = 100 // loadRecentActivity assembles the landing-surface unified timeline by // fetching activityFeedSourceLimit rows from each event source (grants, // transitions, invoices, payments), resolving org and person UUIDs to // display names, then merging by timestamp DESC. Per-source over-fetch is // correct because no single source's recent N rows are guaranteed to be // the global recent N. The full merged window is returned; the caller // pages it (renderLanding, pageSliceClamped). // // Failures from any individual source are logged and the source is // dropped — the timeline degrades gracefully rather than 500ing the // landing surface. An empty result is the legitimate empty state. func (h *OperatorHandler) loadRecentActivity(ctx context.Context) []ActivityEvent { events := make([]ActivityEvent, 0, activityFeedSourceLimit*4) if grants, err := h.EntitlementsQ.ListRecentGrants(ctx, activityFeedSourceLimit); err == nil { for _, g := range grants { orgID := "" if g.GrantedToOrgID.Valid { orgID = g.GrantedToOrgID.UUID.String() } personID := "" if g.GrantedByPersonID.Valid { personID = g.GrantedByPersonID.UUID.String() } productName := "" if p, err := h.BillingQ.GetProductByID(ctx, g.ProductID); err == nil { productName = p.Name } events = append(events, ActivityEvent{ EventType: "grant_issued", Timestamp: g.CreatedAt.Format("Jan 2, 2006 3:04 PM"), SortKey: g.CreatedAt.UnixNano(), OrgID: orgID, PersonID: personID, Summary: "Issued " + productName + " (" + g.GrantReason + ")", Href: "/operator/organizations/" + orgID, }) } } else { h.Logger.Warn("activity feed: list recent grants failed", slog.Any("error", err)) } if transitions, err := h.EntitlementsQ.ListRecentTransitions(ctx, activityFeedSourceLimit); err == nil { // ACC-26 / operator-panel-navigation "Tier events read as the // composite reads them": the summary uses the SAME vocabulary the // organization composite's Tier changes list uses — humanized verbs // (transitionChangeLabels, operator_enrollment.go) and resolved // tier product names — never the raw transition_type or a bare // integer rank ("initiate (rank — → 0, by system)"). Ladder IDs are // batch-resolved once for the whole page (ListRecentTransitions // itself carries only org_id, not plan_ladder_id); tier names are // then cached per ladder by the resolver. transitionIDs := make([]string, len(transitions)) for i, t := range transitions { transitionIDs[i] = t.TransitionID } ladderIDs := transitionLadderIDs(ctx, h.Database, h.Logger, transitionIDs) tierNames := newTierNameResolver(ctx, h.BillingQ) for _, t := range transitions { personID := "" if t.ActorID.Valid { personID = t.ActorID.UUID.String() } ladderID := ladderIDs[t.TransitionID] fromLabel := tierNames.label(ladderID, t.FromRank) toLabel := tierNames.label(ladderID, t.ToRank) events = append(events, ActivityEvent{ EventType: "transition", Timestamp: t.EffectiveAt.Format("Jan 2, 2006 3:04 PM"), SortKey: t.EffectiveAt.UnixNano(), OrgID: t.OrgID, PersonID: personID, Summary: humanizeTransitionSummary(t.TransitionType, fromLabel, toLabel), Href: "/operator/organizations/" + t.OrgID, }) } } else { h.Logger.Warn("activity feed: list recent transitions failed", slog.Any("error", err)) } if invoices, err := h.BillingQ.ListRecentInvoices(ctx, activityFeedSourceLimit); err == nil { for _, inv := range invoices { events = append(events, ActivityEvent{ EventType: "invoice_created", Timestamp: inv.CreatedAt.Format("Jan 2, 2006 3:04 PM"), SortKey: inv.CreatedAt.UnixNano(), OrgID: inv.OrgID, Summary: "Invoice " + formatCurrency(inv.AmountDue, inv.Currency) + " · " + inv.Status, Href: "/operator/billing/invoices", }) } } else { h.Logger.Warn("activity feed: list recent invoices failed", slog.Any("error", err)) } if payments, err := h.BillingQ.ListRecentPayments(ctx, activityFeedSourceLimit); err == nil { for _, p := range payments { events = append(events, ActivityEvent{ EventType: "payment_received", Timestamp: p.CreatedAt.Format("Jan 2, 2006 3:04 PM"), SortKey: p.CreatedAt.UnixNano(), OrgID: p.OrgID, Summary: "Payment " + formatCurrency(p.Amount, p.Currency) + " · " + p.Status, Href: "/operator/billing/payments", }) } } else { h.Logger.Warn("activity feed: list recent payments failed", slog.Any("error", err)) } // Merge by timestamp DESC. The full window is returned; the caller pages // it (overview-consistency D5, round 2), so no trim happens here. // Newest first; ties (events stamped in the same instant, as a seed or a // signup burst produces) break on kind, organization, and summary so the // feed's order is a function of its data, never of query arrival order. sort.SliceStable(events, func(i, j int) bool { a, b := events[i], events[j] if a.SortKey != b.SortKey { return a.SortKey > b.SortKey } if a.EventType != b.EventType { return a.EventType < b.EventType } if a.OrgName != b.OrgName { return a.OrgName < b.OrgName } if a.Summary != b.Summary { return a.Summary < b.Summary } // Total order: full ties (a pinned demo snapshot stamps whole // batches into one instant) must not fall back to query arrival // order, which synchronized sequential scans vary run to run. if a.OrgID != b.OrgID { return a.OrgID < b.OrgID } if a.PersonID != b.PersonID { return a.PersonID < b.PersonID } return a.Href < b.Href }) // Resolve org and person display names in a second pass. Per-row // lookups would be N+1 — collect distinct IDs first, then map. orgNames := make(map[string]string) personNames := make(map[string]string) for _, e := range events { if e.OrgID != "" { orgNames[e.OrgID] = "" } if e.PersonID != "" { personNames[e.PersonID] = "" } } for orgID := range orgNames { if o, err := h.OrgQ.GetOrganizationByID(ctx, orgID); err == nil { orgNames[orgID] = o.Name } } for personID := range personNames { if p, err := h.IdentityQ.GetPersonByID(ctx, personID); err == nil { personNames[personID] = p.DisplayName } } for i := range events { events[i].OrgName = orgNames[events[i].OrgID] events[i].PersonName = personNames[events[i].PersonID] } return events } // legacyTabRedirects maps retired ?tab= bookmarks to their replacement // MPA route. Scheduled for removal in M8 — see openspec change // operator-mpa-conversion task 6.2. var legacyTabRedirects = map[string]string{ "orgs": "/operator/organizations", "organizations": "/operator/organizations", "grants": "/operator/grants", "billing": "/operator/billing/accounts", "org-types": "/operator/org-types", "products": "/operator/products", "entitlement-sets": "/operator/entitlement-sets", "plan-ladders": "/operator/plan-ladders", // Persons has no browse route per design D4. tab=people bookmarks // land on the operator landing surface, where the lookup affordance // will route them to the per-person detail page. "people": "/operator", // "sites" (the legacy FedWiki sites tab) is intentionally absent: its // destination is now an integration-registered route (design.md // Decision 8; see internal/integrations/fedwiki/web), and core must // not hardcode an integration-specific path here to redirect to it // (openspec/changes/integration-extraction task 2.7). A ?tab=sites // bookmark now falls through to the landing surface below rather than // resolving to a specific redirect — acceptable since this whole map // is already slated for removal (operator-mpa-conversion task 6.2). }