Files
member-console/internal/server/operator_activity_vocab.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

132 lines
5.4 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"context"
"database/sql"
"log/slog"
"strconv"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"github.com/lib/pq"
)
// This file backs ACC-26 / operator-panel-navigation ("Tier events read as
// the composite reads them"): the landing surface's recent-activity feed
// renders transition events through the SAME vocabulary the organization
// composite's "Tier changes" list uses (transitionChangeLabels and the
// tier-name resolution in operator_enrollment.go's buildOrgEnrollmentData),
// never the raw transition_type and integer ranks it rendered before
// ("initiate (rank — → 0, by system)"). It is a NEW file rather than an
// edit to operator_enrollment.go or operator_partials.go — a thin wrapper
// around the package-level transitionChangeLabels map those files already
// declare (this lane does not own either file).
// humanizeTransitionSummary renders one transition event's one-line
// summary in the composite's vocabulary: a humanized verb (transitionChangeLabels)
// plus the resolved tier label(s) the change moved between. fromLabel and
// toLabel are already resolved product names (or a "rank N" / "—"
// fallback — see tierNameResolver) so this function only picks which
// label(s) the sentence needs for the given change type.
func humanizeTransitionSummary(transitionType, fromLabel, toLabel string) string {
verb := transitionChangeLabels[transitionType]
if verb == "" {
verb = transitionType
}
// Separators are a colon and the word "to": UI copy carries no em
// dashes (design-system §4; the lint rule covers templates, this
// string reaches the page through one).
switch transitionType {
case "upgrade", "downgrade":
return verb + ": " + fromLabel + " to " + toLabel
case "end":
return verb + ": " + fromLabel
default:
// initiate, transfer, and any future type: the position lands ON
// toLabel (transfer keeps the same tier under a new funding source,
// so fromLabel/toLabel are equal there).
return verb + ": " + toLabel
}
}
// transitionLadderIDs batch-resolves plan_ladder_id per transition_id for
// the activity feed's transitions. ListRecentTransitions
// (internal/entitlements/queries/pool_provision_transitions.sql) carries
// only org_id — the column the landing feed needed until now — not
// plan_ladder_id, so this queries core.pool_provision_transitions directly
// (the same cross-schema raw-SQL pattern operator_pages.go's
// ownedResourceKeys uses) rather than widening a shared entitlements query
// for this one caller. Failures degrade to an empty map — the caller falls
// back to "rank N" labels rather than failing the feed.
func transitionLadderIDs(ctx context.Context, db *sql.DB, logger *slog.Logger, transitionIDs []string) map[string]string {
ladderIDs := make(map[string]string, len(transitionIDs))
if db == nil || len(transitionIDs) == 0 {
return ladderIDs
}
rows, err := db.QueryContext(ctx,
`SELECT transition_id, plan_ladder_id FROM core.pool_provision_transitions WHERE transition_id = ANY($1)`,
pq.Array(transitionIDs))
if err != nil {
logger.Warn("activity feed: transition ladder lookup failed", slog.Any("error", err))
return ladderIDs
}
defer rows.Close()
for rows.Next() {
var transitionID, ladderID string
if err := rows.Scan(&transitionID, &ladderID); err != nil {
logger.Warn("activity feed: transition ladder scan failed", slog.Any("error", err))
return ladderIDs
}
ladderIDs[transitionID] = ladderID
}
if err := rows.Err(); err != nil {
logger.Warn("activity feed: transition ladder rows failed", slog.Any("error", err))
}
return ladderIDs
}
// tierNameResolver resolves a (ladder, rank) pair to its tier's product
// name — the same names the organization composite's Tier changes table
// shows (BillingQ.ListTiersByLadderWithProducts) — caching per ladder so a
// feed page with many rows on the same ladder costs one query per ladder,
// not one per row. Mirrors operator_enrollment.go's buildOrgEnrollmentData
// tierLabel closure. Falls back to "rank N" when the ladder or rank cannot
// be resolved (a reordered ladder can have moved products since — the same
// caveat the composite's own table carries), and to "—" when the rank
// itself is absent (an "initiate" transition's FromRank).
type tierNameResolver struct {
ctx context.Context
billingQ billing.Querier
cache map[string]map[int32]string
}
// newTierNameResolver builds a resolver scoped to one loadRecentActivity
// call, so its per-ladder cache is reused across every transition row on
// the page instead of querying the same ladder's tiers repeatedly.
func newTierNameResolver(ctx context.Context, billingQ billing.Querier) *tierNameResolver {
return &tierNameResolver{ctx: ctx, billingQ: billingQ, cache: map[string]map[int32]string{}}
}
func (r *tierNameResolver) label(ladderID string, rank sql.NullInt32) string {
if !rank.Valid {
return "—"
}
if _, ok := r.cache[ladderID]; !ok {
names := map[int32]string{}
if ladderID != "" && r.billingQ != nil {
if tiers, err := r.billingQ.ListTiersByLadderWithProducts(r.ctx, ladderID); err == nil {
for _, t := range tiers {
names[t.Rank] = t.ProductName
}
}
}
r.cache[ladderID] = names
}
if name, ok := r.cache[ladderID][rank.Int32]; ok {
return name
}
return "rank " + strconv.Itoa(int(rank.Int32))
}