diff --git a/internal/cache/redis.go b/internal/cache/redis.go new file mode 100644 index 00000000..d53f46b9 --- /dev/null +++ b/internal/cache/redis.go @@ -0,0 +1,148 @@ +// Package cache wraps the Redis client with a typed GetOrSet helper that +// collapses concurrent identical requests via singleflight and fails open +// when Redis is unavailable. +// +// Designed for the §13 eventual-consistency surfaces (billing/usage, +// team/summary) where: +// +// - The per-team aggregation is expensive enough that N concurrent +// dashboard tabs should NOT trigger N DB scans — singleflight collapses +// them to one in-process compute + one cache write. +// - A Redis outage MUST NOT break the read endpoint (the underlying DB is +// still authoritative). GetOrSet falls through to fn on every Redis +// error so the user sees data, just without the cache amortisation. +// - Hot-path callers prefer a typed result (struct, not []byte). The +// generic `T any` parameter keeps callers off encoding/json directly. +// +// Real-time paths (POST /db/new quota checks, webhook handlers) MUST NOT +// use this helper — they read fresh per the §13 freshness matrix. +package cache + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/redis/go-redis/v9" + "golang.org/x/sync/singleflight" +) + +// group is the per-process singleflight that collapses concurrent calls to +// GetOrSet sharing the same key. Keys live in one global namespace so callers +// must scope them (e.g. "billing:usage:" + teamID). +var group singleflight.Group + +// GetOrSet returns the cached value for key when present and fresh. +// +// Miss path (cache empty or returns a NOT FOUND): runs fn under singleflight, +// stores the encoded result with TTL ttl, returns the result. +// +// Failure modes (intentional fail-open semantics): +// +// - Redis GET errored — log + skip cache, run fn, return its result without +// attempting another SET (the cache layer is currently broken; don't +// hammer it). This matches the "Redis down → fall through" cell in the +// §13 freshness matrix. +// - JSON unmarshal of the cached value failed — treat as miss. Most likely +// cause is a serialised value shape change across deploys; the next SET +// after fn runs heals the cache entry. +// - fn returned an error — propagate it without touching the cache. +// - Redis SET errored on the way back — log + return the freshly-computed +// value anyway. The next call will re-attempt the SET. +// +// Negative caching (fn returned a zero-value T) is allowed and uses the same +// ttl — callers that want a shorter negative TTL should branch outside. +func GetOrSet[T any]( + ctx context.Context, + rdb *redis.Client, + key string, + ttl time.Duration, + fn func(context.Context) (T, error), +) (T, error) { + var zero T + + // Fast path: try the cache. A nil client means cache is disabled — go + // straight to fn without using singleflight (no point — there's nothing + // to collapse on). + if rdb != nil { + raw, err := rdb.Get(ctx, key).Bytes() + switch { + case err == nil: + var out T + if jerr := json.Unmarshal(raw, &out); jerr == nil { + return out, nil + } + // Corrupt cache entry — treat as miss, log so the shape skew is + // visible. Don't return the unmarshal error to the caller. + slog.Warn("cache.get_unmarshal_failed", "key", key, "error", "json decode") + case errors.Is(err, redis.Nil): + // True miss — fall through to fn under singleflight. + default: + // Redis is unreachable / down. Fail open: run fn without the + // cache wrapper and skip the SET path entirely so we don't + // hammer a flapping Redis. Bypassing singleflight here means + // N concurrent callers will all hit the DB during an outage, + // which is acceptable — the cache being down IS the + // degradation, the DB is the source of truth. + slog.Warn("cache.get_failed_fail_open", "key", key, "error", err.Error()) + return fn(ctx) + } + } + + // Miss path: collapse concurrent callers to one fn invocation. + // + // singleflight returns (value, error, shared). We ignore `shared`; both + // the leader and the followers see the same value+error pair. The leader + // is the only one that touches Redis SET — followers piggyback on the + // returned value. + v, err, _ := group.Do(key, func() (interface{}, error) { + out, fnErr := fn(ctx) + if fnErr != nil { + return out, fnErr + } + if rdb != nil { + encoded, jerr := json.Marshal(out) + if jerr != nil { + // Encoding failure is a programmer error (T can't be + // marshalled). Don't poison the cache; log + return the + // value so the request still succeeds. + slog.Warn("cache.set_marshal_failed", "key", key, "error", jerr.Error()) + return out, nil + } + if setErr := rdb.Set(ctx, key, encoded, ttl).Err(); setErr != nil { + // Same fail-open as GET: log but return the value. + slog.Warn("cache.set_failed", "key", key, "error", setErr.Error()) + } + } + return out, nil + }) + + if err != nil { + return zero, err + } + // singleflight returns the leader's value via interface{}. The type + // parameter T is the same for every caller of this key, so the assertion + // is safe under normal use; a panic here would indicate two callers + // using the same cache key with different T (a bug in caller code). + out, ok := v.(T) + if !ok { + return zero, fmt.Errorf("cache.GetOrSet: type mismatch for key %q", key) + } + return out, nil +} + +// Invalidate deletes a cache key. Use it from write paths that change the +// underlying aggregate (e.g. a deploy completing should invalidate +// billing:usage:). A nil client is a no-op so callers can wire this +// in without conditional checks. +func Invalidate(ctx context.Context, rdb *redis.Client, key string) { + if rdb == nil { + return + } + if err := rdb.Del(ctx, key).Err(); err != nil { + slog.Warn("cache.invalidate_failed", "key", key, "error", err.Error()) + } +} diff --git a/internal/cache/redis_test.go b/internal/cache/redis_test.go new file mode 100644 index 00000000..1a3b9d07 --- /dev/null +++ b/internal/cache/redis_test.go @@ -0,0 +1,244 @@ +package cache_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/cache" +) + +// newMiniRedis returns a *redis.Client backed by an in-memory miniredis +// instance plus a cleanup func. Used everywhere we need a real-shaped +// Redis without a Docker container. +func newMiniRedis(t *testing.T) (*redis.Client, func()) { + t.Helper() + mr, err := miniredis.Run() + require.NoError(t, err) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + return rdb, func() { + rdb.Close() + mr.Close() + } +} + +type usagePayload struct { + Postgres int64 `json:"postgres"` + Redis int64 `json:"redis"` +} + +// TestGetOrSet_MissRunsFnOnceAndCaches verifies the basic Redis-miss path: +// the first call runs fn, the second call short-circuits to the cache. +func TestGetOrSet_MissRunsFnOnceAndCaches(t *testing.T) { + rdb, cleanup := newMiniRedis(t) + defer cleanup() + + var calls atomic.Int32 + fn := func(_ context.Context) (usagePayload, error) { + calls.Add(1) + return usagePayload{Postgres: 100, Redis: 50}, nil + } + + ctx := context.Background() + v1, err := cache.GetOrSet(ctx, rdb, "test:k1", 60*time.Second, fn) + require.NoError(t, err) + assert.Equal(t, usagePayload{Postgres: 100, Redis: 50}, v1) + + v2, err := cache.GetOrSet(ctx, rdb, "test:k1", 60*time.Second, fn) + require.NoError(t, err) + assert.Equal(t, usagePayload{Postgres: 100, Redis: 50}, v2) + + assert.Equal(t, int32(1), calls.Load(), "fn should have run exactly once across both calls") +} + +// TestGetOrSet_SingleflightCollapsesConcurrentCallers — the headline §10.20 +// guarantee: N concurrent identical requests collapse to 1 fn invocation. +// Without singleflight, N callers would race past the empty-cache check and +// all run fn before any of them got to SET. With singleflight, the leader +// runs fn and the followers receive its result. +func TestGetOrSet_SingleflightCollapsesConcurrentCallers(t *testing.T) { + rdb, cleanup := newMiniRedis(t) + defer cleanup() + + const concurrency = 20 + var calls atomic.Int32 + // gate holds fn open until every goroutine is in flight, so they all + // observe the same "cache empty" snapshot. Without it the test races — + // goroutine #N might run after goroutine #1 already set the cache. + gate := make(chan struct{}) + fn := func(_ context.Context) (usagePayload, error) { + <-gate + calls.Add(1) + // A small sleep makes the singleflight window visible — the leader + // is still inside fn when followers arrive. Without it the timing + // can occasionally let a follower miss the inflight entry. + time.Sleep(20 * time.Millisecond) + return usagePayload{Postgres: 42}, nil + } + + ctx := context.Background() + results := make(chan usagePayload, concurrency) + errs := make(chan error, concurrency) + + var wg sync.WaitGroup + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + v, err := cache.GetOrSet(ctx, rdb, "test:sf", 60*time.Second, fn) + results <- v + errs <- err + }() + } + // Let every goroutine reach the gate before any of them runs fn. + time.Sleep(50 * time.Millisecond) + close(gate) + wg.Wait() + close(results) + close(errs) + + for err := range errs { + require.NoError(t, err) + } + for v := range results { + assert.Equal(t, usagePayload{Postgres: 42}, v) + } + assert.Equal(t, int32(1), calls.Load(), "singleflight should collapse %d concurrent callers to 1 fn invocation", concurrency) +} + +// TestGetOrSet_RedisDownFailsOpen verifies that when Redis errors on GET, +// GetOrSet falls through to fn and returns its result. The cache being +// unreachable must never break the read path. +func TestGetOrSet_RedisDownFailsOpen(t *testing.T) { + // Point at a closed port — the dial will fail fast. + rdb := redis.NewClient(&redis.Options{ + Addr: "127.0.0.1:1", // reserved low port, refuses connections + DialTimeout: 50 * time.Millisecond, + }) + defer rdb.Close() + + var calls atomic.Int32 + fn := func(_ context.Context) (usagePayload, error) { + calls.Add(1) + return usagePayload{Postgres: 7}, nil + } + + ctx := context.Background() + v, err := cache.GetOrSet(ctx, rdb, "test:down", 60*time.Second, fn) + require.NoError(t, err) + assert.Equal(t, usagePayload{Postgres: 7}, v) + assert.Equal(t, int32(1), calls.Load(), "fn must run when redis is down") + + // A second call must also reach fn — we bypass singleflight on the + // Redis-down path to avoid hammering a flapping cache, and the cache + // itself can't serve the entry. (See §10.20 fail-open contract.) + v2, err := cache.GetOrSet(ctx, rdb, "test:down", 60*time.Second, fn) + require.NoError(t, err) + assert.Equal(t, usagePayload{Postgres: 7}, v2) + assert.Equal(t, int32(2), calls.Load()) +} + +// TestGetOrSet_NilClientPassesThrough — a nil *redis.Client means "no cache +// configured"; GetOrSet should still call fn and return its result. Useful +// in tests and in dev configs where Redis isn't wired. +func TestGetOrSet_NilClientPassesThrough(t *testing.T) { + var calls atomic.Int32 + fn := func(_ context.Context) (usagePayload, error) { + calls.Add(1) + return usagePayload{Postgres: 1}, nil + } + v, err := cache.GetOrSet(context.Background(), nil, "test:nil", 60*time.Second, fn) + require.NoError(t, err) + assert.Equal(t, usagePayload{Postgres: 1}, v) + assert.Equal(t, int32(1), calls.Load()) +} + +// TestGetOrSet_FnErrorPropagates — a fn error must not be cached and must +// surface to the caller verbatim. +func TestGetOrSet_FnErrorPropagates(t *testing.T) { + rdb, cleanup := newMiniRedis(t) + defer cleanup() + + sentinel := errors.New("aggregate failed") + fn := func(_ context.Context) (usagePayload, error) { + return usagePayload{}, sentinel + } + + _, err := cache.GetOrSet(context.Background(), rdb, "test:err", 60*time.Second, fn) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) + + // Confirm the cache was NOT populated. + _, ferr := rdb.Get(context.Background(), "test:err").Bytes() + assert.ErrorIs(t, ferr, redis.Nil) +} + +// TestGetOrSet_ZeroValueCachesNegative — fn returning a zero-value T is a +// valid result (e.g. a team with no resources). It must still be cached so +// the next caller doesn't re-run the aggregate. +func TestGetOrSet_ZeroValueCachesNegative(t *testing.T) { + rdb, cleanup := newMiniRedis(t) + defer cleanup() + + var calls atomic.Int32 + fn := func(_ context.Context) (usagePayload, error) { + calls.Add(1) + return usagePayload{}, nil + } + ctx := context.Background() + _, err := cache.GetOrSet(ctx, rdb, "test:empty", 60*time.Second, fn) + require.NoError(t, err) + _, err = cache.GetOrSet(ctx, rdb, "test:empty", 60*time.Second, fn) + require.NoError(t, err) + assert.Equal(t, int32(1), calls.Load(), "zero-value results must still be cached") +} + +// TestGetOrSet_CorruptCacheEntryFallsThrough — if a cache entry was +// serialised under an older shape, json.Unmarshal returns an error and +// GetOrSet treats it as a miss. The next SET heals the entry. +func TestGetOrSet_CorruptCacheEntryFallsThrough(t *testing.T) { + rdb, cleanup := newMiniRedis(t) + defer cleanup() + + // Plant a value that doesn't decode as usagePayload. + require.NoError(t, rdb.Set(context.Background(), "test:corrupt", "not-json", time.Minute).Err()) + + var calls atomic.Int32 + fn := func(_ context.Context) (usagePayload, error) { + calls.Add(1) + return usagePayload{Postgres: 999}, nil + } + v, err := cache.GetOrSet(context.Background(), rdb, "test:corrupt", time.Minute, fn) + require.NoError(t, err) + assert.Equal(t, usagePayload{Postgres: 999}, v) + assert.Equal(t, int32(1), calls.Load()) +} + +// TestInvalidate_DeletesKey ensures Invalidate clears the cache and a nil +// client is a no-op. +func TestInvalidate_DeletesKey(t *testing.T) { + rdb, cleanup := newMiniRedis(t) + defer cleanup() + + fn := func(_ context.Context) (usagePayload, error) { + return usagePayload{Postgres: 5}, nil + } + ctx := context.Background() + _, err := cache.GetOrSet(ctx, rdb, "test:inv", time.Minute, fn) + require.NoError(t, err) + + cache.Invalidate(ctx, rdb, "test:inv") + _, err = rdb.Get(ctx, "test:inv").Bytes() + assert.ErrorIs(t, err, redis.Nil) + + // nil client → no panic. + cache.Invalidate(ctx, nil, "test:inv") +} diff --git a/internal/handlers/billing_usage.go b/internal/handlers/billing_usage.go new file mode 100644 index 00000000..11a5291c --- /dev/null +++ b/internal/handlers/billing_usage.go @@ -0,0 +1,230 @@ +package handlers + +import ( + "context" + "database/sql" + "log/slog" + "strconv" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "instant.dev/internal/cache" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/plans" +) + +// BillingUsageHandler serves the cached billing-usage aggregate consumed by +// the dashboard's BillingPage. It replaces the client-side aggregation that +// previously summed storage_bytes per type in the browser — that path forced +// the dashboard to fetch the full resource list just to compute the Usage +// panel, and every concurrent tab triggered its own DB scan via /resources. +// +// Now the aggregation happens once per team per cache window (30s) and the +// answer is shared across every surface that needs it (BillingPage, the +// future MCP agent_usage_summary tool, etc.). +// +// Real-time paths (POST /db/new etc.) MUST NOT use this aggregate — they +// gate on a fresh DB read per §13. +type BillingUsageHandler struct { + db *sql.DB + rdb *redis.Client + plans *plans.Registry +} + +// NewBillingUsageHandler builds a BillingUsageHandler. rdb may be nil in +// tests / configs where caching is disabled (the cache helper handles nil +// transparently). +func NewBillingUsageHandler(db *sql.DB, rdb *redis.Client, p *plans.Registry) *BillingUsageHandler { + return &BillingUsageHandler{db: db, rdb: rdb, plans: p} +} + +// billingUsageTTL is the cache freshness window for /api/v1/billing/usage. +// 30s matches the §13 freshness target and the Cache-Control max-age below. +// Tune as a single source of truth: change here, the response's +// `freshness_seconds` and the Cache-Control header both follow. +const billingUsageTTL = 30 * time.Second + +// usageSummary is the cached payload — JSON-encoded into Redis. Field tags +// match the public response shape per §10.20.2 so the same struct serialises +// both ways (cache value + HTTP response body) and there's no second mapping +// step. +type usageSummary struct { + OK bool `json:"ok"` + FreshnessSeconds int `json:"freshness_seconds"` + AsOf string `json:"as_of"` + Usage map[string]usageMetric `json:"usage"` +} + +// usageMetric carries both `bytes` (storage services) and `count` (deploys, +// webhooks, vault, members). Only the relevant field renders per metric — +// the other stays at -1 to mean "not applicable to this kind". Limits stay +// at -1 to mean "unlimited" (matches plans.yaml convention). +type usageMetric struct { + Bytes int64 `json:"bytes,omitempty"` + LimitBytes int64 `json:"limit_bytes,omitempty"` + Count int `json:"count,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// GetUsage handles GET /api/v1/billing/usage. +// +// Auth: session JWT (required by the /api/v1 RequireAuth middleware in the +// router). Team scope comes from the JWT claims — no team_id in the path. +// +// Cache: 30s in Redis under "billing:usage:". Concurrent callers +// collapse via singleflight. Redis-down → fall through to fn (no caching +// for that request). HTTP response sets: +// +// Cache-Control: private, max-age=30, stale-while-revalidate=60 +// +// so browsers + intermediate proxies honour the same window without +// hammering the API. +// +// Response shape (per §10.20.2): +// +// { +// "ok": true, +// "freshness_seconds": 30, +// "as_of": "2026-05-12T00:00:00Z", +// "usage": { +// "postgres": { "bytes": ..., "limit_bytes": ... }, +// "redis": { "bytes": ..., "limit_bytes": ... }, +// "mongodb": { ... }, +// "deployments": { "count": ..., "limit": ... }, +// "webhooks": { "count": ..., "limit": ... }, +// "vault": { "count": ..., "limit": ... }, +// "members": { "count": ..., "limit": ... } +// } +// } +func (h *BillingUsageHandler) GetUsage(c *fiber.Ctx) error { + teamIDStr := middleware.GetTeamID(c) + teamID, err := uuid.Parse(teamIDStr) + if err != nil { + return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required") + } + + key := "billing:usage:" + teamID.String() + + summary, err := cache.GetOrSet(c.Context(), h.rdb, key, billingUsageTTL, + func(ctx context.Context) (usageSummary, error) { + return h.computeUsage(ctx, teamID) + }) + if err != nil { + slog.Error("billing.usage.compute_failed", + "error", err, "team_id", teamID, + "request_id", middleware.GetRequestID(c)) + return respondError(c, fiber.StatusInternalServerError, "usage_failed", "Failed to compute usage") + } + + // Cache-Control matches the TTL so the browser respects the same + // window. private = don't cache in shared proxies (per-team data). + // stale-while-revalidate gives a 60s grace window where the browser + // can serve the stale value while it kicks off a background refresh. + c.Set("Cache-Control", "private, max-age="+strconv.Itoa(int(billingUsageTTL.Seconds()))+", stale-while-revalidate=60") + return c.JSON(summary) +} + +// computeUsage runs the DB aggregations for one team. Called from cache miss +// + every Redis-down request. The function is broken out so tests can hit +// it directly (counting DB queries) without going through the cache layer. +// +// Each aggregate is queried independently — a failure on `members` doesn't +// fail the storage rows. The first hard error wins (returned to caller); the +// rest are best-effort. +func (h *BillingUsageHandler) computeUsage(ctx context.Context, teamID uuid.UUID) (usageSummary, error) { + tier, err := h.tierForTeam(ctx, teamID) + if err != nil { + return usageSummary{}, err + } + + usage := map[string]usageMetric{} + + // Storage services (bytes + limit_bytes). MB limits from plans.yaml are + // converted to bytes inline so the dashboard doesn't need a unit-aware + // formatter. + for _, svc := range []string{"postgres", "redis", "mongodb"} { + bytes, sumErr := models.SumStorageBytesByTeamAndType(ctx, h.db, teamID, svc) + if sumErr != nil { + return usageSummary{}, sumErr + } + limitMB := h.plans.StorageLimitMB(tier, svc) + usage[svc] = usageMetric{ + Bytes: bytes, + LimitBytes: mbToBytes(limitMB), + } + } + + // Counts: deployments / webhooks / vault / members. Each independent. + deployCount, _ := h.countDeployments(ctx, teamID) + usage["deployments"] = usageMetric{ + Count: deployCount, + Limit: h.plans.DeploymentsAppsLimit(tier), + } + + webhookCount, _ := models.CountActiveResourcesByTeamAndType(ctx, h.db, teamID, "webhook") + usage["webhooks"] = usageMetric{ + Count: webhookCount, + Limit: h.plans.StorageLimitMB(tier, "webhook"), // webhook_requests_stored, reused here as a count cap + } + + vaultCount, _ := models.CountVaultKeysByTeam(ctx, h.db, teamID) + usage["vault"] = usageMetric{ + Count: vaultCount, + Limit: h.plans.VaultMaxEntries(tier), + } + + memberCount, _ := models.CountTeamMembers(ctx, h.db, teamID) + usage["members"] = usageMetric{ + Count: memberCount, + Limit: h.plans.TeamMemberLimit(tier), + } + + return usageSummary{ + OK: true, + FreshnessSeconds: int(billingUsageTTL.Seconds()), + AsOf: time.Now().UTC().Format(time.RFC3339Nano), + Usage: usage, + }, nil +} + +// tierForTeam resolves the team's current plan_tier. Falls back to +// "anonymous" if the team row is missing — defensive against a torn DB +// state, and the plans.Registry treats unknown tiers as anonymous anyway. +func (h *BillingUsageHandler) tierForTeam(ctx context.Context, teamID uuid.UUID) (string, error) { + team, err := models.GetTeamByID(ctx, h.db, teamID) + if err != nil { + return "anonymous", err + } + return team.PlanTier, nil +} + +// countDeployments counts a team's deployments across all envs. Status +// values "deleted" / "stopped" are excluded so the count reflects what the +// user can see — matches the dashboard's "active deployments" framing. +// Implemented as a SELECT COUNT so we don't pull every row across the wire. +func (h *BillingUsageHandler) countDeployments(ctx context.Context, teamID uuid.UUID) (int, error) { + var n int + err := h.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM deployments + WHERE team_id = $1 + AND status NOT IN ('deleted', 'stopped') + `, teamID).Scan(&n) + if err != nil { + return 0, err + } + return n, nil +} + +// mbToBytes converts a plans.yaml megabyte value to bytes. -1 (unlimited) +// stays -1 so the dashboard renders "∞". +func mbToBytes(mb int) int64 { + if mb < 0 { + return -1 + } + return int64(mb) * 1024 * 1024 +} diff --git a/internal/handlers/billing_usage_test.go b/internal/handlers/billing_usage_test.go new file mode 100644 index 00000000..54c5cafc --- /dev/null +++ b/internal/handlers/billing_usage_test.go @@ -0,0 +1,217 @@ +package handlers_test + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/alicebob/miniredis/v2" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/plans" +) + +// expectUsageQueries primes a sqlmock with the exact query sequence +// BillingUsageHandler.computeUsage runs. Match strings are kept loose +// (substring expectations) so a future re-format of the SQL doesn't break +// these tests as long as the semantic shape stays the same. +// +// Order matters in sqlmock — expectations are satisfied in FIFO order. +// computeUsage runs: +// +// 1) SELECT … FROM teams WHERE id = $1 (tierForTeam) +// 2-4) SELECT COALESCE(SUM(storage_bytes)…) (postgres, redis, mongodb) +// 5) SELECT COUNT(*) FROM deployments (countDeployments) +// 6) SELECT COUNT(*) FROM resources … resource_type='webhook' +// 7) SELECT COUNT(*) FROM vault_secrets (CountVaultKeysByTeam) +// 8) SELECT COUNT(*) FROM team_members (CountTeamMembers) +// +// The team_members one differs slightly across schema versions; we use +// QueryMatcherEqual=off (default) so substring matching catches both shapes. +func expectUsageQueries(mock sqlmock.Sqlmock, teamID uuid.UUID) { + // 1) teams row → tier "hobby" + mock.ExpectQuery(`SELECT.*FROM teams WHERE id`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "plan_tier", "stripe_customer_id", "trial_ends_at", "created_at", + }).AddRow(teamID, sql.NullString{}, "hobby", sql.NullString{}, nil, time.Now())) + + // 2-4) storage sums for postgres, redis, mongodb. Each returns 0 bytes + // — we only care that the query fires. + for range []string{"postgres", "redis", "mongodb"} { + mock.ExpectQuery(`SELECT COALESCE\(SUM\(storage_bytes\)`). + WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(0))) + } + + // 5) deployments count + mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM deployments`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + // 6) webhook resource count — CountActiveResourcesByTeamAndType + mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM resources`). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + // 7) vault keys count — CountVaultKeysByTeam does + // SELECT COUNT(DISTINCT key) FROM vault_secrets WHERE team_id = $1 + mock.ExpectQuery(`SELECT COUNT\(DISTINCT key\) FROM vault_secrets`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + // 8) team members count — models.CountTeamMembers does `FROM users` + // (a team is the parent table; users.team_id is the FK). + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) +} + +// newUsageApp wires a Fiber app with the billing-usage route mounted under +// /api/v1 with a no-op auth middleware that just stamps team_id onto the +// context. Lets the test drive the handler without minting a real JWT. +func newUsageApp(t *testing.T, db *sql.DB, rdb *redis.Client, teamID uuid.UUID) *fiber.App { + t.Helper() + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(middleware.RequestID()) + app.Use(func(c *fiber.Ctx) error { + c.Locals(middleware.LocalKeyTeamID, teamID.String()) + c.Locals(middleware.LocalKeyUserID, uuid.NewString()) + return c.Next() + }) + h := handlers.NewBillingUsageHandler(db, rdb, plans.Default()) + app.Get("/api/v1/billing/usage", h.GetUsage) + return app +} + +// TestBillingUsage_CachedHitSkipsDBOnSecondCall — the headline §10.20 +// guarantee: calling /billing/usage twice in <30s for the same team runs +// ONE DB aggregation, not two. The second call is served entirely from +// Redis. The sqlmock asserts no extra queries fire. +func TestBillingUsage_CachedHitSkipsDBOnSecondCall(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer rdb.Close() + + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + teamID := uuid.New() + expectUsageQueries(mock, teamID) + // NO extra expectations — a second app.Test call must not run a single + // new query. sqlmock's ExpectationsWereMet() fails if any extra queries + // fire (it's strict mode). + + app := newUsageApp(t, db, rdb, teamID) + + // First call: cache miss → runs the aggregations + sets Redis. + req1 := httptest.NewRequest(http.MethodGet, "/api/v1/billing/usage", nil) + resp1, err := app.Test(req1, 5000) + require.NoError(t, err) + defer resp1.Body.Close() + assert.Equal(t, http.StatusOK, resp1.StatusCode) + assert.Equal(t, "private, max-age=30, stale-while-revalidate=60", resp1.Header.Get("Cache-Control")) + + var body1 map[string]any + require.NoError(t, json.NewDecoder(resp1.Body).Decode(&body1)) + assert.Equal(t, true, body1["ok"]) + assert.Equal(t, float64(30), body1["freshness_seconds"]) + assert.NotEmpty(t, body1["as_of"], "as_of timestamp must be set so the UI can render 'as of Ns ago'") + usage, ok := body1["usage"].(map[string]any) + require.True(t, ok) + // Every expected metric is populated. + for _, k := range []string{"postgres", "redis", "mongodb", "deployments", "webhooks", "vault", "members"} { + _, exists := usage[k] + assert.True(t, exists, "usage[%s] must be present", k) + } + + // Second call: cache hit → no DB activity. + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/billing/usage", nil) + resp2, err := app.Test(req2, 5000) + require.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusOK, resp2.StatusCode) + + // sqlmock strict mode: any unexpected query would have failed the test + // already. ExpectationsWereMet() verifies the queue is empty (no + // expectations left unsatisfied). + require.NoError(t, mock.ExpectationsWereMet(), "expected exactly one set of DB queries across two cached requests") +} + +// TestBillingUsage_RedisDownStillServesData — with Redis unreachable, every +// request runs the aggregation but the response stays 200 + valid JSON. +// Proves the §13 fail-open contract: cache down ≠ endpoint down. +func TestBillingUsage_RedisDownStillServesData(t *testing.T) { + // Closed port — dial fails fast. + rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}) + defer rdb.Close() + + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + teamID := uuid.New() + // Two full sets of queries — once per call, since Redis can't cache. + expectUsageQueries(mock, teamID) + expectUsageQueries(mock, teamID) + + app := newUsageApp(t, db, rdb, teamID) + + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodGet, "/api/v1/billing/usage", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + } + require.NoError(t, mock.ExpectationsWereMet()) +} + +// TestBillingUsage_DifferentTeamsGetDifferentCacheEntries — cache keys +// scope by team_id (§14 question 7). Team A's cached value must not be +// served to team B. +func TestBillingUsage_DifferentTeamsGetDifferentCacheEntries(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer rdb.Close() + + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + teamA := uuid.New() + teamB := uuid.New() + expectUsageQueries(mock, teamA) + expectUsageQueries(mock, teamB) + + for _, tid := range []uuid.UUID{teamA, teamB} { + app := newUsageApp(t, db, rdb, tid) + req := httptest.NewRequest(http.MethodGet, "/api/v1/billing/usage", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + } + require.NoError(t, mock.ExpectationsWereMet(), "each team must trigger its own aggregation") +} diff --git a/internal/handlers/team_summary.go b/internal/handlers/team_summary.go new file mode 100644 index 00000000..7d58292a --- /dev/null +++ b/internal/handlers/team_summary.go @@ -0,0 +1,225 @@ +package handlers + +import ( + "context" + "database/sql" + "log/slog" + "strconv" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "instant.dev/internal/cache" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/plans" +) + +// TeamSummaryHandler serves the cached team-level counts the dashboard +// sidebar (SidebarUpgradeCard, badge numbers) renders. It avoids the +// previous pattern where every page-load triggered its own +// /api/v1/resources scan to compute a single number. +// +// 5-minute cache window — the sidebar numbers don't need to be fresh on +// the millisecond; a resource provisioned in another tab will appear on +// the next refresh within 5 min. The §13 freshness matrix calls this +// eventual-consistent on purpose. +type TeamSummaryHandler struct { + db *sql.DB + rdb *redis.Client + plans *plans.Registry +} + +// NewTeamSummaryHandler builds a TeamSummaryHandler. rdb may be nil. +func NewTeamSummaryHandler(db *sql.DB, rdb *redis.Client, p *plans.Registry) *TeamSummaryHandler { + return &TeamSummaryHandler{db: db, rdb: rdb, plans: p} +} + +// teamSummaryTTL — 5 minutes is long enough that one signed-in user opening +// every dashboard page across a session triggers ~1 aggregate per surface, +// short enough that a provision/delete is visible quickly. +const teamSummaryTTL = 5 * time.Minute + +// teamSummary is both the cached payload and the public response. Keeping +// the struct shared means a deploy-time JSON shape change naturally +// invalidates older cache entries (json.Unmarshal fails → cache helper +// treats as miss → next request rebuilds). +type teamSummary struct { + OK bool `json:"ok"` + FreshnessSeconds int `json:"freshness_seconds"` + AsOf string `json:"as_of"` + Tier string `json:"tier"` + Counts teamSummaryCountsRes `json:"counts"` +} + +// teamSummaryCountsRes carries the four "how many X do we have" counts the +// sidebar consumes. Each is a separate field rather than a generic map so +// the JSON shape is stable (and the dashboard's TypeScript types match +// exactly). +type teamSummaryCountsRes struct { + Resources resourceTypeCounts `json:"resources"` + Deployments int `json:"deployments"` + Members int `json:"members"` + VaultKeys int `json:"vault_keys"` +} + +// resourceTypeCounts gives per-type breakdown of active resources. Total is +// the sum (saves the dashboard from re-adding). Per-type values let the +// sidebar's badge numbers ("Resources · 7") show without an extra query. +type resourceTypeCounts struct { + Total int `json:"total"` + Postgres int `json:"postgres"` + Redis int `json:"redis"` + Mongodb int `json:"mongodb"` + Webhook int `json:"webhook"` + Queue int `json:"queue"` + Storage int `json:"storage"` + Other int `json:"other"` +} + +// GetSummary handles GET /api/v1/team/summary. +// +// Auth: session JWT. Team scope comes from the JWT claims. +// +// Cache: 5 min in Redis under "team:summary:". Concurrent callers +// collapse via singleflight. HTTP response sets: +// +// Cache-Control: private, max-age=300 +// +// (No stale-while-revalidate — at 5 min, the freshness window is already +// large enough that we don't need a soft-revalidate phase.) +func (h *TeamSummaryHandler) GetSummary(c *fiber.Ctx) error { + teamIDStr := middleware.GetTeamID(c) + teamID, err := uuid.Parse(teamIDStr) + if err != nil { + return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required") + } + + key := "team:summary:" + teamID.String() + + summary, err := cache.GetOrSet(c.Context(), h.rdb, key, teamSummaryTTL, + func(ctx context.Context) (teamSummary, error) { + return h.computeSummary(ctx, teamID) + }) + if err != nil { + slog.Error("team.summary.compute_failed", + "error", err, "team_id", teamID, + "request_id", middleware.GetRequestID(c)) + return respondError(c, fiber.StatusInternalServerError, "summary_failed", "Failed to compute team summary") + } + + c.Set("Cache-Control", "private, max-age="+strconv.Itoa(int(teamSummaryTTL.Seconds()))) + return c.JSON(summary) +} + +// computeSummary runs the DB queries for one team. Each is wrapped to be +// best-effort except the first (which determines the tier — a hard +// requirement). Broken out so tests can count DB calls directly. +func (h *TeamSummaryHandler) computeSummary(ctx context.Context, teamID uuid.UUID) (teamSummary, error) { + team, err := models.GetTeamByID(ctx, h.db, teamID) + if err != nil { + return teamSummary{}, err + } + + counts := teamSummaryCountsRes{} + + if rt, rterr := h.countResourcesByType(ctx, teamID); rterr == nil { + counts.Resources = rt + } else { + slog.Warn("team.summary.resource_count_failed", "error", rterr, "team_id", teamID) + } + + if n, derr := h.countDeployments(ctx, teamID); derr == nil { + counts.Deployments = n + } else { + slog.Warn("team.summary.deploy_count_failed", "error", derr, "team_id", teamID) + } + + if n, merr := models.CountTeamMembers(ctx, h.db, teamID); merr == nil { + counts.Members = n + } else { + slog.Warn("team.summary.member_count_failed", "error", merr, "team_id", teamID) + } + + if n, verr := models.CountVaultKeysByTeam(ctx, h.db, teamID); verr == nil { + counts.VaultKeys = n + } else { + slog.Warn("team.summary.vault_count_failed", "error", verr, "team_id", teamID) + } + + return teamSummary{ + OK: true, + FreshnessSeconds: int(teamSummaryTTL.Seconds()), + AsOf: time.Now().UTC().Format(time.RFC3339Nano), + Tier: team.PlanTier, + Counts: counts, + }, nil +} + +// countResourcesByType runs one GROUP BY resource_type query and bins the +// rows into the resourceTypeCounts struct. One query for the whole breakdown +// — cheaper than six separate COUNTs. +func (h *TeamSummaryHandler) countResourcesByType(ctx context.Context, teamID uuid.UUID) (resourceTypeCounts, error) { + out := resourceTypeCounts{} + rows, err := h.db.QueryContext(ctx, ` + SELECT resource_type, COUNT(*) + FROM resources + WHERE team_id = $1 AND status = 'active' + GROUP BY resource_type + `, teamID) + if err != nil { + return out, err + } + defer rows.Close() + + for rows.Next() { + var t string + var n int + if scanErr := rows.Scan(&t, &n); scanErr != nil { + return out, scanErr + } + out.Total += n + switch t { + case "postgres": + out.Postgres = n + case "redis": + out.Redis = n + case "mongodb": + out.Mongodb = n + case "webhook": + out.Webhook = n + case "queue": + out.Queue = n + case "storage": + out.Storage = n + default: + // Unknown resource_type — most likely a new service shipped + // since this code was written. Fold it into `other` so the + // total stays accurate even when the breakdown doesn't have + // a typed bucket yet. + out.Other += n + } + } + return out, rows.Err() +} + +// countDeployments mirrors BillingUsageHandler.countDeployments — same +// "exclude deleted/stopped" rule. Duplicated rather than factored out +// because the two handlers live in different files and the duplication is +// trivial; consolidating would mean a small models.CountDeployments +// helper which is one PR's worth of churn for negligible value here. +func (h *TeamSummaryHandler) countDeployments(ctx context.Context, teamID uuid.UUID) (int, error) { + var n int + err := h.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM deployments + WHERE team_id = $1 + AND status NOT IN ('deleted', 'stopped') + `, teamID).Scan(&n) + if err != nil { + return 0, err + } + return n, nil +} diff --git a/internal/handlers/team_summary_test.go b/internal/handlers/team_summary_test.go new file mode 100644 index 00000000..38f00852 --- /dev/null +++ b/internal/handlers/team_summary_test.go @@ -0,0 +1,165 @@ +package handlers_test + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/alicebob/miniredis/v2" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/plans" +) + +// expectTeamSummaryQueries primes sqlmock with the four-query sequence +// TeamSummaryHandler.computeSummary runs: +// +// 1) teams row → tier +// 2) GROUP BY resource_type (countResourcesByType) +// 3) COUNT(*) FROM deployments (countDeployments) +// 4) COUNT(*) FROM users WHERE team_id (CountTeamMembers) +// 5) COUNT(DISTINCT key) FROM vault_secrets (CountVaultKeysByTeam) +func expectTeamSummaryQueries(mock sqlmock.Sqlmock, teamID uuid.UUID) { + mock.ExpectQuery(`SELECT.*FROM teams WHERE id`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "plan_tier", "stripe_customer_id", "trial_ends_at", "created_at", + }).AddRow(teamID, sql.NullString{}, "pro", sql.NullString{}, nil, time.Now())) + + // resource_type breakdown — one row per type. The handler bins each + // row into the typed struct; unknown types fold into `other`. + mock.ExpectQuery(`SELECT resource_type, COUNT\(\*\)`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{"resource_type", "count"}). + AddRow("postgres", 2). + AddRow("redis", 1). + AddRow("webhook", 3)) + + // deployments count + mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM deployments`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + // team members + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + + // vault keys + mock.ExpectQuery(`SELECT COUNT\(DISTINCT key\) FROM vault_secrets`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(5)) +} + +func newSummaryApp(t *testing.T, db *sql.DB, rdb *redis.Client, teamID uuid.UUID) *fiber.App { + t.Helper() + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(middleware.RequestID()) + app.Use(func(c *fiber.Ctx) error { + c.Locals(middleware.LocalKeyTeamID, teamID.String()) + c.Locals(middleware.LocalKeyUserID, uuid.NewString()) + return c.Next() + }) + h := handlers.NewTeamSummaryHandler(db, rdb, plans.Default()) + app.Get("/api/v1/team/summary", h.GetSummary) + return app +} + +// TestTeamSummary_CachedHitSkipsDBOnSecondCall — same headline guarantee +// as /billing/usage: two calls inside the 5-min window run ONE aggregation. +func TestTeamSummary_CachedHitSkipsDBOnSecondCall(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer rdb.Close() + + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + teamID := uuid.New() + expectTeamSummaryQueries(mock, teamID) + + app := newSummaryApp(t, db, rdb, teamID) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/team/summary", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "private, max-age=300", resp.Header.Get("Cache-Control")) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, true, body["ok"]) + assert.Equal(t, float64(300), body["freshness_seconds"]) + assert.Equal(t, "pro", body["tier"]) + assert.NotEmpty(t, body["as_of"]) + + counts, ok := body["counts"].(map[string]any) + require.True(t, ok) + resourcesObj := counts["resources"].(map[string]any) + assert.Equal(t, float64(6), resourcesObj["total"], "2 postgres + 1 redis + 3 webhook = 6") + assert.Equal(t, float64(2), resourcesObj["postgres"]) + assert.Equal(t, float64(1), resourcesObj["redis"]) + assert.Equal(t, float64(3), resourcesObj["webhook"]) + assert.Equal(t, float64(1), counts["deployments"]) + assert.Equal(t, float64(2), counts["members"]) + assert.Equal(t, float64(5), counts["vault_keys"]) + + // Second call: must not touch the DB. + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/team/summary", nil) + resp2, err := app.Test(req2, 5000) + require.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusOK, resp2.StatusCode) + require.NoError(t, mock.ExpectationsWereMet(), "second call must hit cache, not DB") +} + +// TestTeamSummary_DifferentTeamsGetDifferentCacheEntries — team-scoped +// keys (§14 question 7). Two teams = two DB roundtrips. +func TestTeamSummary_DifferentTeamsGetDifferentCacheEntries(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer rdb.Close() + + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + teamA := uuid.New() + teamB := uuid.New() + expectTeamSummaryQueries(mock, teamA) + expectTeamSummaryQueries(mock, teamB) + + for _, tid := range []uuid.UUID{teamA, teamB} { + app := newSummaryApp(t, db, rdb, tid) + req := httptest.NewRequest(http.MethodGet, "/api/v1/team/summary", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + } + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/internal/router/router.go b/internal/router/router.go index 5554146e..6743b964 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -228,6 +228,13 @@ func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *middleware.G app.Post("/billing/checkout", middleware.RequireAuth(cfg), billing.CreateCheckoutAPI) app.Post("/razorpay/webhook", billing.RazorpayWebhook) + // §10.20 cached-aggregation endpoints. Separate handlers from BillingHandler + // so the caching contract (Redis + singleflight + Cache-Control headers) + // is visible at the route + handler boundary, not buried inside the billing + // state aggregator. Wired below under the /api/v1 group. + billingUsageH := handlers.NewBillingUsageHandler(db, rdb, planRegistry) + teamSummaryH := handlers.NewTeamSummaryHandler(db, rdb, planRegistry) + // Public webhook request listing — token IS the credential (no session needed). // Authenticated callers use the same handler; it additionally verifies team ownership. app.Get("/api/v1/webhooks/:token/requests", middleware.OptionalAuth(cfg), webhookH.ListRequests) @@ -271,6 +278,13 @@ func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *middleware.G api.Post("/billing/update-payment", billing.UpdatePaymentMethodAPI) api.Post("/billing/change-plan", billing.ChangePlanAPI) + // §10.20 cached aggregates — see billing_usage.go / team_summary.go. + // Both cache per-team in Redis (30s / 5min) with singleflight + Cache-Control + // response headers. The dashboard's BillingPage + SidebarUpgradeCard + // consume these instead of computing aggregates client-side. + api.Get("/billing/usage", billingUsageH.GetUsage) + api.Get("/team/summary", teamSummaryH.GetSummary) + // Deploy management endpoints — Phase 6 (aliases under /api/v1) api.Get("/deployments", deployH.List) api.Get("/deployments/:id", deployH.Get)