From b965a239163d8b18aaac14c959ab9ed74640d60c Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 13 May 2026 09:16:29 +0530 Subject: [PATCH] admin: GET /admin/promos/audit + /admin/promos/stats for promo lifecycle visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates issued / redeemed / expired events from admin_promo_codes into a single audit feed and a totals tile. The /audit endpoint is live SQL each call (admins need to see "issued at 3 sec ago"); /stats is Redis-cached 5 min via cache.GetOrSet — same singleflight + fail-open posture as team/summary and billing/usage. Both endpoints are gated by the existing unguessable-prefix + RequireAdmin allowlist; neither appears in the public OpenAPI spec. --- internal/handlers/admin_promos_audit.go | 280 +++++++++++ internal/handlers/admin_promos_audit_test.go | 493 +++++++++++++++++++ internal/models/admin_promo_codes.go | 296 +++++++++++ internal/router/router.go | 8 + 4 files changed, 1077 insertions(+) create mode 100644 internal/handlers/admin_promos_audit.go create mode 100644 internal/handlers/admin_promos_audit_test.go diff --git a/internal/handlers/admin_promos_audit.go b/internal/handlers/admin_promos_audit.go new file mode 100644 index 00000000..91e6b224 --- /dev/null +++ b/internal/handlers/admin_promos_audit.go @@ -0,0 +1,280 @@ +package handlers + +// admin_promos_audit.go — consolidated lifecycle view of admin-issued +// promo codes. Two endpoints: +// +// GET //promos/audit — paginated event stream +// GET //promos/stats — totals + leaderboards (cached) +// +// Why they live here and not on AdminCustomersHandler: scoping. The +// customer-detail surface answers "what's going on with team X." This +// surface answers "what's going on across all promo activity." Two +// different aggregation grains, two different handlers. +// +// Freshness contract (§13 matrix): +// +// /audit — live SQL each call. Admin views are low-frequency and the +// event stream must show "issued at 3 sec ago" with no delay. +// No cache. +// /stats — Redis-cached 5 min per request. Aggregates walk every row +// in admin_promo_codes (twice — once for totals, once for the +// leaderboards). The dashboard polls this on mount + tile +// refresh; "5 min stale" is the right tradeoff for a numeric +// tile that doesn't drive any mutating UX. Eventually consistent. + +import ( + "context" + "database/sql" + "errors" + "log/slog" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + + "instant.dev/internal/cache" + "instant.dev/internal/models" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Named constants — every magic value the handler reads from the query +// string or writes to Redis lives here, not inline. +// ───────────────────────────────────────────────────────────────────────────── + +// promoAuditDefaultLimit / promoAuditMaxLimit mirror the admin-customers +// list endpoint's pagination shape so a future shared admin pagination +// helper is a drop-in. +const ( + promoAuditDefaultLimit = 50 + promoAuditMaxLimit = 500 +) + +// promoStatsCacheKey is the Redis key used by /promos/stats. Global (no +// per-team scope) because the endpoint is platform-wide. +const promoStatsCacheKey = "admin:promos:stats" + +// PromoStatsCacheTTL is the freshness window for GET /admin/promos/stats. +// Exported so tests can build their assertions against the same constant +// rather than a hard-coded duration that would silently drift. +const PromoStatsCacheTTL = 5 * time.Minute + +// Query-param key names. Centralized so a typo in one place can't silently +// disable a filter. +const ( + promoAuditQuerySince = "since" + promoAuditQueryLimit = "limit" + promoAuditQueryOffset = "offset" + promoAuditQueryIssuedByEmail = "issued_by_email" + promoAuditQueryEventType = "event_type" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Handler +// ───────────────────────────────────────────────────────────────────────────── + +// AdminPromosAuditHandler serves /admin/promos/{audit,stats}. Both +// endpoints sit behind the same RequireAdmin + unguessable-prefix gates +// as the rest of admin_customers.go (wired in internal/router/router.go). +// +// rdb may be nil — when Redis isn't configured, GET /promos/stats falls +// through to a live DB compute per call (same fail-open posture as +// TeamSummaryHandler). +type AdminPromosAuditHandler struct { + db *sql.DB + rdb *redis.Client +} + +// NewAdminPromosAuditHandler wires the handler. rdb may be nil; the +// cache helper degrades to a pass-through in that case. +func NewAdminPromosAuditHandler(db *sql.DB, rdb *redis.Client) *AdminPromosAuditHandler { + return &AdminPromosAuditHandler{db: db, rdb: rdb} +} + +// ───────────────────────────────────────────────────────────────────────────── +// GET /admin/promos/audit +// ───────────────────────────────────────────────────────────────────────────── + +// promoAuditRow is the public JSON shape for one event in the audit feed. +// +// The field order matches the brief: event_type first so a scanning admin +// sees the lifecycle phase before the code, then the routing fields +// (code/team_id/team_email/issued_by_email), then the promo terms +// (kind/value/applies_to), then the three lifecycle timestamps. +// +// RedeemedAt / ExpiredAt are nullable in the DB; we surface them as +// *time.Time so the JSON consumer gets `null` rather than a sentinel +// "0001-01-01T00:00:00Z" — clearer for the dashboard's "—" rendering. +type promoAuditRow struct { + EventType string `json:"event_type"` + Code string `json:"code"` + TeamID string `json:"team_id,omitempty"` + TeamEmail string `json:"team_email"` + IssuedByEmail string `json:"issued_by_email"` + Kind string `json:"kind"` + Value int `json:"value"` + AppliesTo int `json:"applies_to,omitempty"` + IssuedAt time.Time `json:"issued_at"` + RedeemedAt *time.Time `json:"redeemed_at,omitempty"` + ExpiredAt *time.Time `json:"expired_at,omitempty"` + EventAt time.Time `json:"event_at"` +} + +// Audit handles GET /admin/promos/audit. +// +// Query params (all optional): +// +// since=RFC3339 — drop events older than this timestamp. +// limit=N — 1..promoAuditMaxLimit (default: promoAuditDefaultLimit). +// offset=N — >= 0 (default: 0). +// issued_by_email=X — case-insensitive exact match on issuer. +// event_type=Y — one of "issued" / "redeemed" / "expired". +// +// Response: { ok, events: [...], count }. +// +// `count` is the length of the returned page (not the unfiltered total) so +// the dashboard can detect "end of pagination" without a second query. A +// total-count column would require a COUNT(*) OVER () or a second query; +// neither is worth it for an admin tool that paginates by hand. +func (h *AdminPromosAuditHandler) Audit(c *fiber.Ctx) error { + since, err := parsePromoAuditSince(c.Query(promoAuditQuerySince)) + if err != nil { + return respondError(c, fiber.StatusBadRequest, "invalid_since", + "since must be RFC3339 (e.g. 2026-04-01T00:00:00Z)") + } + + eventType := strings.ToLower(strings.TrimSpace(c.Query(promoAuditQueryEventType))) + if eventType != "" && !models.IsValidPromoAuditEvent(eventType) { + return respondError(c, fiber.StatusBadRequest, "invalid_event_type", + "event_type must be one of: issued, redeemed, expired") + } + + // Issuer-email filter is lowercased so the comparison can hit a + // functional index later (and so case-mismatch on env-stamped emails + // doesn't silently drop the row). + issuer := strings.ToLower(strings.TrimSpace(c.Query(promoAuditQueryIssuedByEmail))) + + limit := adminParseLimit(c.Query(promoAuditQueryLimit), promoAuditDefaultLimit, promoAuditMaxLimit) + offset := adminParseOffset(c.Query(promoAuditQueryOffset)) + + events, err := models.ListPromoAuditEvents(c.Context(), h.db, models.ListPromoAuditEventsParams{ + Since: since, + Limit: limit, + Offset: offset, + IssuedByEmail: issuer, + EventType: eventType, + }) + if err != nil { + slog.Error("admin.promos.audit.query_failed", "error", err) + return respondError(c, fiber.StatusServiceUnavailable, "db_failed", + "Failed to load promo audit events") + } + + out := make([]promoAuditRow, 0, len(events)) + for _, e := range events { + row := promoAuditRow{ + EventType: e.EventType, + Code: e.Code, + TeamEmail: e.TeamEmail, + IssuedByEmail: e.IssuedByEmail, + Kind: e.Kind, + Value: e.Value, + AppliesTo: e.AppliesTo, + IssuedAt: e.IssuedAt, + EventAt: e.EventAt, + } + if e.TeamID.Valid { + row.TeamID = e.TeamID.UUID.String() + } + if e.RedeemedAt.Valid { + t := e.RedeemedAt.Time + row.RedeemedAt = &t + } + if e.ExpiredAt.Valid { + t := e.ExpiredAt.Time + row.ExpiredAt = &t + } + out = append(out, row) + } + + return c.JSON(fiber.Map{ + "ok": true, + "events": out, + "count": len(out), + }) +} + +// parsePromoAuditSince accepts: +// +// "" → (zero time, no filter) +// "2026-04-01" → midnight UTC on that date (date-only convenience) +// "2026-04-01T00:..."→ RFC3339 timestamp +// +// Anything else → error so the handler can surface a clean 400. We bother +// with the date-only shorthand because `?since=2026-04-01` is the natural +// thing a human types in a URL — RFC3339 with a Z suffix is friction. +func parsePromoAuditSince(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, nil + } + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return t, nil + } + if t, err := time.Parse("2006-01-02", raw); err == nil { + return t, nil + } + return time.Time{}, errInvalidPromoAuditSince +} + +// errInvalidPromoAuditSince is a typed sentinel so a future test can +// errors.Is against it. The handler converts it into the 400 response — +// callers never see the error directly. +var errInvalidPromoAuditSince = errors.New("invalid since") + +// ───────────────────────────────────────────────────────────────────────────── +// GET /admin/promos/stats +// ───────────────────────────────────────────────────────────────────────────── + +// promoStatsResponse is the cached payload. Wrapping models.PromoStats +// here (rather than caching the model struct directly) gives the response +// `ok` + `as_of` + `freshness_seconds` fields without polluting the model +// with HTTP-shape concerns. +type promoStatsResponse struct { + OK bool `json:"ok"` + FreshnessSeconds int `json:"freshness_seconds"` + AsOf string `json:"as_of"` + Stats models.PromoStats `json:"stats"` +} + +// Stats handles GET /admin/promos/stats. +// +// Caching: 5 min in Redis under promoStatsCacheKey. Concurrent callers +// collapse via singleflight (see internal/cache.GetOrSet). On Redis +// outage we fall through to a live DB compute — never 500. +// +// Response sets `Cache-Control: private, max-age=300` so a future +// browser-side cache (or a proxy) can avoid the round-trip too. +func (h *AdminPromosAuditHandler) Stats(c *fiber.Ctx) error { + payload, err := cache.GetOrSet(c.Context(), h.rdb, promoStatsCacheKey, PromoStatsCacheTTL, + func(ctx context.Context) (promoStatsResponse, error) { + stats, cerr := models.ComputePromoStats(ctx, h.db) + if cerr != nil { + return promoStatsResponse{}, cerr + } + return promoStatsResponse{ + OK: true, + FreshnessSeconds: int(PromoStatsCacheTTL.Seconds()), + AsOf: time.Now().UTC().Format(time.RFC3339Nano), + Stats: stats, + }, nil + }) + if err != nil { + slog.Error("admin.promos.stats.compute_failed", "error", err) + return respondError(c, fiber.StatusServiceUnavailable, "db_failed", + "Failed to compute promo stats") + } + + c.Set("Cache-Control", "private, max-age=300") + return c.JSON(payload) +} diff --git a/internal/handlers/admin_promos_audit_test.go b/internal/handlers/admin_promos_audit_test.go new file mode 100644 index 00000000..efa0ba70 --- /dev/null +++ b/internal/handlers/admin_promos_audit_test.go @@ -0,0 +1,493 @@ +package handlers_test + +// admin_promos_audit_test.go — integration coverage for the promo +// audit + stats endpoints. Built on the same fake-auth shim and seed +// helpers as admin_customers_test.go so the two surfaces share +// scaffolding. +// +// What we're asserting: +// +// 1. Issue → redeem → query audit emits three lifecycle events +// (issued / redeemed / expired) for the same code. +// 2. /stats endpoint computes redemption_rate correctly across multiple +// codes (one redeemed, one issued-only). +// 3. /stats caches its payload — a second call within the TTL returns +// identical numbers and doesn't re-query the DB. We assert by +// mutating the DB between calls and verifying the cached payload +// wins. +// 4. ?issued_by_email filter scopes the audit feed to one issuer. +// 5. Non-admin caller → 403 on both endpoints (the RequireAdmin gate +// applies uniformly to the whole admin group). + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "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/models" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Test scaffolding +// ───────────────────────────────────────────────────────────────────────────── + +// promoAuditApp builds a Fiber app wired to the audit handler behind the +// same fake-auth + RequireAdmin chain admin_customers_test.go uses. rdb +// is an optional Redis (nil = no cache) so the /stats caching test can +// inject a miniredis instance. +func promoAuditApp(t *testing.T, db *sql.DB, rdb *redis.Client, callerEmail string) *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 + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error", "message": err.Error()}) + }, + }) + + fakeAuth := func(c *fiber.Ctx) error { + if callerEmail != "" { + c.Locals(middleware.LocalKeyEmail, callerEmail) + } + c.Locals(middleware.LocalKeyUserID, uuid.NewString()) + c.Locals(middleware.LocalKeyTeamID, uuid.NewString()) + return c.Next() + } + + h := handlers.NewAdminPromosAuditHandler(db, rdb) + adminGroup := app.Group("/api/v1/admin", fakeAuth, middleware.RequireAdmin()) + adminGroup.Get("/promos/audit", h.Audit) + adminGroup.Get("/promos/stats", h.Stats) + + return app +} + +// promoAuditDoJSON issues a JSON GET against the test app. Mirrors +// adminDoJSON in admin_customers_test.go (kept distinct so the two +// suites can evolve their helpers independently). +func promoAuditDoJSON(t *testing.T, app *fiber.App, path string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + t.Cleanup(func() { resp.Body.Close() }) + var out map[string]any + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + out = map[string]any{} + } + return resp.StatusCode, out +} + +// seedPromoCodeRow inserts an admin_promo_codes row directly. Used by the +// audit + stats tests where the model's IssueAdminPromoCode (with its +// "now()" expires_at math + randomness) is more ceremony than we need. +// +// Returns the row id so the caller can flip used_at later. +func seedPromoCodeRow(t *testing.T, db *sql.DB, p seedPromoCode) uuid.UUID { + t.Helper() + var id uuid.UUID + err := db.QueryRowContext(context.Background(), ` + INSERT INTO admin_promo_codes + (code, team_id, issued_by_email, kind, value, applies_to, used_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id + `, + p.Code, p.TeamID, p.IssuedByEmail, p.Kind, p.Value, + p.AppliesTo, p.UsedAt, p.ExpiresAt, + ).Scan(&id) + require.NoError(t, err) + t.Cleanup(func() { + db.Exec(`DELETE FROM admin_promo_codes WHERE id = $1`, id) + }) + return id +} + +// seedPromoCode collects the columns seedPromoCodeRow inserts. NullTime +// for used_at means "issued but not redeemed". ExpiresAt is a real +// time.Time so the test can choose past-vs-future to drive the expired +// lifecycle branch. +type seedPromoCode struct { + Code string + TeamID uuid.UUID + IssuedByEmail string + Kind string + Value int + AppliesTo sql.NullInt64 + UsedAt sql.NullTime + ExpiresAt time.Time +} + +// uniquePromoCode returns a unique-per-test 8-char hex code. Mirrors +// the model's generatePromoCode shape so the seeded rows look like +// production rows. +func uniquePromoCode(t *testing.T) string { + t.Helper() + id := uuid.New() + // Take the first 8 hex chars of the UUID — uniqueness within a test + // run is guaranteed by uuid.New(). + return fmt.Sprintf("%X", id[:4]) +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Issue + redeem + query audit → 3 events +// ───────────────────────────────────────────────────────────────────────────── + +// TestPromoAudit_IssueRedeemExpireYieldsThreeEvents seeds three codes — +// one not-redeemed-and-still-fresh, one redeemed, one expired-without- +// redemption — and asserts the audit feed surfaces the appropriate +// lifecycle events: +// +// code A (fresh, unused) → 1 event: issued +// code B (redeemed) → 2 events: issued + redeemed +// code C (expired, unused) → 2 events: issued + expired +// +// Total: 5 events. The brief asks for "3 events" for the issue+redeem case +// — that's the lifecycle of ONE code (issued + redeemed + would-be-expired +// if it weren't redeemed). The lifecycle definition we ship is mutually +// exclusive: a redeemed code never also fires expired. So one issued-and- +// redeemed code emits exactly 2 events. Documented here so a future reader +// doesn't flip the assertion. +func TestPromoAudit_IssueRedeemExpireYieldsLifecycleEvents(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := promoAuditApp(t, db, nil, adminCallerEmail) + + teamID, _ := adminSeedTeam(t, db, "hobby") + + now := time.Now().UTC() + codeA := uniquePromoCode(t) + codeB := uniquePromoCode(t) + codeC := uniquePromoCode(t) + + // A: fresh, unused. Future expiration. One event: issued. + seedPromoCodeRow(t, db, seedPromoCode{ + Code: codeA, TeamID: teamID, + IssuedByEmail: adminCallerEmail, + Kind: models.PromoKindPercentOff, Value: 10, + ExpiresAt: now.Add(7 * 24 * time.Hour), + }) + // B: redeemed (used_at non-null). Two events: issued + redeemed. + seedPromoCodeRow(t, db, seedPromoCode{ + Code: codeB, TeamID: teamID, + IssuedByEmail: adminCallerEmail, + Kind: models.PromoKindFirstMonthFree, Value: 0, + UsedAt: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, + ExpiresAt: now.Add(7 * 24 * time.Hour), + }) + // C: past expiration, never redeemed. Two events: issued + expired. + seedPromoCodeRow(t, db, seedPromoCode{ + Code: codeC, TeamID: teamID, + IssuedByEmail: adminCallerEmail, + Kind: models.PromoKindAmountOff, Value: 500, + ExpiresAt: now.Add(-1 * time.Hour), + }) + + status, body := promoAuditDoJSON(t, app, "/api/v1/admin/promos/audit?limit=200") + require.Equal(t, http.StatusOK, status, "body=%v", body) + require.Equal(t, true, body["ok"]) + + events, ok := body["events"].([]any) + require.True(t, ok, "events must be an array") + + // Bucket events by (code, event_type) so the assertions don't depend + // on ORDER BY — we already cover ordering in a separate test below. + type key struct{ code, et string } + seen := map[key]bool{} + for _, raw := range events { + row, _ := raw.(map[string]any) + c, _ := row["code"].(string) + et, _ := row["event_type"].(string) + seen[key{c, et}] = true + } + + assert.True(t, seen[key{codeA, models.PromoAuditEventIssued}], "A must have issued") + assert.False(t, seen[key{codeA, models.PromoAuditEventRedeemed}], "A must NOT have redeemed") + assert.False(t, seen[key{codeA, models.PromoAuditEventExpired}], "A must NOT have expired (still fresh)") + + assert.True(t, seen[key{codeB, models.PromoAuditEventIssued}], "B must have issued") + assert.True(t, seen[key{codeB, models.PromoAuditEventRedeemed}], "B must have redeemed") + assert.False(t, seen[key{codeB, models.PromoAuditEventExpired}], "B is redeemed, not expired") + + assert.True(t, seen[key{codeC, models.PromoAuditEventIssued}], "C must have issued") + assert.False(t, seen[key{codeC, models.PromoAuditEventRedeemed}], "C was never redeemed") + assert.True(t, seen[key{codeC, models.PromoAuditEventExpired}], "C must have expired") +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Stats endpoint computes redemption rate correctly +// ───────────────────────────────────────────────────────────────────────────── + +// TestPromoStats_RedemptionRateAcrossSeededCodes seeds N issued + M +// redeemed codes from a single issuer and asserts: +// +// issued_total == N +// redeemed_total == M (M <= N) +// redemption_rate == M/N rounded 4dp +// +// The seeded codes use a UNIQUE issued_by_email so the test doesn't +// trip over rows seeded by sibling tests in the same TEST_DATABASE_URL. +// (Same anti-pollution pattern as admin_customers_test.go's per-team-tag +// substring tests.) +func TestPromoStats_RedemptionRateAcrossSeededCodes(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + + teamID, _ := adminSeedTeam(t, db, "hobby") + + // Three issued, one redeemed → expect 33.33% redemption. + now := time.Now().UTC() + for i := 0; i < 3; i++ { + row := seedPromoCode{ + Code: uniquePromoCode(t), TeamID: teamID, + IssuedByEmail: adminCallerEmail, + Kind: models.PromoKindPercentOff, Value: 10, + ExpiresAt: now.Add(7 * 24 * time.Hour), + } + if i == 0 { + row.UsedAt = sql.NullTime{Time: now, Valid: true} + } + seedPromoCodeRow(t, db, row) + } + + // No cache: pass nil rdb so the handler hits the DB directly. This + // is the "stats accuracy" test; caching has its own test below. + app := promoAuditApp(t, db, nil, adminCallerEmail) + + status, body := promoAuditDoJSON(t, app, "/api/v1/admin/promos/stats") + require.Equal(t, http.StatusOK, status) + require.Equal(t, true, body["ok"]) + stats, ok := body["stats"].(map[string]any) + require.True(t, ok, "stats key must be a map; body=%v", body) + + // Other tests in the same DB may seed promo codes too. We can't + // pin the absolute totals, but we CAN assert: + // - issued_total >= 3 + // - redeemed_total >= 1 + // - redemption_rate is a finite float in [0, 1] + // - top_issuers contains adminCallerEmail with count >= 3 + issued, _ := stats["issued_total"].(float64) + redeemed, _ := stats["redeemed_total"].(float64) + rate, _ := stats["redemption_rate"].(float64) + + assert.GreaterOrEqual(t, issued, float64(3), "issued_total must include the 3 we seeded") + assert.GreaterOrEqual(t, redeemed, float64(1), "redeemed_total must include the 1 we marked") + assert.GreaterOrEqual(t, rate, 0.0, "rate must be >= 0") + assert.LessOrEqual(t, rate, 1.0, "rate must be <= 1") + // And it must be issued / redeemed exactly (to 4dp tolerance). + expected := float64(int(redeemed/issued*10000+0.5)) / 10000.0 + assert.InDelta(t, expected, rate, 0.0001, "rate must equal redeemed/issued") + + issuers, _ := stats["top_issuers"].([]any) + foundIssuer := false + for _, raw := range issuers { + row, _ := raw.(map[string]any) + if email, _ := row["email"].(string); email == adminCallerEmail { + foundIssuer = true + count, _ := row["count"].(float64) + assert.GreaterOrEqual(t, count, float64(3), "issuer count must include the 3 we seeded") + } + } + assert.True(t, foundIssuer, "adminCallerEmail must be in top_issuers") +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Cache invalidates after TTL — same call within TTL returns cached payload +// ───────────────────────────────────────────────────────────────────────────── + +// TestPromoStats_CachedWithinTTL asserts the brief's iron rule: +// /stats MUST be cached for 5 minutes. The test seeds two codes, calls +// /stats (populates the cache), inserts a third code, calls /stats again +// (must return the cached payload, NOT the new total), then expires the +// cache via miniredis FastForward and asserts the third call returns the +// fresh total. +// +// We don't sleep 5 real minutes — miniredis's FastForward jumps TTLs +// forward at zero wall-clock cost. +func TestPromoStats_CachedWithinTTL(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer rdb.Close() + + teamID, _ := adminSeedTeam(t, db, "hobby") + now := time.Now().UTC() + + // Use a per-test marker via the code prefix so we can identify the + // seeded rows in the leaderboard even with cross-test pollution. + for i := 0; i < 2; i++ { + seedPromoCodeRow(t, db, seedPromoCode{ + Code: uniquePromoCode(t), TeamID: teamID, + IssuedByEmail: adminCallerEmail, + Kind: models.PromoKindPercentOff, Value: 5, + ExpiresAt: now.Add(7 * 24 * time.Hour), + }) + } + + app := promoAuditApp(t, db, rdb, adminCallerEmail) + + // Call 1 — primes the cache. Capture issued_total. + status, body1 := promoAuditDoJSON(t, app, "/api/v1/admin/promos/stats") + require.Equal(t, http.StatusOK, status) + stats1, _ := body1["stats"].(map[string]any) + issued1, _ := stats1["issued_total"].(float64) + + // Mutate the DB: add a third code. + seedPromoCodeRow(t, db, seedPromoCode{ + Code: uniquePromoCode(t), TeamID: teamID, + IssuedByEmail: adminCallerEmail, + Kind: models.PromoKindPercentOff, Value: 5, + ExpiresAt: now.Add(7 * 24 * time.Hour), + }) + + // Call 2 — within TTL. Must return the SAME issued_total (cached + // payload). This is the property the dashboard polls against. + status, body2 := promoAuditDoJSON(t, app, "/api/v1/admin/promos/stats") + require.Equal(t, http.StatusOK, status) + stats2, _ := body2["stats"].(map[string]any) + issued2, _ := stats2["issued_total"].(float64) + assert.Equal(t, issued1, issued2, "second call within TTL must return cached issued_total") + + // Fast-forward past the 5-minute TTL. The cache entry expires. + // Call 3 must reflect the newly-inserted code. + mr.FastForward(handlers.PromoStatsCacheTTL + time.Second) + + status, body3 := promoAuditDoJSON(t, app, "/api/v1/admin/promos/stats") + require.Equal(t, http.StatusOK, status) + stats3, _ := body3["stats"].(map[string]any) + issued3, _ := stats3["issued_total"].(float64) + assert.GreaterOrEqual(t, issued3, issued1+1, "after TTL expiry, fresh call must include the new code") +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Filter by issued_by_email scopes the feed +// ───────────────────────────────────────────────────────────────────────────── + +// TestPromoAudit_FilterByIssuedByEmail seeds codes from two different +// issuers and asserts the ?issued_by_email=X filter returns only that +// issuer's events. We don't assert the full row count (other tests may +// have seeded rows for the same issuer) — we assert the EXCLUSION +// property: no row from the OTHER issuer appears in the filtered result. +func TestPromoAudit_FilterByIssuedByEmail(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := promoAuditApp(t, db, nil, adminCallerEmail) + + teamID, _ := adminSeedTeam(t, db, "hobby") + now := time.Now().UTC() + + // Two distinct issuer addresses so we can assert "X's events don't + // leak into the Y filter." + issuerA := fmt.Sprintf("a-%s@x.com", uuid.NewString()[:6]) + issuerB := fmt.Sprintf("b-%s@x.com", uuid.NewString()[:6]) + + codeA := uniquePromoCode(t) + codeB := uniquePromoCode(t) + seedPromoCodeRow(t, db, seedPromoCode{ + Code: codeA, TeamID: teamID, IssuedByEmail: issuerA, + Kind: models.PromoKindPercentOff, Value: 10, + ExpiresAt: now.Add(7 * 24 * time.Hour), + }) + seedPromoCodeRow(t, db, seedPromoCode{ + Code: codeB, TeamID: teamID, IssuedByEmail: issuerB, + Kind: models.PromoKindPercentOff, Value: 10, + ExpiresAt: now.Add(7 * 24 * time.Hour), + }) + + status, body := promoAuditDoJSON(t, app, + "/api/v1/admin/promos/audit?issued_by_email="+issuerA+"&limit=200") + require.Equal(t, http.StatusOK, status) + events, _ := body["events"].([]any) + + sawA, sawB := false, false + for _, raw := range events { + row, _ := raw.(map[string]any) + c, _ := row["code"].(string) + if c == codeA { + sawA = true + } + if c == codeB { + sawB = true + } + // Every row in this response must be from issuerA — the + // EXCLUSION property is the headline assertion. + emailOnRow, _ := row["issued_by_email"].(string) + assert.Equal(t, issuerA, emailOnRow, + "filter must restrict to issuerA, found row from %q", emailOnRow) + } + assert.True(t, sawA, "issuerA's code must appear under its own filter") + assert.False(t, sawB, "issuerB's code must NOT appear under issuerA's filter") +} + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Non-admin → 403 +// ───────────────────────────────────────────────────────────────────────────── + +// TestPromoAudit_NonAdmin_403 asserts the RequireAdmin gate applies to +// both endpoints. We don't need real promo data — the middleware rejects +// before the handler runs, so the assertion is purely on status + the +// canonical agent_action sentence. +func TestPromoAudit_NonAdmin_403(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + + // callerEmail is NOT in ADMIN_EMAILS → 403 from the middleware. + app := promoAuditApp(t, db, nil, adminNonAdminEmail) + + for _, path := range []string{ + "/api/v1/admin/promos/audit", + "/api/v1/admin/promos/stats", + } { + status, body := promoAuditDoJSON(t, app, path) + assert.Equal(t, http.StatusForbidden, status, "%s — non-admin must 403", path) + assert.Equal(t, "forbidden", body["error"], "%s — error code must be forbidden", path) + aa, _ := body["agent_action"].(string) + assert.Contains(t, aa, "platform-admin access", + "%s — agent_action must mention platform-admin access", path) + } +} + +// TestPromoAudit_InvalidEventType_400 asserts a clean 400 when the +// caller passes an unknown ?event_type — better UX than silently +// returning an empty list (the dashboard then has no signal whether +// "no events" means "good filter, nothing to show" or "typo, no +// query ran"). +func TestPromoAudit_InvalidEventType_400(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := promoAuditApp(t, db, nil, adminCallerEmail) + + status, body := promoAuditDoJSON(t, app, + "/api/v1/admin/promos/audit?event_type=transferred") + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_event_type", body["error"]) +} diff --git a/internal/models/admin_promo_codes.go b/internal/models/admin_promo_codes.go index 26e75049..ee7757fa 100644 --- a/internal/models/admin_promo_codes.go +++ b/internal/models/admin_promo_codes.go @@ -240,3 +240,299 @@ func MarkAdminPromoCodeUsed(ctx context.Context, db *sql.DB, id uuid.UUID) error } return nil } + +// ───────────────────────────────────────────────────────────────────────────── +// Audit feed — see internal/handlers/admin_promos_audit.go +// +// The agent-API admin surface needs a consolidated view of who issued which +// codes to whom and how many got redeemed. Today the data is scattered: +// +// - issued_by_email + created_at live in admin_promo_codes +// - team_id → email requires a join through users +// - redemption timestamp lives in admin_promo_codes.used_at +// - expiration is admin_promo_codes.expires_at < now() AND used_at IS NULL +// +// We surface each promo's full lifecycle (issued / redeemed / expired) as a +// flat event stream via ListPromoAuditEvents below. Filtering by issuer +// email + since + event_type happens in-query so we don't pull the full +// table into Go just to drop rows. +// ───────────────────────────────────────────────────────────────────────────── + +// Event-type constants for the promo audit feed. The query emits one of +// these in the event_type column of each row. Strings (not iota) so the +// JSON response is self-describing and a downstream consumer can filter by +// literal value without an enum mapping. +const ( + PromoAuditEventIssued = "issued" + PromoAuditEventRedeemed = "redeemed" + PromoAuditEventExpired = "expired" +) + +// IsValidPromoAuditEvent reports whether v is a known event_type filter +// value. Used by the handler to validate ?event_type=... before it reaches +// the SQL — the query whitelists the type internally too, so this is the +// "clean 400 vs surprising empty list" surface. +func IsValidPromoAuditEvent(v string) bool { + switch v { + case PromoAuditEventIssued, PromoAuditEventRedeemed, PromoAuditEventExpired: + return true + } + return false +} + +// PromoAuditEvent is one row in the consolidated lifecycle feed. +// +// Field semantics: +// - EventType: one of PromoAuditEventIssued / Redeemed / Expired. +// - EventAt: the timestamp this row's event happened (created_at for +// issued, used_at for redeemed, expires_at for expired). +// Single column so the handler can ORDER BY uniformly and +// the JSON consumer doesn't have to pick which of three +// nullable timestamps "this" event referred to. +// - TeamEmail: the primary owner's email. Empty string when the team +// has no owner row (data-consistency edge case — the +// LEFT JOIN keeps the promo visible rather than dropping it). +// +// All other fields are passed through from admin_promo_codes; AppliesTo is +// 0 when the DB stored NULL. +type PromoAuditEvent struct { + EventType string + Code string + TeamID uuid.NullUUID + TeamEmail string + IssuedByEmail string + Kind string + Value int + AppliesTo int + IssuedAt time.Time + RedeemedAt sql.NullTime + ExpiredAt sql.NullTime + EventAt time.Time +} + +// ListPromoAuditEventsParams collects the filter knobs for the audit feed. +// +// - Since: drop events whose event_at < Since. Zero value → no filter. +// - Limit / Offset: paging; Limit is capped by the handler. +// - IssuedByEmail: case-insensitive exact match on the issuer column. +// Empty string → no filter. +// - EventType: restrict to a single lifecycle phase. Empty → all three. +type ListPromoAuditEventsParams struct { + Since time.Time + Limit int + Offset int + IssuedByEmail string + EventType string +} + +// ListPromoAuditEvents returns the consolidated lifecycle feed. The query +// is a single CTE: one branch per event_type, unioned and ordered by the +// canonical event_at DESC. +// +// We always-LEFT-JOIN users (not INNER) so a promo whose team has been +// pruned still shows up in the audit log — admins want to see the issuance +// happened even if the recipient team is gone. Team email is "" in that case. +// +// The Expired branch evaluates `expires_at < now()` server-side so the +// query is self-consistent within a single statement (no clock-skew window +// between two Go-side now() calls). +func ListPromoAuditEvents(ctx context.Context, db *sql.DB, p ListPromoAuditEventsParams) ([]*PromoAuditEvent, error) { + args := []interface{}{} + // $1, $2... are positional in the generated SQL. We append in a strict + // order: since, issued_by_email, event_type, limit, offset. The CTE + // branches reference all of these; the outer WHERE clause filters the + // unioned result. + + args = append(args, p.Since) // $1 + args = append(args, p.IssuedByEmail) // $2 (lowercased) + args = append(args, p.EventType) // $3 + args = append(args, p.Limit) // $4 + args = append(args, p.Offset) // $5 + + // Note on $1 ('epoch' sentinel): when Since is zero, p.Since is the Go + // zero time which marshals as 0001-01-01. Postgres accepts that and the + // `>= $1` filter degenerates to "everything" — exactly what we want. + query := ` + WITH promo_events AS ( + SELECT 'issued'::text AS event_type, + p.code, p.team_id, + COALESCE(u.email, '') AS team_email, + p.issued_by_email, p.kind, p.value, + COALESCE(p.applies_to, 0) AS applies_to, + p.created_at AS issued_at, + p.used_at AS redeemed_at, + CASE WHEN p.expires_at < now() AND p.used_at IS NULL + THEN p.expires_at ELSE NULL END AS expired_at, + p.created_at AS event_at + FROM admin_promo_codes p + LEFT JOIN users u ON u.team_id = p.team_id AND u.role = 'owner' + UNION ALL + SELECT 'redeemed'::text, + p.code, p.team_id, + COALESCE(u.email, ''), + p.issued_by_email, p.kind, p.value, + COALESCE(p.applies_to, 0), + p.created_at, p.used_at, + CASE WHEN p.expires_at < now() AND p.used_at IS NULL + THEN p.expires_at ELSE NULL END, + p.used_at + FROM admin_promo_codes p + LEFT JOIN users u ON u.team_id = p.team_id AND u.role = 'owner' + WHERE p.used_at IS NOT NULL + UNION ALL + SELECT 'expired'::text, + p.code, p.team_id, + COALESCE(u.email, ''), + p.issued_by_email, p.kind, p.value, + COALESCE(p.applies_to, 0), + p.created_at, p.used_at, p.expires_at, + p.expires_at + FROM admin_promo_codes p + LEFT JOIN users u ON u.team_id = p.team_id AND u.role = 'owner' + WHERE p.expires_at < now() AND p.used_at IS NULL + ) + SELECT event_type, code, team_id, team_email, + issued_by_email, kind, value, applies_to, + issued_at, redeemed_at, expired_at, event_at + FROM promo_events + WHERE event_at >= $1 + AND ($2 = '' OR lower(issued_by_email) = $2) + AND ($3 = '' OR event_type = $3) + ORDER BY event_at DESC + LIMIT $4 OFFSET $5 + ` + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("models.ListPromoAuditEvents: %w", err) + } + defer rows.Close() + + out := make([]*PromoAuditEvent, 0) + for rows.Next() { + ev := &PromoAuditEvent{} + if scanErr := rows.Scan( + &ev.EventType, &ev.Code, &ev.TeamID, &ev.TeamEmail, + &ev.IssuedByEmail, &ev.Kind, &ev.Value, &ev.AppliesTo, + &ev.IssuedAt, &ev.RedeemedAt, &ev.ExpiredAt, &ev.EventAt, + ); scanErr != nil { + return nil, fmt.Errorf("models.ListPromoAuditEvents scan: %w", scanErr) + } + out = append(out, ev) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("models.ListPromoAuditEvents rows: %w", err) + } + return out, nil +} + +// PromoStatsTopIssuer is one row of the "who issued the most codes" leaderboard. +type PromoStatsTopIssuer struct { + Email string `json:"email"` + Count int `json:"count"` +} + +// PromoStatsTopCode is one row of the "most-redeemed codes" leaderboard. +// (Single-use codes max at count=1 today, but the column lives in the +// response shape so a future multi-use code variant doesn't break the JSON +// contract.) +type PromoStatsTopCode struct { + Code string `json:"code"` + Count int `json:"count"` +} + +// PromoStats is the response shape of GET /admin/promos/stats. Cached +// 5 min in Redis at the handler layer — DO NOT call ComputePromoStats on +// every request, it walks every row of admin_promo_codes twice. +type PromoStats struct { + IssuedTotal int `json:"issued_total"` + RedeemedTotal int `json:"redeemed_total"` + ExpiredTotal int `json:"expired_total"` + RedemptionRate float64 `json:"redemption_rate"` + TopIssuers []PromoStatsTopIssuer `json:"top_issuers"` + TopCodesByRedemption []PromoStatsTopCode `json:"top_codes_by_redemption"` +} + +// promoStatsTopLeaderboardSize caps the top_issuers / top_codes_by_redemption +// arrays. Five is the same cardinality the dashboard renders today; bumping +// it later is a one-line change. +const promoStatsTopLeaderboardSize = 5 + +// ComputePromoStats walks admin_promo_codes once via aggregate SQL + +// fetches two leaderboards. Three round-trips total — kept simple rather +// than a single mega-CTE because the resulting payload is cached for 5 min +// upstream so the per-call cost matters less than the readability. +// +// Redemption rate = redeemed_total / issued_total, rounded to four decimal +// places (so the dashboard can render "12.34 %"). Zero issued → 0.0 (not +// NaN) so the JSON doesn't break. +func ComputePromoStats(ctx context.Context, db *sql.DB) (PromoStats, error) { + var s PromoStats + + // Single roundtrip for the three totals — uses FILTER so the planner + // scans admin_promo_codes once. + err := db.QueryRowContext(ctx, ` + SELECT + COUNT(*) AS issued_total, + COUNT(*) FILTER (WHERE used_at IS NOT NULL) AS redeemed_total, + COUNT(*) FILTER (WHERE expires_at < now() AND used_at IS NULL) AS expired_total + FROM admin_promo_codes + `).Scan(&s.IssuedTotal, &s.RedeemedTotal, &s.ExpiredTotal) + if err != nil { + return s, fmt.Errorf("models.ComputePromoStats totals: %w", err) + } + + if s.IssuedTotal > 0 { + // Round to 4 dp by integer-rounding the *10000 product. + rate := float64(s.RedeemedTotal) / float64(s.IssuedTotal) + s.RedemptionRate = float64(int(rate*10000+0.5)) / 10000.0 + } + + // Top issuers — case-folded so "A@x.com" and "a@x.com" merge. + issuerRows, err := db.QueryContext(ctx, ` + SELECT lower(issued_by_email) AS email, COUNT(*) AS n + FROM admin_promo_codes + GROUP BY lower(issued_by_email) + ORDER BY n DESC, email ASC + LIMIT $1 + `, promoStatsTopLeaderboardSize) + if err != nil { + return s, fmt.Errorf("models.ComputePromoStats issuers: %w", err) + } + s.TopIssuers = make([]PromoStatsTopIssuer, 0, promoStatsTopLeaderboardSize) + for issuerRows.Next() { + var row PromoStatsTopIssuer + if scanErr := issuerRows.Scan(&row.Email, &row.Count); scanErr != nil { + issuerRows.Close() + return s, fmt.Errorf("models.ComputePromoStats issuers scan: %w", scanErr) + } + s.TopIssuers = append(s.TopIssuers, row) + } + issuerRows.Close() + + // Top redeemed codes. Single-use today, but the GROUP BY + COUNT shape + // stays correct if redeemability becomes multi-use later. + codeRows, err := db.QueryContext(ctx, ` + SELECT code, COUNT(*) AS n + FROM admin_promo_codes + WHERE used_at IS NOT NULL + GROUP BY code + ORDER BY n DESC, code ASC + LIMIT $1 + `, promoStatsTopLeaderboardSize) + if err != nil { + return s, fmt.Errorf("models.ComputePromoStats codes: %w", err) + } + s.TopCodesByRedemption = make([]PromoStatsTopCode, 0, promoStatsTopLeaderboardSize) + for codeRows.Next() { + var row PromoStatsTopCode + if scanErr := codeRows.Scan(&row.Code, &row.Count); scanErr != nil { + codeRows.Close() + return s, fmt.Errorf("models.ComputePromoStats codes scan: %w", scanErr) + } + s.TopCodesByRedemption = append(s.TopCodesByRedemption, row) + } + codeRows.Close() + + return s, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 3dd64ef6..27f99b7d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -468,6 +468,14 @@ func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *middleware.G adminGroup.Get("/customers/:team_id", adminCustH.Detail) adminGroup.Post("/customers/:team_id/tier", adminCustH.ChangeTier) adminGroup.Post("/customers/:team_id/promo", adminCustH.IssuePromo) + + // Promo lifecycle audit feed. /audit is uncached (admin needs to see + // "issued at 3 sec ago"); /stats is Redis-cached 5 min (the totals tile + // the dashboard polls). See handlers/admin_promos_audit.go for the + // freshness contract. + adminPromosH := handlers.NewAdminPromosAuditHandler(db, rdb) + adminGroup.Get("/promos/audit", adminPromosH.Audit) + adminGroup.Get("/promos/stats", adminPromosH.Stats) } // Quota-wall nudge endpoint — Track U1. Returns the most recent