Skip to content

feat: add superadmin endpoint to rebalance scan_stats cache #76

@balebbae

Description

@balebbae

Problem

scan_stats lives in the settings table as a JSON map[string]int (scan_type → count) — a counter cache for fast lookup. It is updated inside the same transaction as every scan insert (incrementScanStat in internal/store/settings.go), and reset via resetScanStats in the hackathon reset flow.

Because it is a denormalized cache, it can drift from the source of truth (scans table) — e.g. partially recovered failures, manual DB edits, future bugs, or schema changes. Today there is no way to recompute it short of a SQL shell. We need a super-admin-triggered rebalance.

Proposed Endpoint

POST /v1/superadmin/scans/rebalance-stats (super-admin only)

Recomputes scan_stats from the scans table and returns the new stats. Response matches the existing ScanStatsResponse shape so the frontend can reuse it.

Implementation Plan

Store (internal/store/scans.go)

Add (*ScansStore).RebalanceStats(ctx) ([]ScanStat, error):

  1. Open a transaction.
  2. SELECT value FROM settings WHERE key = 'scan_stats' FOR UPDATE — serializes against concurrent incrementScanStat calls.
  3. SELECT scan_type, COUNT(*) FROM scans GROUP BY scan_type — authoritative source.
  4. Build a map[string]int, JSON-marshal it.
  5. UPDATE settings SET value = $1, updated_at = NOW() WHERE key = 'scan_stats'.
  6. Commit and return the recomputed []ScanStat (sorted by scan_type, same shape as GetStats).

Concurrency note: a scan insert that commits between the COUNT(*) and the FOR UPDATE lock acquisition could be missed. Acceptable for a manual-triggered rebalance — document the caveat, or use a pg advisory lock for stricter correctness if we want it.

Handler (cmd/api/scans.go)

Add rebalanceScanStatsHandler:

  • No request body.
  • Calls app.store.Scans.RebalanceStats(r.Context()).
  • Returns ScanStatsResponse on success.
  • Log the rebalance with admin user id for auditability.
  • Swagger annotation under @Tags superadmin/scans.

Route (cmd/api/api.go)

Mount inside the existing super-admin group (around line 245):

r.Route("/scans", func(r chi.Router) {
    r.Post("/rebalance-stats", app.rebalanceScanStatsHandler)
})

Also add superadmin/scans to the swagger tags list at the top of the file (around line 98).

Mock (internal/store/mock_store.go)

Add RebalanceStats(ctx) to the mock ScansStore using mock.Mock.

Tests (cmd/api/scans_test.go)

  • Happy path: super-admin calls it, mock returns stats, response matches.
  • 403 when called by admin (non-super-admin).
  • 401 when unauthenticated.
  • 500 on store error.

Docs

  • Regenerate Swagger via task gen-docs.
  • Add the route to the Super Admin routes section in CLAUDE.md.

Frontend (optional — can split into a follow-up)

client/web/src/pages/admin/scans/components/ScanStatsCards.tsx: add a "Rebalance stats" button visible only to super admins. On click, call the endpoint, show a toast, and re-fetch the stats. Add the API call to client/web/src/pages/admin/scans/api.ts.

Out of Scope

  • Scheduled/self-healing rebalance job.
  • Migrating scan_stats away from settings into a materialized view (larger architectural change).
  • Altering the write path in incrementScanStat.

Files Touched

  • internal/store/scans.go
  • internal/store/mock_store.go
  • cmd/api/scans.go
  • cmd/api/api.go
  • cmd/api/scans_test.go
  • docs/ (regenerated)
  • CLAUDE.md
  • (optional) client/web/src/pages/admin/scans/{api.ts,components/ScanStatsCards.tsx}

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions