Skip to content

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

Open
ZukwiZ wants to merge 1 commit into
masterfrom
feat/public-stats-api
Open

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

Conversation

@ZukwiZ
Copy link
Copy Markdown
Collaborator

@ZukwiZ ZukwiZ commented May 27, 2026

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
Comment on lines +23 to +52
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)
}
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 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.

Comment thread api/v1/stats/router.go
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(...)).

}

render.JSON(w, r, listRecentResponse{Data: data})
}
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 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."

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

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