operator_activity.go holds activityFeedSourceLimit and everything the feed reads, pairs, builds, sorts and names, in the order it had in operator.go. operator_tier_changes.go holds supersessionRow, supersededEnds, foldSupersessions, humanizeTransitionReason, transitionChangeLabels and transitionChange from operator_enrollment.go, then humanizeTransitionSummary. operator_activity_vocab.go is deleted with its file comment, which described how the file was added rather than what it holds. operator.go loses its sort import, and the overview's comment names the new file for loadRecentActivity. A pure move. git diff -M --color-moved shows moved blocks, the imports of the two new files, the dropped sort import, the deleted file comment and the one comment edit, and nothing else. The 1407 test and subtest names of internal/server have the same results as at the parent, the operator walkthroughs (22, 16 pass, 6 skip) are equal at both commits, and the screens differ only in operator-integration-stripe (both widths) and the Age cell of operator-domains, which differ on every run. feed.json and feed.html are byte-identical. Breaks run at the parent (5 records) and here (5), all caught: newest first, an end beside an upgrade stays, an upgrade names both sides, a successor states its from side, an extension reads Extended; the texts are the same, in their new files. Nothing new is unprobed.
672 lines
28 KiB
Go
672 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: a transition's or grant's id, which is time-ordered.
|
|
// Empty for kinds without one.
|
|
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).
|
|
}
|