package server import ( "bytes" "context" "database/sql" "html/template" "io/fs" "log/slog" "net/http" "sort" "strconv" "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/identity" "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/middleware" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" "github.com/spf13/viper" ) // 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 } // 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 } // 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, }) tmpl, err = tmpl.ParseFS(templateSubFS, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html") 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), }, nil } // RegisterRoutes registers operator routes func (h *OperatorHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /operator", h.requireOperatorRole(h.GetOperatorPage)) mux.HandleFunc("POST /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 CSRFToken string // IAPosition is the positional tuple `:[:]` // declared by every operator page per the breadcrumb requirement. The // landing surface declares `runtime:landing` since it sits in the // runtime group as the curated hot-path entry point. IAPosition string // ActiveCapability is the sidebar-link key the current page belongs to // ("organizations", "grants", ...). Drives the sidebar `active` class. // Empty on the landing surface (Overview light up). ActiveCapability string // BodyTemplate names the partial template to render inside
. When // non-empty the operator.html shell dispatches via renderBody; when // empty it renders the curated landing surface. BodyTemplate string BodyData any // Activity is the unified-timeline rows for the landing surface's // "Recent activity" section (operator-composite-expansion D4). // Populated only on the landing branch; nil/empty on MPA pages. Activity []ActivityEvent // 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, shared by the landing // region (rendered here, ahead of Overview's tiles, only while // Setup.RequiredIncomplete) and GetSetupPage's full-page rendering // (setup_state.go, operator_setup.go). Populated only on the landing // branch; the zero value's RequiredIncomplete is false, so MPA pages // render no setup region (they have none to render). 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 is 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 LookupNoMatch bool } // 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 } // ActivityEvent is one row in the landing-surface unified timeline. Each // row identifies its event type, the org (and person, when applicable), // a short human summary, and a precomputed link target. Per D4 the // template branches on EventType to render the right badge/icon — the // loader doesn't pick visual styles, it picks data. type ActivityEvent struct { EventType string // "grant_issued" | "transition" | "invoice_created" | "payment_received" Timestamp string // pre-formatted display string ("Jan 2, 2006 3:04 PM") SortKey int64 // unix nanos, used to sort the merged slice OrgID string OrgName string PersonID string // empty when no person attribution (system/webhook actor or billing event) PersonName string Summary string // human-readable description of what happened // Href is the entry's own addressable operator surface (D8/task 7.4: // "feed entries link to their subjects where addressable"): the org // detail page for a grant or transition, the billing view for an // invoice or payment. Empty when the entry has no addressable surface — // the template renders those entries as plain text, never a dead link. Href string } // GetOperatorPage handles GET /operator func (h *OperatorHandler) GetOperatorPage(w http.ResponseWriter, r *http.Request) { // Permanent redirect for legacy ?tab= bookmarks. Slated for // removal in M8 once operator analytics show no remaining hits. if tab := r.URL.Query().Get("tab"); tab != "" { if redirect, ok := legacyTabRedirects[tab]; ok { http.Redirect(w, r, redirect, http.StatusMovedPermanently) return } } h.renderLanding(w, r, "", nil, false) } // OperatorLookup handles POST /operator/lookup — resolves the operator's // typed lookup term to a person detail, an org composite, a disambiguation // listing, or a no-match notice. Resolution order: // 1. 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). // 2. org slug exact; if found → HX-Redirect to org composite. // 3. otherwise → render the result partial with a no-match notice. // // 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) { if err := r.ParseForm(); err != nil { h.renderLookupResult(w, "", nil, true) return } term := strings.TrimSpace(r.FormValue("term")) if term == "" { // Empty submit: nothing to render. Return empty body so HTMX clears // any previous result without showing a state. h.renderLookupResult(w, "", nil, false) return } ctx := r.Context() 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: w.Header().Set("HX-Redirect", "/operator/persons/"+persons[0].PersonID) w.WriteHeader(http.StatusOK) return case 0: // Fall through to org-slug check. default: candidates := make([]LookupCandidateViewModel, len(persons)) for i, p := range persons { candidates[i] = LookupCandidateViewModel{ PersonID: p.PersonID, DisplayName: p.DisplayName, Email: p.PrimaryEmail, } } h.renderLookupResult(w, term, candidates, false) return } if org, err := h.OrgQ.GetOrganizationBySlug(ctx, term); err == nil { w.Header().Set("HX-Redirect", "/operator/organizations/"+org.OrgID) w.WriteHeader(http.StatusOK) return } h.renderLookupResult(w, term, nil, true) } // renderLookupResult renders the lookup-result partial for HTMX swap. // `term` populates the no-match alert / disambiguation header; `candidates` // drives the person picker; `noMatch` triggers the warning alert. func (h *OperatorHandler) renderLookupResult(w http.ResponseWriter, term string, candidates []LookupCandidateViewModel, noMatch bool) { data := struct { LookupTerm string LookupCandidates []LookupCandidateViewModel LookupNoMatch bool }{ LookupTerm: term, LookupCandidates: candidates, LookupNoMatch: noMatch, } 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` triggers the disambiguation // list; `noMatch` triggers the no-match notice. All three are off on a // plain landing render. func (h *OperatorHandler) renderLanding(w http.ResponseWriter, r *http.Request, lookupTerm string, candidates []LookupCandidateViewModel, noMatch bool) { ctx := r.Context() limit := parseActivityLimit(r.URL.Query().Get("limit")) data := OperatorPageData{ Name: h.AuthConfig.GetUserName(ctx), Username: h.AuthConfig.GetUsername(ctx), Email: h.AuthConfig.GetUserEmail(ctx), KeycloakAccountURL: viper.GetString("oidc-idp-issuer-url") + "/account", CSRFToken: middleware.CSRFToken(r), IAPosition: "runtime:landing", Overview: h.loadOverview(ctx), Setup: h.DeriveSetupState(ctx), Activity: h.loadRecentActivity(ctx, limit), LookupTerm: lookupTerm, LookupCandidates: candidates, LookupNoMatch: noMatch, } h.Templates.Render(w, "operator.html", data) } // parseActivityLimit clamps the ?limit=N param to [1, 100] with a default // of 20. Per D4 the activity feed is one unified timeline and one knob — // no separate ?activity_limit / ?billing_limit splits. func parseActivityLimit(raw string) int32 { const defaultLimit, maxLimit = 20, 100 if raw == "" { return defaultLimit } n, err := strconv.Atoi(raw) if err != nil || n < 1 { return defaultLimit } if n > maxLimit { return maxLimit } return int32(n) } // loadRecentActivity assembles the landing-surface unified timeline by // fetching `limit` rows from each event source (grants, transitions, // invoices, payments), resolving org and person UUIDs to display names, // then merging by timestamp DESC and trimming back to `limit`. Per-source // over-fetch + post-merge trim is correct because no single source's // recent N rows are guaranteed to be the global recent N. // // 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, limit int32) []ActivityEvent { events := make([]ActivityEvent, 0, int(limit)*4) if grants, err := h.EntitlementsQ.ListRecentGrants(ctx, limit); 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, limit); err == nil { for _, t := range transitions { personID := "" if t.ActorID.Valid { personID = t.ActorID.UUID.String() } from := "—" if t.FromRank.Valid { from = strconv.Itoa(int(t.FromRank.Int32)) } to := "—" if t.ToRank.Valid { to = strconv.Itoa(int(t.ToRank.Int32)) } 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: t.TransitionType + " (rank " + from + " → " + to + ", by " + t.ActorType + ")", 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, limit); 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, limit); 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, then trim to limit. sort.Slice(events, func(i, j int) bool { return events[i].SortKey > events[j].SortKey }) if int32(len(events)) > limit { events = events[:limit] } // Resolve org and person display names in a second pass. Per-row // lookups would be N+1 — collect distinct IDs first, then map. orgNames := make(map[string]string) personNames := make(map[string]string) for _, e := range events { if e.OrgID != "" { orgNames[e.OrgID] = "" } if e.PersonID != "" { personNames[e.PersonID] = "" } } for orgID := range orgNames { if o, err := h.OrgQ.GetOrganizationByID(ctx, orgID); err == nil { orgNames[orgID] = o.Name } } for personID := range personNames { if p, err := h.IdentityQ.GetPersonByID(ctx, personID); err == nil { personNames[personID] = p.DisplayName } } for i := range events { events[i].OrgName = orgNames[events[i].OrgID] events[i].PersonName = personNames[events[i].PersonID] } return events } // legacyTabRedirects maps retired ?tab= bookmarks to their replacement // MPA route. Scheduled for removal in M8 — see openspec change // operator-mpa-conversion task 6.2. var legacyTabRedirects = map[string]string{ "orgs": "/operator/organizations", "organizations": "/operator/organizations", "grants": "/operator/grants", "billing": "/operator/billing/accounts", "org-types": "/operator/org-types", "products": "/operator/products", "entitlement-sets": "/operator/entitlement-sets", "plan-ladders": "/operator/plan-ladders", // Persons has no browse route per design D4. tab=people bookmarks // land on the operator landing surface, where the lookup affordance // will route them to the per-person detail page. "people": "/operator", // "sites" (the legacy FedWiki sites tab) is intentionally absent: its // destination is now an integration-registered route (design.md // Decision 8; see internal/integrations/fedwiki/web), and core must // not hardcode an integration-specific path here to redirect to it // (openspec/changes/integration-extraction task 2.7). A ?tab=sites // bookmark now falls through to the landing surface below rather than // resolving to a specific redirect — acceptable since this whole map // is already slated for removal (operator-mpa-conversion task 6.2). }