api/v1: public stats and recent reversals endpoints#69
Conversation
Three new IP-rate-limited public endpoints, mirroring the existing
api/v1/users pattern (no auth, throttled per IP):
GET /api/v1/stats/summary
GET /api/v1/stats/reversals/daily?days={7|30|60|90|180|365}
GET /api/v1/reversals/recent?limit={1..100}
The two /stats endpoints share a 60s in-process sync.Map cache and a
shared throttle. The /reversals/recent endpoint returns a slim public
projection (marketplace_slug, steam_id, reversed_at, created_at).
Authenticated /reversals routes are now wrapped in a chi.Group so
AuthMiddleware no longer applies to the new /recent path while
preserving every other route's behavior. No schema changes; all
queries filter deleted_at IS NULL.
Aggregates use raw SQL (COUNT DISTINCT + FILTER, date bucketing via
to_char on reversed_at) so we don't drag GORM through a non-trivial
expression; the list endpoint stays on the GORM path.
README adds a postgres superuser note for pgtestdb and a public
endpoints table.
Co-authored-by: Cursor <cursoragent@cursor.com>
| type cacheEntry struct { | ||
| at time.Time | ||
| payload []byte | ||
| } | ||
|
|
||
| var cache sync.Map | ||
|
|
||
| func cacheGet(key string) ([]byte, bool) { | ||
| v, ok := cache.Load(key) | ||
| if !ok { | ||
| return nil, false | ||
| } | ||
| e := v.(cacheEntry) | ||
| if time.Since(e.at) > cacheTTL { | ||
| return nil, false | ||
| } | ||
| return e.payload, true | ||
| } | ||
|
|
||
| func cacheSet(key string, payload []byte) { | ||
| cache.Store(key, cacheEntry{at: time.Now(), payload: payload}) | ||
| } | ||
|
|
||
| // writeCachedJSON bypasses render.JSON so we can serve the same marshalled | ||
| // bytes on every cache hit without re-encoding. | ||
| func writeCachedJSON(w http.ResponseWriter, payload []byte) { | ||
| w.Header().Set("Content-Type", "application/json; charset=utf-8") | ||
| w.WriteHeader(http.StatusOK) | ||
| _, _ = w.Write(payload) | ||
| } |
There was a problem hiding this comment.
I think we can remove the caching for now. There are maybe 20K reversals in the database at the moment. Aggregates of this size are trivial to compute on page-load. We can look into caching if performance degrades sometime in the future.
| r := chi.NewRouter() | ||
| throttle := ratelimit.ThrottleByIP(time.Minute, 60) | ||
| r.With(throttle).Get("/summary", summaryHandler) | ||
| r.With(throttle).Get("/reversals/daily", dailyHandler) |
There was a problem hiding this comment.
Can wrap this in a chi.Group and use r.Use(ratelimit.ThrottleByIP(...)).
| } | ||
|
|
||
| render.JSON(w, r, listRecentResponse{Data: data}) | ||
| } |
There was a problem hiding this comment.
I believe we can use the List(opts) function for this functionality instead of creating a bespoke ListRecent function.
The options also allow the use of a cursor for pagination, which will be useful when the user wants to click "Load More."
| // /stats owns aggregate read endpoints (summary, reversals/daily). The | ||
| // /stats/reversals/daily path is semantically adjacent to /reversals/* | ||
| // but lives here because it's a public, IP-rate-limited read with a | ||
| // different cache policy. |
There was a problem hiding this comment.
Can remove these comments
| err := r.conn.Raw(` | ||
| SELECT | ||
| COUNT(DISTINCT steam_id) AS traders_indexed, | ||
| COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL) AS traders_flagged, |
There was a problem hiding this comment.
traders_indexed and traders_flagged will be the same or close to it since we hardly ever expunge a reversal.
In order to track "SteamIDs Searched" (which I believe is what you're looking for here instead of traders_indexed) we would need a separate table that holds counts. Maybe something like (steam_id, count, last_searched_at).
Summary
Three new IP-rate-limited public endpoints, mirroring the existing `api/v1/users` pattern (no auth, throttled per IP):
Implementation notes
README
Adds a postgres superuser note (required by `pgtestdb` for the new repository tests) and a public endpoints table. Seeding and dashboard sections are intentionally deferred to follow-up PRs.
Test plan
Made with Cursor
Note
Medium Risk
Introduces unauthenticated read APIs that expose Steam IDs and marketplace activity aggregates; mitigated by IP rate limits, expunged-row filtering on recent/daily data, and no write/auth changes to existing entity routes.
Overview
Adds three public, IP-rate-limited read APIs (no bearer token), alongside docs and tests for local Postgres/
pgtestdb./api/v1/statsexposesGET /summary(three trader KPIs) andGET /reversals/daily?days=…(UTC daily reversal counts, zero-filled for 7/30/60/90/180/365). Both use a 60s in-processsync.Mapcache and 60 req/min per IP.GET /api/v1/reversals/recentreturns the newest non-expunged rows as a slim JSON projection (marketplace_slug,steam_id,reversed_at,created_at), default/limit 1–100, 30 req/min per IP. The reversals router is refactored soAuthMiddlewareonly wraps authenticated routes;/recentstays public.Repository work adds
SummaryStats,DailyCounts, andListRecenton the public reversal repo (raw SQL for aggregates; GORM for recent list), with matching handler and repository tests. README updates cover DB/superuser setup for tests,go test ./..., and a public endpoints table.Reviewed by Cursor Bugbot for commit 9be036d. Bugbot is set up for automated code reviews on this repo. Configure here.