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):
- Open a transaction.
SELECT value FROM settings WHERE key = 'scan_stats' FOR UPDATE — serializes against concurrent incrementScanStat calls.
SELECT scan_type, COUNT(*) FROM scans GROUP BY scan_type — authoritative source.
- Build a
map[string]int, JSON-marshal it.
UPDATE settings SET value = $1, updated_at = NOW() WHERE key = 'scan_stats'.
- 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}
Problem
scan_statslives in thesettingstable as a JSONmap[string]int(scan_type → count) — a counter cache for fast lookup. It is updated inside the same transaction as every scan insert (incrementScanStatininternal/store/settings.go), and reset viaresetScanStatsin the hackathon reset flow.Because it is a denormalized cache, it can drift from the source of truth (
scanstable) — 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_statsfrom thescanstable and returns the new stats. Response matches the existingScanStatsResponseshape so the frontend can reuse it.Implementation Plan
Store (
internal/store/scans.go)Add
(*ScansStore).RebalanceStats(ctx) ([]ScanStat, error):SELECT value FROM settings WHERE key = 'scan_stats' FOR UPDATE— serializes against concurrentincrementScanStatcalls.SELECT scan_type, COUNT(*) FROM scans GROUP BY scan_type— authoritative source.map[string]int, JSON-marshal it.UPDATE settings SET value = $1, updated_at = NOW() WHERE key = 'scan_stats'.[]ScanStat(sorted by scan_type, same shape asGetStats).Concurrency note: a scan insert that commits between the
COUNT(*)and theFOR UPDATElock 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:app.store.Scans.RebalanceStats(r.Context()).ScanStatsResponseon success.@Tags superadmin/scans.Route (
cmd/api/api.go)Mount inside the existing super-admin group (around line 245):
Also add
superadmin/scansto the swagger tags list at the top of the file (around line 98).Mock (
internal/store/mock_store.go)Add
RebalanceStats(ctx)to the mockScansStoreusingmock.Mock.Tests (
cmd/api/scans_test.go)Docs
task gen-docs.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 toclient/web/src/pages/admin/scans/api.ts.Out of Scope
scan_statsaway fromsettingsinto a materialized view (larger architectural change).incrementScanStat.Files Touched
internal/store/scans.gointernal/store/mock_store.gocmd/api/scans.gocmd/api/api.gocmd/api/scans_test.godocs/(regenerated)CLAUDE.mdclient/web/src/pages/admin/scans/{api.ts,components/ScanStatsCards.tsx}