Files
member-console/internal/server/operator.go
T
cgalo5758 782ca8f326 Derive Stripe mode from API key and refine disabled controls
Derive Stripe test/live mode from the API key prefix at boot, failing on
unrecognized prefixes, and drop the separate `stripe-mode` config key.

Refine disabled controls to render through the shared `disabledControl`
part with the not-allowed cursor, and add a lint rule refusing
hand-rolled disabled buttons.

Adjust plan cards to offer no purchase control on free rungs, fix bound
checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted"
with enhanced readiness verdicts.
2026-09-13 16:59:12 -05:00

888 lines
37 KiB
Go

// 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 `<group>:<capability>[:<instance>]`
// 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 <main>. 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=<id> 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=<id> 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).
}