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(¶ms, 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(¶ms, 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) }