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.
132 lines
5.4 KiB
Go
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))
|
|
}
|