Files
member-console/internal/server/operator_pages.go
T
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.

People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.

Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.

Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.

Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
2026-08-24 03:58:18 -05:00

533 lines
22 KiB
Go

package server
import (
"context"
"database/sql"
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
"github.com/spf13/viper"
)
// buildOperatorPageData populates the auth/CSRF/keycloak fields every
// operator MPA page needs. Page handlers layer IAPosition, ActiveCapability,
// and the BodyTemplate/BodyData pair on top of this base before rendering
// operator.html.
func (h *OperatorPartialsHandler) buildOperatorPageData(r *http.Request) OperatorPageData {
return BuildOperatorPageData(r, h.AuthConfig, h.Database, h.Logger)
}
// BuildOperatorPageData is buildOperatorPageData, exported so an
// integration registering its own operator surface (design.md Decision 8;
// e.g. internal/integrations/fedwiki/web's read-only sites page) can
// assemble the same shell page data — the auth/CSRF/keycloak fields — that
// OperatorPartialsHandler's own MPA pages use. Callers still layer
// IAPosition, ActiveCapability, and the BodyTemplate/BodyData pair on top
// before rendering "operator.html". The sidebar itself is a fixed flat list
// of the seven sections; provider surfaces are reached from the
// Integrations home, not the sidebar, so no registry query happens here.
func BuildOperatorPageData(r *http.Request, authConfig *auth.Config, database *sql.DB, logger *slog.Logger) OperatorPageData {
ctx := r.Context()
return OperatorPageData{
Name: authConfig.GetUserName(ctx),
Username: authConfig.GetUsername(ctx),
Email: authConfig.GetUserEmail(ctx),
KeycloakAccountURL: viper.GetString("oidc-idp-issuer-url") + "/account",
CSRFToken: middleware.CSRFToken(r),
}
}
// organizationsListNav builds the list-controls view model for the
// Organizations page: the org-type facet vocabulary is the same
// core.org_types set the Org Types management page reads (h.OrgQ.ListOrgTypes),
// so a type only appears as a filter option once it actually exists. An
// unknown or stale org_type value in the URL is ignored rather than erroring
// (operator-panel-navigation: "Unknown filter params do not error").
func (h *OperatorPartialsHandler) organizationsListNav(r *http.Request) (ListNav, string) {
nav := ListNav{
BasePath: "/operator/organizations",
SearchPlaceholder: "Search by name, slug, or owner",
FacetParam: "org_type",
}
orgTypes, err := h.OrgQ.ListOrgTypes(r.Context())
if err != nil {
h.Logger.Error("failed to list org types for organizations facet", slog.Any("error", err))
} else {
nav.FacetOptions = make([]FacetOption, len(orgTypes))
for i, ot := range orgTypes {
nav.FacetOptions[i] = FacetOption{Value: ot.OrgType, Label: ot.DisplayName}
}
}
params := ParseListParams(r, "org_type")
facet := ValidFacet(params.Facet, nav.FacetOptions)
nav.Q, nav.Facet, nav.Page = params.Q, facet, params.Page
return nav, facet
}
// GetOrganizationsPage handles GET /operator/organizations — the runtime
// organizations browse page. Reuses operator_organizations.html as the body
// partial inside the operator.html shell. Server-side search, org-type
// filter, and pagination with a true total count (operator-list-scale D1/D2).
func (h *OperatorPartialsHandler) GetOrganizationsPage(w http.ResponseWriter, r *http.Request) {
bodyData := OrganizationsData{}
nav, facet := h.organizationsListNav(r)
params := ListParams{Q: nav.Q, Facet: facet, Page: nav.Page}
orgs, total, err := FetchPage(&params, func(limit, offset int32) ([]organization.ListOrganizationsPageRow, int64, error) {
rows, lErr := h.OrgQ.ListOrganizationsPage(r.Context(), organization.ListOrganizationsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgType: sql.NullString{String: facet, Valid: facet != ""},
PageLimit: limit,
PageOffset: offset,
})
if lErr != nil || len(rows) == 0 {
return rows, 0, lErr
}
return rows, rows[0].TotalCount, nil
})
nav.Page = params.Page
nav.Total = total
if err != nil {
h.Logger.Error("failed to list organizations", slog.Any("error", err))
bodyData.Error = "Failed to retrieve organizations"
} else {
vms := make([]OrganizationViewModel, len(orgs))
for i, org := range orgs {
memberCount := 0
if members, mErr := h.OrgQ.GetOrgMembersByOrgID(r.Context(), org.OrgID); mErr == nil {
memberCount = len(members)
}
ownerName := org.OwnerDisplayName.String
if ownerName == "" {
ownerName = "Unknown"
}
vms[i] = OrganizationViewModel{
OrgID: org.OrgID,
Name: org.Name,
Slug: org.Slug,
OrgType: org.OrgType,
Status: org.Status,
MemberCount: memberCount,
CreatedAt: org.CreatedAt.Format("Jan 2, 2006"),
OwnerName: ownerName,
IsSystemOrg: org.OrgType == systemtenant.OrgType,
}
}
bodyData.Organizations = vms
}
bodyData.Nav = nav
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:organizations"
page.ActiveCapability = "organizations"
page.BodyTemplate = "operator_organizations.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// IntegrationRow is one registered integration on the Integrations landing
// — every provider kind, not just provisioning, so a payments integration
// like Stripe has a row (and its settings link) instead of being half-
// present (2026-07-22 fresh-eyes audit).
type IntegrationRow struct {
Slug string
DisplayName string
Kind string
Status string
SurfacePath string // "" when the provider declares no operator surface
SettingsPath string // "" when the integration declares no configuration
Operations []string
ResourceKeys []ResourceKeyOption
// Configured reports whether every required configuration key
// (RequiredGroup-tagged) resolves to a value — the SAME check the
// settings page performs (configurationReadiness), never a live probe.
// true for an integration with no required keys at all: there is
// nothing an operator could set. MissingKeysText names the unresolved
// keys (comma-joined) for the row's tooltip when false.
Configured bool
MissingKeysText string
}
// IntegrationsData is the body data for operator_integrations.html.
type IntegrationsData struct {
Integrations []IntegrationRow
// Orphans: override rows whose key matches no installed integration's
// declaration (integration removed or key retired). Harmless at boot
// (the overlay skips them); listed here with a delete affordance.
Orphans []OrphanOverrideRow
Error string
}
// OrphanOverrideRow is one unrecognized override row on the Integrations
// landing.
type OrphanOverrideRow struct {
Key string
Value string
}
// GetIntegrationsPage handles GET /operator/integrations — the Integrations
// section landing. Registry-driven: lists every provisioning provider with its
// status, declared lifecycle operations, owned resource keys, and admin-surface
// link. Read-only; a new conforming provider appears here with no code edit.
func (h *OperatorPartialsHandler) GetIntegrationsPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
bodyData := IntegrationsData{}
// Settings links + orphan classification come from the adapter
// registry (Config.IntegrationConfigs) — the authority on declared
// configuration, independent of provider kind.
settingsBySlug := map[string]string{}
declaredKeys := map[string]bool{}
for _, info := range h.IntegrationConfigs {
if len(info.Keys) == 0 {
continue
}
settingsBySlug[info.Slug] = "/operator/integrations/" + info.Slug + "/settings"
for _, k := range info.Keys {
declaredKeys[k.Name] = true
}
}
iq := integration.New(h.Database)
providers, err := iq.ListProviders(ctx)
if err != nil {
h.Logger.Error("failed to list providers for integrations page", slog.Any("error", err))
bodyData.Error = "Failed to retrieve integrations"
} else {
rows := make([]IntegrationRow, 0, len(providers))
for _, p := range providers {
configured, missing := configurationReadiness(h.IntegrationConfigs, p.Slug)
row := IntegrationRow{
Slug: p.Slug,
DisplayName: p.DisplayName,
Kind: p.ProviderKind,
Status: p.Status,
SettingsPath: settingsBySlug[p.Slug],
ResourceKeys: h.ownedResourceKeys(ctx, p.Slug),
Configured: configured,
MissingKeysText: strings.Join(missing, ", "),
}
delete(settingsBySlug, p.Slug)
if p.OperatorSurfacePath.Valid {
row.SurfacePath = p.OperatorSurfacePath.String
}
if ops, oErr := iq.ListProviderOperations(ctx, p.Slug); oErr == nil {
row.Operations = ops
} else {
h.Logger.Warn("failed to list provider operations", slog.String("provider", p.Slug), slog.Any("error", oErr))
}
rows = append(rows, row)
}
// A configuring adapter with no provider row (none exist today —
// every integration registers a manifest) still gets a minimal row
// so its settings page stays reachable.
for _, info := range h.IntegrationConfigs {
if path, ok := settingsBySlug[info.Slug]; ok && len(info.Keys) > 0 {
configured, missing := configurationReadiness(h.IntegrationConfigs, info.Slug)
rows = append(rows, IntegrationRow{
Slug: info.Slug, DisplayName: info.DisplayName, SettingsPath: path,
Configured: configured, MissingKeysText: strings.Join(missing, ", "),
})
}
}
bodyData.Integrations = rows
}
if overrideRows, oErr := iq.ListConfigOverrides(ctx); oErr != nil {
h.Logger.Error("failed to list config overrides for integrations page", slog.Any("error", oErr))
} else {
for _, row := range overrideRows {
if !declaredKeys[row.Key] {
bodyData.Orphans = append(bodyData.Orphans, OrphanOverrideRow{Key: row.Key, Value: row.Value})
}
}
}
page := h.buildOperatorPageData(r)
page.IAPosition = "integration:integrations"
page.ActiveCapability = "integrations"
page.BodyTemplate = "operator_integrations.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// ownedResourceKeys returns the resource keys a provider owns
// (core.resource_keys.provider = slug). Read-only raw query: the
// entitlements ResourceKey sqlc model doesn't carry the provider column, which
// lives behind the integration module's cross-schema queries. Failures degrade
// to an empty list rather than failing the page. Carries display_name
// alongside the raw key so the landing table can lead with the friendly name
// (ui-vocabulary 4.3), the same way a picker would.
func (h *OperatorPartialsHandler) ownedResourceKeys(ctx context.Context, slug string) []ResourceKeyOption {
rows, err := h.Database.QueryContext(ctx,
`SELECT resource_key, display_name FROM core.resource_keys WHERE provider = $1 ORDER BY resource_key`, slug)
if err != nil {
h.Logger.Warn("failed to list owned resource keys", slog.String("provider", slug), slog.Any("error", err))
return nil
}
defer rows.Close()
var keys []ResourceKeyOption
for rows.Next() {
var k ResourceKeyOption
if err := rows.Scan(&k.ResourceKey, &k.DisplayName); err != nil {
h.Logger.Warn("scan owned resource key", slog.String("provider", slug), slog.Any("error", err))
return keys
}
keys = append(keys, k)
}
return keys
}
// grantsFacetOptions is the grants list's delivery-state filter vocabulary
// (operator-list-scale: "Status filters exist where a status vocabulary
// exists" — grants: the derived Live/Superseded/Inactive states, design
// D4). Values match sqlc.narg(delivery_state) in ListGrantsWithDeliveryPage
// and the DeliveryState strings buildGrantViewModels renders.
var grantsFacetOptions = []FacetOption{
{Value: "live", Label: "Live"},
{Value: "superseded", Label: "Superseded"},
{Value: "inactive", Label: "Inactive"},
}
// GetGrantsPage handles GET /operator/grants — read-only audit/lookup view
// of every grant in the system. Per operator-mpa-conversion D8, no
// Issue/Extend/Revoke or CreateGrant affordances are exposed here; those
// live on the per-org composite. Reuses operator_grants.html as the body
// partial (which task 3.5 stripped to read-only).
//
// Governed by operator-list-scale: server-side search (org name or product
// name), the delivery-state filter, and true-total pagination via
// ListGrantsWithDeliveryPage (design D4). FetchPage's load closure ignores
// its own limit/offset arguments and instead reads params.Limit()/Offset()
// directly — safe because FetchPage always calls load with exactly those
// values (or, on the past-the-end clamp retry, with p.Page already reset
// to 1, which makes params.Offset() recompute to the same 0 it passes
// explicitly), and params is captured by reference so loadGrantsListData
// always sees FetchPage's current Page.
func (h *OperatorPartialsHandler) GetGrantsPage(w http.ResponseWriter, r *http.Request) {
params := ParseListParams(r, "state")
params.Facet = ValidFacet(params.Facet, grantsFacetOptions)
var bodyData GrantsData
grants, total, _ := FetchPage(&params, func(limit, offset int32) ([]GrantViewModel, int64, error) {
bodyData = h.loadGrantsListData(r, params, "", "")
return bodyData.Grants, bodyData.Nav.Total, nil
})
bodyData.Grants = grants
bodyData.Nav = ListNav{
BasePath: "/operator/grants",
SearchPlaceholder: "Search by organization or product",
FacetParam: "state",
FacetOptions: grantsFacetOptions,
Q: params.Q,
Facet: params.Facet,
Page: params.Page,
Total: total,
}
// ux-first-run 3.3: the grants list's empty state must distinguish
// "blocked: no product has been published yet" from "empty: issue a
// grant from an organization's detail page". GrantsData (defined in
// operator_partials.go, outside this lane's edit scope) has no field for
// that distinction, so it rides on the existing but otherwise-unused
// Products field here: the legacy create-grant form it originally fed
// was removed from this read-only page in M7e Slice D, so its length is
// safe to repurpose purely as the template's blocked/not-blocked signal.
anyPublished, err := h.BillingQ.AnyPublishedProduct(r.Context())
if err != nil {
h.Logger.Error("failed to check for a published product", slog.Any("error", err))
anyPublished = true // fail open: don't show a false blocked banner on a transient error
}
if anyPublished {
bodyData.Products = []ProductViewModel{{}}
}
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:grants"
page.ActiveCapability = "grants"
page.BodyTemplate = "operator_grants.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// OperatorBillingWrapperData is the dot context for operator_billing.html.
// Each GetBillingXPage handler populates ActiveSection (drives the pill
// active class) plus the inner template name and its data struct; the
// wrapper renders the pill nav and dispatches via renderBody.
type OperatorBillingWrapperData struct {
ActiveSection string
InnerTemplate string
InnerData any
// DataAsOf / NoEventsProcessed carry the webhook-projection recency
// stamp (design.md Decision 3; spec: operator-billing-views) shared by
// all four billing sub-pages: renderBillingPage computes it once here
// so every InnerTemplate shows it without a query of its own.
// DataAsOf is formatted for display and empty when NoEventsProcessed.
DataAsOf string
NoEventsProcessed bool
}
// renderBillingPage is the shared shell for the four billing sub-pages. It
// loads the inner data via the supplied loader, wraps the result in the
// operator_billing.html dispatcher, and renders inside operator.html.
func (h *OperatorPartialsHandler) renderBillingPage(w http.ResponseWriter, r *http.Request, activeSection, innerTemplate, capability string, innerData any) {
wrapper := OperatorBillingWrapperData{
ActiveSection: activeSection,
InnerTemplate: innerTemplate,
InnerData: innerData,
}
if processedAt, ok := h.latestProcessedWebhookEventAt(r.Context()); ok {
wrapper.DataAsOf = processedAt.Format("Jan 2, 2006 15:04 MST")
} else {
wrapper.NoEventsProcessed = true
}
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:billing:" + activeSection
page.ActiveCapability = capability
page.BodyTemplate = "operator_billing.html"
page.BodyData = wrapper
h.Templates.Render(w, "operator.html", page)
}
// GetBillingAccountsPage handles GET /operator/billing/accounts — runtime
// billing tier. Reuses operator_billing_accounts.html via the shared
// operator_billing.html wrapper; data hydration extracted into
// loadBillingAccountsData (shared with the legacy partial endpoint).
func (h *OperatorPartialsHandler) GetBillingAccountsPage(w http.ResponseWriter, r *http.Request) {
h.renderBillingPage(w, r, "accounts", "operator_billing_accounts.html", "billing-accounts", h.loadBillingAccountsData(r))
}
// GetSubscriptionsPage handles GET /operator/billing/subscriptions.
func (h *OperatorPartialsHandler) GetSubscriptionsPage(w http.ResponseWriter, r *http.Request) {
h.renderBillingPage(w, r, "subscriptions", "operator_subscriptions.html", "billing-subscriptions", h.loadSubscriptionsData(r))
}
// GetInvoicesPage handles GET /operator/billing/invoices.
func (h *OperatorPartialsHandler) GetInvoicesPage(w http.ResponseWriter, r *http.Request) {
h.renderBillingPage(w, r, "invoices", "operator_invoices.html", "billing-invoices", h.loadInvoicesData(r))
}
// GetPaymentsPage handles GET /operator/billing/payments.
func (h *OperatorPartialsHandler) GetPaymentsPage(w http.ResponseWriter, r *http.Request) {
h.renderBillingPage(w, r, "payments", "operator_payments.html", "billing-payments", h.loadPaymentsData(r))
}
// GetOrgTypesPage handles GET /operator/org-types — catalog tier. Reuses
// operator_org_types.html as the body partial; data hydration extracted into
// loadOrgTypesPageData (shared with the legacy partial endpoint).
func (h *OperatorPartialsHandler) GetOrgTypesPage(w http.ResponseWriter, r *http.Request) {
bodyData := h.loadOrgTypesPageData(r, OrgTypesData{}, "", "")
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:org-types"
page.ActiveCapability = "org-types"
page.BodyTemplate = "operator_org_types.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// GetProductsPage handles GET /operator/products — catalog tier. Reuses
// operator_products.html as the body partial; data hydration extracted into
// loadProductsPageData (shared with the legacy partial endpoint).
func (h *OperatorPartialsHandler) GetProductsPage(w http.ResponseWriter, r *http.Request) {
bodyData := h.loadProductsPageData(r, "", "")
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:products"
page.ActiveCapability = "products"
page.BodyTemplate = "operator_products.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// GetEntitlementSetsPage handles GET /operator/entitlement-sets — catalog tier.
// Reuses operator_entitlement_sets.html; data hydration extracted into
// loadEntitlementSetsPageData (shared with the legacy partial endpoint).
func (h *OperatorPartialsHandler) GetEntitlementSetsPage(w http.ResponseWriter, r *http.Request) {
bodyData := h.loadEntitlementSetsPageData(r, "", "")
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:entitlement-sets"
page.ActiveCapability = "entitlement-sets"
page.BodyTemplate = "operator_entitlement_sets.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// GetPlanLaddersPage handles GET /operator/plan-ladders — catalog tier.
// Reuses operator_plan_ladders.html; data hydration extracted into
// loadPlanLaddersPageData (shared with the legacy partial endpoint).
func (h *OperatorPartialsHandler) GetPlanLaddersPage(w http.ResponseWriter, r *http.Request) {
bodyData := h.loadPlanLaddersPageData(r, "", "")
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:plan-ladders"
page.ActiveCapability = "plan-ladders"
page.BodyTemplate = "operator_plan_ladders.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// GetPersonPage handles GET /operator/persons/{personID} — resolved-detail
// view for a single person. Per design D4 there was originally no flat
// /operator/persons browse route; maintainer decision 2026-08-23
// (operator-people-directory) reversed that: the People directory now
// exists at /operator/persons, and operators reach this detail page from a
// directory row, the landing-surface lookup affordance, or by drilling
// into an org member row.
func (h *OperatorPartialsHandler) GetPersonPage(w http.ResponseWriter, r *http.Request) {
personID := r.PathValue("personID")
bodyData := h.loadPersonDetailData(r, personID)
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:persons:" + personID
// ActiveCapability "persons" now lights the People sidebar entry
// (operator-people-directory: "the existing person detail ... marks
// the People sidebar entry active as its section's second-level
// page"), the same way any other detail page marks its section active.
page.ActiveCapability = "persons"
page.BodyTemplate = "operator_person_detail.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// GetOrganizationDetailPage handles GET /operator/organizations/{orgID} — the
// per-org composite. Reuses operator_enrollment.html as the body partial; the
// composite's data hydration lives in loadOrgEnrollmentData (shared with the
// legacy partial endpoint).
//
// 3.4 spec gaps tracked for follow-up commits: a billing summary section and
// the second labeled CreateGrant form for non-plan products. This commit
// lands the route and shell wiring — the existing template already covers
// the enrollment header, grant history, Issue/Extend forms, and Revoke.
func (h *OperatorPartialsHandler) GetOrganizationDetailPage(w http.ResponseWriter, r *http.Request) {
orgID := r.PathValue("orgID")
bodyData := h.loadOrgEnrollmentData(r, orgID, "", "")
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:organizations:" + orgID
page.ActiveCapability = "organizations"
page.BodyTemplate = "operator_enrollment.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}