414 lines
17 KiB
Go
414 lines
17 KiB
Go
// 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,
|
|
}
|
|
}
|