Files
cgalo5758 12f1d3fc00 Fix the four Slice 3 walk findings and page every operator list
Archives openspec change slice3-walk-fixes and syncs its five delta
specs (fedwiki-sites, entitlements, operator-panel-navigation,
operator-list-scale, ui-quality-gate).

- FedWiki site usage is read from active site rows in both quota
  readers; the reservation counter converges on the rows: raise-only
  after farm sync and inside the create quota check, exact at boot.
  The understated production counters repair on the first boot.
- The People tile caption excludes the reserved system person through
  the same query parameter the directory uses.
- The operator Domains live-claims list is a governed list: pages of
  50, true total, search over root name and organization, a
  pending/active facet.
- New lint rule table-without-list-controls refuses an unpaged
  page-body table unless it carries a list-scale exempt marker with a
  reason; six curated or detail tables carry one. Its first run caught
  the operator FedWiki sites list, which is now governed the same way.
- Entitlement-set rule copy: "Per unit", "Multiplied by the quantity
  purchased or granted."
2026-09-12 01:16:17 -05:00

425 lines
17 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package web
import (
"bytes"
"database/sql"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows"
temporalsdk "go.temporal.io/sdk/client"
)
// FedWikiSiteViewModel represents a site for the operator template
// rendering. Renamed from the core package's OperatorSiteViewModel
// (openspec/changes/integration-extraction task 2.3): that name suggested
// a generic "sites" concept, but it was 100% FedWiki-specific — its only
// consumer was this page.
type FedWikiSiteViewModel struct {
ID string
Domain string
URL string // composed with the configured fedwiki-site-scheme; see GetSitesPage
OwnerOrgName string
IsCustomDomain bool
CreatedAt string
Status string // active | readonly | archived
}
// FedWikiSitesData holds data for the fedwiki_operator_sites.html partial.
// Renamed from the core package's OperatorSitesData (see
// FedWikiSiteViewModel's doc comment for why).
type FedWikiSitesData struct {
// Configured reports whether FedWiki's required configuration group
// (fedwiki-farm-api-url, fedwiki-admin-token) resolves. false renders
// the same blocked not-configured shape the Discourse operator page
// renders instead of an empty sites listing (ACC-2; fedwiki-sites spec
// "Operator panel site listing").
Configured bool
// Description, when the provider manifest declares one, overrides the
// page header's default lead (operator-panel-navigation: "A provider
// page leads with the integration's description").
Description string
Sites []FedWikiSiteViewModel
Error string
// Sync health (latest schedule-spawned sync execution, from Temporal)
// — same panel the Discourse operator page leads with; the shared
// anatomy is documented in docs/building-an-integration.md.
SyncStatus string
SyncTime string
// Nav governs the sites table (operator-list-scale): search over the
// domain and the holder's name, the status facet over fedwiki.sites'
// own vocabulary, and true-total pagination. The section count above
// the table is that same true total.
Nav server.ListNav
}
// fedWikiSiteFacets is the status vocabulary fedwiki.sites carries
// (sites_status_valid, 00001_init), so the filter offers exactly the
// values a row can hold and no more.
var fedWikiSiteFacets = []server.FacetOption{
{Value: "active", Label: "Active"},
{Value: "readonly", Label: "Read only"},
{Value: "archived", Label: "Archived"},
}
// FedWikiOperatorHandler serves the read-only operator-tier view of every
// FedWiki site in the system, at the path the FedWiki provider manifest
// declares (OperatorSurfacePath). Moved from the core OperatorPartialsHandler
// (GetFedWikiSitesPage, internal/server/operator_pages.go:100-152 pre-move)
// per openspec/changes/integration-extraction task 2.3: unlike the
// member-facing API/partials handlers, this page was previously grafted
// onto the shared operator handler rather than owning its own handler
// type — it now does, mirroring FedWikiHandler/FedWikiPartialsHandler.
type FedWikiOperatorHandler struct {
SiteQ fwmod.Querier
Database *sql.DB
Logger *slog.Logger
AuthConfig *auth.Config
FedWikiSiteScheme string // http or https; mirrors FedWikiHandler/FedWikiPartialsHandler's field
Templates *server.SafeTemplates
Temporal temporalsdk.Client
SyncScheduleID string
// Configured mirrors DiscourseOperatorHandler's field: whether
// FedWiki's required config group resolves, computed once by the
// adapter (fedwiki.go's own viper concern) and threaded through.
Configured bool
// Description: see FedWikiSitesData.Description.
Description string
}
// FedWikiOperatorHandlerConfig holds configuration for FedWikiOperatorHandler.
type FedWikiOperatorHandlerConfig struct {
SiteQ fwmod.Querier
Database *sql.DB
Logger *slog.Logger
AuthConfig *auth.Config
FedWikiSiteScheme string
// TemplatesFS is FedWiki's own template directory, supplied by the
// Adapter from its embedded templates tree — see
// FedWikiPartialsConfig.TemplatesFS's doc comment for why it's injected
// rather than embedded in this package directly (the same cycle
// argument applies here). Core's operator.html shell, by contrast, is
// fetched directly from internal/embeds below — no cycle risk, since
// embeds is a leaf core package.
TemplatesFS fs.FS
// Temporal + SyncScheduleID feed the Sync health panel (nil/"" →
// "no sync yet"); see workflows.ScheduleRunStatus for why health is
// resolved through the schedule rather than a workflow ID.
Temporal temporalsdk.Client
SyncScheduleID string
// Configured: see FedWikiOperatorHandler.Configured.
Configured bool
// Description: see FedWikiSitesData.Description.
Description string
}
// NewFedWikiOperatorHandler creates a new FedWikiOperatorHandler.
func NewFedWikiOperatorHandler(cfg FedWikiOperatorHandlerConfig) (*FedWikiOperatorHandler, error) {
shellFS, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
return nil, err
}
// operator.html dispatches to a named body partial via the `renderBody`
// template func (html/template's `{{ template "literal" }}` action
// requires a compile-time-literal name, hence the indirection). This
// handler only ever renders its own one body partial
// (fedwiki_operator_sites.html), so, unlike OperatorPartialsHandler's
// shared set, renderBody here is a closure over that single template
// rather than a dynamic dispatcher over many.
var tmpl *template.Template
tmpl = template.New("fedwiki-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,
// The shell's <title> names the page (server.PageTitle).
"pageTitle": server.PageTitle,
})
// The shell's landing branch references operator_lookup_result.html;
// html/template's escape analysis requires every referenced template in
// the set even on branches this page never takes, so parse the shell's
// partial dependency alongside it or every render fails.
tmpl, err = tmpl.ParseFS(shellFS, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html")
if err != nil {
return nil, err
}
if tmpl, err = web.ParseUIPartials(tmpl); err != nil {
return nil, err
}
// An integration's operator page is an operator page: its trail is
// rooted at the operator surface like every other (design D18).
tmpl = tmpl.Funcs(template.FuncMap{"surfaceRoot": web.OperatorSurfaceRoot})
tmpl, err = tmpl.ParseFS(cfg.TemplatesFS, "fedwiki_operator_sites.html")
if err != nil {
return nil, err
}
return &FedWikiOperatorHandler{
SiteQ: cfg.SiteQ,
Database: cfg.Database,
Logger: cfg.Logger,
AuthConfig: cfg.AuthConfig,
FedWikiSiteScheme: cfg.FedWikiSiteScheme,
Templates: server.NewSafeTemplates(tmpl, cfg.Logger),
Temporal: cfg.Temporal,
SyncScheduleID: cfg.SyncScheduleID,
Configured: cfg.Configured,
Description: cfg.Description,
}, nil
}
// GetSitesPage handles GET <the FedWiki provider manifest's
// OperatorSurfacePath> — read-only integration-tier view of every FedWiki
// site in the system. The existing fedwiki_operator_sites.html partial is
// already read-only (table + external links, no forms), so no template
// changes were needed for the move.
func (h *FedWikiOperatorHandler) GetSitesPage(w http.ResponseWriter, r *http.Request) {
bodyData := FedWikiSitesData{Configured: h.Configured, Description: h.Description}
if !h.Configured {
page := server.BuildOperatorPageData(r, h.AuthConfig, h.Database, h.Logger)
page.IAPosition = "integration:integrations:fedwiki"
page.ActiveCapability = "integrations"
page.BodyTemplate = "fedwiki_operator_sites.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
return
}
bodyData.SyncStatus, bodyData.SyncTime = workflows.ScheduleRunStatus(r.Context(), h.Temporal, h.SyncScheduleID)
if bodyData.SyncStatus == "" {
bodyData.SyncStatus = "No sync has run yet"
}
// The governed list's state comes off the request (operator-list-scale):
// search, the status facet, and the page. The owning organization rides
// along on each row from the query's join, so the search reaches it and
// the old workspace -> organization lookup cache is gone.
params := server.ParseListParams(r, "status")
params.Facet = server.ValidFacet(params.Facet, fedWikiSiteFacets)
sites, total, err := server.FetchPage(&params, func(limit, offset int32) ([]fwmod.ListAllSitesForOperatorPageRow, int64, error) {
rows, qErr := h.SiteQ.ListAllSitesForOperatorPage(r.Context(), fwmod.ListAllSitesForOperatorPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""},
PageLimit: limit,
PageOffset: offset,
})
if qErr != nil || len(rows) == 0 {
return rows, 0, qErr
}
return rows, rows[0].TotalCount, nil
})
if err != nil {
h.Logger.Error("failed to list sites", slog.Any("error", err))
bodyData.Error = "Failed to retrieve sites"
} else {
// Same config knob the member-facing FedWikiPartialsHandler.buildSiteURL
// uses (default https) — audit finding #37: this page used to hardcode
// https:// regardless, breaking links on http-only dev/test farms.
scheme := h.FedWikiSiteScheme
if scheme == "" {
scheme = "https"
}
siteVMs := make([]FedWikiSiteViewModel, len(sites))
for i, site := range sites {
siteVMs[i] = FedWikiSiteViewModel{
ID: site.SiteID,
Domain: site.Domain,
URL: scheme + "://" + site.Domain,
OwnerOrgName: site.OrgName,
IsCustomDomain: site.IsCustomDomain,
CreatedAt: site.CreatedAt.Format("Jan 2, 2006"),
Status: site.Status,
}
}
bodyData.Sites = siteVMs
bodyData.Nav = server.ListNav{
BasePath: fwmod.ProviderSource().ProviderManifest().OperatorSurfacePath,
SearchPlaceholder: "Search by domain or organization",
FacetParam: "status",
FacetOptions: fedWikiSiteFacets,
Q: params.Q,
Facet: params.Facet,
Page: params.Page,
Total: total,
}
}
page := server.BuildOperatorPageData(r, h.AuthConfig, h.Database, h.Logger)
// Second-level page of the Integrations section: the section's sidebar
// entry lights up via ActiveCapability (the page itself is reached from
// the Integrations home's provider cards, not the sidebar); the
// ia-position tuple nests under the section landing.
page.IAPosition = "integration:integrations:fedwiki"
page.ActiveCapability = "integrations"
page.BodyTemplate = "fedwiki_operator_sites.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// ---- Page-anatomy pipeline (anatomy sweep group 9). The shared parts
// (pageHeader, sectionHeader, statusBadge, emptyState) are parsed into
// this handler's template set by web.ParseUIPartials; these methods
// supply their data using the server package's part types, so badge
// labels and tones stay in the one badge map (design D3).
func (d FedWikiSitesData) Header() server.PageHeader {
lead := "Every wiki site the FedWiki integration manages, with its owning organization and status."
if d.Description != "" {
lead = d.Description
}
return server.PageHeader{
Title: "FedWiki sites",
Lead: lead,
Crumbs: []server.Link{{Label: "Integrations", URL: "/operator/integrations"}},
Action: &server.Link{Label: "Settings", URL: "/operator/integrations/fedwiki/settings"},
}
}
func (FedWikiSitesData) SyncHeader() server.SectionHeader {
return server.SectionHeader{Title: "Sync health", Help: "The sync is the periodic background job that reconciles this list with the wiki farm."}
}
// SitesHeader counts the whole filtered set, never the page's rows
// (operator-list-scale).
func (d FedWikiSitesData) SitesHeader() server.SectionHeader {
h := server.SectionHeader{Title: "Sites"}
switch n := d.Nav.Total; {
case n == 1:
h.Count = "1 site"
case n > 1:
h.Count = fmt.Sprintf("%d sites", n)
}
return h
}
func (FedWikiSitesData) Empty() server.EmptyStateParams {
return server.EmptyStateParams{Headline: "No sites yet."}
}
// NotConfigured is the page's blocked state, modelled on Discourse's
// operator page (ACC-2): the integration is registered but dormant until
// its required keys are set, and the settings page is where an operator
// sees which keys those are.
func (FedWikiSitesData) NotConfigured() server.EmptyStateParams {
return server.EmptyStateParams{
Blocked: true,
BlockerCopy: "FedWiki is not configured. Set fedwiki-farm-api-url and fedwiki-admin-token to enable the integration; until then it is registered but dormant.",
PrerequisiteURL: "/operator/integrations/fedwiki/settings",
PrerequisiteLabel: "Open FedWiki settings",
}
}
func (s FedWikiSiteViewModel) TypeBadge() server.Badge {
if s.IsCustomDomain {
return server.StatusBadge("custom")
}
return server.StatusBadge("subdomain")
}
// StatusBadge maps the store's status strings ("readonly" has no
// underscore) onto the shared badge map's states.
func (s FedWikiSiteViewModel) StatusBadge() server.Badge {
switch s.Status {
case "readonly":
return server.StatusBadge("read_only")
case "archived":
return server.StatusBadge("archived")
default:
return server.StatusBadge("active")
}
}
// ---- Member-surface anatomy (sweep group 10): the sites card and the
// custom-domain dialog render their badges and empty states through the
// shared parts, with the labels and tones in the one badge map.
func (s SiteViewModel) TypeBadge() server.Badge {
if s.IsCustomDomain {
return server.StatusBadge("custom")
}
return server.StatusBadge("subdomain")
}
func (s SiteViewModel) StatusBadge() server.Badge {
if s.IsReadonly {
return server.StatusBadge("read_only")
}
return server.StatusBadge("active")
}
// NoDomainsEmpty names the deployment-side blocker: no site domains are
// configured, which is never the member's fault and never their plan's
// (ux-first-run task 3.6). No prerequisite link: a member cannot fix it.
func (SitesData) NoDomainsEmpty() server.EmptyStateParams {
return server.EmptyStateParams{
Headline: "Sites can't be created yet.",
Note: "The service hasn't configured any site domains. This isn't fixable here; check back later.",
}
}
func (SitesData) NoEntitlementEmpty() server.EmptyStateParams {
return server.EmptyStateParams{
Headline: "Your plan doesn't currently include FedWiki site creation.",
}
}
// NoPublishedPlanEmpty is the entitlement branch's third case: no published
// product confers site creation at all, so the deployment itself hasn't
// caught up yet — never the member's plan (fedwiki-sites spec "The sites
// card distinguishes an empty catalog from an excluded entitlement", ACC-7;
// mirrors NoDomainsEmpty's "not something you can fix" framing).
func (SitesData) NoPublishedPlanEmpty() server.EmptyStateParams {
return server.EmptyStateParams{
Headline: "Website plans aren't published yet.",
Note: "This deployment hasn't published a plan that includes FedWiki sites. This isn't fixable here; check back later.",
}
}
func (SitesData) NoSitesEmpty() server.EmptyStateParams {
return server.EmptyStateParams{
Headline: "You don't have any active FedWiki sites yet.",
Note: "Create a site to get started.",
}
}
// StateBadge mirrors the core member-domains record mapping
// (server.MemberDomainRecordView.StateBadge) for the dialog's own record
// rows.
func (r CustomDomainRecordView) StateBadge() server.Badge {
switch r.State {
case "match":
return server.StatusBadge("found")
case "mismatch":
return server.StatusBadge("found_mismatch")
case "missing":
return server.StatusBadge("not_found")
case "error":
return server.StatusBadge("check_failed").WithTitle("We couldn't complete the DNS lookup; we'll keep trying.")
default:
return server.StatusBadge("not_checked")
}
}
func (r CustomDomainRecordView) RecordKindBadge() server.Badge {
return server.Badge{Label: r.Kind, Tone: "light"}
}