Skip to content

api/v1: public stats and recent reversals endpoints#69

Open
ZukwiZ wants to merge 5 commits into
masterfrom
feat/public-stats-api
Open

api/v1: public stats and recent reversals endpoints#69
ZukwiZ wants to merge 5 commits into
masterfrom
feat/public-stats-api

Conversation

@ZukwiZ

@ZukwiZ ZukwiZ commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three new IP-rate-limited public endpoints, mirroring the existing `api/v1/users` pattern (no auth, throttled per IP):

  • `GET /api/v1/stats/summary` — three KPI counts in one call
  • `GET /api/v1/stats/reversals/daily?days={7|30|60|90|180|365}` — daily reversal counts, UTC, zero-filled to the requested window
  • `GET /api/v1/reversals/recent?limit={1..100}` — newest non-expunged reversals, slim public projection (`marketplace_slug`, `steam_id`, `reversed_at`, `created_at`)

Implementation notes

  • `/stats/*` share a 60s in-process `sync.Map` cache and a 60 req/min/IP throttle. `/reversals/recent` has its own 30 req/min/IP throttle and bypasses the cache.
  • Authenticated `/api/v1/reversals` routes are wrapped in a `chi.Group` so `AuthMiddleware` no longer applies to the new `/recent` path while preserving every other route's behavior. No existing route changes shape.
  • Aggregates use raw SQL (`COUNT DISTINCT … FILTER` for summary; `to_char` on `reversed_at` for daily bucketing) so we don't drag GORM through non-trivial expressions. The list endpoint stays on the GORM path.
  • All queries filter `deleted_at IS NULL`; the flag/24h KPIs additionally filter `expunged_at IS NULL`.
  • No schema changes.

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/stats exposes GET /summary (three trader KPIs) and GET /reversals/daily?days=… (UTC daily reversal counts, zero-filled for 7/30/60/90/180/365). Both use a 60s in-process sync.Map cache and 60 req/min per IP.

GET /api/v1/reversals/recent returns 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 so AuthMiddleware only wraps authenticated routes; /recent stays public.

Repository work adds SummaryStats, DailyCounts, and ListRecent on 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.

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>
@zedimytch zedimytch self-requested a review June 3, 2026 16:54
Comment thread api/v1/stats/stats.go Outdated
Comment thread api/v1/stats/router.go Outdated
r := chi.NewRouter()
throttle := ratelimit.ThrottleByIP(time.Minute, 60)
r.With(throttle).Get("/summary", summaryHandler)
r.With(throttle).Get("/reversals/daily", dailyHandler)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can wrap this in a chi.Group and use r.Use(ratelimit.ThrottleByIP(...)).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — wrapped in a chi.Group with r.Use(ratelimit.ThrottleByIP(...)).

Comment thread api/v1/reversals/reversals.go
Comment thread api/v1/v1.go Outdated
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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a search_counts (steam_id, count, last_searched_at) table, incremented on each lookup; the summary now returns steam_ids_searched + total_searches instead of traders_indexed. I also left a top-level comment on the PR about what this KPI should ultimately represent — would value your take.

- 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>
Comment thread repository/public/reversal.go Outdated
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>
@ZukwiZ

ZukwiZ commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Note on the Steam IDs Searched KPI — flagging for discussion before this ships.

As implemented here, the KPI is wired to the new search_counts table, which increments on each lookup through the public dashboard. So it currently measures dashboard searches (distinct IDs typed into the search box) and starts at 0 on a fresh deploy.

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:

  1. Where should this number come from — do we have a source for "total Steam IDs checked" today?
  2. If not, do we keep the current search-count behavior as an interim metric (and relabel the card so it's honest about what it shows), or hold the card until we can feed it the real coverage count?

The search_counts table + increment-on-lookup is still useful regardless (engagement analytics), so nothing here is wasted — it's just a question of what the headline KPI should represent.

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>
Comment thread api/v1/reversals/reversals.go
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>
@ZukwiZ ZukwiZ requested a review from zedimytch June 24, 2026 13:40

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

err := r.conn.Raw(`
SELECT
COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL) AS traders_flagged,
COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL AND created_at >= ?) AS traders_flagged24h

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

24h KPI uses wrong timestamp

Medium Severity

SummaryStats computes traders_flagged_24h with a rolling window on created_at, while DailyCounts and /reversals/recent use reversed_at. Backfilled or delayed reports can show up in the 24h KPI but not in the daily chart or recent feed, so the public dashboard can disagree across widgets.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a1d8b76. Configure here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use reversed_at instead of created_at.

SecondaryOrderParam: &dto.OrderParam{
Column: "id",
Direction: dto.DESC,
},

@zedimytch zedimytch Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a reason for this secondary order param, am I missing something? Ordering by reversed_at should be all that you need.

Edit: It looks like you'll need the secondary ordering for pagination. In that case, we can change the OrderParam in the options struct to be of type clause.OrderBy.

MarketplaceSlug: rev.MarketplaceSlug,
SteamID: rev.SteamID,
ReversedAt: rev.ReversedAt,
CreatedAt: rev.CreatedAt,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think 'created_at' will be used by the frontend, can you clarify what this is for?

var stats dto.SummaryStats
err := r.conn.Raw(`
SELECT
COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL) AS traders_flagged,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can move the expunged_at is NULL filter to be part of the where clause.

err := r.conn.Raw(`
SELECT
COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL) AS traders_flagged,
COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL AND created_at >= ?) AS traders_flagged24h

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can do the same thing here.

err := r.conn.Raw(`
SELECT
COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL) AS traders_flagged,
COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL AND created_at >= ?) AS traders_flagged24h

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use reversed_at instead of created_at.

Comment thread README.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, we don't need to update this README. Please revert everything except for the misspell fix.

@zedimytch

Copy link
Copy Markdown
Collaborator

Note on the Steam IDs Searched KPI — flagging for discussion before this ships.

As implemented here, the KPI is wired to the new search_counts table, which increments on each lookup through the public dashboard. So it currently measures dashboard searches (distinct IDs typed into the search box) and starts at 0 on a fresh deploy.

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:

  1. Where should this number come from — do we have a source for "total Steam IDs checked" today?
  2. If not, do we keep the current search-count behavior as an interim metric (and relabel the card so it's honest about what it shows), or hold the card until we can feed it the real coverage count?

The search_counts table + increment-on-lookup is still useful regardless (engagement analytics), so nothing here is wasted — it's just a question of what the headline KPI should represent.

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.

ZukwiZ commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

No agree. That would be the best.
But if we dont have anything else, then let's just keep that for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants