Files
member-console/internal/server/operator.go
T
cgalo5758 340f19e21c List same-instant feed events by kind, then the later-written row first
Events of the recent-activity feed that share one timestamp now list by
kind (grant, invoice, payment, tier change, as before) and, within a kind,
the one the console recorded later first. Invoice and payment events had no
Seq, so two of them at one instant fell through to their summary text and
organization id: "$100.00" listed above "$20.00" and Zeta above Alpha
whichever was recorded later. They now carry their own row's id, as grant
and tier events already did, and activityLess breaks the tie on it, so
every event has one place in the order. The comment on ActivityEvent.Seq
and on activityLess say so. The requirement "Invoice and payment events
are placed when they happened" now states the order of events that share
one timestamp, through the change same-instant-feed-order, archived in
this commit with the delta synced into the main spec.

How it was shown to change only that: TestRecentActivityTiesListTheLaterWrittenFirst
(invoices of two organizations, invoices of one organization, rows of one
kind in any input order) fails in all three subtests at the parent and
passes here. The feed's golden diff is exactly two pairs of rows swapped in
feed.json and in feed.html: Alpha's invoice above Zeta's, and "$20.00"
above "$100.00" for the invoices of one organization. Names and results of
internal/server are equal to the parent's (1473 at the parent, 1477 here:
the new test and its three subtests), 0 failures. Four breaks (the invoice
Seq, the payment Seq, the id direction, the kind direction) are each caught
by the new test or the feed's golden. The lint snapshot is unchanged
(0 added, 0 removed). Walkthroughs are equal at the parent and here (16
pass, 6 skip); screens at the parent and here differ from the baseline only
in the masked regions, so operator-overview is unchanged: the demo feed
holds no same-kind ties.

Unprobed: payments tied in the database. The database subtests tie
invoices only; ties of payments, and of three rows of one kind, are checked
without a database through the comparator, because the tie rests on the
payment row carrying its id, which the comparator check and the payment
break cover.
2026-10-10 20:42:09 -05:00

671 lines
28 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"context"
"database/sql"
"errors"
"log/slog"
"net/http"
"regexp"
"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/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) {
// operator.html dispatches to its body template through renderBody; the
// set also holds the lookup-result and setup-checklist partials the
// landing region and GetSetupPage render.
tmpl, err := web.OperatorTemplates(web.CoreTemplates(), web.OperatorOptions{})
if err != nil {
return nil, err
}
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 windows the
// merged feed to the requested page and names that page's rows,
// 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"
When DateTime // rendered by the dateTime part, the date over the 24-hour time, as the organization page reads (D20)
SortKey int64 // unix nanos, used to sort the merged slice
// Seq orders rows of one kind stamped in the same instant by when they
// were written: the row's own id, which is time-ordered.
Seq string
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. Removal was
// deferred and is not scheduled.
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.
//
// The form submits through HTMX (hx-post), the request shape every
// operator mutation form takes (docs/operator-ux-conventions.md §2); it
// carries no CSRF token, the server's origin check admits it as
// same-origin.
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 != "" {
redirectToRecord(w, r, res.RedirectPath)
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))
names := h.names(ctx)
names.WantOrgs(idsOf(invoices, func(row billing.GetInvoicesByNumberRow) string { return row.OrgID })...)
for i, inv := range invoices {
orgName := inv.OrgID
if name, ok := names.Org(inv.OrgID); ok {
orgName = name
}
issuedAt := "—"
if inv.IssuedAt.Valid {
issuedAt = inv.IssuedAt.Time.Format("Jan 2, 2006")
}
candidates[i] = LookupInvoiceCandidateViewModel{
InvoiceID: inv.InvoiceID,
InvoiceNumber: inv.InvoiceNumber.String,
OrgID: inv.OrgID,
OrgName: orgName,
IssuedAt: issuedAt,
}
}
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 windows the merged feed to the
// requested page and names that page's rows; the parameters are
// namespaced "act_" so they never collide 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 := 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)
}
// legacyTabRedirects maps retired ?tab=<id> bookmarks to their replacement
// MPA route. Removal was deferred and is not scheduled; 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).
}