From 9be036d31a8af19d70cbd2f6d0850d80c0ead1f4 Mon Sep 17 00:00:00 2001 From: zukwiz Date: Wed, 27 May 2026 19:06:00 +0200 Subject: [PATCH 1/7] api/v1: public stats and recent reversals endpoints 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 --- README.md | 36 ++- api/v1/reversals/reversals.go | 49 ++++ api/v1/reversals/reversals_recent_test.go | 240 +++++++++++++++++++ api/v1/reversals/router.go | 32 ++- api/v1/stats/router.go | 17 ++ api/v1/stats/stats.go | 114 +++++++++ api/v1/stats/stats_test.go | 280 ++++++++++++++++++++++ api/v1/v1.go | 6 + domain/dto/stats.go | 12 + domain/repository/public.go | 4 + repository/public/reversal.go | 67 ++++++ repository/public/reversal_test.go | 234 ++++++++++++++++++ 12 files changed, 1074 insertions(+), 17 deletions(-) create mode 100644 api/v1/reversals/reversals_recent_test.go create mode 100644 api/v1/stats/router.go create mode 100644 api/v1/stats/stats.go create mode 100644 api/v1/stats/stats_test.go create mode 100644 domain/dto/stats.go diff --git a/README.md b/README.md index 1926b7f4..4e60dc32 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # [reverse.watch](https://reverse.watch) -Community-driven open trade reversal tracking database for Steam. Participating entities can report trade reverals to the open database. +Community-driven open trade reversal tracking database for Steam. Participating entities can report trade reversals to the open database. ## Interested in Participating? @@ -9,17 +9,45 @@ If you're looking to participate by contributing reversal reports (i.e. marketpl ## Running Locally 1. Ensure Go 1.24+ and PostgreSQL are installed. -2. Copy the config template and fill in your local database credentials: +2. Create the two local databases and (for tests) a `postgres` superuser: + ```bash + createdb private + createdb public + psql -d postgres -c "CREATE USER postgres WITH SUPERUSER PASSWORD 'postgres';" + # If the user already exists: + # psql -d postgres -c "ALTER USER postgres WITH SUPERUSER PASSWORD 'postgres';" + ``` + The superuser is required by [`pgtestdb`](https://github.com/peterldowns/pgtestdb), which spins up disposable databases per test. +3. Copy the config template and fill in your local database credentials: ```bash cp config.example.json config.json ``` -3. Run the service: +4. Run the service: ```bash go run main.go ``` The server starts on port `80` by default (configurable via `HTTP_PORT`). +### Running tests + +```bash +go test ./... +``` + +Tests use `pgtestdb` to provision a fresh database per test against the local Postgres. The `postgres/postgres` superuser from step 2 above is required. + +## Public read endpoints + +| Endpoint | Purpose | +|---|---| +| `GET /api/v1/users/{steamId}` | Single Steam-ID lookup (existing) | +| `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 | +| `GET /api/v1/reversals/recent?limit={1..100}` | Latest non-expunged reversals (slim public projection) | + +All four are public, IP-rate-limited, and return JSON. The two `/stats` endpoints have a 60-second in-process cache. + ## Configuration Configuration is loaded from environment variables or a `config.json` file. @@ -47,4 +75,4 @@ API keys are scoped to an entity and carry a permission bitfield. Keys are prefi ## Rate Limiting -Rate limits are enforced in-memory per process. Throttled responses return `429 Too Many Requests` with `X-RateLimit-*` and `Retry-After` headers. \ No newline at end of file +Rate limits are enforced in-memory per process. Throttled responses return `429 Too Many Requests` with `X-RateLimit-*` and `Retry-After` headers. diff --git a/api/v1/reversals/reversals.go b/api/v1/reversals/reversals.go index 0b3a294a..d6d2f929 100644 --- a/api/v1/reversals/reversals.go +++ b/api/v1/reversals/reversals.go @@ -236,6 +236,55 @@ func exportReversals(w http.ResponseWriter, r *http.Request) { w.Write(buf.Bytes()) } +type recentReversal struct { + MarketplaceSlug string `json:"marketplace_slug"` + SteamID models.SteamID `json:"steam_id"` + ReversedAt uint64 `json:"reversed_at"` + CreatedAt uint64 `json:"created_at"` +} + +type listRecentResponse struct { + Data []recentReversal `json:"data"` +} + +func listRecentHandler(w http.ResponseWriter, r *http.Request) { + factory, ok := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) + if !ok { + render.Errorf(w, r, errors.InternalServerError, "missing factory from context") + return + } + + const maxRecentLimit = 100 + + limit := maxRecentLimit + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + parsed, err := strconv.Atoi(limitStr) + if err != nil || parsed <= 0 || parsed > maxRecentLimit { + render.Errorf(w, r, errors.BadRequest, "limit must be between 1 and %d", maxRecentLimit) + return + } + limit = parsed + } + + reversals, err := factory.Reversal().ListRecent(limit) + if err != nil { + render.Errorf(w, r, errors.InternalServerError, "failed to list recent reversals") + return + } + + data := make([]recentReversal, 0, len(reversals)) + for _, rev := range reversals { + data = append(data, recentReversal{ + MarketplaceSlug: rev.MarketplaceSlug, + SteamID: rev.SteamID, + ReversedAt: rev.ReversedAt, + CreatedAt: rev.CreatedAt, + }) + } + + render.JSON(w, r, listRecentResponse{Data: data}) +} + func expungeReversal(w http.ResponseWriter, r *http.Request) { factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) key := r.Context().Value(middleware.KeyContextKey).(*models.Key) diff --git a/api/v1/reversals/reversals_recent_test.go b/api/v1/reversals/reversals_recent_test.go new file mode 100644 index 00000000..494325e6 --- /dev/null +++ b/api/v1/reversals/reversals_recent_test.go @@ -0,0 +1,240 @@ +package reversals + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "reverse-watch/domain/models" + "reverse-watch/domain/models/constants" + "reverse-watch/errors" + "reverse-watch/internal/testutil" + "reverse-watch/middleware" + "reverse-watch/repository/factory" + "reverse-watch/secret" + "reverse-watch/util" + + "gorm.io/gorm" +) + +func buildRecentHandlerStack(t *testing.T) (http.Handler, *gorm.DB) { + t.Helper() + + db := testutil.NewTestDB(t) + keygen := secret.NewKeyGenerator(constants.EnvironmentDevelopment) + f, err := factory.NewFactoryWithConfig(&factory.Config{ + PrivateDB: db, + PublicDB: db, + KeyGen: keygen, + }) + if err != nil { + t.Fatalf("NewFactoryWithConfig(): %v", err) + } + + handler := http.HandlerFunc(listRecentHandler) + return middleware.FactoryMiddleware(f)(handler), db +} + +func TestListRecentHandler(t *testing.T) { + t.Parallel() + + handler, db := buildRecentHandlerStack(t) + + base := models.Epoch + 1000 + + // 5 rows, monotonically increasing CreatedAt. Row id=3 is expunged. + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: base + 100}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: base + 200}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 3, CreatedAt: base + 300}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + ExpungedAt: util.Ptr(base + 400), + }, + &models.Reversal{ + Model: models.Model{ID: 4, CreatedAt: base + 500}, + SteamID: models.SteamID(76561197960287933), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 5, CreatedAt: base + 600}, + SteamID: models.SteamID(76561197960287934), + MarketplaceSlug: "csfloat", + }, + ) + + r := httptest.NewRequest(http.MethodGet, "/recent", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + var body listRecentResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + + wantSteamIDs := []models.SteamID{ + 76561197960287934, // id=5 + 76561197960287933, // id=4 + 76561197960287931, // id=2 + 76561197960287930, // id=1 + } + if len(body.Data) != len(wantSteamIDs) { + t.Fatalf("len(data) = %d, want %d", len(body.Data), len(wantSteamIDs)) + } + for i, want := range wantSteamIDs { + if body.Data[i].SteamID != want { + t.Errorf("data[%d].SteamID = %d, want %d", i, body.Data[i].SteamID, want) + } + } +} + +func TestListRecentHandler_RespectsLimit(t *testing.T) { + t.Parallel() + + handler, db := buildRecentHandlerStack(t) + + base := models.Epoch + 1000 + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: base + 100}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: base + 200}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 3, CreatedAt: base + 300}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + }, + ) + + r := httptest.NewRequest(http.MethodGet, "/recent?limit=2", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + var body listRecentResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Data) != 2 { + t.Errorf("len(data) = %d, want 2", len(body.Data)) + } +} + +func TestListRecentHandler_InvalidLimit(t *testing.T) { + t.Parallel() + + handler, _ := buildRecentHandlerStack(t) + + testCases := []struct { + name string + limit string + }{ + {name: "zero", limit: "0"}, + {name: "negative", limit: "-1"}, + {name: "overMax", limit: "101"}, + {name: "nonNumeric", limit: "abc"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/recent?limit="+tc.limit, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + var body errors.Error + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Details != "limit must be between 1 and 100" { + t.Errorf("details = %q, want %q", body.Details, "limit must be between 1 and 100") + } + }) + } +} + +func TestListRecentHandler_ResponseShape(t *testing.T) { + t.Parallel() + + handler, db := buildRecentHandlerStack(t) + + base := models.Epoch + 1000 + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: base + 100}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + ReversedAt: base + 50, + }, + ) + + r := httptest.NewRequest(http.MethodGet, "/recent", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + // Decode as raw JSON to assert the exact wire shape (especially steam_id as a string). + var raw struct { + Data []map[string]interface{} `json:"data"` + } + if err := json.NewDecoder(w.Result().Body).Decode(&raw); err != nil { + t.Fatalf("decode: %v", err) + } + if len(raw.Data) != 1 { + t.Fatalf("len(data) = %d, want 1", len(raw.Data)) + } + row := raw.Data[0] + + expectedKeys := []string{"marketplace_slug", "steam_id", "reversed_at", "created_at"} + for _, k := range expectedKeys { + if _, ok := row[k]; !ok { + t.Errorf("missing key %q in response", k) + } + } + for k := range row { + ok := false + for _, want := range expectedKeys { + if k == want { + ok = true + break + } + } + if !ok { + t.Errorf("unexpected key %q in response", k) + } + } + + steamIDValue, ok := row["steam_id"].(string) + if !ok { + t.Errorf("steam_id should be a JSON string, got %T", row["steam_id"]) + } + if steamIDValue != "76561197960287930" { + t.Errorf("steam_id = %q, want %q", steamIDValue, "76561197960287930") + } +} diff --git a/api/v1/reversals/router.go b/api/v1/reversals/router.go index cb710d06..5af57520 100644 --- a/api/v1/reversals/router.go +++ b/api/v1/reversals/router.go @@ -12,23 +12,29 @@ import ( func Router() chi.Router { r := chi.NewRouter() - r.Use(middleware.AuthMiddleware) - r.With( - middleware.RequirePermissions(models.PermissionWrite), - ratelimit.ThrottleByAPIKey(time.Hour, 2_000), - ).Post("/", createReversals) + r.With(ratelimit.ThrottleByIP(time.Minute, 30)).Get("/recent", listRecentHandler) - r.With( - middleware.RequirePermissions(models.PermissionDelete), - ratelimit.ThrottleByAPIKey(time.Hour, 2_000), - ).Delete("/{id}", expungeReversal) + r.Group(func(r chi.Router) { + r.Use(middleware.AuthMiddleware) - r.Route("/", func(r chi.Router) { - r.Use(middleware.RequirePermissions(models.PermissionExport)) + r.With( + middleware.RequirePermissions(models.PermissionWrite), + ratelimit.ThrottleByAPIKey(time.Hour, 2_000), + ).Post("/", createReversals) - r.With(ratelimit.ThrottleByAPIKey(time.Minute, 300)).Get("/", listReversalsHandler) - r.With(ratelimit.ThrottleByAPIKey(time.Minute, 60)).Get("/export", exportReversals) + r.With( + middleware.RequirePermissions(models.PermissionDelete), + ratelimit.ThrottleByAPIKey(time.Hour, 2_000), + ).Delete("/{id}", expungeReversal) + + r.Route("/", func(r chi.Router) { + r.Use(middleware.RequirePermissions(models.PermissionExport)) + + r.With(ratelimit.ThrottleByAPIKey(time.Minute, 300)).Get("/", listReversalsHandler) + r.With(ratelimit.ThrottleByAPIKey(time.Minute, 60)).Get("/export", exportReversals) + }) }) + return r } diff --git a/api/v1/stats/router.go b/api/v1/stats/router.go new file mode 100644 index 00000000..ab0b8ad8 --- /dev/null +++ b/api/v1/stats/router.go @@ -0,0 +1,17 @@ +package stats + +import ( + "time" + + "reverse-watch/ratelimit" + + "github.com/go-chi/chi/v5" +) + +func Router() chi.Router { + r := chi.NewRouter() + throttle := ratelimit.ThrottleByIP(time.Minute, 60) + r.With(throttle).Get("/summary", summaryHandler) + r.With(throttle).Get("/reversals/daily", dailyHandler) + return r +} diff --git a/api/v1/stats/stats.go b/api/v1/stats/stats.go new file mode 100644 index 00000000..1d763c60 --- /dev/null +++ b/api/v1/stats/stats.go @@ -0,0 +1,114 @@ +package stats + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strconv" + "sync" + "time" + + "reverse-watch/domain/dto" + "reverse-watch/domain/repository" + "reverse-watch/errors" + "reverse-watch/middleware" + "reverse-watch/render" +) + +const cacheTTL = 60 * time.Second + +var allowedDays = []int{7, 30, 60, 90, 180, 365} + +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) +} + +func summaryHandler(w http.ResponseWriter, r *http.Request) { + const key = "summary" + if payload, ok := cacheGet(key); ok { + writeCachedJSON(w, payload) + return + } + + factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) + + stats, err := factory.Reversal().SummaryStats() + if err != nil { + render.Errorf(w, r, errors.InternalServerError, "failed to load summary stats") + return + } + + payload, err := json.Marshal(stats) + if err != nil { + render.Errorf(w, r, errors.InternalServerError, "failed to encode summary stats") + return + } + cacheSet(key, payload) + writeCachedJSON(w, payload) +} + +type dailyResponse struct { + Data []dto.DailyCount `json:"data"` +} + +func dailyHandler(w http.ResponseWriter, r *http.Request) { + days := 30 + if daysStr := r.URL.Query().Get("days"); daysStr != "" { + parsed, err := strconv.Atoi(daysStr) + if err != nil || !slices.Contains(allowedDays, parsed) { + render.Errorf(w, r, errors.BadRequest, "days must be one of 7, 30, 60, 90, 180, 365") + return + } + days = parsed + } + + key := fmt.Sprintf("daily:%d", days) + if payload, ok := cacheGet(key); ok { + writeCachedJSON(w, payload) + return + } + + factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) + + counts, err := factory.Reversal().DailyCounts(days) + if err != nil { + render.Errorf(w, r, errors.InternalServerError, "failed to load daily counts") + return + } + + payload, err := json.Marshal(dailyResponse{Data: counts}) + if err != nil { + render.Errorf(w, r, errors.InternalServerError, "failed to encode daily counts") + return + } + cacheSet(key, payload) + writeCachedJSON(w, payload) +} diff --git a/api/v1/stats/stats_test.go b/api/v1/stats/stats_test.go new file mode 100644 index 00000000..df9af69b --- /dev/null +++ b/api/v1/stats/stats_test.go @@ -0,0 +1,280 @@ +package stats + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + "time" + + "reverse-watch/domain/dto" + "reverse-watch/domain/models" + "reverse-watch/domain/models/constants" + "reverse-watch/errors" + "reverse-watch/internal/testutil" + "reverse-watch/middleware" + "reverse-watch/repository/factory" + "reverse-watch/secret" + "reverse-watch/util" + + "github.com/google/go-cmp/cmp" + "gorm.io/gorm" +) + +func resetCache() { + cache = sync.Map{} +} + +func buildHandlerStack(t *testing.T) (http.Handler, *gorm.DB) { + t.Helper() + + db := testutil.NewTestDB(t) + keygen := secret.NewKeyGenerator(constants.EnvironmentDevelopment) + f, err := factory.NewFactoryWithConfig(&factory.Config{ + PrivateDB: db, + PublicDB: db, + KeyGen: keygen, + }) + if err != nil { + t.Fatalf("NewFactoryWithConfig(): %v", err) + } + + router := Router() + finalHandler := middleware.FactoryMiddleware(f)(router) + return finalHandler, db +} + +func TestSummaryHandler(t *testing.T) { + resetCache() + + handler, db := buildHandlerStack(t) + + now := uint64(time.Now().UnixMilli()) + hourMs := uint64(60 * 60 * 1000) + withinDay := now - hourMs + olderThanDay := now - 36*hourMs + + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: withinDay}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: olderThanDay}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 3, CreatedAt: olderThanDay}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + ExpungedAt: util.Ptr(now - 24*hourMs), + }, + ) + + r := httptest.NewRequest(http.MethodGet, "/summary", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + var got dto.SummaryStats + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + want := dto.SummaryStats{ + TradersIndexed: 3, + TradersFlagged: 2, + TradersFlagged24h: 1, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("SummaryStats mismatch (-want +got):\n%s", diff) + } +} + +func TestSummaryHandler_Caches(t *testing.T) { + resetCache() + + handler, db := buildHandlerStack(t) + + now := uint64(time.Now().UnixMilli()) + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: now - 60*60*1000}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + ) + + hit := func() dto.SummaryStats { + r := httptest.NewRequest(http.MethodGet, "/summary", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + var s dto.SummaryStats + if err := json.NewDecoder(w.Result().Body).Decode(&s); err != nil { + t.Fatalf("decode: %v", err) + } + return s + } + + first := hit() + + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: now - 30*60*1000}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + }, + ) + + second := hit() + if second != first { + t.Errorf("expected cached response unchanged: first=%+v second=%+v", first, second) + } +} + +func TestDailyHandler(t *testing.T) { + resetCache() + + handler, db := buildHandlerStack(t) + + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + ReversedAt: uint64(today.UnixMilli()) + 1, + }, + &models.Reversal{ + Model: models.Model{ID: 2}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + ReversedAt: uint64(today.AddDate(0, 0, -1).Add(12 * time.Hour).UnixMilli()), + }, + ) + + r := httptest.NewRequest(http.MethodGet, "/reversals/daily?days=30", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + var got dailyResponse + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Data) != 30 { + t.Fatalf("len(data) = %d, want 30", len(got.Data)) + } + + todayKey := today.Format("2006-01-02") + yesterdayKey := today.AddDate(0, 0, -1).Format("2006-01-02") + + byDate := make(map[string]uint64, len(got.Data)) + for _, b := range got.Data { + byDate[b.Date] = b.Count + } + if byDate[todayKey] != 1 { + t.Errorf("today bucket = %d, want 1", byDate[todayKey]) + } + if byDate[yesterdayKey] != 1 { + t.Errorf("yesterday bucket = %d, want 1", byDate[yesterdayKey]) + } +} + +func TestDailyHandler_InvalidDays(t *testing.T) { + resetCache() + handler, _ := buildHandlerStack(t) + + const wantDetails = "days must be one of 7, 30, 60, 90, 180, 365" + testCases := []struct { + name string + days string + }{ + {name: "outOfRange", days: "45"}, + {name: "negative", days: "-1"}, + {name: "nonNumeric", days: "abc"}, + {name: "zero", days: "0"}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/reversals/daily?days="+tc.days, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + var body errors.Error + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Details != wantDetails { + t.Errorf("details = %q, want %q", body.Details, wantDetails) + } + }) + } +} + +func TestDailyHandler_AcceptedDays(t *testing.T) { + // Each accepted value should return a fully zero-filled series of + // exactly that many buckets. Empty DB keeps the assertion focused on + // the length contract that the picker depends on. + for _, days := range []int{7, 30, 60, 90, 180, 365} { + days := days + t.Run(strconv.Itoa(days), func(t *testing.T) { + resetCache() + handler, _ := buildHandlerStack(t) + + r := httptest.NewRequest(http.MethodGet, "/reversals/daily?days="+strconv.Itoa(days), nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + var got dailyResponse + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Data) != days { + t.Errorf("len(data) = %d, want %d", len(got.Data), days) + } + }) + } +} + +func TestDailyHandler_DefaultDays(t *testing.T) { + resetCache() + handler, _ := buildHandlerStack(t) + + r := httptest.NewRequest(http.MethodGet, "/reversals/daily", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + var got dailyResponse + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Data) != 30 { + t.Errorf("default len(data) = %d, want 30", len(got.Data)) + } +} diff --git a/api/v1/v1.go b/api/v1/v1.go index e4ffe1b3..a104facf 100644 --- a/api/v1/v1.go +++ b/api/v1/v1.go @@ -5,6 +5,7 @@ import ( "reverse-watch/api/v1/health" "reverse-watch/api/v1/marketplace" "reverse-watch/api/v1/reversals" + "reverse-watch/api/v1/stats" "reverse-watch/api/v1/users" "github.com/go-chi/chi/v5" @@ -15,6 +16,11 @@ func Router() chi.Router { r.Mount("/health", health.Router()) r.Mount("/marketplace", marketplace.Router()) r.Mount("/reversals", reversals.Router()) + // /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. + r.Mount("/stats", stats.Router()) r.Mount("/users", users.Router()) r.Mount("/admin", admin.Router()) return r diff --git a/domain/dto/stats.go b/domain/dto/stats.go new file mode 100644 index 00000000..b263bdb6 --- /dev/null +++ b/domain/dto/stats.go @@ -0,0 +1,12 @@ +package dto + +type SummaryStats struct { + TradersIndexed uint64 `json:"traders_indexed"` + TradersFlagged uint64 `json:"traders_flagged"` + TradersFlagged24h uint64 `json:"traders_flagged_24h"` +} + +type DailyCount struct { + Date string `json:"date"` + Count uint64 `json:"count"` +} diff --git a/domain/repository/public.go b/domain/repository/public.go index 13520fa8..96645a9b 100644 --- a/domain/repository/public.go +++ b/domain/repository/public.go @@ -13,4 +13,8 @@ type ReversalRepository interface { Delete(id models.Snowflake) error DeleteAllUserReports(steamId models.SteamID) error List(opts *dto.ReversalListOptions) ([]*models.Reversal, error) + + SummaryStats() (*dto.SummaryStats, error) + DailyCounts(days int) ([]dto.DailyCount, error) + ListRecent(limit int) ([]*models.Reversal, error) } diff --git a/repository/public/reversal.go b/repository/public/reversal.go index cebda95d..272667a7 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -1,6 +1,8 @@ package public import ( + "time" + "reverse-watch/domain/dto" "reverse-watch/domain/models" "reverse-watch/domain/repository" @@ -123,3 +125,68 @@ func (r *reversalRepository) List(opts *dto.ReversalListOptions) ([]*models.Reve } return reversals, nil } + +func (r *reversalRepository) SummaryStats() (*dto.SummaryStats, error) { + cutoffMs := uint64(time.Now().UnixMilli() - 24*60*60*1000) + + var stats dto.SummaryStats + 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, + COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL AND created_at >= ?) AS traders_flagged24h + FROM reversals + WHERE deleted_at IS NULL + `, cutoffMs).Scan(&stats).Error + if err != nil { + return nil, err + } + return &stats, nil +} + +func (r *reversalRepository) DailyCounts(days int) ([]dto.DailyCount, error) { + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + windowStart := today.AddDate(0, 0, -(days - 1)) + + var rows []dto.DailyCount + err := r.conn.Raw(` + 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 date + ORDER BY date ASC + `, uint64(windowStart.UnixMilli())).Scan(&rows).Error + if err != nil { + return nil, err + } + + byDate := make(map[string]uint64, len(rows)) + for _, row := range rows { + byDate[row.Date] = row.Count + } + + result := make([]dto.DailyCount, 0, days) + for d := windowStart; !d.After(today); d = d.AddDate(0, 0, 1) { + key := d.Format("2006-01-02") + result = append(result, dto.DailyCount{Date: key, Count: byDate[key]}) + } + return result, nil +} + +func (r *reversalRepository) ListRecent(limit int) ([]*models.Reversal, error) { + var reversals []*models.Reversal + err := r.conn.Model(&models.Reversal{}). + Where("expunged_at IS NULL"). + Order("created_at DESC"). + Limit(limit). + Find(&reversals).Error + if err != nil { + return nil, err + } + return reversals, nil +} diff --git a/repository/public/reversal_test.go b/repository/public/reversal_test.go index 4f5fc6c0..bba5e646 100644 --- a/repository/public/reversal_test.go +++ b/repository/public/reversal_test.go @@ -812,6 +812,240 @@ func TestReversalRepository_List(t *testing.T) { } } +func TestReversalRepository_SummaryStats(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + reversalRepo := NewReversalRepository(db) + + now := uint64(time.Now().UnixMilli()) + hourMs := uint64(60 * 60 * 1000) + withinDay := now - hourMs // -1h: counts as "last 24h" + olderThanDay := now - 36*hourMs // -36h: outside "last 24h" + + // A: 1 non-expunged within 24h + 1 non-expunged older -> indexed, flagged, flagged_24h + // B: 1 expunged within 24h + 1 non-expunged older -> indexed, flagged (not 24h) + // C: 1 expunged older -> indexed only + // D: 1 non-expunged within 24h -> indexed, flagged, flagged_24h + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: withinDay}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: olderThanDay}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat-2", + }, + &models.Reversal{ + Model: models.Model{ID: 3, CreatedAt: withinDay}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + ExpungedAt: util.Ptr(now), + }, + &models.Reversal{ + Model: models.Model{ID: 4, CreatedAt: olderThanDay}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat-2", + }, + &models.Reversal{ + Model: models.Model{ID: 5, CreatedAt: olderThanDay}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + ExpungedAt: util.Ptr(now - 24*hourMs), + }, + &models.Reversal{ + Model: models.Model{ID: 6, CreatedAt: withinDay}, + SteamID: models.SteamID(76561197960287933), + MarketplaceSlug: "csfloat", + }, + ) + + got, err := reversalRepo.SummaryStats() + if err != nil { + t.Fatalf("SummaryStats(): %v", err) + } + want := &dto.SummaryStats{ + TradersIndexed: 4, + TradersFlagged: 3, + TradersFlagged24h: 2, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("SummaryStats() mismatch (-want +got):\n%s", diff) + } +} + +func TestReversalRepository_DailyCounts(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + reversalRepo := NewReversalRepository(db) + + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + dayMs := func(offset int, addHours int) uint64 { + return uint64(today.AddDate(0, 0, offset).Add(time.Duration(addHours) * time.Hour).UnixMilli()) + } + + // today: 2 non-expunged (very early today, safely past) + // today-1: 1 non-expunged + 1 expunged (excluded) + // today-2: 1 non-expunged + // today-3: 1 non-expunged (outside days=3 window) + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + ReversedAt: uint64(today.UnixMilli()) + 1, + }, + &models.Reversal{ + Model: models.Model{ID: 2}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + ReversedAt: uint64(today.UnixMilli()) + 2, + }, + &models.Reversal{ + Model: models.Model{ID: 3}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + ReversedAt: dayMs(-1, 12), + }, + &models.Reversal{ + Model: models.Model{ID: 4, CreatedAt: dayMs(-1, 15)}, + SteamID: models.SteamID(76561197960287933), + MarketplaceSlug: "csfloat", + ReversedAt: dayMs(-1, 15), + ExpungedAt: util.Ptr(dayMs(-1, 16)), + }, + &models.Reversal{ + Model: models.Model{ID: 5}, + SteamID: models.SteamID(76561197960287934), + MarketplaceSlug: "csfloat", + ReversedAt: dayMs(-2, 5), + }, + &models.Reversal{ + Model: models.Model{ID: 6}, + SteamID: models.SteamID(76561197960287935), + MarketplaceSlug: "csfloat", + ReversedAt: dayMs(-3, 1), + }, + ) + + got, err := reversalRepo.DailyCounts(3) + if err != nil { + t.Fatalf("DailyCounts(3): %v", err) + } + want := []dto.DailyCount{ + {Date: today.AddDate(0, 0, -2).Format("2006-01-02"), Count: 1}, + {Date: today.AddDate(0, 0, -1).Format("2006-01-02"), Count: 1}, + {Date: today.Format("2006-01-02"), Count: 2}, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("DailyCounts(3) mismatch (-want +got):\n%s", diff) + } +} + +func TestReversalRepository_DailyCounts_ZeroFill(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + reversalRepo := NewReversalRepository(db) + + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + got, err := reversalRepo.DailyCounts(5) + if err != nil { + t.Fatalf("DailyCounts(5): %v", err) + } + if len(got) != 5 { + t.Fatalf("DailyCounts(5): got %d buckets, want 5", len(got)) + } + for i, b := range got { + wantDate := today.AddDate(0, 0, -(4 - i)).Format("2006-01-02") + if b.Date != wantDate { + t.Errorf("bucket[%d].Date = %q, want %q", i, b.Date, wantDate) + } + if b.Count != 0 { + t.Errorf("bucket[%d].Count = %d, want 0", i, b.Count) + } + } +} + +func TestReversalRepository_ListRecent(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + reversalRepo := NewReversalRepository(db) + + base := models.Epoch + 1000 + + // 5 rows with strictly increasing CreatedAt. Row id=3 is expunged. + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: base + 100}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: base + 200}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 3, CreatedAt: base + 300}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + ExpungedAt: util.Ptr(base + 400), + }, + &models.Reversal{ + Model: models.Model{ID: 4, CreatedAt: base + 500}, + SteamID: models.SteamID(76561197960287933), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 5, CreatedAt: base + 600}, + SteamID: models.SteamID(76561197960287934), + MarketplaceSlug: "csfloat", + }, + ) + + testCases := []struct { + name string + limit int + wantIDs []models.Snowflake + }{ + { + name: "newestFirstExcludingExpunged", + limit: 10, + wantIDs: []models.Snowflake{5, 4, 2, 1}, + }, + { + name: "respectsLimit", + limit: 2, + wantIDs: []models.Snowflake{5, 4}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := reversalRepo.ListRecent(tc.limit) + if err != nil { + t.Fatalf("ListRecent(%d): %v", tc.limit, err) + } + if len(got) != len(tc.wantIDs) { + t.Fatalf("ListRecent(%d): got %d rows, want %d", tc.limit, len(got), len(tc.wantIDs)) + } + for i, wantID := range tc.wantIDs { + if got[i].ID != wantID { + t.Errorf("ListRecent(%d)[%d].ID = %d, want %d", tc.limit, i, got[i].ID, wantID) + } + } + }) + } +} + func TestReversalRepository_List_Pagination(t *testing.T) { t.Parallel() From 3b308814d60c57bc0ea047aee1833c5cc5f59cb3 Mon Sep 17 00:00:00 2001 From: zukwiz Date: Wed, 24 Jun 2026 13:30:58 +0200 Subject: [PATCH 2/7] api/v1: address stats review feedback - 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 --- api/v1/reversals/reversals.go | 15 +++++-- api/v1/stats/router.go | 8 ++-- api/v1/stats/stats.go | 65 +----------------------------- api/v1/stats/stats_test.go | 53 ------------------------ api/v1/v1.go | 4 -- domain/dto/reversal.go | 1 + domain/repository/public.go | 1 - repository/public/reversal.go | 16 ++------ repository/public/reversal_test.go | 33 ++++++++++----- 9 files changed, 46 insertions(+), 150 deletions(-) diff --git a/api/v1/reversals/reversals.go b/api/v1/reversals/reversals.go index d6d2f929..be390231 100644 --- a/api/v1/reversals/reversals.go +++ b/api/v1/reversals/reversals.go @@ -256,17 +256,26 @@ func listRecentHandler(w http.ResponseWriter, r *http.Request) { const maxRecentLimit = 100 - limit := maxRecentLimit + limit := uint(maxRecentLimit) if limitStr := r.URL.Query().Get("limit"); limitStr != "" { parsed, err := strconv.Atoi(limitStr) if err != nil || parsed <= 0 || parsed > maxRecentLimit { render.Errorf(w, r, errors.BadRequest, "limit must be between 1 and %d", maxRecentLimit) return } - limit = parsed + limit = uint(parsed) } - reversals, err := factory.Reversal().ListRecent(limit) + opts := &dto.ReversalListOptions{ + Limit: &limit, + OrderParam: &dto.OrderParam{ + Column: "id", + Direction: dto.DESC, + }, + ExcludeExpunged: true, + } + + reversals, err := factory.Reversal().List(opts) if err != nil { render.Errorf(w, r, errors.InternalServerError, "failed to list recent reversals") return diff --git a/api/v1/stats/router.go b/api/v1/stats/router.go index ab0b8ad8..27502826 100644 --- a/api/v1/stats/router.go +++ b/api/v1/stats/router.go @@ -10,8 +10,10 @@ import ( func Router() chi.Router { r := chi.NewRouter() - throttle := ratelimit.ThrottleByIP(time.Minute, 60) - r.With(throttle).Get("/summary", summaryHandler) - r.With(throttle).Get("/reversals/daily", dailyHandler) + r.Group(func(r chi.Router) { + r.Use(ratelimit.ThrottleByIP(time.Minute, 60)) + r.Get("/summary", summaryHandler) + r.Get("/reversals/daily", dailyHandler) + }) return r } diff --git a/api/v1/stats/stats.go b/api/v1/stats/stats.go index 1d763c60..c3307769 100644 --- a/api/v1/stats/stats.go +++ b/api/v1/stats/stats.go @@ -1,13 +1,9 @@ package stats import ( - "encoding/json" - "fmt" "net/http" "slices" "strconv" - "sync" - "time" "reverse-watch/domain/dto" "reverse-watch/domain/repository" @@ -16,48 +12,9 @@ import ( "reverse-watch/render" ) -const cacheTTL = 60 * time.Second - var allowedDays = []int{7, 30, 60, 90, 180, 365} -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) -} - func summaryHandler(w http.ResponseWriter, r *http.Request) { - const key = "summary" - if payload, ok := cacheGet(key); ok { - writeCachedJSON(w, payload) - return - } - factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) stats, err := factory.Reversal().SummaryStats() @@ -66,13 +23,7 @@ func summaryHandler(w http.ResponseWriter, r *http.Request) { return } - payload, err := json.Marshal(stats) - if err != nil { - render.Errorf(w, r, errors.InternalServerError, "failed to encode summary stats") - return - } - cacheSet(key, payload) - writeCachedJSON(w, payload) + render.JSON(w, r, stats) } type dailyResponse struct { @@ -90,12 +41,6 @@ func dailyHandler(w http.ResponseWriter, r *http.Request) { days = parsed } - key := fmt.Sprintf("daily:%d", days) - if payload, ok := cacheGet(key); ok { - writeCachedJSON(w, payload) - return - } - factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) counts, err := factory.Reversal().DailyCounts(days) @@ -104,11 +49,5 @@ func dailyHandler(w http.ResponseWriter, r *http.Request) { return } - payload, err := json.Marshal(dailyResponse{Data: counts}) - if err != nil { - render.Errorf(w, r, errors.InternalServerError, "failed to encode daily counts") - return - } - cacheSet(key, payload) - writeCachedJSON(w, payload) + render.JSON(w, r, dailyResponse{Data: counts}) } diff --git a/api/v1/stats/stats_test.go b/api/v1/stats/stats_test.go index df9af69b..6494ae6b 100644 --- a/api/v1/stats/stats_test.go +++ b/api/v1/stats/stats_test.go @@ -5,7 +5,6 @@ import ( "net/http" "net/http/httptest" "strconv" - "sync" "testing" "time" @@ -23,10 +22,6 @@ import ( "gorm.io/gorm" ) -func resetCache() { - cache = sync.Map{} -} - func buildHandlerStack(t *testing.T) (http.Handler, *gorm.DB) { t.Helper() @@ -47,8 +42,6 @@ func buildHandlerStack(t *testing.T) (http.Handler, *gorm.DB) { } func TestSummaryHandler(t *testing.T) { - resetCache() - handler, db := buildHandlerStack(t) now := uint64(time.Now().UnixMilli()) @@ -98,50 +91,7 @@ func TestSummaryHandler(t *testing.T) { } } -func TestSummaryHandler_Caches(t *testing.T) { - resetCache() - - handler, db := buildHandlerStack(t) - - now := uint64(time.Now().UnixMilli()) - testutil.Insert(t, db, - &models.Reversal{ - Model: models.Model{ID: 1, CreatedAt: now - 60*60*1000}, - SteamID: models.SteamID(76561197960287930), - MarketplaceSlug: "csfloat", - }, - ) - - hit := func() dto.SummaryStats { - r := httptest.NewRequest(http.MethodGet, "/summary", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - var s dto.SummaryStats - if err := json.NewDecoder(w.Result().Body).Decode(&s); err != nil { - t.Fatalf("decode: %v", err) - } - return s - } - - first := hit() - - testutil.Insert(t, db, - &models.Reversal{ - Model: models.Model{ID: 2, CreatedAt: now - 30*60*1000}, - SteamID: models.SteamID(76561197960287931), - MarketplaceSlug: "csfloat", - }, - ) - - second := hit() - if second != first { - t.Errorf("expected cached response unchanged: first=%+v second=%+v", first, second) - } -} - func TestDailyHandler(t *testing.T) { - resetCache() - handler, db := buildHandlerStack(t) now := time.Now().UTC() @@ -195,7 +145,6 @@ func TestDailyHandler(t *testing.T) { } func TestDailyHandler_InvalidDays(t *testing.T) { - resetCache() handler, _ := buildHandlerStack(t) const wantDetails = "days must be one of 7, 30, 60, 90, 180, 365" @@ -236,7 +185,6 @@ func TestDailyHandler_AcceptedDays(t *testing.T) { for _, days := range []int{7, 30, 60, 90, 180, 365} { days := days t.Run(strconv.Itoa(days), func(t *testing.T) { - resetCache() handler, _ := buildHandlerStack(t) r := httptest.NewRequest(http.MethodGet, "/reversals/daily?days="+strconv.Itoa(days), nil) @@ -259,7 +207,6 @@ func TestDailyHandler_AcceptedDays(t *testing.T) { } func TestDailyHandler_DefaultDays(t *testing.T) { - resetCache() handler, _ := buildHandlerStack(t) r := httptest.NewRequest(http.MethodGet, "/reversals/daily", nil) diff --git a/api/v1/v1.go b/api/v1/v1.go index a104facf..e6cee6f9 100644 --- a/api/v1/v1.go +++ b/api/v1/v1.go @@ -16,10 +16,6 @@ func Router() chi.Router { r.Mount("/health", health.Router()) r.Mount("/marketplace", marketplace.Router()) r.Mount("/reversals", reversals.Router()) - // /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. r.Mount("/stats", stats.Router()) r.Mount("/users", users.Router()) r.Mount("/admin", admin.Router()) diff --git a/domain/dto/reversal.go b/domain/dto/reversal.go index 28db6941..1486ee22 100644 --- a/domain/dto/reversal.go +++ b/domain/dto/reversal.go @@ -13,6 +13,7 @@ type ReversalListOptions struct { Cursor *Cursor Limit *uint OrderParam *OrderParam + ExcludeExpunged bool } type ReversalUpdates struct { diff --git a/domain/repository/public.go b/domain/repository/public.go index 96645a9b..e363781b 100644 --- a/domain/repository/public.go +++ b/domain/repository/public.go @@ -16,5 +16,4 @@ type ReversalRepository interface { SummaryStats() (*dto.SummaryStats, error) DailyCounts(days int) ([]dto.DailyCount, error) - ListRecent(limit int) ([]*models.Reversal, error) } diff --git a/repository/public/reversal.go b/repository/public/reversal.go index 272667a7..cf4ae5a5 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -103,6 +103,9 @@ func (r *reversalRepository) buildListQuery(opts *dto.ReversalListOptions) *gorm if opts.MarketplaceSlug != nil && *opts.MarketplaceSlug != "" { query = query.Where("marketplace_slug = ?", opts.MarketplaceSlug) } + if opts.ExcludeExpunged { + query = query.Where("expunged_at IS NULL") + } if opts.Cursor != nil { // Adjust direction based on order specified if desc { @@ -177,16 +180,3 @@ func (r *reversalRepository) DailyCounts(days int) ([]dto.DailyCount, error) { } return result, nil } - -func (r *reversalRepository) ListRecent(limit int) ([]*models.Reversal, error) { - var reversals []*models.Reversal - err := r.conn.Model(&models.Reversal{}). - Where("expunged_at IS NULL"). - Order("created_at DESC"). - Limit(limit). - Find(&reversals).Error - if err != nil { - return nil, err - } - return reversals, nil -} diff --git a/repository/public/reversal_test.go b/repository/public/reversal_test.go index bba5e646..02fa8d54 100644 --- a/repository/public/reversal_test.go +++ b/repository/public/reversal_test.go @@ -973,7 +973,7 @@ func TestReversalRepository_DailyCounts_ZeroFill(t *testing.T) { } } -func TestReversalRepository_ListRecent(t *testing.T) { +func TestReversalRepository_List_ExcludeExpunged(t *testing.T) { t.Parallel() db := testutil.NewTestDB(t) @@ -1013,33 +1013,46 @@ func TestReversalRepository_ListRecent(t *testing.T) { testCases := []struct { name string - limit int + opts *dto.ReversalListOptions wantIDs []models.Snowflake }{ { - name: "newestFirstExcludingExpunged", - limit: 10, + name: "newestFirstExcludingExpunged", + opts: &dto.ReversalListOptions{ + ExcludeExpunged: true, + OrderParam: &dto.OrderParam{ + Column: "id", + Direction: dto.DESC, + }, + }, wantIDs: []models.Snowflake{5, 4, 2, 1}, }, { - name: "respectsLimit", - limit: 2, + name: "respectsLimit", + opts: &dto.ReversalListOptions{ + ExcludeExpunged: true, + Limit: util.Ptr[uint](2), + OrderParam: &dto.OrderParam{ + Column: "id", + Direction: dto.DESC, + }, + }, wantIDs: []models.Snowflake{5, 4}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - got, err := reversalRepo.ListRecent(tc.limit) + got, err := reversalRepo.List(tc.opts) if err != nil { - t.Fatalf("ListRecent(%d): %v", tc.limit, err) + t.Fatalf("List(): %v", err) } if len(got) != len(tc.wantIDs) { - t.Fatalf("ListRecent(%d): got %d rows, want %d", tc.limit, len(got), len(tc.wantIDs)) + t.Fatalf("List(): got %d rows, want %d", len(got), len(tc.wantIDs)) } for i, wantID := range tc.wantIDs { if got[i].ID != wantID { - t.Errorf("ListRecent(%d)[%d].ID = %d, want %d", tc.limit, i, got[i].ID, wantID) + t.Errorf("List()[%d].ID = %d, want %d", i, got[i].ID, wantID) } } }) From 8440a0defb0898fd2709c4478f7d73e663d4d50d Mon Sep 17 00:00:00 2001 From: zukwiz Date: Wed, 24 Jun 2026 14:07:38 +0200 Subject: [PATCH 3/7] repository: track Steam ID searches for stats summary 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 --- api/v1/stats/stats_test.go | 9 ++- api/v1/users/users.go | 8 +++ api/v1/users/users_test.go | 91 +++++++++++++++++++++++++++ domain/dto/stats.go | 7 ++- domain/models/searchcount.go | 14 +++++ domain/repository/factory.go | 2 + domain/repository/public.go | 6 ++ internal/testutil/db.go | 1 + repository/factory/factory.go | 6 ++ repository/factory/transaction.go | 14 +++-- repository/public/public.go | 1 + repository/public/reversal.go | 12 +++- repository/public/reversal_test.go | 18 +++++- repository/public/searchcount.go | 33 ++++++++++ repository/public/searchcount_test.go | 69 ++++++++++++++++++++ 15 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 domain/models/searchcount.go create mode 100644 repository/public/searchcount.go create mode 100644 repository/public/searchcount_test.go diff --git a/api/v1/stats/stats_test.go b/api/v1/stats/stats_test.go index 6494ae6b..ca187a62 100644 --- a/api/v1/stats/stats_test.go +++ b/api/v1/stats/stats_test.go @@ -68,6 +68,12 @@ func TestSummaryHandler(t *testing.T) { }, ) + // 2 distinct Steam IDs searched a total of 3 times. + testutil.Insert(t, db, + &models.SearchCount{SteamID: models.SteamID(76561197960287940), Count: 2, LastSearchedAt: now}, + &models.SearchCount{SteamID: models.SteamID(76561197960287941), Count: 1, LastSearchedAt: now}, + ) + r := httptest.NewRequest(http.MethodGet, "/summary", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, r) @@ -82,7 +88,8 @@ func TestSummaryHandler(t *testing.T) { t.Fatalf("decode: %v", err) } want := dto.SummaryStats{ - TradersIndexed: 3, + SteamIDsSearched: 2, + TotalSearches: 3, TradersFlagged: 2, TradersFlagged24h: 1, } diff --git a/api/v1/users/users.go b/api/v1/users/users.go index 7ae68843..a6a79fdf 100644 --- a/api/v1/users/users.go +++ b/api/v1/users/users.go @@ -7,6 +7,7 @@ import ( "reverse-watch/domain/models" "reverse-watch/domain/repository" "reverse-watch/errors" + "reverse-watch/logging" "reverse-watch/middleware" "reverse-watch/render" @@ -38,6 +39,13 @@ func fetchUserStatus(w http.ResponseWriter, r *http.Request) { return } + // Record the lookup for the "Steam IDs Searched" KPI. This is analytics + // only, so a failure here must never fail the user-facing lookup: log and + // continue. + if err := factory.SearchCount().Increment(*steamId); err != nil { + logging.Log.Errorf("failed to increment search count for steam id %q: %v", steamId, err) + } + data := &fetchUserStatusResponse{ SteamID: *steamId, } diff --git a/api/v1/users/users_test.go b/api/v1/users/users_test.go index 3ef8efce..581a6217 100644 --- a/api/v1/users/users_test.go +++ b/api/v1/users/users_test.go @@ -11,6 +11,7 @@ import ( "reverse-watch/domain/models/constants" "reverse-watch/errors" "reverse-watch/internal/testutil" + "reverse-watch/logging" "reverse-watch/middleware" "reverse-watch/repository/factory" "reverse-watch/secret" @@ -457,3 +458,93 @@ func TestFetchUserStatus(t *testing.T) { }) } } + +func newUserStatusHandler(t *testing.T, db *gorm.DB) http.Handler { + t.Helper() + + keygen := secret.NewKeyGenerator(constants.EnvironmentDevelopment) + f, err := factory.NewFactoryWithConfig(&factory.Config{ + PrivateDB: db, + PublicDB: db, + KeyGen: keygen, + }) + if err != nil { + t.Fatalf("NewFactoryWithConfig(): %v", err) + } + return middleware.FactoryMiddleware(f)(http.HandlerFunc(fetchUserStatus)) +} + +func userStatusRequest(steamID models.SteamID) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/"+steamID.String(), nil) + chiContext := chi.NewRouteContext() + chiContext.URLParams.Add("steamId", steamID.String()) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chiContext)) +} + +func TestFetchUserStatus_IncrementsSearchCount(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + handler := newUserStatusHandler(t, db) + steamID := models.SteamID(76561197960287930) + + do := func() { + w := httptest.NewRecorder() + handler.ServeHTTP(w, userStatusRequest(steamID)) + if w.Result().StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Result().StatusCode, http.StatusOK) + } + } + + do() + var sc models.SearchCount + if err := db.Where("steam_id = ?", uint64(steamID)).First(&sc).Error; err != nil { + t.Fatalf("First(): %v", err) + } + if sc.Count != 1 { + t.Errorf("Count = %d, want 1", sc.Count) + } + if sc.LastSearchedAt == 0 { + t.Errorf("LastSearchedAt = 0, want non-zero") + } + + do() + if err := db.Where("steam_id = ?", uint64(steamID)).First(&sc).Error; err != nil { + t.Fatalf("First(): %v", err) + } + if sc.Count != 2 { + t.Errorf("Count = %d, want 2", sc.Count) + } +} + +func TestFetchUserStatus_IncrementFailureDoesNotFailLookup(t *testing.T) { + // A counting failure must never break the user-facing lookup. Drop the + // search_counts table so the increment errors, then assert the lookup still + // succeeds. logging.Initialize() is required because the handler logs the + // swallowed error. + logging.Initialize() + + db := testutil.NewTestDB(t) + handler := newUserStatusHandler(t, db) + + if err := db.Migrator().DropTable(&models.SearchCount{}); err != nil { + t.Fatalf("DropTable(): %v", err) + } + + w := httptest.NewRecorder() + handler.ServeHTTP(w, userStatusRequest(models.SteamID(76561197960287930))) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d (lookup must succeed despite count failure)", resp.StatusCode, http.StatusOK) + } + + defer resp.Body.Close() + var respData fetchUserStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if respData.HasReversed { + t.Errorf("HasReversed = true, want false") + } +} diff --git a/domain/dto/stats.go b/domain/dto/stats.go index b263bdb6..c0cc0522 100644 --- a/domain/dto/stats.go +++ b/domain/dto/stats.go @@ -1,7 +1,12 @@ package dto type SummaryStats struct { - TradersIndexed uint64 `json:"traders_indexed"` + // SteamIDsSearched is the number of distinct Steam IDs ever looked up via + // 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). + TotalSearches uint64 `json:"total_searches"` TradersFlagged uint64 `json:"traders_flagged"` TradersFlagged24h uint64 `json:"traders_flagged_24h"` } diff --git a/domain/models/searchcount.go b/domain/models/searchcount.go new file mode 100644 index 00000000..ebf8254f --- /dev/null +++ b/domain/models/searchcount.go @@ -0,0 +1,14 @@ +package models + +// SearchCount tracks how many times a Steam ID has been looked up via the +// 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. +type SearchCount struct { + SteamID SteamID `gorm:"primaryKey;not null;autoIncrement:false" json:"steam_id"` + Count uint64 `gorm:"not null;default:0" json:"count"` + // LastSearchedAt is the timestamp of the most recent lookup in + // milliseconds since the Unix epoch. + LastSearchedAt uint64 `gorm:"autoUpdateTime:milli" json:"last_searched_at"` +} diff --git a/domain/repository/factory.go b/domain/repository/factory.go index 5b75d7fd..07d4809f 100644 --- a/domain/repository/factory.go +++ b/domain/repository/factory.go @@ -18,6 +18,7 @@ type PublicTransaction interface { gorm.TxCommitter Reversal() ReversalRepository + SearchCount() SearchCountRepository } type Factory interface { @@ -27,6 +28,7 @@ type Factory interface { Marketplace() MarketplaceRepository AdminAudit() AdminAuditRepository Reversal() ReversalRepository + SearchCount() SearchCountRepository NewPrivateTransaction() PrivateTransaction RunInTransactionPrivate(fn func(PrivateTransaction) error) error diff --git a/domain/repository/public.go b/domain/repository/public.go index e363781b..4450016c 100644 --- a/domain/repository/public.go +++ b/domain/repository/public.go @@ -17,3 +17,9 @@ type ReversalRepository interface { SummaryStats() (*dto.SummaryStats, error) DailyCounts(days int) ([]dto.DailyCount, error) } + +type SearchCountRepository interface { + // Increment records a single lookup of the given Steam ID, inserting a new + // row or atomically incrementing the existing count. + Increment(steamID models.SteamID) error +} diff --git a/internal/testutil/db.go b/internal/testutil/db.go index b8db631c..85669e3e 100644 --- a/internal/testutil/db.go +++ b/internal/testutil/db.go @@ -27,6 +27,7 @@ func NewTestDB(t *testing.T) *gorm.DB { (*models.Key)(nil), (*models.AdminAudit)(nil), (*models.Reversal)(nil), + (*models.SearchCount)(nil), } for _, model := range mods { diff --git a/repository/factory/factory.go b/repository/factory/factory.go index c8b900ed..a23ae40a 100644 --- a/repository/factory/factory.go +++ b/repository/factory/factory.go @@ -28,6 +28,7 @@ type factory struct { marketplace repository.MarketplaceRepository adminAudit repository.AdminAuditRepository reversal repository.ReversalRepository + searchCount repository.SearchCountRepository } type Config struct { @@ -58,6 +59,7 @@ func NewFactoryWithConfig(cfg *Config) (repository.Factory, error) { marketplace: private.NewMarketplaceRepository(cfg.PrivateDB), adminAudit: private.NewAdminAuditRepository(cfg.PrivateDB), reversal: public.NewReversalRepository(cfg.PublicDB), + searchCount: public.NewSearchCountRepository(cfg.PublicDB), }, nil } @@ -174,6 +176,10 @@ func (f *factory) Reversal() repository.ReversalRepository { return f.reversal } +func (f *factory) SearchCount() repository.SearchCountRepository { + return f.searchCount +} + func (f *factory) Close() error { var errs []error if err := closeDB(f.private); err != nil { diff --git a/repository/factory/transaction.go b/repository/factory/transaction.go index f4ad9d4e..3fa60f90 100644 --- a/repository/factory/transaction.go +++ b/repository/factory/transaction.go @@ -46,14 +46,16 @@ func (t *privateTransaction) AdminAudit() repository.AdminAuditRepository { } type publicTransaction struct { - tx *gorm.DB - reversal repository.ReversalRepository + tx *gorm.DB + reversal repository.ReversalRepository + searchCount repository.SearchCountRepository } func newPublicTransaction(tx *gorm.DB) *publicTransaction { return &publicTransaction{ - tx: tx, - reversal: public.NewReversalRepository(tx), + tx: tx, + reversal: public.NewReversalRepository(tx), + searchCount: public.NewSearchCountRepository(tx), } } @@ -68,3 +70,7 @@ func (t *publicTransaction) Rollback() error { func (t *publicTransaction) Reversal() repository.ReversalRepository { return t.reversal } + +func (t *publicTransaction) SearchCount() repository.SearchCountRepository { + return t.searchCount +} diff --git a/repository/public/public.go b/repository/public/public.go index e0f39346..cb9e60a3 100644 --- a/repository/public/public.go +++ b/repository/public/public.go @@ -9,6 +9,7 @@ import ( func MigrateModels(tx *gorm.DB) error { publicModels := []interface{}{ (*models.Reversal)(nil), + (*models.SearchCount)(nil), } for _, model := range publicModels { diff --git a/repository/public/reversal.go b/repository/public/reversal.go index cf4ae5a5..457fed84 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -135,7 +135,6 @@ func (r *reversalRepository) SummaryStats() (*dto.SummaryStats, error) { var stats dto.SummaryStats 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, COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL AND created_at >= ?) AS traders_flagged24h FROM reversals @@ -144,6 +143,17 @@ func (r *reversalRepository) SummaryStats() (*dto.SummaryStats, error) { if err != nil { return nil, err } + + // "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. + if err := r.conn.Raw(` + SELECT COUNT(*), COALESCE(SUM(count), 0) + FROM search_counts + `).Row().Scan(&stats.SteamIDsSearched, &stats.TotalSearches); err != nil { + return nil, err + } return &stats, nil } diff --git a/repository/public/reversal_test.go b/repository/public/reversal_test.go index 02fa8d54..b0b5b7c4 100644 --- a/repository/public/reversal_test.go +++ b/repository/public/reversal_test.go @@ -862,12 +862,28 @@ func TestReversalRepository_SummaryStats(t *testing.T) { }, ) + // "Steam IDs Searched" is sourced from the search_counts table and is + // independent of the reversal data above: 2 distinct Steam IDs searched a + // total of 5 times. + searchRepo := NewSearchCountRepository(db) + for i := 0; i < 3; i++ { + if err := searchRepo.Increment(models.SteamID(76561197960287940)); err != nil { + t.Fatalf("Increment(): %v", err) + } + } + for i := 0; i < 2; i++ { + if err := searchRepo.Increment(models.SteamID(76561197960287941)); err != nil { + t.Fatalf("Increment(): %v", err) + } + } + got, err := reversalRepo.SummaryStats() if err != nil { t.Fatalf("SummaryStats(): %v", err) } want := &dto.SummaryStats{ - TradersIndexed: 4, + SteamIDsSearched: 2, + TotalSearches: 5, TradersFlagged: 3, TradersFlagged24h: 2, } diff --git a/repository/public/searchcount.go b/repository/public/searchcount.go new file mode 100644 index 00000000..df667055 --- /dev/null +++ b/repository/public/searchcount.go @@ -0,0 +1,33 @@ +package public + +import ( + "time" + + "reverse-watch/domain/models" + "reverse-watch/domain/repository" + + "gorm.io/gorm" +) + +type searchCountRepository struct { + conn *gorm.DB +} + +var _ repository.SearchCountRepository = (*searchCountRepository)(nil) + +func NewSearchCountRepository(conn *gorm.DB) repository.SearchCountRepository { + return &searchCountRepository{ + conn: conn, + } +} + +func (r *searchCountRepository) Increment(steamID models.SteamID) error { + now := uint64(time.Now().UnixMilli()) + return r.conn.Exec(` + INSERT INTO search_counts (steam_id, count, last_searched_at) + VALUES (?, 1, ?) + ON CONFLICT (steam_id) DO UPDATE + SET count = search_counts.count + 1, + last_searched_at = EXCLUDED.last_searched_at + `, uint64(steamID), now).Error +} diff --git a/repository/public/searchcount_test.go b/repository/public/searchcount_test.go new file mode 100644 index 00000000..447c7136 --- /dev/null +++ b/repository/public/searchcount_test.go @@ -0,0 +1,69 @@ +package public + +import ( + "testing" + + "reverse-watch/domain/models" + "reverse-watch/internal/testutil" +) + +func TestSearchCountRepository_Increment(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + repo := NewSearchCountRepository(db) + + steamID := models.SteamID(76561197960287930) + + // First lookup inserts a fresh row with count 1. + if err := repo.Increment(steamID); err != nil { + t.Fatalf("Increment(): %v", err) + } + + var first models.SearchCount + if err := db.Where("steam_id = ?", uint64(steamID)).First(&first).Error; err != nil { + t.Fatalf("First(): %v", err) + } + if first.SteamID != steamID { + t.Errorf("SteamID = %d, want %d", first.SteamID, steamID) + } + if first.Count != 1 { + t.Errorf("Count = %d, want 1", first.Count) + } + if first.LastSearchedAt == 0 { + t.Errorf("LastSearchedAt = 0, want non-zero") + } + + // Subsequent lookups upsert and increment the same row. + if err := repo.Increment(steamID); err != nil { + t.Fatalf("Increment(): %v", err) + } + if err := repo.Increment(steamID); err != nil { + t.Fatalf("Increment(): %v", err) + } + + var second models.SearchCount + if err := db.Where("steam_id = ?", uint64(steamID)).First(&second).Error; err != nil { + t.Fatalf("First(): %v", err) + } + if second.Count != 3 { + t.Errorf("Count = %d, want 3", second.Count) + } + if second.LastSearchedAt < first.LastSearchedAt { + t.Errorf("LastSearchedAt = %d, want >= %d", second.LastSearchedAt, first.LastSearchedAt) + } + + // Distinct Steam IDs get their own rows. + otherID := models.SteamID(76561197960287931) + if err := repo.Increment(otherID); err != nil { + t.Fatalf("Increment(): %v", err) + } + + var rows int64 + if err := db.Model(&models.SearchCount{}).Count(&rows).Error; err != nil { + t.Fatalf("Count(): %v", err) + } + if rows != 2 { + t.Errorf("rows = %d, want 2", rows) + } +} From 8652b503898081969507708ba386a66bfae4b927 Mon Sep 17 00:00:00 2001 From: zukwiz Date: Wed, 24 Jun 2026 15:07:44 +0200 Subject: [PATCH 4/7] Harden DailyCounts GROUP BY to positional form 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 --- repository/public/reversal.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repository/public/reversal.go b/repository/public/reversal.go index 457fed84..808cf6f9 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -171,8 +171,8 @@ func (r *reversalRepository) DailyCounts(days int) ([]dto.DailyCount, error) { WHERE deleted_at IS NULL AND expunged_at IS NULL AND reversed_at >= ? - GROUP BY date - ORDER BY date ASC + GROUP BY 1 + ORDER BY 1 ASC `, uint64(windowStart.UnixMilli())).Scan(&rows).Error if err != nil { return nil, err From a1d8b76da0436307f296a2960031b88a56a99066 Mon Sep 17 00:00:00 2001 From: zukwiz Date: Wed, 24 Jun 2026 15:37:36 +0200 Subject: [PATCH 5/7] Order recent reversals feed by reversed_at DESC, id DESC 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 --- api/v1/reversals/reversals.go | 8 ++++ api/v1/reversals/reversals_recent_test.go | 20 ++++++-- domain/dto/reversal.go | 5 +- repository/public/reversal.go | 8 ++++ repository/public/reversal_test.go | 57 +++++++++++++++++++++++ 5 files changed, 92 insertions(+), 6 deletions(-) diff --git a/api/v1/reversals/reversals.go b/api/v1/reversals/reversals.go index be390231..e4bf414e 100644 --- a/api/v1/reversals/reversals.go +++ b/api/v1/reversals/reversals.go @@ -268,7 +268,15 @@ func listRecentHandler(w http.ResponseWriter, r *http.Request) { opts := &dto.ReversalListOptions{ Limit: &limit, + // Order by when the reversal actually occurred so the feed agrees with + // the daily stats (which bucket on reversed_at) and "latest reversals" + // semantics. id DESC is a deterministic tiebreaker for stable ordering + // (e.g. backfilled rows with a high id but older reversed_at). OrderParam: &dto.OrderParam{ + Column: "reversed_at", + Direction: dto.DESC, + }, + SecondaryOrderParam: &dto.OrderParam{ Column: "id", Direction: dto.DESC, }, diff --git a/api/v1/reversals/reversals_recent_test.go b/api/v1/reversals/reversals_recent_test.go index 494325e6..bf670d5d 100644 --- a/api/v1/reversals/reversals_recent_test.go +++ b/api/v1/reversals/reversals_recent_test.go @@ -43,33 +43,43 @@ func TestListRecentHandler(t *testing.T) { base := models.Epoch + 1000 - // 5 rows, monotonically increasing CreatedAt. Row id=3 is expunged. + // 5 rows. The feed orders by reversed_at DESC, id DESC. CreatedAt is set + // in ascending id order (i.e. ingest order) to prove the feed does NOT use + // id/ingest order. Row id=3 is expunged and must be excluded. Row id=5 is a + // backfill case: it has the highest id but the oldest reversed_at, so it + // must sort last rather than first. Rows id=1 and id=4 share a reversed_at + // to exercise the id DESC tiebreaker (id=4 must come before id=1). testutil.Insert(t, db, &models.Reversal{ Model: models.Model{ID: 1, CreatedAt: base + 100}, SteamID: models.SteamID(76561197960287930), MarketplaceSlug: "csfloat", + ReversedAt: base + 100, }, &models.Reversal{ Model: models.Model{ID: 2, CreatedAt: base + 200}, SteamID: models.SteamID(76561197960287931), MarketplaceSlug: "csfloat", + ReversedAt: base + 500, }, &models.Reversal{ Model: models.Model{ID: 3, CreatedAt: base + 300}, SteamID: models.SteamID(76561197960287932), MarketplaceSlug: "csfloat", + ReversedAt: base + 900, ExpungedAt: util.Ptr(base + 400), }, &models.Reversal{ Model: models.Model{ID: 4, CreatedAt: base + 500}, SteamID: models.SteamID(76561197960287933), MarketplaceSlug: "csfloat", + ReversedAt: base + 100, }, &models.Reversal{ Model: models.Model{ID: 5, CreatedAt: base + 600}, SteamID: models.SteamID(76561197960287934), MarketplaceSlug: "csfloat", + ReversedAt: base + 50, }, ) @@ -88,10 +98,10 @@ func TestListRecentHandler(t *testing.T) { } wantSteamIDs := []models.SteamID{ - 76561197960287934, // id=5 - 76561197960287933, // id=4 - 76561197960287931, // id=2 - 76561197960287930, // id=1 + 76561197960287931, // id=2, reversed_at=base+500 + 76561197960287933, // id=4, reversed_at=base+100 (id tiebreaker over id=1) + 76561197960287930, // id=1, reversed_at=base+100 + 76561197960287934, // id=5, reversed_at=base+50 (backfill: high id, oldest) } if len(body.Data) != len(wantSteamIDs) { t.Fatalf("len(data) = %d, want %d", len(body.Data), len(wantSteamIDs)) diff --git a/domain/dto/reversal.go b/domain/dto/reversal.go index 1486ee22..e8c7362f 100644 --- a/domain/dto/reversal.go +++ b/domain/dto/reversal.go @@ -13,7 +13,10 @@ type ReversalListOptions struct { Cursor *Cursor Limit *uint OrderParam *OrderParam - ExcludeExpunged bool + // SecondaryOrderParam is an optional tiebreaker applied after OrderParam, + // producing deterministic, stable ordering when the primary column has ties. + SecondaryOrderParam *OrderParam + ExcludeExpunged bool } type ReversalUpdates struct { diff --git a/repository/public/reversal.go b/repository/public/reversal.go index 808cf6f9..63f28b8c 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -96,6 +96,14 @@ func (r *reversalRepository) buildListQuery(opts *dto.ReversalListOptions) *gorm query = query.Order(orderBy) } + if opts.SecondaryOrderParam != nil { + tiebreaker := clause.OrderByColumn{ + Column: clause.Column{Name: opts.SecondaryOrderParam.Column}, + Desc: opts.SecondaryOrderParam.Direction == dto.DESC, + } + + query = query.Order(tiebreaker) + } if opts.SteamID.IsValid() { query = query.Where("steam_id = ?", opts.SteamID) diff --git a/repository/public/reversal_test.go b/repository/public/reversal_test.go index b0b5b7c4..14b77994 100644 --- a/repository/public/reversal_test.go +++ b/repository/public/reversal_test.go @@ -812,6 +812,63 @@ func TestReversalRepository_List(t *testing.T) { } } +func TestReversalRepository_List_SecondaryOrder(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + reversalRepo := NewReversalRepository(db) + + base := models.Epoch + 1000 + + // id=3 is a backfill row: highest id but the oldest reversed_at, so it must + // sort last under reversed_at DESC. id=1 and id=2 share a reversed_at to + // exercise the id DESC tiebreaker (id=2 must come before id=1). + id1 := &models.Reversal{ + Model: models.Model{ID: 1}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "test-slug", + ReversedAt: base + 100, + } + id2 := &models.Reversal{ + Model: models.Model{ID: 2}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "test-slug", + ReversedAt: base + 100, + } + id3 := &models.Reversal{ + Model: models.Model{ID: 3}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "test-slug", + ReversedAt: base + 50, + } + id4 := &models.Reversal{ + Model: models.Model{ID: 4}, + SteamID: models.SteamID(76561197960287933), + MarketplaceSlug: "test-slug", + ReversedAt: base + 500, + } + testutil.Insert(t, db, id1, id2, id3, id4) + + got, err := reversalRepo.List(&dto.ReversalListOptions{ + OrderParam: &dto.OrderParam{ + Column: "reversed_at", + Direction: dto.DESC, + }, + SecondaryOrderParam: &dto.OrderParam{ + Column: "id", + Direction: dto.DESC, + }, + }) + if err != nil { + t.Fatalf("List(): %v", err) + } + + want := []*models.Reversal{id4, id2, id1, id3} + if diff := cmp.Diff(got, want, cmpopts.IgnoreFields(models.Reversal{}, "CreatedAt", "UpdatedAt", "ReversedAt")); diff != "" { + t.Error(diff) + } +} + func TestReversalRepository_SummaryStats(t *testing.T) { t.Parallel() From b6aa57fe2bf9fe8d5c3dd9238184744ffab38061 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 08:12:45 +0000 Subject: [PATCH 6/7] Address PR #69 review feedback from zedimytch - 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 --- README.md | 32 +---------- api/v1/reversals/reversals.go | 18 +----- api/v1/reversals/reversals_recent_test.go | 2 +- api/v1/stats/stats_test.go | 3 + api/v1/users/users.go | 4 +- domain/dto/order.go | 21 ++++++- domain/dto/reversal.go | 9 ++- ingestors/csfloat/csfloat.go | 5 +- repository/public/reversal.go | 25 ++------ repository/public/reversal_test.go | 70 ++++++----------------- 10 files changed, 58 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 4e60dc32..9c1e9e52 100644 --- a/README.md +++ b/README.md @@ -9,45 +9,17 @@ If you're looking to participate by contributing reversal reports (i.e. marketpl ## Running Locally 1. Ensure Go 1.24+ and PostgreSQL are installed. -2. Create the two local databases and (for tests) a `postgres` superuser: - ```bash - createdb private - createdb public - psql -d postgres -c "CREATE USER postgres WITH SUPERUSER PASSWORD 'postgres';" - # If the user already exists: - # psql -d postgres -c "ALTER USER postgres WITH SUPERUSER PASSWORD 'postgres';" - ``` - The superuser is required by [`pgtestdb`](https://github.com/peterldowns/pgtestdb), which spins up disposable databases per test. -3. Copy the config template and fill in your local database credentials: +2. Copy the config template and fill in your local database credentials: ```bash cp config.example.json config.json ``` -4. Run the service: +3. Run the service: ```bash go run main.go ``` The server starts on port `80` by default (configurable via `HTTP_PORT`). -### Running tests - -```bash -go test ./... -``` - -Tests use `pgtestdb` to provision a fresh database per test against the local Postgres. The `postgres/postgres` superuser from step 2 above is required. - -## Public read endpoints - -| Endpoint | Purpose | -|---|---| -| `GET /api/v1/users/{steamId}` | Single Steam-ID lookup (existing) | -| `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 | -| `GET /api/v1/reversals/recent?limit={1..100}` | Latest non-expunged reversals (slim public projection) | - -All four are public, IP-rate-limited, and return JSON. The two `/stats` endpoints have a 60-second in-process cache. - ## Configuration Configuration is loaded from environment variables or a `config.json` file. diff --git a/api/v1/reversals/reversals.go b/api/v1/reversals/reversals.go index e4bf414e..00ae9af0 100644 --- a/api/v1/reversals/reversals.go +++ b/api/v1/reversals/reversals.go @@ -77,11 +77,8 @@ func createReversals(w http.ResponseWriter, r *http.Request) { func listReversals(f repository.Factory, values url.Values, defaultLimit, maxLimit uint) ([]*models.Reversal, *dto.Cursor, error) { opts := &dto.ReversalListOptions{ - Limit: &defaultLimit, - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.DESC, - }, + Limit: &defaultLimit, + OrderBy: dto.OrderByCol("id", dto.DESC), } if steamIdStr := values.Get("steam_id"); steamIdStr != "" { @@ -240,7 +237,6 @@ type recentReversal struct { MarketplaceSlug string `json:"marketplace_slug"` SteamID models.SteamID `json:"steam_id"` ReversedAt uint64 `json:"reversed_at"` - CreatedAt uint64 `json:"created_at"` } type listRecentResponse struct { @@ -272,14 +268,7 @@ func listRecentHandler(w http.ResponseWriter, r *http.Request) { // the daily stats (which bucket on reversed_at) and "latest reversals" // semantics. id DESC is a deterministic tiebreaker for stable ordering // (e.g. backfilled rows with a high id but older reversed_at). - OrderParam: &dto.OrderParam{ - Column: "reversed_at", - Direction: dto.DESC, - }, - SecondaryOrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.DESC, - }, + OrderBy: dto.OrderByDesc("reversed_at", "id"), ExcludeExpunged: true, } @@ -295,7 +284,6 @@ func listRecentHandler(w http.ResponseWriter, r *http.Request) { MarketplaceSlug: rev.MarketplaceSlug, SteamID: rev.SteamID, ReversedAt: rev.ReversedAt, - CreatedAt: rev.CreatedAt, }) } diff --git a/api/v1/reversals/reversals_recent_test.go b/api/v1/reversals/reversals_recent_test.go index bf670d5d..f0617f0a 100644 --- a/api/v1/reversals/reversals_recent_test.go +++ b/api/v1/reversals/reversals_recent_test.go @@ -221,7 +221,7 @@ func TestListRecentHandler_ResponseShape(t *testing.T) { } row := raw.Data[0] - expectedKeys := []string{"marketplace_slug", "steam_id", "reversed_at", "created_at"} + expectedKeys := []string{"marketplace_slug", "steam_id", "reversed_at"} for _, k := range expectedKeys { if _, ok := row[k]; !ok { t.Errorf("missing key %q in response", k) diff --git a/api/v1/stats/stats_test.go b/api/v1/stats/stats_test.go index ca187a62..a3a69f24 100644 --- a/api/v1/stats/stats_test.go +++ b/api/v1/stats/stats_test.go @@ -54,17 +54,20 @@ func TestSummaryHandler(t *testing.T) { Model: models.Model{ID: 1, CreatedAt: withinDay}, SteamID: models.SteamID(76561197960287930), MarketplaceSlug: "csfloat", + ReversedAt: withinDay, }, &models.Reversal{ Model: models.Model{ID: 2, CreatedAt: olderThanDay}, SteamID: models.SteamID(76561197960287931), MarketplaceSlug: "csfloat", + ReversedAt: olderThanDay, }, &models.Reversal{ Model: models.Model{ID: 3, CreatedAt: olderThanDay}, SteamID: models.SteamID(76561197960287932), MarketplaceSlug: "csfloat", ExpungedAt: util.Ptr(now - 24*hourMs), + ReversedAt: olderThanDay, }, ) diff --git a/api/v1/users/users.go b/api/v1/users/users.go index a6a79fdf..879dcd1c 100644 --- a/api/v1/users/users.go +++ b/api/v1/users/users.go @@ -31,8 +31,8 @@ func fetchUserStatus(w http.ResponseWriter, r *http.Request) { } reversals, err := factory.Reversal().List(&dto.ReversalListOptions{ - SteamID: steamId, - OrderParam: &dto.OrderParam{Column: "id", Direction: dto.DESC}, + SteamID: steamId, + OrderBy: dto.OrderByCol("id", dto.DESC), }) if err != nil { render.Errorf(w, r, errors.InternalServerError, "failed to list reversals for steam id %q", steamId) diff --git a/domain/dto/order.go b/domain/dto/order.go index ea27066a..8694abde 100644 --- a/domain/dto/order.go +++ b/domain/dto/order.go @@ -1,5 +1,7 @@ package dto +import "gorm.io/gorm/clause" + type Direction string const ( @@ -7,7 +9,20 @@ const ( ASC Direction = "ASC" ) -type OrderParam struct { - Column string - Direction Direction +// OrderByCol builds a single-column ORDER BY clause. +func OrderByCol(column string, direction Direction) *clause.OrderBy { + return &clause.OrderBy{ + Columns: []clause.OrderByColumn{ + {Column: clause.Column{Name: column}, Desc: direction == DESC}, + }, + } +} + +// OrderByDesc builds a multi-column ORDER BY clause, all descending. +func OrderByDesc(columns ...string) *clause.OrderBy { + cols := make([]clause.OrderByColumn, len(columns)) + for i, col := range columns { + cols[i] = clause.OrderByColumn{Column: clause.Column{Name: col}, Desc: true} + } + return &clause.OrderBy{Columns: cols} } diff --git a/domain/dto/reversal.go b/domain/dto/reversal.go index e8c7362f..c4cc5068 100644 --- a/domain/dto/reversal.go +++ b/domain/dto/reversal.go @@ -5,6 +5,8 @@ import ( "reverse-watch/domain/models" "reverse-watch/errors" + + "gorm.io/gorm/clause" ) type ReversalListOptions struct { @@ -12,11 +14,8 @@ type ReversalListOptions struct { MarketplaceSlug *string Cursor *Cursor Limit *uint - OrderParam *OrderParam - // SecondaryOrderParam is an optional tiebreaker applied after OrderParam, - // producing deterministic, stable ordering when the primary column has ties. - SecondaryOrderParam *OrderParam - ExcludeExpunged bool + OrderBy *clause.OrderBy + ExcludeExpunged bool } type ReversalUpdates struct { diff --git a/ingestors/csfloat/csfloat.go b/ingestors/csfloat/csfloat.go index fb300aef..a070a8f7 100644 --- a/ingestors/csfloat/csfloat.go +++ b/ingestors/csfloat/csfloat.go @@ -142,10 +142,7 @@ func (i *csfloatIngestor) sync(ctx context.Context) error { reversals, err := i.factory.Reversal().List(&dto.ReversalListOptions{ MarketplaceSlug: util.Ptr("csfloat"), Limit: util.Ptr[uint](1), - OrderParam: &dto.OrderParam{ - Column: "reporter_internal_id", - Direction: dto.DESC, - }, + OrderBy: dto.OrderByCol("reporter_internal_id", dto.DESC), }) if err != nil { return fmt.Errorf("failed to list recently inserted reversals: %v", err) diff --git a/repository/public/reversal.go b/repository/public/reversal.go index 63f28b8c..8d94b33a 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -8,7 +8,6 @@ import ( "reverse-watch/domain/repository" "gorm.io/gorm" - "gorm.io/gorm/clause" ) type reversalRepository struct { @@ -87,22 +86,9 @@ func (r *reversalRepository) buildListQuery(opts *dto.ReversalListOptions) *gorm } var desc bool - if opts.OrderParam != nil { - desc = opts.OrderParam.Direction == dto.DESC - orderBy := clause.OrderByColumn{ - Column: clause.Column{Name: opts.OrderParam.Column}, - Desc: desc, - } - - query = query.Order(orderBy) - } - if opts.SecondaryOrderParam != nil { - tiebreaker := clause.OrderByColumn{ - Column: clause.Column{Name: opts.SecondaryOrderParam.Column}, - Desc: opts.SecondaryOrderParam.Direction == dto.DESC, - } - - query = query.Order(tiebreaker) + if opts.OrderBy != nil && len(opts.OrderBy.Columns) > 0 { + query = query.Order(opts.OrderBy) + desc = opts.OrderBy.Columns[0].Desc } if opts.SteamID.IsValid() { @@ -143,10 +129,11 @@ func (r *reversalRepository) SummaryStats() (*dto.SummaryStats, error) { var stats dto.SummaryStats 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 + COUNT(DISTINCT steam_id) AS traders_flagged, + COUNT(DISTINCT steam_id) FILTER (WHERE reversed_at >= ?) AS traders_flagged24h FROM reversals WHERE deleted_at IS NULL + AND expunged_at IS NULL `, cutoffMs).Scan(&stats).Error if err != nil { return nil, err diff --git a/repository/public/reversal_test.go b/repository/public/reversal_test.go index 14b77994..2d22f4ed 100644 --- a/repository/public/reversal_test.go +++ b/repository/public/reversal_test.go @@ -747,10 +747,7 @@ func TestReversalRepository_List(t *testing.T) { Cursor: &dto.Cursor{ ID: 3, }, - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.DESC, - }, + OrderBy: dto.OrderByCol("id", dto.DESC), }, want: []*models.Reversal{ testReversals[1], @@ -763,10 +760,7 @@ func TestReversalRepository_List(t *testing.T) { Cursor: &dto.Cursor{ ID: 1, }, - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.ASC, - }, + OrderBy: dto.OrderByCol("id", dto.ASC), }, want: []*models.Reversal{ testReversals[1], @@ -785,10 +779,7 @@ func TestReversalRepository_List(t *testing.T) { { name: "withOrder", opts: &dto.ReversalListOptions{ - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.DESC, - }, + OrderBy: dto.OrderByCol("id", dto.DESC), }, want: []*models.Reversal{ testReversals[2], @@ -850,14 +841,7 @@ func TestReversalRepository_List_SecondaryOrder(t *testing.T) { testutil.Insert(t, db, id1, id2, id3, id4) got, err := reversalRepo.List(&dto.ReversalListOptions{ - OrderParam: &dto.OrderParam{ - Column: "reversed_at", - Direction: dto.DESC, - }, - SecondaryOrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.DESC, - }, + OrderBy: dto.OrderByDesc("reversed_at", "id"), }) if err != nil { t.Fatalf("List(): %v", err) @@ -889,33 +873,39 @@ func TestReversalRepository_SummaryStats(t *testing.T) { Model: models.Model{ID: 1, CreatedAt: withinDay}, SteamID: models.SteamID(76561197960287930), MarketplaceSlug: "csfloat", + ReversedAt: withinDay, }, &models.Reversal{ Model: models.Model{ID: 2, CreatedAt: olderThanDay}, SteamID: models.SteamID(76561197960287930), MarketplaceSlug: "csfloat-2", + ReversedAt: olderThanDay, }, &models.Reversal{ Model: models.Model{ID: 3, CreatedAt: withinDay}, SteamID: models.SteamID(76561197960287931), MarketplaceSlug: "csfloat", ExpungedAt: util.Ptr(now), + ReversedAt: withinDay, }, &models.Reversal{ Model: models.Model{ID: 4, CreatedAt: olderThanDay}, SteamID: models.SteamID(76561197960287931), MarketplaceSlug: "csfloat-2", + ReversedAt: olderThanDay, }, &models.Reversal{ Model: models.Model{ID: 5, CreatedAt: olderThanDay}, SteamID: models.SteamID(76561197960287932), MarketplaceSlug: "csfloat", ExpungedAt: util.Ptr(now - 24*hourMs), + ReversedAt: olderThanDay, }, &models.Reversal{ Model: models.Model{ID: 6, CreatedAt: withinDay}, SteamID: models.SteamID(76561197960287933), MarketplaceSlug: "csfloat", + ReversedAt: withinDay, }, ) @@ -1093,10 +1083,7 @@ func TestReversalRepository_List_ExcludeExpunged(t *testing.T) { name: "newestFirstExcludingExpunged", opts: &dto.ReversalListOptions{ ExcludeExpunged: true, - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.DESC, - }, + OrderBy: dto.OrderByCol("id", dto.DESC), }, wantIDs: []models.Snowflake{5, 4, 2, 1}, }, @@ -1105,10 +1092,7 @@ func TestReversalRepository_List_ExcludeExpunged(t *testing.T) { opts: &dto.ReversalListOptions{ ExcludeExpunged: true, Limit: util.Ptr[uint](2), - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.DESC, - }, + OrderBy: dto.OrderByCol("id", dto.DESC), }, wantIDs: []models.Snowflake{5, 4}, }, @@ -1185,10 +1169,7 @@ func TestReversalRepository_List_Pagination(t *testing.T) { { name: "firstPageDESC", opts: &dto.ReversalListOptions{ - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.DESC, - }, + OrderBy: dto.OrderByCol("id", dto.DESC), Limit: util.Ptr[uint](2), }, want: []*models.Reversal{ @@ -1202,10 +1183,7 @@ func TestReversalRepository_List_Pagination(t *testing.T) { Cursor: &dto.Cursor{ ID: 50, }, - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.DESC, - }, + OrderBy: dto.OrderByCol("id", dto.DESC), Limit: util.Ptr[uint](2), }, want: []*models.Reversal{ @@ -1219,10 +1197,7 @@ func TestReversalRepository_List_Pagination(t *testing.T) { Cursor: &dto.Cursor{ ID: 30, }, - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.DESC, - }, + OrderBy: dto.OrderByCol("id", dto.DESC), Limit: util.Ptr[uint](2), }, want: []*models.Reversal{ @@ -1232,10 +1207,7 @@ func TestReversalRepository_List_Pagination(t *testing.T) { { name: "firstPageASC", opts: &dto.ReversalListOptions{ - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.ASC, - }, + OrderBy: dto.OrderByCol("id", dto.ASC), Limit: util.Ptr[uint](2), }, want: []*models.Reversal{ @@ -1249,10 +1221,7 @@ func TestReversalRepository_List_Pagination(t *testing.T) { Cursor: &dto.Cursor{ ID: 30, }, - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.ASC, - }, + OrderBy: dto.OrderByCol("id", dto.ASC), Limit: util.Ptr[uint](2), }, want: []*models.Reversal{ @@ -1266,10 +1235,7 @@ func TestReversalRepository_List_Pagination(t *testing.T) { Cursor: &dto.Cursor{ ID: 50, }, - OrderParam: &dto.OrderParam{ - Column: "id", - Direction: dto.ASC, - }, + OrderBy: dto.OrderByCol("id", dto.ASC), }, want: []*models.Reversal{ testReversals[0], From 897fd8eb60f1752574c2c5981346afd0b1c28617 Mon Sep 17 00:00:00 2001 From: zukwiz Date: Fri, 3 Jul 2026 20:51:18 +0200 Subject: [PATCH 7/7] Fix reversal list ordering regression from OrderBy refactor 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 --- repository/public/reversal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repository/public/reversal.go b/repository/public/reversal.go index 8d94b33a..f826061c 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -87,7 +87,7 @@ func (r *reversalRepository) buildListQuery(opts *dto.ReversalListOptions) *gorm var desc bool if opts.OrderBy != nil && len(opts.OrderBy.Columns) > 0 { - query = query.Order(opts.OrderBy) + query = query.Order(*opts.OrderBy) desc = opts.OrderBy.Columns[0].Desc }