// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "context" "errors" "log/slog" "strconv" "strings" "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant" "github.com/google/uuid" ) // This file backs the "At a glance" and "System" regions of the operator // landing surface (operator.html, the branch taken when BodyTemplate is // empty). The landing surface answers one question — "what is this // deployment doing right now?" — in three passes, coarsest first: // // 1. At a glance three daily-read tiles: People, Team organizations, // Monthly recurring // 2. System one Integrations card listing every registered // provider with its status (Stripe's row also carrying // mode and queue health) // 3. Recent activity the unified event timeline (loadRecentActivity, in // operator.go) // // Open-invoice, delivery, and catalog counts do not render as headline // tiles (maintainer 2026-09-02: "not that important" — those surfaces are // reached from the sidebar; design D3), and the System region never // carries the full delivery-queue report or operation identifiers: that // detail lives on the Stripe provider page (design D24), which this // region only summarizes and links to. // // Every number here is a live read. Nothing is cached and nothing is // precomputed at boot: an operator looking at this page is looking at the // database as of this request. // errBillingQuerierUnavailable is the Monthly recurring tile's error when // the handler carries no BillingQ at all (a test fixture, or a // misconfigured deployment) — distinct from a query that ran and failed, // but rendered identically (the em-dash unavailable marker). var errBillingQuerierUnavailable = errors.New("billing querier unavailable") // OverviewStat is one tile in the landing surface's "At a glance" row. // // Value is pre-formatted in Go rather than in the template because the // grouping separator is a presentation decision and html/template has no // number formatter; the template's job is layout only. // // Available distinguishes "counted zero" from "could not count". A failed // count must never render as 0 — an operator reading 0 organizations off a // broken query would draw exactly the wrong conclusion — so the template // renders an em dash for unavailable tiles instead. type OverviewStat struct { Label string Value string Caption string // one line stating precisely what was counted Href string // operator section this tile drills into; "" renders a static tile Available bool } // OverviewStripeFacts are the two extra facts Stripe's row in the // Integrations card shows after its status (design D3): the configured // mode and the outbox summary. Alarm styling is reserved for dead-lettered // work — pending/retrying are normal draining states, not something an // operator needs to act on. type OverviewStripeFacts struct { ModeLabel string // "Test mode" | "Live mode", from the mode derived from the API key Queued int64 Attention int64 Available bool // whether the outbox probe succeeded } // NeedsAttention drives the alarm styling: only dead-lettered work warrants // it. func (f OverviewStripeFacts) NeedsAttention() bool { return f.Attention > 0 } // OverviewIntegrationRow is one row of the System region's Integrations // card (design D3): every registered provider, in registry order, reusing // the SAME status derivation the Integrations table shows // (IntegrationRow.StatusBadge / LinkPath, operator_pages.go) so the two // surfaces can never disagree about what "Configured" means. StripeFacts // is non-nil only for the "stripe" row. type OverviewIntegrationRow struct { IntegrationRow StripeFacts *OverviewStripeFacts } // OverviewIntegrationsCard is the System region's one card: every // registered provider, one row each, in registry order — never a // dead-letter table or operation identifiers, which live on the Stripe // provider page (design D24). type OverviewIntegrationsCard struct { Rows []OverviewIntegrationRow } // OverviewData is everything the landing surface renders above the activity // timeline. type OverviewData struct { Stats []OverviewStat Integrations OverviewIntegrationsCard } // loadOverview assembles the landing surface's counts and system signals. // // Like loadRecentActivity, every source degrades independently: a failing // query costs its own tile (rendered unavailable) and is logged, but never // 500s the operator's entry point. The landing page is the surface an // operator reaches for when something is already wrong, so it has to render // under partial failure. func (h *OperatorHandler) loadOverview(ctx context.Context) OverviewData { data := OverviewData{} // Resolved once and shared by the headline and the caption below so the // two counts can never disagree about which row is the reserved system // person (design D3). "" (unresolved) is passed through as NULL, which // each query already treats as "exclude nothing". sysID := h.systemPersonID(ctx) var excludeSystemPerson uuid.NullUUID if sysID != "" { if id, err := uuid.Parse(sysID); err == nil { excludeSystemPerson = uuid.NullUUID{UUID: id, Valid: true} } } persons, personsErr := h.IdentityQ.CountActivePersons(ctx) h.logCountErr("active persons", personsErr) // The tile excludes the reserved system person the People directory // also hides (ACC-10): CountActivePersons counts every active person // row, including the synthetic System-tenant owner, so subtract it here // when it resolves and is itself active. Detected the same structural // way the directory does (systemPersonID below) — never by name or // email match. if personsErr == nil && sysID != "" { if sysPerson, err := h.IdentityQ.GetPersonByID(ctx, sysID); err == nil && sysPerson.Status == "active" { persons-- } } // The caption degrades independently of the headline: it excludes the // system person via the query's own parameter (design D3) rather than // the headline's GetPersonByID check, so a failure there never affects // this count. joined, joinedErr := h.IdentityQ.CountPersonsJoinedLast30Days(ctx, excludeSystemPerson) h.logCountErr("persons joined in 30 days", joinedErr) teams, teamsErr := h.OrgQ.CountTeamOrganizations(ctx) h.logCountErr("team organizations", teamsErr) var recurring []moneyBucket recurringErr := errBillingQuerierUnavailable var subActive, subTrialing int64 subCountsErr := errBillingQuerierUnavailable if h.BillingQ != nil { var recurringRows []billing.SumMonthlyRecurringByCurrencyRow recurringRows, recurringErr = h.BillingQ.SumMonthlyRecurringByCurrency(ctx) h.logCountErr("monthly recurring", recurringErr) recurring = make([]moneyBucket, 0, len(recurringRows)) for _, r := range recurringRows { recurring = append(recurring, moneyBucket{currency: r.Currency, cents: r.MonthlyCents}) } var subCounts billing.CountLiveSubscriptionsByStatusRow subCounts, subCountsErr = h.BillingQ.CountLiveSubscriptionsByStatus(ctx) h.logCountErr("live subscriptions", subCountsErr) subActive, subTrialing = subCounts.ActiveCount, subCounts.TrialingCount } data.Stats = []OverviewStat{ // Links to the People directory (operator-people-directory: "the // landing surface's People metric tile SHALL link to the // directory") so the count it advertises has a one-click answer. newOverviewStat("People", personsErr, persons, peopleCaption(joined, joinedErr), "/operator/persons"), // Personal orgs exist one-per-member by structural convention; a // count including them tracks the People tile and reads as an // error. CountTeamOrganizations already applies the negative-space // predicate (neither personal nor reserved), so ACC-39's tile keeps // counting exactly the deliberately-created organizations. newOverviewStat("Team organizations", teamsErr, teams, "Personal orgs excluded", "/operator/organizations"), // The money tile (design D3: round 1 removed it on a misread of // "the bottom three"; round 2 restores it). newMoneyStat("Monthly recurring", recurringErr, recurring, recurringCaption(subActive, subTrialing, subCountsErr, len(recurring)), "/operator/billing/subscriptions"), } data.Integrations = h.loadOverviewIntegrationsCard(ctx) return data } // systemPersonID resolves the reserved system person's ID the same way the // People directory does (operator_persons.go's systemPersonID — a method on // a different handler struct, OperatorPartialsHandler, taking *http.Request // rather than a bare context, so not directly reusable from here): the // owner of the System tenant (systemtenant.OrgType), detected structurally, // never by matching name or email (ACC-10, maintainer decision 2026-08-23). // Returns "" (excludes nothing) when the System tenant hasn't been // provisioned yet or the lookup fails, degrading to an unfiltered count // rather than failing the tile. func (h *OperatorHandler) systemPersonID(ctx context.Context) string { orgs, err := h.OrgQ.ListOrganizationsByType(ctx, systemtenant.OrgType) if err != nil || len(orgs) == 0 { return "" } return orgs[0].OwnerPersonID } // peopleCaption prefers velocity over a restatement of what a person record // is; the census is already the headline above it. func peopleCaption(joined int64, err error) string { if err != nil { return "Active person records; find one with the search above" } return formatCount(joined) + " joined in the last 30 days" } // recurringCaption pairs the money headline with the subscriptions behind // it. Trialing subscriptions contribute no money (they are not paying yet) // but stay visible here so the pipeline is never invisible. otherBuckets // counts the currency buckets the headline could not show; conversion is // out of scope (issues.md). func recurringCaption(active, trialing int64, err error, buckets int) string { if err != nil { return "Active subscriptions" } c := formatCount(active) + " active " + pluralize(active, "subscription", "subscriptions") if trialing > 0 { c += "; " + formatCount(trialing) + " trialing" } if buckets > 1 { c += "; plus " + formatCount(int64(buckets-1)) + " more " + pluralize(int64(buckets-1), "currency", "currencies") } return c } // moneyBucket is one currency's summed amount. Money never crosses // currencies on this surface: the largest bucket is displayed and the rest // are acknowledged, because converting needs a rate source this deployment // does not have (issues.md). type moneyBucket struct { currency string cents int64 } // largestBucket returns the biggest bucket and how many others exist. The // input is ORDER BY-ed descending by both backing queries, but sorting here // too keeps the helper safe to reuse. func largestBucket(buckets []moneyBucket) (moneyBucket, int) { top := buckets[0] for _, b := range buckets[1:] { if b.cents > top.cents { top = b } } return top, len(buckets) - 1 } // newMoneyStat builds the recurring-money tile. An empty bucket list with a // nil error is a real zero (no active subscriptions), rendered as "$0" only // when a currency is unknowable — there is nothing to prefer — so it falls // back to the bare digit. func newMoneyStat(label string, err error, buckets []moneyBucket, caption, href string) OverviewStat { stat := OverviewStat{Label: label, Caption: caption, Href: href, Available: err == nil} if err != nil { return stat } if len(buckets) == 0 { stat.Value = "0" return stat } // formatMoney is the package's shared renderer (member_products.go), so // the operator headline and member-facing prices read identically. top, _ := largestBucket(buckets) stat.Value = formatMoney(top.cents, top.currency) return stat } // newOverviewStat builds a tile, folding the load error into the tile's own // availability so a caller never has to branch on err at every call site. func newOverviewStat(label string, err error, value int64, caption, href string) OverviewStat { stat := OverviewStat{Label: label, Caption: caption, Href: href, Available: err == nil} if err == nil { stat.Value = formatCount(value) } return stat } func (h *OperatorHandler) logCountErr(what string, err error) { if err != nil { h.Logger.Warn("operator overview: count failed", slog.String("metric", what), slog.Any("error", err)) } } // formatCount renders n with thin comma grouping ("12,480"). Written out // rather than pulled from golang.org/x/text because the operator surface is // English-only and a locale-aware formatter would be the only thing that // dependency was used for. func formatCount(n int64) string { digits := strconv.FormatInt(n, 10) sign := "" if strings.HasPrefix(digits, "-") { sign, digits = "-", digits[1:] } if len(digits) <= 3 { return sign + digits } // Walk from the least-significant end, inserting a separator every third // digit, then reverse once at the end. var reversed strings.Builder for i := 0; i < len(digits); i++ { if i > 0 && i%3 == 0 { reversed.WriteByte(',') } reversed.WriteByte(digits[len(digits)-1-i]) } out := []byte(reversed.String()) for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { out[i], out[j] = out[j], out[i] } return sign + string(out) } // pluralize picks a noun form for n. Only the two irregular-free cases the // overview needs; not a general inflector. func pluralize(n int64, singular, plural string) string { if n == 1 { return singular } return plural } // loadOverviewIntegrationsCard assembles the System region's one // Integrations card: every registered provider, in registry order, with // its name link and single Status — the SAME configurationReadiness signal // and registry lifecycle the Integrations list reads, reused here through // the shared IntegrationRow type (operator_pages.go) rather than // duplicated. Stripe's row additionally carries mode and outbox health, // and its link always points at its provider page (design D24) even before // the registry declares an OperatorSurfacePath for it. func (h *OperatorHandler) loadOverviewIntegrationsCard(ctx context.Context) OverviewIntegrationsCard { card := OverviewIntegrationsCard{} if h.IntegrationQ == nil { return card } providers, err := h.IntegrationQ.ListProviders(ctx) if err != nil { h.Logger.Warn("operator overview: list providers failed", slog.Any("error", err)) return card } // Settings links come from the adapter registry (Config.IntegrationConfigs), // mirroring GetIntegrationsPage's settingsByKey construction, so a // provider with no declared operator surface still links somewhere. settingsByKey := map[string]string{} for _, info := range h.IntegrationConfigs { if len(info.Keys) > 0 { settingsByKey[info.Key] = "/operator/integrations/" + info.Key + "/settings" } } rows := make([]OverviewIntegrationRow, 0, len(providers)) for _, p := range providers { configured, missing := configurationReadiness(h.IntegrationConfigs, p.Provider) row := OverviewIntegrationRow{ IntegrationRow: IntegrationRow{ Key: p.Provider, DisplayName: p.DisplayName, Kind: p.ProviderKind, Status: p.Status, SettingsPath: settingsByKey[p.Provider], Configured: configured, MissingKeysText: strings.Join(missing, ", "), }, } if p.OperatorSurfacePath.Valid { row.SurfacePath = p.OperatorSurfacePath.String } if p.Provider == "stripe" { // Link Stripe's provider page regardless of the registry's // declared surface (design D3: "link it regardless" — the page // exists on this surface even before the registry catches up). row.SurfacePath = "/operator/integrations/stripe" facts := h.loadOverviewStripeFacts(ctx) row.StripeFacts = &facts } rows = append(rows, row) } card.Rows = rows return card } // loadOverviewStripeFacts assembles Stripe's extra row facts: which mode // it's in and the outbox summary (queued / need attention) — never the // full delivery-queue report, which lives on the Stripe provider page and // reads the same loadDeliveryQueue call so the two can never disagree // about what the outbox holds. Attention counts dead-lettered work in both // directions: outbox entries and inbound webhook events, read the way the // provider page reads them. func (h *OperatorHandler) loadOverviewStripeFacts(ctx context.Context) OverviewStripeFacts { mode := stripeModeLabel(h.StripeMode) queue := loadDeliveryQueue(ctx, h.IntegrationQ, h.Logger) inbound := loadInboundEvents(ctx, h.Database, h.StripeMode, h.Logger) return OverviewStripeFacts{ ModeLabel: mode, Queued: queue.Queued(), Attention: queue.DeadLetter + inbound.DeadLetter, Available: queue.Available, } }