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>
- Remove the in-process 60s sync.Map cache and serve summary/daily stats live; aggregates over ~20K rows are trivial, add caching later if perf degrades. - Group the stats routes under a chi.Group with r.Use(ThrottleByIP) instead of attaching the rate-limit middleware per route. - Reimplement /reversals/recent on top of the existing List(opts) (id DESC, limit, exclude expunged) and drop the bespoke ListRecent method, interface entry, and repo-level test. - Add ReversalListOptions.ExcludeExpunged so List can omit expunged rows for the public recent endpoint. - Remove stale routing comments in v1.go. Co-authored-by: Cursor <cursoragent@cursor.com>
Add a dedicated search_counts table (steam_id PK, count, last_searched_at) in the public database so the public /stats/summary endpoint can report a real "Steam IDs Searched" KPI instead of deriving it from reversal counts. The public user-status lookup now upserts/increments the per-Steam-ID count on each search; counting errors are logged and swallowed so analytics never breaks the user-facing lookup. SummaryStats reads COUNT(*) (distinct Steam IDs searched) and SUM(count) (total searches) from the new table. The summary response field traders_indexed is replaced by steam_ids_searched and total_searches. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Note on the Steam IDs Searched KPI — flagging for discussion before this ships. As implemented here, the KPI is wired to the new What I actually want this card to communicate is how many Steam IDs the service has checked/screened in total — including the clean ones the CSFloat extension evaluates as people browse marketplaces. i.e. a "we've screened N traders" coverage/credibility number, not site traffic. The reversal DB only holds flagged traders, so that total-checked figure has to come from somewhere else (the extension / ingestion pipeline?). Questions for you:
The |
Use positional GROUP BY 1 / ORDER BY 1 instead of referencing the SELECT output alias `date`. PostgreSQL supports grouping by output aliases as a documented extension, but the positional form is unambiguous and avoids any reliance on that extension across versions. Results are identical. Co-authored-by: Cursor <cursoragent@cursor.com>
The public /api/v1/reversals/recent feed ordered by snowflake id DESC, but each row's reversed_at reflects when the reversal actually occurred (often from upstream ingest). Backfilled or late-ingested rows can have a high id but an older reversed_at, making the feed order disagree with the daily stats (which bucket on reversed_at) and "latest reversals" semantics. Order the feed by reversed_at DESC with id DESC as a deterministic tiebreaker for stable ordering. Add SecondaryOrderParam to ReversalListOptions and apply it in buildListQuery. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a1d8b76. Configure here.
We currently do not have a metric for the number of Steam IDs screened. As far as I know, our extension checks every user that has a trade in pending state on CSFloat, but only the rollbacks are reported to our backend. You're more than welcome to keep the current search-count behavior, but I don't think it provides as much value as traders indexed as you mentioned. |
|
No agree. That would be the best. |
- Replace OrderParam/SecondaryOrderParam with clause.OrderBy in list options - Remove unused created_at from /reversals/recent response - Fix SummaryStats: filter expunged in WHERE, use reversed_at for 24h KPI - Revert README to master content (spelling fix only) Co-authored-by: Morten Byskov <ZukwiZ@users.noreply.github.com>
The refactor to clause.OrderBy in b6aa57f passed *clause.OrderBy (a pointer) to gorm's DB.Order(). Order() only matches clause.OrderBy (value), clause.OrderByColumn, or string, so the pointer fell through the type switch and no ORDER BY was emitted. This silently dropped all ordering, breaking the authenticated list/export pagination endpoints and the public recent feed. Dereference the pointer so the ORDER BY clause is applied again. Co-authored-by: Cursor <cursoragent@cursor.com>
| cols[i] = clause.OrderByColumn{Column: clause.Column{Name: col}, Desc: true} | ||
| } | ||
| return &clause.OrderBy{Columns: cols} | ||
| } |
There was a problem hiding this comment.
I don't see much use for these wrappers. I think they can be removed.
| // the public user-status endpoint (COUNT(*) of the search_counts table). | ||
| SteamIDsSearched uint64 `json:"steam_ids_searched"` | ||
| // TotalSearches is the total number of lookups across all Steam IDs | ||
| // (SUM(count) of the search_counts table). |
There was a problem hiding this comment.
You can remove these comments
| } | ||
|
|
||
| type DailyCount struct { | ||
| Date string `json:"date"` |
There was a problem hiding this comment.
Why not use time.Time here?
| // public user-status endpoint. It lives in the public database so the public | ||
| // summary stats query can read it directly. The row is upserted on every | ||
| // lookup, so the number of rows equals the number of distinct Steam IDs ever | ||
| // searched and the sum of Count equals the total number of searches. |
There was a problem hiding this comment.
You can remove this comment.
| // "Steam IDs Searched" comes from the dedicated search_counts table: the | ||
| // number of rows is the count of distinct Steam IDs ever searched, and the | ||
| // sum of count is the total number of searches. Read positionally to avoid | ||
| // depending on column-name mapping for the aggregate aliases. |
There was a problem hiding this comment.
Can remove this comment.
| SELECT | ||
| to_char(to_timestamp(reversed_at / 1000) AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date, | ||
| COUNT(*) AS count | ||
| FROM reversals | ||
| WHERE deleted_at IS NULL | ||
| AND expunged_at IS NULL | ||
| AND reversed_at >= ? | ||
| GROUP BY 1 | ||
| ORDER BY 1 ASC |
There was a problem hiding this comment.
I'd prefer doing something like this:
SELECT
date_trunc('day', to_timestamp(reversed_at / 1000) AT TIME ZONE 'UTC') AS date,
COUNT(*) AS count
FROM reversals
WHERE deleted_at IS NULL
AND expunged_at IS NULL
AND reversed_at >= ?
GROUP BY 1
ORDER BY 1 ASCWe'd parse date into a time.Time field and let the frontend format in UTC or treat the value as a UTC bucket. Have to be careful about formatting in browser-local time, though.
The backend would still handle filling in the empty days.

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.