diff --git a/README.md b/README.md index 1926b7f4..a2adfeec 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, and anyone can browse activity at [reverse.watch](https://reverse.watch) — a public dashboard with reversal volume trends, recent reports, and a single-Steam-ID lookup. ## Interested in Participating? @@ -9,16 +9,62 @@ 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`). +The HTTP port is configured under `HTTP.Port` in `config.json`. + +### Seeding local data + +Production ingests live data from contributing marketplaces. For local development, a CSV fixture (98 real rows from a CSFloat export) lives at `internal/devseed/fixtures/reversals_seed.csv` and can be loaded with: + +```bash +go run ./cmd/seed +``` + +The seed: + +- Refuses to run unless `Environment` is `development`. +- Uses `INSERT … ON CONFLICT (id) DO NOTHING`, so it's safe to re-run. +- After seeding, the dashboard at `/` shows three days of historical activity (2026-05-16 → 2026-05-18) with ~100 KPI counts. + +Pass `-csv path/to/other.csv` to load a different fixture. + +### 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 dashboard + +The dashboard is a single self-contained file at [`static/index.html`](static/index.html) (inline CSS + JS, no build step) consuming four 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={30\|60\|90}` | 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. See [`docs/dashboard/PRD.md`](docs/dashboard/PRD.md) for the full product spec and [`docs/dashboard/HANDOFF.md`](docs/dashboard/HANDOFF.md) for the engineering build plan. ## Configuration 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/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 00000000..ba657017 --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,80 @@ +// Command seed loads dev-only fixture data into the local Reverse Watch +// Postgres databases. It is NEVER intended to run in production. +// +// Two modes: +// +// go run ./cmd/seed # load the 98-row real CSV fixture +// go run ./cmd/seed -csv path/.csv # load a different CSV +// go run ./cmd/seed -synthetic # generate a deterministic ~9.8k-row +// # 6-month dataset with spikes +// +// Run from the repo root. Both modes are idempotent — the underlying +// INSERT uses ON CONFLICT (id) DO NOTHING. +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "reverse-watch/config" + "reverse-watch/domain/models" + "reverse-watch/domain/models/constants" + "reverse-watch/internal/devseed" + "reverse-watch/logging" + "reverse-watch/repository/factory" + "reverse-watch/secret" +) + +func main() { + csvPath := flag.String("csv", "internal/devseed/fixtures/reversals_seed.csv", "Path to seed CSV file (ignored when -synthetic is set)") + synthetic := flag.Bool("synthetic", false, "Generate a deterministic 6-month synthetic dataset instead of loading the CSV") + flag.Parse() + + logging.Initialize() + cfg := config.Load() + + if cfg.Environment != constants.EnvironmentDevelopment { + fmt.Fprintf(os.Stderr, "refusing to seed: environment is %q (only %q is allowed)\n", cfg.Environment, constants.EnvironmentDevelopment) + os.Exit(1) + } + + // Required by factory bootstrap (e.g. admin API key seeding). Our + // own seed rows pre-populate their IDs, so the generator does not + // actually run for them. + models.InitSnowflakeGenerator(0, 0) + + keygen := secret.NewKeyGenerator(cfg.Environment) + f, err := factory.NewFactory(cfg, keygen) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create factory: %v\n", err) + os.Exit(1) + } + defer func() { + if err := f.Close(); err != nil { + fmt.Fprintf(os.Stderr, "failed to close factory: %v\n", err) + } + }() + + var reversals []*models.Reversal + if *synthetic { + reversals = devseed.GenerateSynthetic(time.Now().UTC()) + fmt.Printf("generated %d synthetic reversals (deterministic seed)\n", len(reversals)) + } else { + reversals, err = devseed.LoadFromCSV(*csvPath) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load CSV: %v\n", err) + os.Exit(1) + } + fmt.Printf("loaded %d reversals from %s\n", len(reversals), *csvPath) + } + + inserted, err := devseed.InsertReversals(f.PublicDB(), reversals) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to insert reversals: %v\n", err) + os.Exit(1) + } + skipped := int64(len(reversals)) - inserted + fmt.Printf("seed complete: %d inserted, %d already present (skipped)\n", inserted, skipped) +} diff --git a/docs/dashboard/ABOUT-MORTEN.md b/docs/dashboard/ABOUT-MORTEN.md new file mode 100644 index 00000000..dc43afac --- /dev/null +++ b/docs/dashboard/ABOUT-MORTEN.md @@ -0,0 +1,72 @@ +# About me (Morten) — read before responding + +## Who I am +- Morten Byskov, Head of Product at CSFloat.com (largest CS2 skin marketplace in the West). +- Started Jan 2026. +- Primary focus is to improve our products across CSFloat (mobile app and desktop web-app) +- **Second engineering project.** First was CSFloat Guides in `phoenix` (Angular/TypeScript). That was vibe-coded. This one — Reverse Watch — is Go + Postgres + chi. I know `git add/commit/push`. I cannot read code, I don't know syntax details, tooling conventions, or file structures intuitively yet. Treat me as a very junior eng who's pairing with you to learn the stack. +- Goal in this repo: ship the public Reverse Watch dashboard (v1 per `PRD.md`) end-to-end with AI assistance, while learning a bit Go and this codebase. + +## How to work with me + +### Communication +- Be direct and concise. Skip fluff and preambles. +- Challenge my assumptions. Ask questions when context is unclear. +- NEVER present speculation as fact. If you're guessing about CSFloat-internal details, say so explicitly. +- When you find conflicts between `HANDOFF.md` decisions and the repo's actual conventions: surface them, recommend, let me decide. Never silently override. + +### Explaining code and tools +- Assume I do NOT know anything regarding this project and code stack. +- Translate jargon into plain English the first time you use it in a session. +- Before running shell commands: explain in one line what they'll do and what "success" looks like. +- When I hit an error I don't understand, debug it WITH me — don't just fix it and move on. + +### Working pace +- Prefer small, verifiable steps over big leaps. I want to see what happens at each stage. +- Stop and check in after meaningful milestones instead of plowing through an entire task. +- When I say "nothing happens" or "I don't understand", it's literal. Explain from the basics, but keep it short. +- I don't want to write the code myself. I expect you code but give me short clear info about what you do and have done. + +### Code changes +- Before editing files: tell me what you're changing and why. +- After edits: summarize what changed, in plain English. +- Respect existing Go conventions in this repo. Mirror patterns from: + - `api/v1/users/` for public, IP-rate-limited handlers. + - `repository/public/reversal.go` for GORM repo methods. + - `api/v1/reversals/reversals_test.go` and `api/v1/users/users_test.go` for handler tests. +- Don't invent new patterns when an existing one fits. + +### Git workflow +- **Commit locally after every meaningful chunk of work** — e.g. after a coherent step, a passing test suite, or a doc update I've signed off on. Don't let unstaged work pile up across multiple "phases." +- Always show me the proposed commit message first; commit on my nod. +- Prefer two small commits with separate concerns over one big commit, unless I ask for a single one. +- Never push without me explicitly asking. +- Never push to `master`. `master` is protected on `csfloat/reverse-watch` — all changes go through PR. +- **`csfloat/reverse-watch` is private code I do not own.** Default to extreme caution: no force-pushes, no rewrites of public history, no merges I haven't asked for. + +### Review workflow for this project +- **The Reverse Watch v1 dashboard ships to Zach as ONE review, not piecemeal.** Even though HANDOFF.md frames the work as "PR #1" (backend) and "PR #2" (frontend), we keep both on the same local branch and push only when v1 is complete and I say so. Treat "PR #1 / PR #2" as scoping milestones, not separate GitHub PRs. +- I am the only one who decides when v1 is review-ready. Never assume "ready" because a sub-milestone is done. + +## Project context lives in these files (always read at session start) +- `docs/dashboard/PRD.md` — what we're building (public Reverse Watch dashboard v1) and why. +- `docs/dashboard/HANDOFF.md` — deep build context, decisions log, file layout, open items. +- `docs/dashboard/SESSION-LOG.md` — what we've done and where we left off (read the top entry). +- `docs/dashboard/ENVIRONMENT.md` — local setup notes, gotchas, verification commands. + +## CSFloat team (for reference) +- **Ceegan Hale** (`_perplex` on Discord) — Co-founder. Co-reviewer for Reverse Watch. +- **Stepan** (`step7750`) — Co-founder. **author of this repo.** Go-to for second opinions and any architecture calls. +- **Zachary/Zack** — Backend engineer **Primary reviewer for Reverse Watch** +- **Armin** — Frontend engineer (primary owner of the `phoenix` repo). +- **Logan** — Mobile engineer. +- **Justin** — ML/AI engineer. +- **Razvan** (`razvanbadea`) — Designer. Owns the dashboard mockups in `docs/dashboard/design/`. + +I've specifically agreed with Zachary to try this out on my own and see where it takes us. Zachary is the go-to for any topic related to this project. + +## Things NOT to do +- Don't make large multi-file refactors without my explicit OK first. +- Don't install new Go dependencies (`go get`) without asking. +- Don't run "fix everything" commands (`go mod tidy` is fine; mass auto-format across the repo is not). +- Don't surprise-commit. Always show the proposed message first (see Git workflow above). diff --git a/docs/dashboard/END-CHAT.md b/docs/dashboard/END-CHAT.md new file mode 100644 index 00000000..15eff850 --- /dev/null +++ b/docs/dashboard/END-CHAT.md @@ -0,0 +1,15 @@ +# End-of-session prompt — paste when wrapping up + +Wrap up this session. + +1. **Draft** a new entry for the top of `docs/dashboard/SESSION-LOG.md` using this structure: + - `## YYYY-MM-DD — ` + - `### ✅ Done` — bullets of what we actually completed (not what we attempted). + - `### 🧭 Next session` — concrete first step(s) for next time, specific enough that future-me doesn't have to re-think. + - `### ❓ Open` — unresolved questions, decisions deferred, things I need to check with Stepan / Ceegan / Razvan / the team. + +2. **Show me the draft first. Do NOT write it to the file yet.** + +3. I'll review and correct. Once I say "looks good", write it to the top of the log (above the previous entry) and confirm the file saved. + +4. **Also flag:** anything in `ABOUT-MORTEN.md`, `PRD.md`, `HANDOFF.md`, or `ENVIRONMENT.md` that should be updated based on what we learned today? If yes, propose the edits — don't make them unless I approve. diff --git a/docs/dashboard/ENVIRONMENT.md b/docs/dashboard/ENVIRONMENT.md new file mode 100644 index 00000000..5c5030f0 --- /dev/null +++ b/docs/dashboard/ENVIRONMENT.md @@ -0,0 +1,176 @@ +# Reverse Watch — Local Environment + +Last verified: 2026-05-23. + +## Stack you need installed + +- macOS (this doc is macOS-specific; CI is Ubuntu). +- Go 1.24+ (`go version` to check). +- PostgreSQL 18 via Homebrew (`brew install postgresql@18 && brew services start postgresql@18`). +- `gh` (GitHub CLI) for PR work. + +## One-time Postgres setup + +The repo's tests (`internal/testutil/db.go`) hardcode user `postgres`, password `postgres`, host `localhost:5432`. This matches CI (`.github/workflows/test.yml`). Don't change those test-side credentials — match locally instead: + +```bash +psql -d postgres -c "ALTER USER postgres WITH SUPERUSER PASSWORD 'postgres';" +``` + +(If the `postgres` user doesn't exist yet on your install, swap `ALTER` for `CREATE`.) + +Verify with: + +```bash +PGPASSWORD=postgres psql -U postgres -h localhost -d postgres -c "SELECT current_user;" +``` + +Expected: a row showing `postgres`. If you get a password failure, the above ALTER didn't take. + +## App config (`config.json`) + +`config.json` is gitignored. Current local setup: + +```json +{ + "Database": { + "Host": "localhost", "Port": "5432", + "User": "byskov", "Password": "devpassword", + "PrivateDBName": "reverse_watch_private", + "PublicDBName": "reverse_watch_public" + }, + "HTTP": { "Port": "8080", "AllowedOrigins": ["http://localhost:8080"] }, + "Environment": "development" +} +``` + +Note the **app** uses Morten's user (`byskov/devpassword`) but **tests** use `postgres/postgres`. Both work; they hit different databases. + +## Day-to-day commands + +| Goal | Command | +|--------------------------------|----------------------------------------------------------| +| Boot the dev server | `go run main.go` (runs at `http://localhost:8080/`) | +| Run full test suite | `go test ./...` (~15s, all packages) | +| Run one package's tests | `go test ./repository/public/...` | +| Run one test by name | `go test ./repository/public/ -run TestSummaryStats -v` | +| Compile-only check (no run) | `go build ./...` | +| Format on save | Your editor should do this; `gofmt -w .` if not | + +## Workflow gotchas + +- **Two terminals.** Keep one terminal alive for `go run main.go`. Use a second one for shell commands. Pasting `git`/`go test` into the dev-server terminal silently goes to the server's stdin, not the shell. +- **Don't push to `master`.** It's protected on `csfloat/reverse-watch`. Always branch + PR. Current dev branch: `feature/public-dashboard-v1`. +- **CI is identical to local tests.** If `go test ./...` is green locally, CI will be green. + +## When something's wrong — debug ladder + +1. `brew services list` — is Postgres running? +2. `PGPASSWORD=postgres psql -U postgres -h localhost -d postgres -c "SELECT 1;"` — can I authenticate? +3. `go env GOPATH GOROOT` — is Go on the right version? +4. `go mod download` — are deps in place? +5. Talk to the agent. Don't power through silently. + +--- + +# Project Summary (for a reviewer's first pass) + +## What this branch adds + +`feature/public-dashboard-v1` ships the public Reverse Watch dashboard at `/`: three public read endpoints plus a single-file frontend. No schema changes, no new services, no new dependencies. The existing Steam-ID lookup (`/api/v1/users/{steamId}`) is unchanged. + +The three new endpoints: + +| Method | Path | Purpose | +|--------|---------------------------------------|--------------------------------------------------------------| +| GET | `/api/v1/stats/summary` | KPI totals (indexed, flagged, flagged-24h). 60s cache. | +| GET | `/api/v1/stats/reversals/daily` | Daily reversal counts. `?days=7\|30\|60\|90\|180\|365`. 60s cache. | +| GET | `/api/v1/reversals/recent` | Last N (≤100) reversals, newest first. Uncached. | + +All three are IP-rate-limited via the existing `ratelimit.ThrottleByIP` middleware (60/min on `/stats/*`, 30/min on `/recent`). + +## Where things live + +``` +domain/ + dto/stats.go ─ SummaryStats, DailyCount response shapes + repository/public.go ─ added 3 method signatures to ReversalRepository + +repository/public/reversal.go ─ SummaryStats, DailyCounts, ListRecent (+ tests) + +api/v1/ + v1.go ─ mounts /stats next to existing /reversals + stats/{router,stats,stats_test}.go ─ new /stats group + 60s in-process cache + reversals/router.go ─ restructured: /recent is public, others stay auth-gated + reversals/reversals.go ─ listRecentHandler (subset DTO, not full models.Reversal) + reversals/reversals_recent_test.go ─ handler tests + response-shape golden + +server/server.go ─ serveStaticFile / staticDirHandler + (workaround for macOS sendfile truncation) + +static/ + index.html ─ single-file dashboard, no build step + csfloat-logo.png ─ asset + cs2-events.json ─ editorial chart annotations (date+title+description) + +internal/devseed/ + fixtures/reversals_seed.csv ─ 98-row real-data fixture + sheet.go ─ CSV loader + chunked InsertReversals + synthetic.go ─ deterministic 6-month / ~9,800-row generator +cmd/seed/main.go ─ CLI for both seeds; refuses non-development env +``` + +## Request flow + +```mermaid +flowchart LR + Browser -->|"GET /api/v1/stats/summary"| Chi[chi router] + Chi --> RL[IP rate-limit] + RL --> Cache{"60s cache hit?"} + Cache -->|hit| WriteBytes[write cached bytes] + Cache -->|miss| Handler[handler] + Handler --> Factory[repository.Factory] + Factory --> Repo[ReversalRepository.SummaryStats] + Repo --> PG[(Postgres)] + PG --> Repo + Repo --> Handler + Handler --> Marshal[json.Marshal] + Marshal --> Store[cache.Store] + Store --> WriteBytes + WriteBytes --> Browser +``` + +`/reversals/recent` is the same shape minus the cache branch. The frontend (`static/index.html`) calls all three on boot and re-fetches daily counts when the period picker changes. + +## Notable design decisions + +- **IP rate-limit reuses existing middleware.** `ratelimit.ThrottleByIP` is the same primitive that gates `/api/v1/users/{steamId}` — no new infra, no new config surface. +- **60s in-process cache for `/stats/*`.** A `sync.Map` keyed by request shape, sized to ≤8 keys (one per allowed `days` value + summary). Good enough for v1; if traffic grows we can move to Redis without changing the contract. +- **`/recent` lives under `/reversals` but `/reversals/daily` lives under `/stats`.** `/recent` is row-level data; `/stats/reversals/daily` is an aggregate with a different cache policy. Same comment lives in [api/v1/v1.go](../../api/v1/v1.go). +- **Synthetic seed is gated on `Environment=development`.** `cmd/seed` refuses to run otherwise; production cannot accidentally insert fake data. The generator (`internal/devseed/synthetic.go`) is deterministic (RNG seed 42), so dev environments are reproducible. +- **Static handler reads-per-request.** `server/server.go` skips `http.ServeFile` because the sendfile fast path truncated responses at the first TCP segment on local macOS during development. The files are small enough that re-reading per request is cheap. +- **Frontend is one file, no build step.** Inline CSS + JS, `uPlot` loaded from CDN. Easy to review, easy to ship, no toolchain to maintain. If we outgrow this, the contract with the backend is the JSON shapes — the frontend can be rewritten independently. + +## Known gaps that need a product call + +These are intentional v1 stopgaps. Pointers to PRD sections in [docs/dashboard/PRD.md](PRD.md): + +- **D-open-1: KPI definitions.** Confirm `traders_indexed` = distinct steam_ids in the index (current implementation). PRD §14.1. +- **D-open-4: Steam display-name source.** The Trader column currently shows deterministic *fake* names derived from `steam_id`. Real names need either Steam `GetPlayerSummaries` (rate-limited, needs caching) or a separate `steam_users` table. PRD §14.4. +- **Marketplace registry behavior.** PRD §14.4 flags some ambiguity about whether/how to display marketplace slugs. + +## How to run locally + +```bash +# 1. One-time: ensure the test user exists (see top of this file). +psql -d postgres -c "ALTER USER postgres WITH SUPERUSER PASSWORD 'postgres';" + +# 2. Seed the local public DB (idempotent; safe to re-run). +go run ./cmd/seed # 98 real-data rows from the Google Sheet export +go run ./cmd/seed -synthetic # +9,800 synthetic rows spanning 6 months + +# 3. Run the server. +go run main.go # open http://localhost:8080 +``` + +Tests: `go test ./...` (~15s). diff --git a/docs/dashboard/HANDOFF.md b/docs/dashboard/HANDOFF.md new file mode 100644 index 00000000..f316afe0 --- /dev/null +++ b/docs/dashboard/HANDOFF.md @@ -0,0 +1,328 @@ +# Reverse Watch — Build Handoff + +**Purpose:** Everything a Cursor agent (or Morten) needs to start building the Reverse Watch dashboard inside the cloned [`csfloat/reverse-watch`](https://github.com/csfloat/reverse-watch) repo. Self-contained — designed to be copied into the new workspace alongside `PRD.md` so the dialogue doesn't need to carry over. + +**Travel as a pair:** +- `PRD.md` — the final product spec (read first, authoritative). +- `HANDOFF.md` — this file (build plan, context, next steps, how to work). + +--- + +## 0. Starter prompts + +For session kickoff, use `START-CHAT.md` in this folder. For session wrap-up, use `END-CHAT.md`. The earlier starter prompt that lived here is preserved in git history. + +--- + +## 1. One-paragraph project summary + +Reverse Watch's public site at `reverse.watch` is currently a single Steam-ID lookup served from `static/index.html` by a Go binary (chi + GORM + Postgres). We're turning it into a public dashboard: hero + search (existing flow, restyled) + three KPI cards + a 90-day daily-reversal-count line chart + a "recently reported reversals" table + footer. Everything is additive — three new public read endpoints, three new repo methods, one rewritten HTML file. No schema changes, no migrations, no new services. Morten is building it himself; goal is "second project shipped at CSFloat" and to learn the reverse-watch stack end-to-end. + +--- + +## 2. How to work with Morten + +See `ABOUT-MORTEN.md` in this folder. ABOUT-MORTEN is authoritative for working style. + +--- + +## 3. Decisions locked in (don't re-open without Morten) + +| # | Decision | Rationale | +| --- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| D1 | Build inside `csfloat/reverse-watch` as additive routes + a static HTML rewrite. No new repo, no new service. | Repo is small and purpose-built. Adding scope here is the right home. | +| D2 | New public endpoints follow the `/api/v1/users/{steamId}` precedent: IP-rate-limited via `ratelimit.ThrottleByIP`, no auth, conservative response shapes. | Existing pattern, already proven, already deployed. | +| D3 | Three new endpoints: `/api/v1/stats/summary`, `/api/v1/stats/reversals/daily`, `/api/v1/reversals/recent`. | One per UI section. Single network round-trip per section. | +| D4 | Default chart window: **30 days**. `days` param accepts `30 \| 60 \| 90`. | Matches Razvan's mockups (all four PNGs read "Last 30 days"). Enumerated set lets us cache trivially. | +| D5 | KPI summary and daily endpoints get a 60s in-process cache. Recent-reversals stays uncached. | KPIs are stale-tolerant; the table should feel live. | +| D6 | Frontend stays a single self-contained `static/index.html` — inline CSS, inline JS, charting via CDN. | Matches existing repo convention. No frontend toolchain to maintain. | +| D7 | Charting library: `uPlot` (preferred — small, fast, no deps) or `Chart.js` (more familiar). Decide in PR #2 review. | Lock at PR #2 time, not now. Both are CDN-friendly. | +| D8 | "Load More" on the recent-reversals table is **deferred to v1.1**. v1 fetches 100 rows once. | Cursor pagination already exists internally but exposing it on the public route adds surface to v1. | +| D9 | Marketplace slug → `{name, iconUrl}` mapping is hardcoded in the frontend for v1. Promote to an endpoint in v1.1. | List is small. Don't pre-build. | +| D10 | English only. No i18n in v1. | The repo has no Crowdin wiring. Adding it would balloon scope. | +| D11 | Dark theme only. No light variant. | Razvan's mockups are dark only. | +| D12 | "Date Added" column on the recent-reversals table = `created_at` (when reported), not `reversed_at`. | The label reads "Added", not "Reversed". Matches the column's intent. | + +D-list items still **open** (need Zach input — see §10): + +- **D-open-1** KPI definitions (PRD §6.2). Specifically: 24h KPI bucketed by `created_at` vs `reversed_at`. +- **D-open-4** Steam display name source for the Trader column (PRD §6.4). Options: Steam Web API + cache, a `steam_users` table, or "ship without display names in v1." Current local dashboard renders a deterministic fake derived from `steam_id` as a stopgap. + +(D-open-2 and D-open-3 are now locked — see PRD §14: recent-reversals Steam IDs ship unmasked; analytics is PostHog via `science.csfloat.io`.) + +--- + +## 4. Architecture — the whole thing on one page + +```mermaid +flowchart LR + user["User"] -->|"GET /"| static["static/index.html"] + static -->|"GET /api/v1/stats/summary"| api["chi router"] + static -->|"GET /api/v1/stats/reversals/daily?days=30"| api + static -->|"GET /api/v1/reversals/recent?limit=100"| api + static -->|"GET /api/v1/users/{steamId}"| api + api --> repo["repository/public/reversal.go (extended)"] + repo --> pg[("PostgreSQL")] +``` + +No new services. Zero schema changes. Zero new tables. + +--- + +## 5. File layout inside reverse-watch + +``` +api/v1/ + stats/ + router.go # NEW — chi.Router for /stats/* (mount from api/v1/v1.go) + stats.go # NEW — handlers: summaryHandler, dailyHandler + stats_test.go # NEW + reversals/ + router.go # MODIFY — register GET /recent with public IP rate limit + reversals.go # MODIFY — add listRecentHandler (slim public projection) + reversals_test.go # MODIFY — coverage for the new handler + +repository/public/ + reversal.go # MODIFY — add SummaryStats, DailyCounts, ListRecent + reversal_test.go # MODIFY — coverage for the three new methods + +domain/repository/ + public.go # MODIFY — add the new method signatures to the interface + +static/ + index.html # REWRITE — new layout per the three Razvan mockups + +internal/devseed/ # NEW — seed script for local dev (see PRD §3.5) + sheet.go # ingests the Google Sheet CSV into Postgres + fixtures/ + reversals_seed.csv # 100-row Sheet snapshot (gitignored if too large) + +cmd/seed/ # NEW + main.go # CLI: `go run ./cmd/seed` — loads from the Google Sheet +``` + +--- + +## 6. Endpoint contracts (the source of truth lives in PRD §6) + +### `GET /api/v1/stats/summary` + +```json +{ "traders_indexed": 25678, "traders_flagged": 15536, "traders_flagged_24h": 6456 } +``` + +### `GET /api/v1/stats/reversals/daily?days=30` + +```json +{ + "data": [ + { "date": "2026-02-22", "count": 12 }, + { "date": "2026-02-23", "count": 18 } + ] +} +``` + +- Bucket by `reversed_at` in **UTC**. +- Exclude `expunged_at IS NOT NULL`. +- Include zero-count days inside the window. + +### `GET /api/v1/reversals/recent?limit=100` + +```json +{ + "data": [ + { + "marketplace_slug": "csfloat", + "steam_id": "76561198000000000", + "reversed_at": 1779840000000, + "created_at": 1779843600000 + } + ] +} +``` + +- Order: `created_at DESC`. +- Exclude expunged. +- Cap `limit` at 100. + +--- + +## 7. What to build first — PR #1 scope (backend only) + +Keep PR #1 < ~600 LOC if possible. Big first PRs are a tell. + +**In scope:** +- New repo methods on `repository/public/reversal.go`: + - `SummaryStats() (*dto.SummaryStats, error)` — three counts in one query if reasonable, else three queries inside one transaction. + - `DailyCounts(days int) ([]dto.DailyCount, error)` — postgres `date_trunc('day', to_timestamp(reversed_at / 1000))` + `generate_series` for zero-fill, or do the zero-fill in Go. + - `ListRecent(limit int) ([]*models.Reversal, error)` — order by `created_at DESC`, exclude expunged. +- New `dto.SummaryStats` and `dto.DailyCount` structs. +- Add the methods to `domain/repository/public.go`'s `ReversalRepository` interface. +- New `api/v1/stats/router.go` + `stats.go` with the two stats handlers, mounted from `api/v1/v1.go` at `/stats`. +- New `listRecentHandler` in `api/v1/reversals/reversals.go` registered as `GET /` on a public sub-router (or as a separate `/recent` path — see PR review for which mounts cleaner with the existing auth-gated routes). +- 60s in-process cache wrapper around the two stats handlers (per `days` value for the daily one). +- IP rate limiting on all three new endpoints via `ratelimit.ThrottleByIP`. Suggested: 60/min for stats, 30/min for `/reversals/recent`. +- Tests for all three repo methods using `pgtestdb` (see [`internal/testutil/db.go`](https://github.com/csfloat/reverse-watch/blob/master/internal/testutil/db.go)). +- Tests for all three handlers (mirror [`api/v1/reversals/reversals_test.go`](https://github.com/csfloat/reverse-watch/blob/master/api/v1/reversals/reversals_test.go)). + +**Not in PR #1:** +- The `static/index.html` rewrite (PR #2). +- Analytics wiring (PR #3). +- Local seed scripts (separate PR or on a side branch — useful for testing locally before PR #2 lands). + +**Acceptance for PR #1:** all three endpoints respond with the documented JSON shape. CI passes. Local Postgres seeded from the Google Sheet (per PRD §3.5) shows the documented JSON shape — counts will be small (single marketplace, 3 days of data), and that's fine. Latency feels fine on a dev box (target <500ms p95 in prod). + +--- + +## 8. Local setup on macOS + +```bash +brew install go postgresql gh +gh auth login # GitHub.com, HTTPS, web browser + +mkdir -p ~/code && cd ~/code +gh repo clone csfloat/reverse-watch +cd reverse-watch + +# Branch off master (master is protected — must PR back) +git checkout -b feature/public-dashboard-v1 + +# Postgres setup (or use Docker) +brew services start postgresql@18 +createdb -U postgres reverse_watch_dev # may need: psql -U postgres -c "CREATE USER postgres SUPERUSER;" + +# Config +cp config.example.json config.json +# edit config.json: PrivateDBName + PublicDBName, port, etc. + +# Run +go mod download +go run main.go +``` + +Open `http://localhost:80` (or whichever port from `config.json`). Should see the existing Steam-ID lookup page. + +**Run the seed locally** (after `internal/devseed/` lands — early-side-branch is fine): + +```bash +go run ./cmd/seed --csv=./internal/devseed/fixtures/reversals_seed.csv +``` + +See PRD §3.5 for the dummy-data rationale. See §10 below for the Sheet ingest gotcha (steam IDs as scientific notation on CSV export). + +**Tests:** + +```bash +go test ./... +``` + +Tests use `pgtestdb` and need a running Postgres on `localhost:5432` with `postgres/postgres` creds — same as the CI service. See [`.github/workflows/test.yml`](https://github.com/csfloat/reverse-watch/blob/master/.github/workflows/test.yml). + +**Known risks:** +- Branch is off **protected** master. Push to your branch, open a PR, do not push to master. +- `go.mod` requires Go 1.24+. Confirm `go version` before `go mod download`. + +--- + +## 9. Files in reverse-watch to read first (in this order) + +1. [`README.md`](https://github.com/csfloat/reverse-watch/blob/master/README.md) — repo overview. +2. [`main.go`](https://github.com/csfloat/reverse-watch/blob/master/main.go) — wiring, ingestor manager, server bootstrap. +3. [`server/server.go`](https://github.com/csfloat/reverse-watch/blob/master/server/server.go) — chi setup, CORS allow-list (note: it restricts methods to `GET, OPTIONS` — ours are GETs so we're fine). +4. [`api/v1/v1.go`](https://github.com/csfloat/reverse-watch/blob/master/api/v1/v1.go) — top-level v1 router; this is where we mount `/stats`. +5. [`api/v1/users/users.go`](https://github.com/csfloat/reverse-watch/blob/master/api/v1/users/users.go) + [`router.go`](https://github.com/csfloat/reverse-watch/blob/master/api/v1/users/router.go) — the precedent for a public, IP-rate-limited handler. Read both end-to-end. +6. [`api/v1/reversals/reversals.go`](https://github.com/csfloat/reverse-watch/blob/master/api/v1/reversals/reversals.go) + [`router.go`](https://github.com/csfloat/reverse-watch/blob/master/api/v1/reversals/router.go) — auth-gated patterns; our new public `recent` handler lives in this package. +7. [`repository/public/reversal.go`](https://github.com/csfloat/reverse-watch/blob/master/repository/public/reversal.go) — extend with three new methods. +8. [`domain/repository/public.go`](https://github.com/csfloat/reverse-watch/blob/master/domain/repository/public.go) — add the new method signatures to the interface. +9. [`internal/testutil/db.go`](https://github.com/csfloat/reverse-watch/blob/master/internal/testutil/db.go) — `pgtestdb` test setup; mirror it. +10. [`static/index.html`](https://github.com/csfloat/reverse-watch/blob/master/static/index.html) — current self-contained file. The rewrite preserves the file structure: inline CSS, inline JS, no build step. + +--- + +## 10. Open items — fire these off today, don't block on them + +These are parallel threads. Send the messages, keep coding. + +### To Zach — primary reviewer for Reverse Watch + +> Setting up the Reverse Watch dashboard rewrite. One thing I want your input on before I open PR #1: +> 1. KPI definitions on the homepage cards. "Traders Indexed" I'm reading as `COUNT(DISTINCT steam_id)` including expunged. "Traders Flagged" as the same but `WHERE expunged_at IS NULL` (matches the existing `/users/{steamId}` `has_reversed` logic). "Traders Flagged (24h)" I want to bucket by `created_at` in the last 24h, not `reversed_at` — because reporters can backfill `reversed_at` weeks ago. OK? + +### To Stepan (Discord: `step7750`) — repo author / architecture lead + +FYI message — keep him in the loop: + +> Heads up — I'm building the Reverse Watch dashboard rewrite. Zach is reviewing. Three new public IP-rate-limited endpoints on top of the existing routes, no schema changes. PRD + HANDOFF live in `docs/dashboard/`. Yell if anything looks off. + +### To Ceegan (Discord: `_perplex`) — co-founder, can sanity-check the PRD + +> Drafting a v1 dashboard for `reverse.watch`. PRD is in my notes — want me to walk you through it for 10 min, or fire and forget once Stepan's reviewed? + +### To Razvan (Discord: `razvanbadea`) — design + +> Three q's on the Reverse Watch mockups: +> 1. Got the mobile clear-state mockup — thanks. Need mobile default + mobile flagged before I start PR #2 on the frontend. Same fidelity as the clear one is fine. +> 2. The "RZBO" placeholder — do you have icons / display names for the active marketplaces (csfloat, …), or do I source them? +> 3. Confirming dark only, no light theme variant — yes? + +### Sheet access — resolved 2026-05-22 + +Sheet ID: `1ccGoHiqXTpjy_jtHSOW3QmrNFP2jvfOsqyWFpNBz-UA`. Shared with `cursor-mcp-sheets@csfloat-mcp.iam.gserviceaccount.com`. 100 rows, headers identical to `models.Reversal`. + +**Ingest gotcha to bake into the seed script:** 2 of the 100 `steam_id` cells were truncated to scientific notation (`7.65612E+16`) in the default `FORMATTED_VALUE` Sheets response. When pulling via the Sheets API call `valueRenderOption=UNFORMATTED_VALUE`. When using a CSV export, force the `steam_id` column to plain text in Sheets first (`Format → Number → Plain text`) before downloading, otherwise the export will encode scientific notation as a literal string. + +The dataset is also narrow in shape — every row is `marketplace_slug=csfloat` and `source=0` (direct), no expunged rows, ~3 days of data. The synthetic generator is responsible for breadth (mixed marketplaces, mixed sources, ~5% expunged, 90-day spread). + +--- + +## 11. Linear + +- Linear parent issue: **CSF-1518** — [https://linear.app/csfloat/issue/CSF-1518/improve-reversewatch](https://linear.app/csfloat/issue/CSF-1518/improve-reversewatch) +- Owner: Morten. +- Reviewer: **Zach** (Stepan is repo author / architectural reviewer if needed). +- Sub-issues to open under the parent: + 1. Local setup + onboarding to `reverse-watch` (tracking-only). + 2. PR #1 — three public endpoints + repo methods + tests. + 3. PR #2 — `static/index.html` rewrite consuming the three endpoints. + 4. PR #3 — analytics wiring (PostHog). + 5. (Side branch) — local seed script (`internal/devseed/`, `cmd/seed/`) — loads the Google Sheet per PRD §3.5. + +--- + +## 12. Out of scope — don't do these in v1 + +- New database tables, columns, or indexes (we may add an index on `reversals(reversed_at)` later; benchmark first). +- Auth-gated dashboards, marketplace login, leaderboards. +- Per-marketplace or per-source breakdowns on the chart. +- "Load More" pagination on the recent-reversals table. +- Public marketplace registry endpoint. +- Light theme. +- i18n / translations. +- Mobile app integration (the extension already integrates). +- Caching beyond a 60s in-process TTL (no Redis, no CDN headers — those land in v1.2). + +All of the above live on the Roadmap (PRD §10). Do them only after v1 is live. + +--- + +## 13. Success definition for v1 + +A user lands on `https://reverse.watch/` and sees: hero with the Steam-ID search, three KPI cards with non-zero numbers, a 90-day reversal-volume line chart, the latest 100 reports as a table, and the new footer. They search a Steam ID and get either *Clear* or *Flagged* below the search box. The page scores ≥ 90 on Lighthouse Performance + Accessibility. PostHog (or chosen analytics) shows `dashboard_viewed` events flowing. + +That's it. Celebrate that. Everything else comes after. + +--- + +## 14. Pointers + +- **Product spec:** `PRD.md` (this same folder; copy with this file). +- **Repo:** [`csfloat/reverse-watch`](https://github.com/csfloat/reverse-watch). +- **Production:** [`reverse.watch`](https://reverse.watch). +- **Mockups (4):** desktop default / clear / flagged ([`design/01-default.png`](design/01-default.png), [`design/02-clear.png`](design/02-clear.png), [`design/03-flagged.png`](design/03-flagged.png)) and mobile clear ([`design/04-mobile-clear.png`](design/04-mobile-clear.png)). Mobile default + flagged are pending from Razvan. Copy the whole `design/` folder into the cloned reverse-watch repo alongside the PRD/HANDOFF when you set up `docs/dashboard/`. +- **Existing public endpoint precedent:** [`api/v1/users/`](https://github.com/csfloat/reverse-watch/tree/master/api/v1/users). +- **Test infrastructure:** [`internal/testutil/db.go`](https://github.com/csfloat/reverse-watch/blob/master/internal/testutil/db.go) (uses `pgtestdb`). +- **CI:** [`.github/workflows/test.yml`](https://github.com/csfloat/reverse-watch/blob/master/.github/workflows/test.yml) (Postgres 18, Go 1.x). + +--- + +*End of handoff. Paired with `PRD.md`. Delete both from the reverse-watch workspace once v1 is live (they live permanently in the AI Product Sense workspace).* diff --git a/docs/dashboard/PRD.md b/docs/dashboard/PRD.md new file mode 100644 index 00000000..600e92bb --- /dev/null +++ b/docs/dashboard/PRD.md @@ -0,0 +1,460 @@ +# PRD — Reverse Watch dashboard + +**Version:** 1.0 (draft) +**Status:** Draft — awaiting confirmation on open items in §14. +**Owner:** Morten Byskov +**Last updated:** 2026-05-22 +**Linear:** TBD +**Production target:** `[reverse.watch](https://reverse.watch)` (root path) +**Repo:** `[csfloat/reverse-watch](https://github.com/csfloat/reverse-watch)` (master `df93823…`) + +--- + +## 1. Product overview + +### 1.1 Vision + +Turn `reverse.watch` from a single-purpose Steam-ID lookup into the canonical public dashboard for Steam trade-reversal activity. Anyone — a CS2 trader spooked by a fresh Valve update, a competing marketplace, a community-site operator — should be able to land on `reverse.watch` and immediately see (a) overall reversal volume across the ecosystem, (b) trends over time, and (c) recent reversal activity. The Steam-ID lookup stays as a primary action. + +### 1.2 Problem + +Right now `reverse.watch` answers exactly one question: "has this specific Steam ID been reversed?" That's useful but narrow. It misses the more interesting product surface: + +- **Macro signal.** When Valve ships an update or a wave of fraud rolls through Steam, traders have no public way to see whether reversal volume is spiking. They guess from Reddit threads. +- **Trust building.** A live, populated dashboard signals that the database is real and contributed to. An empty homepage with just a search box does not. +- **Discoverability.** Users who don't already have a specific Steam ID in mind have no reason to visit. A dashboard gives them one. + +### 1.3 Goals + + +| # | Goal | Success metric | Baseline status | +| --- | --------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| G1 | Make `reverse.watch` a destination — not just a lookup | 30-day average DAU on `/` increases ≥3× post-launch | **Baseline pending** — no analytics on `reverse.watch` today (needs G4). | +| G2 | Surface ecosystem-level reversal trends | Daily-bucket chart loads in <500ms p95; chart visible above the fold on desktop | N/A — endpoint doesn't exist yet | +| G3 | Preserve the single-Steam-ID lookup as the primary action | Lookup latency unchanged (<200ms p95); search box still hero on the page | Existing endpoint is `` p95 — needs measurement | +| G4 | Add basic web analytics so we can measure G1–G3 | PostHog or equivalent event tracking landed in the same release | None today | + + +### 1.4 Non-goals (v1) + +- Authenticated user accounts, saved searches, or notifications. +- Per-marketplace breakdowns on the chart (single line, total volume only — defer to v1.1). +- Per-source breakdowns (`direct` / `related_user` / `user_report`) on the chart. +- A new public marketplace registry endpoint — slug → display name + icon stays hardcoded in the frontend for v1. +- New database tables, new Postgres indexes (deferred — see §9.2 if needed). +- Mobile app integration. The CSFloat browser extension already integrates via `/api/v1/users/{steamId}` and that contract is unchanged. +- Internationalisation. English only. The repo has no Crowdin wiring. +- Light theme. Razvan's mockups are dark only — confirmed scope. + +--- + +## 2. Target users & personas + + +| Persona | Description | Primary need | +| ------------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| **Concerned trader** | A CS2 player worried after a Valve update or trade-hold change. Lands from a Reddit / Discord link. | "Are reversals spiking right now?" | +| **Marketplace operator** | A competing marketplace or trading-tool operator considering contributing reports. | "Is this database real and active?" | +| **Community researcher** | A power user, journalist, or YouTuber writing about trade fraud. | "Show me a number I can cite." | +| **Existing lookup user** | Someone already on `reverse.watch` to check a specific Steam ID. | Search box stays where it is and works the same. | + + +--- + +## 3. Architecture + +### 3.1 Where the code lives + +Single repo: `[csfloat/reverse-watch](https://github.com/csfloat/reverse-watch)`. One Go binary. One static HTML file at `[static/index.html](https://github.com/csfloat/reverse-watch/blob/master/static/index.html)`. No frontend build step, no new service, no new repo. + +### 3.2 Stack + +- **Backend**: Go 1.24+ — chi router, GORM, PostgreSQL. Same as today. +- **Frontend**: a single self-contained HTML file with inline CSS + inline JS. Convention from the existing `static/index.html` is preserved. Charting library loaded from CDN — see §6.4. +- **No backend service additions.** No new Postgres tables. No schema changes. + +### 3.3 System diagram + +```mermaid +flowchart LR + user["User browser"] -->|"GET /"| static["static/index.html (rewritten)"] + static -->|"GET /api/v1/stats/summary"| api["chi router (existing)"] + static -->|"GET /api/v1/stats/reversals/daily"| api + static -->|"GET /api/v1/reversals/recent"| api + static -->|"GET /api/v1/users/{steamId}"| api + api --> repo["public reversal repo (GORM)"] + repo --> pg[("PostgreSQL")] + extension["CSFloat browser extension"] -->|"GET /api/v1/users/{steamId}"| api +``` + + + +### 3.4 Why no schema or model changes + +The existing `Reversal` model already has every field we need: `steam_id`, `marketplace_slug`, `source`, `related_steam_id`, `reversed_at` (ms epoch), `expunged_at` (nullable ms epoch), `created_at`, `updated_at`. Aggregations are read queries against the existing table. v1 ships entirely as additive routes + repo methods + a static HTML rewrite. + +### 3.5 Local data source for v1 + +The dashboard runs on **dummy data sourced from the Google Sheet** (`1ccGoHiqXTpjy_jtHSOW3QmrNFP2jvfOsqyWFpNBz-UA`, the 100-row CSFloat export covering 2026-05-15 → 2026-05-18). This applies to **local development and staging only**. Production at `reverse.watch` is unchanged — it continues to ingest live data from contributing marketplaces via the existing pipeline. + +Two seed modes are supported for local development (still local-only — production is unchanged): + +- **CSV seed** (`go run ./cmd/seed`) loads the 98 clean rows from the Google Sheet for verifying real-data behavior. +- **Synthetic seed** (`go run ./cmd/seed -synthetic`) generates a deterministic ~9,800-row dataset spanning the last 180 days, with at least one reversal per day and occasional spikes. Used to exercise the chart's full period range (7d / 30d / 3m / 6m / 1y) without waiting for real data. Lives in `internal/devseed/synthetic.go`. + +Implications for v1: + +- A small ingest script seeds local Postgres (`internal/devseed/` — see HANDOFF §5). +- Local KPIs and the chart reflect whichever seed was loaded; both modes are idempotent (`ON CONFLICT (id) DO NOTHING`) and can coexist. +- Tests use `pgtestdb` with inline fixtures — independent of either seed. +- The synthetic generator is **dev-only**: `cmd/seed` refuses to run unless `Environment == development`. + +--- + +## 4. Information architecture + + +| Route | Purpose | Auth | +| ------------------------------- | ------------------------------------------------ | ------------------- | +| `/` | Public dashboard (rewritten) | Public | +| `/api/v1/users/{id}` | Single Steam-ID lookup (existing, unchanged) | Public, IP-limited | +| `/api/v1/stats/summary` | Three KPI numbers in one call (new) | Public, IP-limited | +| `/api/v1/stats/reversals/daily` | Daily-bucket chart series (new) | Public, IP-limited | +| `/api/v1/reversals/recent` | Recent reversals for the table (new) | Public, IP-limited | +| `/api/v1/reversals` (auth) | Existing `export`-permission listing (unchanged) | Bearer + permission | +| All other existing routes | Unchanged | As today | + + +The dashboard is a single page. No `/about`, no `/dashboard`, no other public routes added. + +--- + +## 5. Data model + +No changes. Reusing existing `[domain/models/reversal.go](https://github.com/csfloat/reverse-watch/blob/master/domain/models/reversal.go)`: + +```go +type Reversal struct { + Model // snowflake ID, created_at, updated_at (ms epoch) + SteamID SteamID + MarketplaceSlug string + Source *Source // nil | direct | related_user | user_report + RelatedSteamID *SteamID // only set when Source == related_user + ReversedAt uint64 // ms epoch (when the trade actually reversed) + ExpungedAt *uint64 // nil = active; non-nil = soft-deleted by reporter +} +``` + +All v1 read queries filter `expunged_at IS NULL` unless explicitly noted. Expunged rows are hidden from the public dashboard. + +--- + +## 6. Feature inventory (v1) + +Razvan's mockups are the source of truth for layout and copy. +Desktop: `[design/01-default.png](design/01-default.png)`, `[design/02-clear.png](design/02-clear.png)`, `[design/03-flagged.png](design/03-flagged.png)`. +Mobile: `[design/04-mobile-clear.png](design/04-mobile-clear.png)` (clear state only — default and flagged mobile mocks are pending from Razvan, see §14). + +### 6.1 Page sections (top to bottom) + +1. **Hero** — "Powered by CSFloat" badge, title "The open trade reversal database", one-sentence subtitle, Steam-ID search input, search button. +2. **Search result chip** — appears below the search input after a query (existing behavior, restyled). Two states from the mockups: *Clear* (green "No Reversals Found") and *Flagged* (red "Reversals Found"). +3. **KPI cards** — three side-by-side cards: "Steam IDs Searched", "Traders Flagged", "Traders Flagged (24h)". Definitions in §6.2. +4. **Reversal Graph** — title "Reversal Graph", segmented period picker (`7d / 30d / 3m / 6m / 1y`, defaulting to `30d`), one-sentence subtitle, single-line chart of daily reversal counts. +5. **Recently Reported Reversals** — title, one-sentence subtitle, table with columns Trader / Steam ID / Date Added, "Load More" button. +6. **Footer** — "What is reverse.watch?" + "Want to contribute?" copy blocks, "Get the extension" CTA, "Powered by CSFloat" lockup. + +### 6.2 KPI definitions + +These are the three numbers in the cards. Locking these requires a sign-off from Zachary because the existing repo doesn't enshrine these definitions anywhere — see §14. + + +| KPI | Working definition (v1 proposal) | SQL sketch | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| **Steam IDs Searched** | Distinct `steam_id`s that have ≥1 row in `reversals`, including expunged. Captures the total population the database has ever touched. (Underlying API field stays `traders_indexed` — the rename is UI-only.) | `SELECT COUNT(DISTINCT steam_id) FROM reversals;` | +| **Traders Flagged** | Distinct `steam_id`s that currently have ≥1 non-expunged reversal. Matches the existing `/users/{steamId}` "has reversed" logic. | `SELECT COUNT(DISTINCT steam_id) FROM reversals WHERE expunged_at IS NULL;` | +| **Traders Flagged (24h)** | Distinct `steam_id`s with a non-expunged row whose `created_at` is within the last 24 hours. Reads as "newly flagged today." | `SELECT COUNT(DISTINCT steam_id) FROM reversals WHERE expunged_at IS NULL AND created_at >= NOW() - INTERVAL '24 hours';` | + + +> The 24h KPI uses `created_at` (when the row was reported), not `reversed_at` (when the trade actually reversed), because the card reads as "what happened today" and reporters can backfill `reversed_at` to weeks ago. Confirm with Zach in §14. + +### 6.3 Reversal Graph — `GET /api/v1/stats/reversals/daily` + +**Request:** + +``` +GET /api/v1/stats/reversals/daily?days=30 +``` + +- `days` is optional. Default `30` (matches Razvan's mockups). Allowed values: `7`, `30`, `60`, `90`, `180`, `365`. Other values → `400 Bad Request`. The frontend period picker uses `7 / 30 / 90 / 180 / 365` directly; `60` stays in the allow-list for v1.1 flexibility. Restricting to a small enumerated set keeps the per-day cache key space tiny (see §9). + +**Annotation chips (editorial):** the chart overlays small chips above the line for CS2 events that may explain reversal spikes (patch releases, Steam policy changes, anti-cheat waves). The data source is `static/cs2-events.json` — a flat JSON file with `{date, title, description?, url?}` entries. The frontend fetches it at boot, filters to the currently-selected period, and row-stacks chips when they overlap (up to 3 rows; oldest overflow is dropped with a console warning). Editing the JSON file and refreshing is the entire authoring loop — no rebuild, no backend involvement. If/when this graduates beyond a manually-curated list it becomes its own endpoint, but v1 is editorial-on-disk by design. + +**Response:** + +```json +{ + "data": [ + { "date": "2026-02-22", "count": 12 }, + { "date": "2026-02-23", "count": 18 } + ] +} +``` + +**Bucketing rules:** + +- Bucket by `reversed_at` (per Morten's brief), in **UTC**. +- Excludes rows with `expunged_at IS NOT NULL`. +- Returns one entry per day in the window, including days with `count: 0` (no gaps — easier for the frontend chart). +- Window is `[NOW() - days, NOW()]` in UTC at request time. + +### 6.4 Recently Reported Reversals — `GET /api/v1/reversals/recent` + +**Request:** + +``` +GET /api/v1/reversals/recent?limit=100 +``` + +- `limit` optional. Default `100`. Max `100`. Other values → `400`. + +**Response:** + +```json +{ + "data": [ + { + "marketplace_slug": "csfloat", + "steam_id": "76561198000000000", + "reversed_at": 1779840000000, + "created_at": 1779843600000 + } + ] +} +``` + +**Rules:** + +- Order: `created_at DESC` (newest reports first — matches "Date Added" semantic). +- Excludes rows with `expunged_at IS NOT NULL`. +- Slim public projection: only the fields needed for the table. Notably no `id`, no `source`, no `related_steam_id` — those ride on the auth-gated `/api/v1/reversals` endpoint and don't belong on the public surface. +- "Load More" pagination is **deferred to v1.1**. v1 fetches the latest 100 once and renders. The button shows but is `disabled` with a tooltip "More coming soon" — or removed entirely. Decide in HANDOFF §3. + +**Table columns:** + +| Column | Source | Notes | +| ----------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| Trader | Steam display name (e.g. `ShadowWolf42`) | **Pending Zach** — see §14 D-open-4. Locally rendered as a deterministic fake derived from `steam_id` until the real source is wired. | +| Steam ID | `steam_id` (64-bit, string) | Monospace. Hidden on mobile. | +| Date Added | `created_at` | Right-aligned. "MMM DD, HH:MM" in viewer's locale. | + +Marketplace is **not** rendered in this table. Razvan's mockup intentionally drops it; the API still returns `marketplace_slug` for v1.1 column additions. + +### 6.5 Single-Steam-ID lookup (existing, unchanged) + +`[GET /api/v1/users/{steamId}](https://github.com/csfloat/reverse-watch/blob/master/api/v1/users/users.go)` — unchanged. The new homepage rewires the existing form to it. Result chip below the search input adopts the new visual treatment from the *Clear* and *Flagged* mockups. + +### 6.6 KPI summary endpoint — `GET /api/v1/stats/summary` + +**Request:** + +``` +GET /api/v1/stats/summary +``` + +**Response:** + +```json +{ + "traders_indexed": 25678, + "traders_flagged": 15536, + "traders_flagged_24h": 6456 +} +``` + +One round-trip for all three KPI cards. Cached server-side (see §9). + +### 6.7 Marketplace display registry + +The "Trader" column shows a marketplace logo + display name (e.g. "RZBO" with an icon) for each row. v1 hardcodes a slug → `{name, iconUrl}` map inside the static HTML. The currently-known marketplaces are sparse enough that this stays maintainable. Promotion to a public `GET /api/v1/marketplaces` endpoint is v1.1 scope — see §12. + +If the frontend encounters an unknown slug it falls back to rendering the slug verbatim with a generic icon. + +--- + +## 7. Non-functional requirements + +### 7.1 Performance + +- All four GET endpoints serving the dashboard return in **<500ms p95** under steady-state production traffic. +- Page LCP **<2.5s on 4G mobile**. +- The chart renders without layout shift (CLS <0.1) — reserve the chart container's height before the data lands. + +### 7.2 Caching + +The KPI summary and daily-bucket endpoints are stale-tolerant: + +- In-process cache with a 60-second TTL on `/api/v1/stats/summary` and `/api/v1/stats/reversals/daily` (per `days` value). Standard-library `sync.Map` + `time.Time` is enough — don't pull in Redis. Existing `ratelimit/` package shows the in-process precedent. +- `/api/v1/reversals/recent` is **not** cached — the table should reflect new reports within seconds. +- `/api/v1/users/{steamId}` is unchanged. + +### 7.3 Rate limits + +All new public endpoints: + +- IP-throttled via the existing `[ratelimit.ThrottleByIP](https://github.com/csfloat/reverse-watch/blob/master/ratelimit/ratelimit.go)`. +- Suggested: 60 req/minute/IP for stats endpoints, 30 req/minute/IP for `/reversals/recent`. Tune in HANDOFF §3. + +### 7.4 Browser support + +Latest 2 versions of Chrome, Edge, Firefox, Safari (desktop + iOS/Android). The mockups are dark-only — no light theme. + +### 7.5 Accessibility + +- Color contrast ≥ WCAG AA on the dark theme. +- Search input is the first focusable element. +- Chart has an accessible text alternative (e.g. "Reversal volume over the last 30 days, peaking at X on YYYY-MM-DD"). +- Table is a real `` with proper ``/`
`. Not a div soup. + +--- + +## 8. Security & privacy + +The repo is open-source by design, the dataset is openly contributed by participating marketplaces, and the Steam IDs in it are already retrievable today via the existing `/users/{steamId}` lookup. So strictly speaking we are not exposing new data. We are exposing the existing data in aggregated and recency-ordered form. + +That said, two things deserve a check: + +1. **Public listing of recently flagged Steam IDs.** Today you have to know the Steam ID to check it. After v1, you can browse the 100 most-recently flagged Steam IDs without knowing any of them up front. Defaulting to **unmasked** to match the design mockups. +2. **Rate limits matter more.** The new endpoints are scrape-friendly. The existing `ratelimit` package handles this; just make sure the limits in §7.3 ship from the start. + +No PII beyond Steam IDs (which are public-by-design Valve identifiers). No cookies, no user accounts, no auth, no tracking pixels added in v1. + +--- + +## 9. Analytics & telemetry + +Today `reverse.watch` has no analytics. We can't measure G1, G2, or G3 without something. Options: + + +| Option | Pro | Con | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| **PostHog** via existing `science.csfloat.io` proxy (`[csfloat/posthog-reverse-proxy](https://github.com/csfloat/posthog-reverse-proxy)`) | Same stack as the rest of CSFloat. Free as long as we stay in the budget. | Couples a public open-source repo to an internal PostHog project. Ad blockers — though the proxy partly handles this. | +| **Plausible / Umami** self-hosted | Lightweight, no third-party calls. | New infra to operate. | +| **Server-side counters in the API binary** | Zero new infra. Already have logging. | Doesn't capture pageviews, doesn't capture extension users. | + + +**Recommendation:** PostHog via the existing proxy, mirroring `phoenix`. Light event set: + + +| Event | Where | Why | +| --------------------- | ------------------------------ | ------------------------------------ | +| `dashboard_viewed` | static/index.html on load | DAU on `/` | +| `lookup_submitted` | search form submit | G3 — lookup is still primary | +| `lookup_result_shown` | API response render | Clear vs flagged ratio | +| `extension_cta_click` | "Get the extension" link click | Top-of-funnel for extension installs | + + +Confirm with Zach in §14 before wiring. + +--- + +## 10. Roadmap + +### v1.1 (post-launch hardening, ~2–4 weeks out) + +- "Load More" on the recent-reversals table — exposes the existing cursor pagination from `dto.ReversalListOptions` to the public endpoint. +- Info chip on graph: let the user see if Valve released something important on a specific date. (Morten should be able to simply add info via an .md file in this project) +- Live counter for the 3 main KPIs +- Per-marketplace breakdown toggle on the daily chart. +- Per-source breakdown (`direct` / `related_user` / `user_report`) — useful signal for understanding reporting quality. +- Public `GET /api/v1/marketplaces` so the slug → name/icon mapping stops living in the frontend. +- Steam ID display: link to Steam profile (`https://steamcommunity.com/profiles/{steamId}`) on the table rows. + +### v1.2 (signal & trust) + +- Daily ETag / Last-Modified on stats endpoints so CDN caching works. +- Sparkline embeds (e.g. ``) so partner sites can drop a tiny chart into their own pages — light viral surface. +- Reverse-watch RSS / Atom feed of recent reversals. + +### v2 (stretch) + +- Authenticated views for marketplace operators (e.g. "your reversals over time"). Requires reusing the existing API-key auth as a session model — non-trivial. +- A Discord webhook channel for high-volume days ("reversal volume up X% over 7-day average"). + +--- + +## 11. Acceptance criteria (launch) + +- `https://reverse.watch/` renders the new layout: hero + search + KPI cards + chart + recent table + footer. +- Searching a Steam ID still works end-to-end and produces either the *Clear* or *Flagged* result chip below the search input. +- All three new endpoints respond <500ms p95 and return the contract in §6. +- All four public endpoints are IP-rate-limited. +- The chart shows the last 30 UTC days, with zero-count days included. +- The recent-reversals table shows the latest 100 non-expunged reversals ordered by `created_at DESC`. +- Lighthouse Performance + Accessibility ≥ 90 on the homepage (desktop + mobile). +- README in the repo is updated with how to run the seed scripts locally. +- PostHog is firing `dashboard_viewed` and `lookup_submitted`. + +--- + +## 12. Test plan + +### Unit / repo + +Mirror existing patterns in `[repository/public/reversal_test.go](https://github.com/csfloat/reverse-watch/blob/master/repository/public/reversal_test.go)` using `pgtestdb` from `[internal/testutil/db.go](https://github.com/csfloat/reverse-watch/blob/master/internal/testutil/db.go)`: + +- `DailyCounts(days)` — fixture: rows across 100 days with some expunged, some on the boundary, one in the future. Assert: only `[NOW()-days, NOW()]`, only non-expunged, zero-count days included. +- `SummaryStats()` — fixture covering each KPI's edge cases. Assert: `traders_indexed` includes expunged, the other two don't. +- `ListRecent(limit)` — fixture with mixed `created_at`. Assert: ordered desc, expunged excluded, capped at limit. + +### API + +Mirror existing patterns in `[api/v1/reversals/reversals_test.go](https://github.com/csfloat/reverse-watch/blob/master/api/v1/reversals/reversals_test.go)`: + +- 200s with the documented JSON shape. +- 400 on out-of-range `days` and `limit`. +- 429 once rate limit is exceeded. + +### Browser smoke (local) + +- Run with the synthetic seed (1k rows). Visually compare against the three Razvan mockups. +- Run with sheet-seeded real data once available. Confirm the dashboard handles ~100 rows gracefully (chart with sparse days, table not over-padded). + +--- + +## 13. Handoff / Linear + +- Linear parent issue: **CSF-1518**, link here: [https://linear.app/csfloat/issue/CSF-1518/improve-reversewatch](https://linear.app/csfloat/issue/CSF-1518/improve-reversewatch) +- Owner: Morten Byskov. +- Reviewer / approver: Zach and Stepan (since `reverse-watch` is a public CSFloat repo and this changes its public surface). +- Engineering pairing: TBD — extension owner is Stepan, repo author also Stepan / Ceegan. Coordinate on PR review path before merging. +- Sub-issues to open under the parent: + 1. PR #1 — daily-buckets endpoint + `SummaryStats` + recent-reversals endpoint + repo methods + tests. + 2. PR #2 — static/index.html rewrite to consume them. + 3. PR #3 — analytics wiring (PostHog or chosen alternative). + 4. (Tracking) Local seed scripts + README update. + +--- + +## 14. Open items — confirm before merging PR #1 + +These are the items where I'm flagging assumptions rather than asserting facts. Confirm or correct before we lock the PRD. + +1. **KPI definitions** (§6.2). Especially: does "Traders Flagged (24h)" mean 24h by `created_at` (newly reported) or 24h by `reversed_at` (recently reversed)? *(D-open-1)* + +2. **Steam display name source** (§6.4). The "Trader" column in `Recently Reported Reversals` is meant to show the Steam display name, not the marketplace. The `reversals` table doesn't carry display names today. Options: + - **Steam Web API** `ISteamUser/GetPlayerSummaries` — rate-limited (~100k calls/day per key), returns persona names + avatars in batches of 100. Would need a cache layer; cold-render performance is the risk. + - **Local `steam_users` table** populated by a background fetcher and joined at read time. Cleanest UX, biggest scope creep — violates the "no schema changes" rule. + - **No display name in v1** — leave the column showing Steam IDs again, defer to v1.1. Cheapest, weakest UX. + - Local dashboard currently renders a deterministic fake name on the client as a stopgap so the column reads right against synthetic data. + + Needs Zach to weigh in. *(D-open-4)* +3. **Public exposure of recent-reversals table** (§8). Default is unmasked Steam IDs to match the design. That is okay for now. +4. **Analytics choice** (§9). We will use PostHog via `science.csfloat.io`. +5. **Marketplace registry** (§6.7). Hardcoding slug→{name, icon} in the frontend is fine for v1. Confirm we don't already maintain this mapping somewhere reusable inside CSFloat (e.g. an internal config in `nezha`). **Unverified by me — Knowledge folder doesn't cover this.** +6. ~~**Google Sheet access**~~ — **resolved 2026-05-22.** Read 100 real rows from sheet `1ccGoHiqXTpjy_jtHSOW3QmrNFP2jvfOsqyWFpNBz-UA` (Studio export, 2026-05-15 → 2026-05-18). Schema matches `models.Reversal` 1:1. Dataset is single-marketplace (`csfloat`) and single-source (`direct`) only — fine as a fixture, not representative for breakdown features (already deferred to v1.1). Note: 2 of 100 `steam_id` cells were truncated to scientific notation (`7.65612E+16`) by Google Sheets formatting — the ingest path must use `UNFORMATTED_VALUE` (Sheets API) or a text-formatted column on CSV export. +7. **Razvan design fidelity**. Dark theme confirmed (no light variant). Will not have lightmode for this. One mobile mockup received (`[design/04-mobile-clear.png](design/04-mobile-clear.png)`) — covers the clear-state result chip only. **Mobile default and mobile flagged states are pending** before we touch PR #2 (frontend rewrite). Filed back to Razvan. + +--- + +*End of PRD. See `[HANDOFF.md](HANDOFF.md)` for the engineering build plan.* \ No newline at end of file diff --git a/docs/dashboard/README.md b/docs/dashboard/README.md new file mode 100644 index 00000000..a1dd41c3 --- /dev/null +++ b/docs/dashboard/README.md @@ -0,0 +1,34 @@ +# Reverse Watch + +Public dashboard for `reverse.watch` — the open trade-reversal database for Steam. Adds aggregate stats (KPI cards, daily reversal graph, recent reversals table) on top of the existing single-Steam-ID lookup, plus a full UI rewrite per Razvan's mockups. + +## OKR link + +None. This is extracurricular product work — it does not map to any Q2 2026 OKR. See [`GOALS.md`](../../GOALS.md) for the formal Q2 list. Flagging because the workspace convention assumes everything in `Projects/` ties to an OKR; this is the exception. + +## Status + +- **2026-05-22** — PRD and engineering handoff drafted in this folder. Repo not yet cloned, branch not yet cut. +- Existing service (`csfloat/reverse-watch`) is live at https://reverse.watch and exposes a single-user lookup at `/api/v1/users/{steamId}`. +- Razvan mockups in [`design/`](design): desktop default / clear / flagged + mobile clear. Mobile default + flagged still pending. Open items in [`HANDOFF.md`](HANDOFF.md) §10. + +## Repo + +[`csfloat/reverse-watch`](https://github.com/csfloat/reverse-watch). Public. Go 1.24+ chi/GORM/Postgres. Master is protected — branch off `master` and PR back. + +## Folder map + +- [`PRD.md`](PRD.md) — v1 product spec for the dashboard rewrite. +- [`HANDOFF.md`](HANDOFF.md) — engineering handoff: starter prompt, local setup, PR breakdown, open items. Travels with `PRD.md` into the cloned-repo Cursor workspace. +- `decisions/` — ADR-style entries as decisions get locked. Currently empty. + +## Principles + +- Keep it stupid simple. The repo today is one Go binary serving one self-contained `static/index.html` from chi. Don't introduce a frontend build step in v1. +- Do not change existing endpoints, models, or schemas. Only add. +- Public endpoints follow the existing precedent: IP-rate-limited, no auth, conservative response shapes (no full row dumps). +- Every CSFloat-internal claim in this folder must be either verified or flagged with "**TBD / unverified**" so we don't ship the PRD with speculation in it. + +## Linear + +TBD — Morten to file a parent issue and link it from `PRD.md` §13 + `HANDOFF.md` §11. CSFloat team prefix is `CSF-` per [Knowledge/Linear/](../../Knowledge/Linear). diff --git a/docs/dashboard/SESSION-LOG.md b/docs/dashboard/SESSION-LOG.md new file mode 100644 index 00000000..69a48756 --- /dev/null +++ b/docs/dashboard/SESSION-LOG.md @@ -0,0 +1,299 @@ +# Reverse Watch — Session Log + +Rolling log of work sessions on the public dashboard build. Newest at top. Each entry is self-contained — read the top entry and you should know where to start. + +--- + +## 2026-05-27 (Wed, afternoon) — Session #6 + +**Branch:** `feature/public-dashboard-v1` (commits `d44dbd4`, `71324f7`). +**Theme:** Design pass against Razvan's mocks. + +- Result chip redesign (commit `d44dbd4`): avatar + fake display name + Steam/CSFloat icon links; mobile chip stacks the verdict pill below the user info; subtitle reads "Last reversal {date}" for flagged, "Added on {date}" for clear. Steam display names are still dev placeholders (PRD §14 D-open-4 — Zach to confirm source). +- Background pattern attempt #2 — inlined Razvan's `docs/dashboard/design/Pattern.svg` with per-line `` classes and CSS animation. Several rounds of size/position/mask tweaks couldn't match the design (over-large, `mix-blend-mode: overlay` darkened the dark backdrop into a black blob, edge cropping). Screen started flickering on one iteration. Full revert via `git restore`. Source SVG kept untracked at `docs/dashboard/design/Pattern.svg` for the next attempt — next try should treat it as a static `` / `background-image`, not animated inline. +- Design polish (commit `71324f7`): single thin-bordered card now wraps search input + helper text + result chip; chip lost its own card chrome (subtle nested fill at 14px radius instead); flagged/clear no longer have a colored top divider; KPI cards centered; "Powered by" logo properly center-aligned with text in hero and footer (line-height + flex); footer disclaimer added — "Reverse.Watch 2026. Not affiliated with Valve Corp."; copy tweaks — "Recent Reversals", trimmed chart and table subtitles, "the CSFloat extension". +- Pending list: PR #3 (PostHog), Lighthouse audit, background pattern revisit (static image approach), Discord pings to Zach + Razvan. + +--- + +## 2026-05-27 (Wed, morning) — Session #5 + +**Branch:** `feature/public-dashboard-v1`. +**Theme:** Pre-review cleanup pass. + +- Backend: dropped GORM tags from `domain/dto/stats.go`, collapsed the local `bucket` struct in `DailyCounts`, switched `allowedDays` to a slice + `slices.Contains`, collapsed duplicate `100` limit, trimmed verbose narration comments across `internal/devseed/*` and `server/server.go`. Full test suite green. +- Frontend (`static/index.html`): three real correctness fixes — `chartInstance.destroy()` before re-render (was leaking uPlot instances), `mouseleave` listener attached once at boot (was duplicating on every period change), `formatDate` now uses `timeZone: 'UTC'` (table dates now match chart). Dead-code sweep: orphan CSS custom properties, unused class rules, dead element IDs, unused JS variables. Comment hygiene: removed ~15 narration comments / section banners; kept the ~6 high-value "why" comments. 1772 → 1705 lines. +- Docs: appended a "Project Summary (for a reviewer's first pass)" section to `docs/dashboard/ENVIRONMENT.md` — what the branch adds, file map, mermaid request-flow, design decisions, open items, run commands. +- Pending list unchanged: PR #3 (PostHog), Lighthouse audit, Discord pings to Zach + Razvan. + +--- + +## 2026-05-26 (Tue, late evening) — Session #4 + +**Branch:** `feature/public-dashboard-v1` (background tweak uncommitted). +**Theme:** Background atmosphere. + +- Tried adding animated SVG background lines/arcs to match Razvan's mock. Didn't work — reverted via `git restore`. +- Checked prod: the "lines" are just a radial gradient artifact, not real geometry. Adopted the prod approach with a tightened `circle 600px at 50% 80px` so the glow stays around the hero and scrolls away with the page. +- Pending list unchanged: PR #3 (PostHog), Lighthouse audit, Discord pings to Zach + Razvan. + +--- + +## 2026-05-26 (Tue, evening) — Session #3 + +**Duration:** Single sitting. +**On branch:** `feature/public-dashboard-v1` — now **11 commits ahead** of `master`. +**Tests:** `go test ./api/v1/stats/...` green; rest of suite unchanged from Session #2. +**Theme:** UI polish + denser data for the new chart range. + +### What got done + +**Synthetic seed (commit `9ef639b`)** + +- New `internal/devseed/synthetic.go`. `GenerateSynthetic(now)` is deterministic (fixed RNG seed = 42), produces ~9,800 reversals over the last 180 UTC days, at least 1 per day. Daily counts follow a gentle sinusoid around ~50/day with ~5% spike days (2.5–5×) and ~10% quiet days (0.2–0.5×). Marketplace mix 80% csfloat / 10% tradeit / 5% skinport / 5% swap.gg. Sources: 90% direct, 5% related_user (with valid related_steam_id), 5% user_report. ~1.5% rows expunged. +- Snowflake IDs are constructed from each row's `created_at` (mirroring `domain/models/snowflake.go` bit layout), so they don't collide with the real CSV seed or with each other. +- `internal/devseed/sheet.go` `InsertReversals` now chunks at 1,000 rows per round trip (Postgres 65,535-parameter limit on a single statement). +- `cmd/seed` gains `-synthetic`. Both modes idempotent via `ON CONFLICT (id) DO NOTHING`; they can coexist (different ID ranges). +- Verified: `go run ./cmd/seed -synthetic` inserts 9,800. KPIs jumped from 100 → 9,900 traders_indexed. + +**Backend period allow-list (commit `6b8d708`)** + +- Extended `allowedDays` in `api/v1/stats/stats.go` from `{30, 60, 90}` to `{7, 30, 60, 90, 180, 365}`. Error string + tests updated. New positive `TestDailyHandler_AcceptedDays` walks every accepted value and asserts the response length equals `days`. +- Restarted dev server (PID 558 was the stale parent; PID 587 was the actual listener — both killed before relaunching). + +**Chart annotation chips (commit pending)** + +- Added editorial event timeline chips that float above the chart line at the date of the event — Pricempire-style. Data source: `static/cs2-events.json` (flat JSON, `{date, title, description?, url?}`). Edit the file and refresh — no rebuild. +- Frontend logic in `static/index.html`: + - `loadCS2Events()` fetches the JSON once at boot with `cache: 'no-store'`. + - `renderEventChips(chart)` filters events to the chart's visible x-range, computes each chip's pixel position via `chart.valToPos(ts, 'x')`, and row-stacks colliding chips (up to 3 rows; oldest overflow dropped with a console warning). + - Re-renders on every chart redraw (period change) and on window resize. +- Hover popover shows full date, title, description, and optional link. +- Documented in PRD §6.3 (chart section). + +**Dashboard UI (commit `cfac9e8`)** + +- Renamed first KPI label "Traders Indexed" → "Steam IDs Searched". JSON contract unchanged (`traders_indexed` stays on the wire); the JS-side comment notes the mapping. +- Added a segmented period picker next to the chart title: `7d / 30d / 3m / 6m / 1y`. Default `30d`. Active state styled with the accent color. Mobile reflow drops `margin-left: auto` so the picker wraps cleanly under the title. +- Removed the static "· Last 30 days" subtitle and rephrased the chart subtitle. +- `loadDaily(days)` now race-safe via a `dailyFetchSeq` counter — rapid picker clicks always settle on the latest selection. +- `renderChart(daily, days)` switches the x-axis label format to month-only when `days > 60`, so 6m and 1y don't overlap. +- PRD updates: §3.1 (label + picker), §3.5 (synthetic seed now in scope — reverses the earlier "no synthetic data" stance), §6.2 (UI-only rename note), §6.3 (new `days` allow-list). + +### Decisions logged + +- **Synthetic data is dev-only.** `cmd/seed` still refuses to run unless `Environment=development`. PRD §3.5 explicitly carves out production from this change. +- **`60d` stays in the allow-list** even though the picker doesn't expose it. Cost is trivial; keeps v1.1 free to add a "2 months" tile without another PR. +- **The KPI rename is UI-only.** Underlying field stays `traders_indexed` because "Steam IDs Searched" is, strictly, a rebrand of the same definition (distinct steam_ids present in the index). A future "actual public search counter" would be a different metric entirely and is a v1.x scope question, not v1. + +### Where we resume — pick from one of these + +| Option | Scope | Why | +|---|---|---| +| **A. PR #3 — PostHog analytics** | Wire `dashboard_viewed`, `lookup_submitted`, `lookup_result_shown`, `extension_cta_click` via `science.csfloat.io` proxy (PRD §9). | PRD §11 launch criterion. ~30–60 min. | +| **B. Lighthouse audit** | Run Lighthouse, fix what falls below 90 on Performance + Accessibility. | PRD §11 launch criterion. Open-ended. | +| **C. Draft Discord pings** | Zach (KPI defs confirmation) + Razvan (mobile mocks). | Unblocks the human-gated items so they progress in parallel. | + +### Open items still outstanding + +Same five items from Session #2, **plus one new**: + +- **D-open-4 (new) — Steam display name source for the Trader column.** The original `static/index.html` was rendering the marketplace slug in the "Trader" column, which was incorrect — Razvan's mockup wants the Steam display name there. Local dashboard now renders a deterministic fake name derived from `steam_id` (djb2 hash → adjective + noun + suffix from ~13.5k combos). Real names need to come from somewhere: Steam's `GetPlayerSummaries` (rate-limited, needs cache layer), a `steam_users` table (cleanest but violates "no schema changes"), or punt to v1.1. PRD §6.4 + §14 D-open-4 + HANDOFF §3 all flagged. Needs Zach. + +Synthetic data unblocks the chart visually but doesn't replace Zach's KPI sign-off, Razvan's mobile mocks, or this new question. + +### Misc state for next session + +- Local dev server is **running in the background** with the new binary (re-spawned during this session, PID at restart was 33315). +- DB now contains 9,800 synthetic rows on top of the 98 real CSV rows and the 2 manual `BulkCreate` rows from Session #1. +- Branch `feature/public-dashboard-v1` is 11 commits ahead of `master`. All local. **Do not push** until Morten explicitly says v1 is ready for Zach. + +### Kick-off prompt for next session + +> Continuing v1 of the Reverse Watch dashboard. Read `docs/dashboard/SESSION-LOG.md` top entry — that's where we stopped. Branch `feature/public-dashboard-v1` is 11 commits ahead of master, all local. KPI rename, period picker, and synthetic seed all in. Next codable items are PR #3 (PostHog analytics) and the Lighthouse pass — both PRD §11 launch criteria. Recommend starting with PR #3. + +--- + +## 2026-05-24 → 2026-05-26 (Sun–Tue) — Session #2 + +**Duration:** Three sittings across three days. +**On branch:** `feature/public-dashboard-v1` — now **7 commits ahead** of `master`. +**Tests:** `go test ./...` green (full suite + new repo/handler tests). +**Workflow shift:** Per the updated `ABOUT-MORTEN.md`, Cursor now writes the code; Morten reviews. Local commits at each meaningful milestone, no push until v1 is fully complete (one PR to Zach, not piecemeal). + +### What got done + +**PR #1 — backend (commit `66f75c5`)** + +All 8 build-order steps from Session #1 complete: + +1. `domain/dto/stats.go` — `SummaryStats` and `DailyCount` DTOs. +2. Extended `domain/repository/public.go` `ReversalRepository` with `SummaryStats()`, `DailyCounts(days)`, `ListRecent(limit)`. +3. Implemented those three in `repository/public/reversal.go`. `SummaryStats` is a single SQL query with `FILTER (WHERE …)` for all three counts. `DailyCounts` zero-fills in Go after the aggregate. +4. Repo tests in `repository/public/reversal_test.go` covering happy paths, zero-fill, date boundaries, soft-delete exclusion, and `traders_indexed` including expunged. +5. New `api/v1/stats/{router.go,stats.go}` with `/summary` and `/reversals/daily` handlers and a 60s in-process `sync.Map`-based cache (per-`days` key for daily; single key for summary). +6. Restructured `api/v1/reversals/router.go` — wrapped existing auth-gated routes in `chi.Group`, added public `/recent` outside. +7. Mounted `/stats` in `api/v1/v1.go`. +8. Handler tests for all three endpoints (happy path, cache hit, invalid params, sorting/limit). + +KPI definitions follow PRD §6.2 verbatim. `traders_flagged_24h` is `created_at`-bucketed. + +**Dev seed (commit `311fe96`)** + +- `internal/devseed/fixtures/reversals_seed.csv` — 98 real rows fetched via the Sheets MCP from `1ccGoHiqXTpjy_jtHSOW3QmrNFP2jvfOsqyWFpNBz-UA`. Two rows from the original 100 dropped due to steam_id precision loss in the source sheet (HANDOFF §10). +- `internal/devseed/sheet.go` — CSV → `[]*models.Reversal`, validates header order, uses `INSERT … ON CONFLICT (id) DO NOTHING` for idempotency. +- `cmd/seed/main.go` — CLI (`go run ./cmd/seed`). Refuses to run unless `Environment=development`. +- Verified: first run inserted 98, second run inserted 0. Stats endpoints reflect the seed (KPIs: 100 / 100 / 2 with Morten's two earlier test rows). + +**PR #2 — frontend (commit `56a3fbc`)** + +Full `static/index.html` rewrite per PRD §§6.1–6.7. Single self-contained file, inline CSS + JS, no build step: + +- Hero with CSFloat logo (`static/csfloat-logo.png`), title, lede, restyled search (icon-only arrow button). +- Search-result chip (clear/flagged) below the input. Background tints follow the verdict via `body.clear-result` / `body.flagged` classes. +- Three KPI cards populated from `/stats/summary`. +- 30-day uPlot line chart (CDN, `uplot@1.6.32`) with custom hover tooltip showing day + count, position-clamped to chart bounds. +- Recently Reported Reversals table — paginated **client-side in 10-row chunks** via "Load More" (button auto-hides when all are shown). Server-side pagination still v1.1 per PRD §6.4. +- Footer with What is reverse.watch? / Want to contribute? columns + Powered-by-CSFloat lockup (using the same logo image). +- Hardcoded `MARKETPLACES` slug-map in JS (D9). Currently just `csfloat`; fallback uses the slug verbatim and a generic `storefront` icon. +- Mobile reflow built against the one mobile mockup we have. Razvan still owes default + flagged mobile mocks. + +**Static-file serving workaround (commit `f3fd493`)** + +`http.ServeFile` / `http.FileServer` were truncating every static response at exactly 512 bytes (first TCP segment) on Morten's local macOS. JSON endpoints were unaffected (different write path). Replaced both static handlers in `server/server.go` with small in-memory variants (`os.ReadFile` + `w.Write`). New `GET /static/*` route added — same handler. Path traversal rejected. Static payloads are tiny (HTML + logo + future icons), so the read-once cost is negligible. Documented in a code comment. + +**Doc updates (commits `2f6bb7e`, `cdb2458`, `5c53975`)** + +- `ABOUT-MORTEN.md`: added Git workflow rules (commit-after-bigger-updates, propose-then-commit, never push without explicit ask, never push to master); added Review workflow ("v1 ships as ONE PR to Zach"); added "private code I don't own" caution. +- `README.md`: dashboard intro, explicit DB + `postgres/postgres` superuser setup (the gotcha Morten hit Session #1), `go run ./cmd/seed` instructions, public API endpoint table, links to PRD + HANDOFF. + +### Decisions logged (not yet captured anywhere else) + +- **Chart library: uPlot** (HANDOFF D7 locked). ~40KB, zero deps, fast. Loaded via CDN. +- **"Load More" pagination is purely client-side reveal** — the API still returns 100 rows in one call. v1.1 will add server-side cursor pagination per PRD §6.4. +- **Search-result chip ignores the marketplace icon from Razvan's mockups** — the existing `/api/v1/users/{steamId}` doesn't return marketplace info, and PRD §6.5 says the lookup endpoint is unchanged. Punt to v1.1 if we want to enrich the response. +- **No favicon / OG image work yet.** Current SVG favicon is the existing one (blue eye). + +### Where we resume — pick from one of these + +| Option | Scope | Why | +|---|---|---| +| **A. PR #3 — PostHog analytics** | Wire `dashboard_viewed`, `lookup_submitted`, `lookup_result_shown`, `extension_cta_click` via `science.csfloat.io` proxy (PRD §9). | PRD §11 launch criterion. ~30–60 min. | +| **B. Lighthouse audit** | Run Lighthouse, fix what falls below 90 on Performance + Accessibility. | PRD §11 launch criterion. Open-ended. | +| **C. Draft Discord pings** | Zach (KPI defs confirmation) + Razvan (mobile mocks). | Unblocks the human-gated items so they progress in parallel. | + +A or B is the more productive next step. C is quick prep work. + +### Open items still outstanding + +- **Zach — KPI defs sign-off (D-open-1)**, especially `traders_flagged_24h` (currently `created_at`-based). Discord/Slack ping not sent yet. +- **Razvan — mobile default + flagged mockups**. Mobile reflow is best-effort until they arrive. +- **Marketplace registry** (PRD §14.4) — confirm nezha doesn't already maintain a slug → name/icon map we should reuse instead of hardcoding. +- **Linear parent issue** CSF-1518 — still no sub-issues filed. +- **Razvan's mocks show a marketplace chip in the lookup result** — defer to v1.1 (would require API extension). + +### Misc state for next session + +- Local dev server is **still running in the background** (PID was 558 at last commit, on port 8080). Restart only needed if `server/server.go` is touched again. +- `config.json` unchanged. +- Branch `feature/public-dashboard-v1` is 7 commits ahead of `master` (`66f75c5`, `2f6bb7e`, `311fe96`, `cdb2458`, `f3fd493`, `56a3fbc`, `5c53975`). All local. **Do not push** until Morten explicitly says v1 is ready for Zach. +- All `docs/dashboard/*` files are tracked in git as of commit `66f75c5` (they rode along with the PR #1 backend commit). + +### Todo state at session end + +- [x] All 8 PR #1 backend steps +- [x] Dev seed (sheet ingest) +- [x] PR #2 frontend rewrite (desktop done; mobile reflow done as far as possible) +- [x] Static-file truncation workaround +- [x] README update (PRD §11 criterion) +- [ ] **A — PR #3 analytics (PostHog)** ← strongest candidate for next session +- [ ] B — Lighthouse pass +- [ ] C — Discord pings to Zach + Razvan +- [ ] (Blocked) Razvan delivers missing mobile mockups +- [ ] (Blocked) Zach confirms KPI definitions +- [ ] (Last) Push branch + open ONE PR to Zach + +### Kick-off prompt for next session + +> Continuing v1 of the Reverse Watch dashboard. Read `docs/dashboard/SESSION-LOG.md` top entry — that's where we stopped. Branch `feature/public-dashboard-v1` is 7 commits ahead of master, all local. PR #1 backend + dev seed + PR #2 frontend are done. Next codable items are PR #3 (PostHog analytics) and the Lighthouse pass — both PRD §11 launch criteria. Recommend starting with PR #3. + +--- + +## 2026-05-23 (Sat eve) — Session #1 + +**Duration:** ~15 min of dialogue, no application code written yet (intentional). +**On branch:** `feature/public-dashboard-v1` (already existed at session start). +**Tests:** `go test ./...` green (~13s, 14 packages with tests). + +### What got done + +- Cursor agent read end-to-end: `HANDOFF.md`, `PRD.md`, `README.md`, plus the repo files HANDOFF §9 listed (api/v1/users, api/v1/reversals, repository/public/reversal.go, domain/repository/public.go, server/server.go, main.go, factory, testutil, render, errors, ratelimit, middleware, dto/reversal, models/reversal, models/steamid, the existing reversals_test.go, etc.). +- No `AGENTS.md` and no `.cursor/rules/` in the repo — nothing extra to obey beyond what HANDOFF/PRD say. +- Local Postgres setup verified working: created a `postgres` superuser with password `postgres` (via `ALTER USER postgres WITH SUPERUSER PASSWORD 'postgres';`) so `pgtestdb` in `internal/testutil/db.go` can spin up temporary test DBs. +- `go test ./...` confirmed green end-to-end. Baseline before any edits. + +### Conflicts surfaced vs HANDOFF (none silently overridden — Morten to decide) + +| # | Issue | Recommendation | Status | +|---|---|---|---| +| C1 | `api/v1/reversals/router.go` opens with `r.Use(middleware.AuthMiddleware)`; chi panics if `Use()` is called after routes are registered, so we can't just add a peer public `/recent` route. | Wrap the existing auth-gated routes in `r.Group(func(r chi.Router) { r.Use(AuthMiddleware); … })` and add `/recent` outside the group. Smallest diff. | Pending Morten ack | +| C2 | KPI definitions (HANDOFF D-open-1) still unconfirmed by Stepan. PRD §6.2 proposes `created_at`-bucketed 24h KPI; both flagged counts filter `expunged_at IS NULL`; `traders_indexed` includes expunged. | Implement to PRD §6.2 spec; one-line repo tweak if Stepan corrects. **Fire HANDOFF §10 Discord message to Stepan ASAP.** | Pending Morten outreach | +| C3 | No DB indexes on `reversed_at` or `created_at`. `ListRecent` does full-table sort; `DailyCounts` scans the window. | PRD §1.4 + HANDOFF §12 explicitly defer this. Ship without; benchmark + add `CREATE INDEX CONCURRENTLY` post-launch. | Accepted, defer to v1.1 | +| C4 | `/api/v1/stats/reversals/daily` semantically adjacent to `/api/v1/reversals/*` (different chi mounts, no real collision). | Accept; add a one-line comment in `api/v1/v1.go` when mounting `/stats`. | Accepted | +| C5 | `models.SteamID.MarshalJSON` already encodes as string — PRD §6.4 response shape is satisfied automatically. **Not a conflict, just confirmed.** | n/a | ✅ | + +### Decisions logged in dialogue (not yet codified anywhere) + +- Build order is **bottom-up**: DTOs → interface → repo impl → repo tests → handlers → router → mount → handler tests. (8 steps; see todos below.) +- Daily-bucket zero-fill will be done in Go after the SQL aggregate returns, not via Postgres `generate_series`. Simpler, portable, ~30 days of map lookups is free. +- `dto.DailyCount.Date` typed as `string` in `"2006-01-02"` UTC, not `time.Time`. Avoids custom MarshalJSON. +- Counts typed as `uint64` to match the existing `ReversedAt`/`CreatedAt` convention. +- `[]DailyCount` (values), not `[]*DailyCount`. `SummaryStats` returned by pointer from the repo (`*dto.SummaryStats`) to match the existing `reversalRepository.Read` signature style. +- In-process 60s cache will live in the `api/v1/stats` package (not in `ratelimit/`). Per-`days` key for the daily endpoint; single key for summary. +- `/reversals/recent` will NOT be cached (PRD §7.2). + +### Where we resume — Step 1 of 8 + +**Next task:** Create `domain/dto/stats.go` with two structs: + +1. `SummaryStats` — three `uint64` fields with JSON tags `traders_indexed`, `traders_flagged`, `traders_flagged_24h`. Matches PRD §6.6. +2. `DailyCount` — `Date string` (JSON `date`) + `Count uint64` (JSON `count`). Matches PRD §6.3. + +Acceptance: `go build ./...` clean. No tests yet — pure types. + +Morten writes the file himself. Agent gave a skeleton with comments-as-prompts in the previous message; **drop those comments** in the real file (code shouldn't narrate itself). + +### Open items still outstanding (parallel — don't block Step 1 on these) + +- HANDOFF §10 Discord pings to **Stepan** (`step7750`) — KPI defs, recent-table Steam ID masking, PostHog proxy origin. +- HANDOFF §10 Discord ping to **Razvan** (`razvanbadea`) — missing mobile default + flagged mockups (only blocks PR #2, not PR #1). +- HANDOFF §10 Discord ping to **Ceegan** (`_perplex`) — sanity check on PRD. +- Linear parent issue: not filed yet. + +### Misc state for tomorrow + +- `config.json` is set up: port 8080, user `byskov`, password `devpassword`, DBs `reverse_watch_private` / `reverse_watch_public`. `go run main.go` boots cleanly. +- `docs/dashboard/` files (HANDOFF, PRD, design PNGs, this log) are still untracked in git — undecided whether they ride along on PR #1 or get their own commit. Default: probably their own commit, since PR #1 is backend-only. +- Existing `feature/public-dashboard-v1` branch — was already there before today's session; no commits on it yet. +- Two terminals in play locally: one running `go run main.go` (kept alive), one for shell commands. Don't paste shell commands into the dev-server terminal. + +### Todo state at session end + +- [x] Local test creds + `go test ./...` green +- [x] On feature branch +- [ ] **Step 1** — `domain/dto/stats.go` (IN PROGRESS — Morten to write) +- [ ] Step 2 — extend `domain/repository/public.go` `ReversalRepository` interface (`SummaryStats`, `DailyCounts`, `ListRecent`) +- [ ] Step 3 — implement the three methods in `repository/public/reversal.go` +- [ ] Step 4 — repo tests in `repository/public/reversal_test.go` +- [ ] Step 5 — `api/v1/stats/{router.go,stats.go}` with summary + daily handlers + 60s in-process cache +- [ ] Step 6 — restructure `api/v1/reversals/router.go` (Group existing auth routes) + add public `/recent` handler in `reversals.go` +- [ ] Step 7 — mount `stats` in `api/v1/v1.go` +- [ ] Step 8 — handler tests for all three endpoints + +### Kick-off prompt for next session + +> Continuing PR #1 on Reverse Watch dashboard. Read `docs/dashboard/SESSION_LOG.md` top entry — that's where we stopped. We're on Step 1 of 8: I need to write `domain/dto/stats.go`. Walk me through it again briefly, then I'll write it. + +--- + +*End of log. Add new sessions above this line.* diff --git a/docs/dashboard/START-CHAT.md b/docs/dashboard/START-CHAT.md new file mode 100644 index 00000000..ad9e0c2f --- /dev/null +++ b/docs/dashboard/START-CHAT.md @@ -0,0 +1,16 @@ +# Opening prompt — paste at the start of every Cursor session + +Start of session. Before responding, read these files **in order**: + +1. `docs/dashboard/ABOUT-MORTEN.md` — how to work with me. +2. `docs/dashboard/SESSION-LOG.md` — what we've done and where we left off. Read the top 1–2 entries. +3. `docs/dashboard/PRD.md` — the dashboard spec. Skim if you've read it before; re-read sections relevant to today's task. +4. `docs/dashboard/HANDOFF.md` — deep build context. Same — skim if familiar. +5. `docs/dashboard/ENVIRONMENT.md` — local setup gotchas. Read once per machine; re-check if anything's failing. + +Then respond with: +- **Where we left off** — 2–3 bullets from the top SESSION-LOG entry. +- **What I think we should do today** — 1–2 bullets, concrete next steps. +- **Open questions** — anything from the log I should resolve before we start coding. + +Wait for my confirmation before doing anything. diff --git a/docs/dashboard/decisions/.gitkeep b/docs/dashboard/decisions/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docs/dashboard/design/01-default.png b/docs/dashboard/design/01-default.png new file mode 100644 index 00000000..2c63a4de Binary files /dev/null and b/docs/dashboard/design/01-default.png differ diff --git a/docs/dashboard/design/02-clear.png b/docs/dashboard/design/02-clear.png new file mode 100644 index 00000000..ac574e2c Binary files /dev/null and b/docs/dashboard/design/02-clear.png differ diff --git a/docs/dashboard/design/03-flagged.png b/docs/dashboard/design/03-flagged.png new file mode 100644 index 00000000..f84212e2 Binary files /dev/null and b/docs/dashboard/design/03-flagged.png differ diff --git a/docs/dashboard/design/04-mobile-clear.png b/docs/dashboard/design/04-mobile-clear.png new file mode 100644 index 00000000..6acfdb62 Binary files /dev/null and b/docs/dashboard/design/04-mobile-clear.png differ 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/internal/devseed/fixtures/reversals_seed.csv b/internal/devseed/fixtures/reversals_seed.csv new file mode 100644 index 00000000..cffe92c2 --- /dev/null +++ b/internal/devseed/fixtures/reversals_seed.csv @@ -0,0 +1,99 @@ +id,created_at,updated_at,steam_id,marketplace_slug,source,related_steam_id,reversed_at,reporter_internal_id,expunged_at +181201483646107648,1778891400262,1778891400262,76561199066658488,csfloat,0,,1778890536618,2807039, +181209033179398144,1778893200211,1778893200211,76561199538933064,csfloat,0,,1778891340246,2807462, +181209033191981056,1778893200214,1778893200214,76561199572563573,csfloat,0,,1778892223452,2807535, +181239232453410816,1778900400279,1778900400279,76561199492751384,csfloat,0,,1778899094924,2807824, +181254331134115840,1778904000085,1778904000085,76561198853377138,csfloat,0,,1778902640011,2807922, +181269431408656384,1778907600271,1778907600271,76561198336222764,csfloat,0,,1778906175244,2808011, +181292080088219648,1778913000137,1778913000137,76561198447831810,csfloat,0,,1778911179655,2808146, +181292080104996864,1778913000141,1778913000141,76561199730689409,csfloat,0,,1778911827756,2808159, +181307179507122176,1778916600119,1778916600119,76561198735591428,csfloat,0,,1778914932489,2808239, +181314730005102592,1778918400298,1778918400298,76561198275002860,csfloat,0,,1778916313539,2808458, +181314730013491200,1778918400300,1778918400300,76561199560264434,csfloat,0,,1778917193843,2808584, +181314730026074112,1778918400303,1778918400303,76561199805079232,csfloat,0,,1778917418852,2808616, +181329829054906368,1778922000192,1778922000192,76561198278651109,csfloat,0,,1778920548524,2808757, +181329829067489280,1778922000195,1778922000195,76561199136195036,csfloat,0,,1778921597978,2808800, +181337379305422848,1778923800312,1778923800312,76561199833157029,csfloat,0,,1778921842952,2808823, +181337379318005760,1778923800315,1778923800315,76561199186761705,csfloat,0,,1778922710002,2808843, +181344928121487360,1778925600090,1778925600090,76561197962251548,csfloat,0,,1778924250590,2808888, +181344928134070272,1778925600093,1778925600093,76561199809279263,csfloat,0,,1778924682613,2808911, +181375127584243712,1778932800203,1778932800203,76561199163372897,csfloat,0,,1778931478399,2809178, +181390227229638656,1778936400239,1778936400239,76561198167029988,csfloat,0,,1778934568162,2809323, +181390227246415872,1778936400243,1778936400243,76561199150718940,csfloat,0,,1778934744118,2809328, +181397777362714624,1778938200331,1778938200331,76561198104296762,csfloat,0,,1778937179970,2809464, +181405326069727232,1778940000083,1778940000083,76561199044902883,csfloat,0,,1778939059926,2809950, +181427975294550016,1778945400079,1778945400079,76561198747683448,csfloat,0,,1778944182367,2810815, +181435525305991168,1778947200142,1778947200142,76561198156159686,csfloat,0,,1778946229577,2810983, +181465724114436096,1778954400099,1778954400099,76561199438256397,csfloat,0,,1778952904312,2811587, +181465724131213312,1778954400103,1778954400103,76561198024709724,csfloat,0,,1778953643281,2811639, +181480824334450688,1778958000272,1778958000272,76561198808118390,csfloat,0,,1778957044363,2811819, +181488373280538624,1778959800081,1778959800081,76561198970357434,csfloat,0,,1778958576351,2811908, +181495923552026624,1778961600206,1778961600206,76561198310761821,csfloat,0,,1778959741306,2811984, +181495923564609536,1778961600209,1778961600209,76561199240559020,csfloat,0,,1778960995840,2812049, +181511023344222208,1778965200277,1778965200277,76561198877834656,csfloat,0,,1778963959695,2812221, +181541221871648768,1778972400167,1778972400167,76561198409586924,csfloat,0,,1778971975545,2812798, +181563871520096256,1778977800264,1778977800264,76561198169018924,csfloat,0,,1778975851478,2812939, +181571420550070272,1778979600093,1778979600093,76561198106470395,csfloat,0,,1778977674539,2813018, +181571420755591168,1778979600142,1778979600142,76561199133364980,csfloat,0,,1778978731156,2813077, +181594070072688640,1778985000160,1778985000160,76561198135469839,csfloat,0,,1778984057836,2813216, +181616719213625344,1778990400136,1778990400136,76561199214162938,csfloat,0,,1778989208596,2813359, +181631819010015232,1778994000208,1778994000208,76561198235394816,csfloat,0,,1778992009450,2813438, +181646918638632960,1778997600240,1778997600240,76561198079150640,csfloat,0,,1778996288292,2813594, +181677117044424704,1779004800101,1779004800101,76561198313695074,csfloat,0,,1779003248391,2814021, +181677117057007616,1779004800104,1779004800104,76561199541222314,csfloat,0,,1779003871332,2814038, +181684666669989888,1779006600072,1779006600072,76561199031037443,csfloat,0,,1779006088007,2814174, +181707316825948160,1779012000290,1779012000290,76561198857393271,csfloat,0,,1779010162251,2814472, +181707316838531072,1779012000293,1779012000293,76561198373862596,csfloat,0,,1779010839213,2814488, +181714866355044352,1779013800238,1779013800238,76561198317332585,csfloat,0,,1779011870429,2814538, +181714866367627264,1779013800241,1779013800241,76561199655452993,csfloat,0,,1779011897641,2814539, +181714866380210176,1779013800244,1779013800244,76561198132473880,csfloat,0,,1779013174029,2814591, +181729965673283584,1779017400196,1779017400196,76561198015644517,csfloat,0,,1779015383853,2814711, +181737516049629184,1779019200346,1779019200346,76561198123400765,csfloat,0,,1779017400950,2814857, +181737516066406400,1779019200350,1779019200350,76561198103210528,csfloat,0,,1779017405209,2814858, +181737516078989312,1779019200353,1779019200353,76561199050483318,csfloat,0,,1779018301216,2815455, +181745064957968384,1779021000146,1779021000146,76561199136487876,csfloat,0,,1779019127239,2815491, +181745064974745600,1779021000150,1779021000150,76561198725431809,csfloat,0,,1779019314897,2815498, +181775263640584192,1779028200073,1779028200073,76561198256849370,csfloat,0,,1779027816949,2815940, +181805463279501312,1779035400228,1779035400228,76561198297373330,csfloat,0,,1779034198210,2816359, +181805463292084224,1779035400231,1779035400231,76561198772754728,csfloat,0,,1779034632235,2816385, +181828112525295616,1779040800229,1779040800229,76561198135983590,csfloat,0,,1779038992635,2816582, +181828112537878528,1779040800232,1779040800232,76561198302202043,csfloat,0,,1779039001716,2816583, +181835661861453824,1779042600131,1779042600131,76561199011578994,csfloat,0,,1779041398027,2816825, +181843211382161408,1779044400077,1779044400077,76561199556772347,csfloat,0,,1779043373534,2816966, +181858311002390528,1779048000107,1779048000107,76561199777437271,csfloat,0,,1779046738552,2817171, +181873410782003200,1779051600175,1779051600175,76561198359033616,csfloat,0,,1779051007437,2817473, +181888510305763328,1779055200182,1779055200182,76561199247938745,csfloat,0,,1779053826270,2817659, +181888510318346240,1779055200185,1779055200185,76561198884715810,csfloat,0,,1779054478439,2817688, +181948908199477248,1779069600162,1779069600162,76561198074554760,csfloat,0,,1779067844365,2818534, +181956457590161408,1779071400077,1779071400077,76561198869656271,csfloat,0,,1779069766464,2818592, +181971558229606400,1779075000350,1779075000350,76561199099596909,csfloat,0,,1779073762967,2818735, +182047054548172800,1779093000075,1779093000075,76561198272230275,csfloat,0,,1779091861051,2819431, +182062154642358272,1779096600218,1779096600218,76561199554650678,csfloat,0,,1779095029682,2819553, +182062154654941184,1779096600221,1779096600221,76561199098612305,csfloat,0,,1779095215894,2819557, +182069703777189888,1779098400072,1779098400072,76561199052559843,csfloat,0,,1779097013669,2819627, +182077253918654464,1779100200166,1779100200166,76561198809567839,csfloat,0,,1779098150417,2819693, +182077253931237376,1779100200169,1779100200169,76561198381882496,csfloat,0,,1779099539753,2820039, +182092353530494976,1779103800194,1779103800194,76561199118451022,csfloat,0,,1779102783714,2820541, +182099903546130432,1779105600258,1779105600258,76561198030067953,csfloat,0,,1779104292691,2820623, +182099903554519040,1779105600260,1779105600260,76561199209850145,csfloat,0,,1779105193738,2820674, +182099903562907648,1779105600262,1779105600262,76561199140493814,csfloat,0,,1779105269854,2820675, +182107453268164608,1779107400252,1779107400252,76561199811913943,csfloat,0,,1779106073556,2820726, +182122551999201280,1779111000070,1779111000070,76561199027164448,csfloat,0,,1779109149488,2820990, +182130102463627264,1779112800241,1779112800241,76561198381232658,csfloat,0,,1779111221067,2821215, +182137651615236096,1779114600099,1779114600099,76561199802969617,csfloat,0,,1779113360974,2821293, +182145201811226624,1779116400206,1779116400206,76561198302098268,csfloat,0,,1779115356902,2821351, +182145201823809536,1779116400209,1779116400209,76561199840955826,csfloat,0,,1779115992038,2821378, +182182950027132928,1779125400082,1779125400082,76561199517775467,csfloat,0,,1779124624737,2822431, +182190500441227264,1779127200241,1779127200241,76561198405933476,csfloat,0,,1779125130416,2822450, +182190500453810176,1779127200244,1779127200244,76561198887456869,csfloat,0,,1779125721195,2822477, +182190500462198784,1779127200246,1779127200246,76561198811206216,csfloat,0,,1779125726647,2822478, +182190500470587392,1779127200248,1779127200248,76561199415466192,csfloat,0,,1779125734643,2822479, +182220698758938624,1779134400081,1779134400081,76561198865435107,csfloat,0,,1779133736250,2823351, +182220700356968448,1779134400462,1779134400462,76561198864339923,csfloat,0,,1779134068597,2823359, +182228249403719680,1779136200295,1779136200295,76561198348396112,csfloat,0,,1779135743326,2823443, +182243348944257024,1779139800306,1779139800306,76561199009407254,csfloat,0,,1779138180480,2823579, +182243348952645632,1779139800308,1779139800308,76561198074307302,csfloat,0,,1779139228057,2823641, +182250898074894336,1779141600159,1779141600159,76561198749590058,csfloat,0,,1779140611508,2823744, +182258448849698816,1779143400404,1779143400404,76561199148031962,csfloat,0,,1779142029848,2823809, +182265998039056384,1779145200271,1779145200271,76561198079244870,csfloat,0,,1779143832024,2824177, +182265998051639296,1779145200274,1779145200274,76561199543707661,csfloat,0,,1779143839021,2824178, diff --git a/internal/devseed/sheet.go b/internal/devseed/sheet.go new file mode 100644 index 00000000..ebed5d75 --- /dev/null +++ b/internal/devseed/sheet.go @@ -0,0 +1,194 @@ +// Package devseed loads dev-only fixture data into the local Postgres +// instance. It is intentionally not wired into the main binary — call it +// from cmd/seed (or a test) when you need realistic data locally. +package devseed + +import ( + "encoding/csv" + "errors" + "fmt" + "io" + "os" + "strconv" + + "reverse-watch/domain/models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// expectedHeader is the exact column ordering the CSV must use. We match +// on order, not name, but we verify the names so a re-export with +// shuffled columns fails loudly instead of silently mis-mapping fields. +var expectedHeader = []string{ + "id", + "created_at", + "updated_at", + "steam_id", + "marketplace_slug", + "source", + "related_steam_id", + "reversed_at", + "reporter_internal_id", + "expunged_at", +} + +// LoadFromCSV reads the dev-seed CSV at path and returns reversals ready +// to insert. The returned models have their IDs and timestamps populated +// from the CSV — the model hooks will not override them. +func LoadFromCSV(path string) ([]*models.Reversal, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open csv: %w", err) + } + defer f.Close() + + reader := csv.NewReader(f) + // Tolerate trailing empty fields (e.g. when expunged_at is blank). + reader.FieldsPerRecord = -1 + + header, err := reader.Read() + if err != nil { + return nil, fmt.Errorf("read header: %w", err) + } + if err := validateHeader(header); err != nil { + return nil, err + } + + var out []*models.Reversal + line := 1 + for { + line++ + rec, err := reader.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("read line %d: %w", line, err) + } + r, err := parseRow(rec, line) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, nil +} + +func validateHeader(got []string) error { + if len(got) != len(expectedHeader) { + return fmt.Errorf("header: got %d columns, want %d", len(got), len(expectedHeader)) + } + for i, name := range expectedHeader { + if got[i] != name { + return fmt.Errorf("header column %d: got %q, want %q", i, got[i], name) + } + } + return nil +} + +func parseRow(rec []string, line int) (*models.Reversal, error) { + // Pad short records (trailing empty columns get trimmed by csv.Reader). + for len(rec) < len(expectedHeader) { + rec = append(rec, "") + } + + id, err := strconv.ParseUint(rec[0], 10, 64) + if err != nil { + return nil, fmt.Errorf("line %d: id: %w", line, err) + } + createdAt, err := strconv.ParseUint(rec[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("line %d: created_at: %w", line, err) + } + updatedAt, err := strconv.ParseUint(rec[2], 10, 64) + if err != nil { + return nil, fmt.Errorf("line %d: updated_at: %w", line, err) + } + steamID, err := models.ToSteamID(rec[3]) + if err != nil { + return nil, fmt.Errorf("line %d: steam_id: %w", line, err) + } + marketplace := rec[4] + if marketplace == "" { + return nil, fmt.Errorf("line %d: marketplace_slug is required", line) + } + srcRaw, err := strconv.ParseUint(rec[5], 10, 64) + if err != nil { + return nil, fmt.Errorf("line %d: source: %w", line, err) + } + src := models.Source(srcRaw) + + var related *models.SteamID + if rec[6] != "" { + related, err = models.ToSteamID(rec[6]) + if err != nil { + return nil, fmt.Errorf("line %d: related_steam_id: %w", line, err) + } + } + + reversedAt, err := strconv.ParseUint(rec[7], 10, 64) + if err != nil { + return nil, fmt.Errorf("line %d: reversed_at: %w", line, err) + } + + var reporterInternalID *uint + if rec[8] != "" { + rip, err := strconv.ParseUint(rec[8], 10, 64) + if err != nil { + return nil, fmt.Errorf("line %d: reporter_internal_id: %w", line, err) + } + v := uint(rip) + reporterInternalID = &v + } + + var expungedAt *uint64 + if rec[9] != "" { + ea, err := strconv.ParseUint(rec[9], 10, 64) + if err != nil { + return nil, fmt.Errorf("line %d: expunged_at: %w", line, err) + } + expungedAt = &ea + } + + return &models.Reversal{ + Model: models.Model{ + ID: models.Snowflake(id), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + SteamID: *steamID, + MarketplaceSlug: marketplace, + Source: &src, + RelatedSteamID: related, + ReversedAt: reversedAt, + ReporterInternalID: reporterInternalID, + ExpungedAt: expungedAt, + }, nil +} + +// insertChunkSize keeps each bulk insert under Postgres's 65,535 +// parameter-per-statement cap. At ~11 columns per Reversal, 1,000 rows +// uses ~11k parameters. +const insertChunkSize = 1000 + +// InsertReversals bulk-inserts reversals with ON CONFLICT (id) DO NOTHING, +// so the seed is idempotent. Returns the number of rows actually inserted. +func InsertReversals(db *gorm.DB, reversals []*models.Reversal) (int64, error) { + if len(reversals) == 0 { + return 0, nil + } + var inserted int64 + for i := 0; i < len(reversals); i += insertChunkSize { + end := i + insertChunkSize + if end > len(reversals) { + end = len(reversals) + } + res := db.Clauses(clause.OnConflict{DoNothing: true}).Create(reversals[i:end]) + if res.Error != nil { + return inserted, res.Error + } + inserted += res.RowsAffected + } + return inserted, nil +} diff --git a/internal/devseed/synthetic.go b/internal/devseed/synthetic.go new file mode 100644 index 00000000..dc1db3f9 --- /dev/null +++ b/internal/devseed/synthetic.go @@ -0,0 +1,147 @@ +package devseed + +import ( + "math" + "math/rand" + "time" + + "reverse-watch/domain/models" +) + +const ( + syntheticRNGSeed int64 = 42 + syntheticDays = 180 + syntheticTargetTotal = 9800 + syntheticBaseSteamID uint64 = 76561198000000000 + syntheticBaseReporter uint = 2_900_000 +) + +var syntheticMarketplaces = []struct { + slug string + weight float64 +}{ + {"csfloat", 0.80}, + {"tradeit", 0.10}, + {"skinport", 0.05}, + {"swap.gg", 0.05}, +} + +// GenerateSynthetic returns a deterministic ~6-month dataset (~9,800 rows, +// at least one per day, gentle sinusoid with occasional spikes / quiet +// days). Snowflake IDs are unique within the slice and won't collide with +// real CSV-seeded IDs, so callers can pipe the result straight into +// InsertReversals. +func GenerateSynthetic(now time.Time) []*models.Reversal { + rng := rand.New(rand.NewSource(syntheticRNGSeed)) + nowMs := uint64(now.UnixMilli()) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + counts := make([]int, syntheticDays) + for d := 0; d < syntheticDays; d++ { + base := 40.0 + 10.0*math.Sin(float64(d)/30.0) + var mult float64 + switch r := rng.Float64(); { + case r < 0.05: + mult = 2.5 + rng.Float64()*2.5 + case r < 0.15: + mult = 0.2 + rng.Float64()*0.3 + default: + mult = 0.7 + rng.Float64()*0.6 + } + counts[d] = int(math.Max(1, base*mult+rng.NormFloat64()*5)) + } + + total := 0 + for _, c := range counts { + total += c + } + if total > 0 { + scale := float64(syntheticTargetTotal) / float64(total) + for d := range counts { + counts[d] = int(math.Max(1, math.Round(float64(counts[d])*scale))) + } + } + + rows := make([]*models.Reversal, 0, syntheticTargetTotal+200) + var steamOffset uint64 = 1 + var seq uint16 + + for d := 0; d < syntheticDays; d++ { + dayStart := today.AddDate(0, 0, -(syntheticDays-1-d)) + for i := 0; i < counts[d]; i++ { + reversedAt := dayStart.Add(time.Duration(rng.Float64() * float64(24*time.Hour))) + if uint64(reversedAt.UnixMilli()) > nowMs { + reversedAt = now.Add(-1 * time.Minute) + } + reportDelay := time.Duration(rng.Intn(8*60)+1) * time.Minute + createdAt := reversedAt.Add(reportDelay) + if uint64(createdAt.UnixMilli()) > nowMs { + createdAt = now + } + + srcRoll := rng.Float64() + var src models.Source + var related *models.SteamID + switch { + case srcRoll < 0.90: + src = models.SourceDirect + case srcRoll < 0.95: + src = models.SourceRelatedUser + relID := models.SteamID(syntheticBaseSteamID + (steamOffset+10_000)*97) + related = &relID + default: + src = models.SourceUserReport + } + + var expunged *uint64 + if rng.Float64() < 0.015 { + eAt := createdAt.Add(time.Duration(rng.Intn(24*60)+1) * time.Minute) + if uint64(eAt.UnixMilli()) < nowMs && uint64(eAt.UnixMilli()) > uint64(createdAt.UnixMilli()) { + ems := uint64(eAt.UnixMilli()) + expunged = &ems + } + } + + steamID := models.SteamID(syntheticBaseSteamID + steamOffset*73 + uint64(rng.Intn(50))) + steamOffset++ + + // Snowflake encodes created_at + a 12-bit per-ms sequence; + // mirrors domain/models/snowflake.go so generated IDs sort + // chronologically alongside production rows. + seq = (seq + 1) & 0x0FFF + sfTs := uint64(createdAt.UnixMilli()) - models.Epoch + sf := models.Snowflake((sfTs << 22) | uint64(seq)) + + reporter := syntheticBaseReporter + uint(steamOffset) + mp := pickMarketplace(rng) + + rows = append(rows, &models.Reversal{ + Model: models.Model{ + ID: sf, + CreatedAt: uint64(createdAt.UnixMilli()), + UpdatedAt: uint64(createdAt.UnixMilli()), + }, + SteamID: steamID, + MarketplaceSlug: mp, + Source: &src, + RelatedSteamID: related, + ReversedAt: uint64(reversedAt.UnixMilli()), + ReporterInternalID: &reporter, + ExpungedAt: expunged, + }) + } + } + return rows +} + +func pickMarketplace(rng *rand.Rand) string { + r := rng.Float64() + cum := 0.0 + for _, mp := range syntheticMarketplaces { + cum += mp.weight + if r < cum { + return mp.slug + } + } + return syntheticMarketplaces[0].slug +} 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() diff --git a/server/server.go b/server/server.go index 5f5a85bc..97dcb1ba 100644 --- a/server/server.go +++ b/server/server.go @@ -1,8 +1,12 @@ package server import ( + "mime" "net/http" + "os" + "path/filepath" "regexp" + "strconv" "strings" "reverse-watch/api" @@ -60,9 +64,13 @@ func New(cfg config.Config, factory repository.Factory) (*Server, error) { r.Use(rwmiddleware.FactoryMiddleware(factory)) - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "static/index.html") - }) + // Static files are read into memory and written in a single response + // body. We avoid http.ServeFile / http.FileServer because the sendfile + // fast path on some local setups truncates large responses at the + // first TCP segment. Files served from here are tiny (HTML + a handful + // of logos/icons), so the read-once cost is negligible. + r.Get("/", serveStaticFile("static/index.html", "text/html; charset=utf-8")) + r.Get("/static/*", staticDirHandler("static")) r.Mount("/api", api.Router()) @@ -74,3 +82,43 @@ func New(cfg config.Config, factory repository.Factory) (*Server, error) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.r.ServeHTTP(w, r) } + +// serveStaticFile returns a handler that reads the file fresh on every +// request and writes its bytes with the given content-type. +func serveStaticFile(path, contentType string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + b, err := os.ReadFile(path) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.Itoa(len(b))) + _, _ = w.Write(b) + } +} + +// staticDirHandler serves files from baseDir under a chi wildcard +// //*. Path traversal is rejected. +func staticDirHandler(baseDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/static/") + if rest == "" || strings.Contains(rest, "..") { + http.NotFound(w, r) + return + } + full := filepath.Join(baseDir, filepath.Clean("/"+rest)) + b, err := os.ReadFile(full) + if err != nil { + http.NotFound(w, r) + return + } + ctype := mime.TypeByExtension(filepath.Ext(full)) + if ctype == "" { + ctype = http.DetectContentType(b) + } + w.Header().Set("Content-Type", ctype) + w.Header().Set("Content-Length", strconv.Itoa(len(b))) + _, _ = w.Write(b) + } +} diff --git a/static/cs2-events.json b/static/cs2-events.json new file mode 100644 index 00000000..28e4933e --- /dev/null +++ b/static/cs2-events.json @@ -0,0 +1,23 @@ +{ + "_comment": "Annotation chips that float above the Reversal Graph at the date of the event. Edit this file directly — it is loaded by static/index.html at boot, no rebuild needed. Each entry needs `date` (YYYY-MM-DD, UTC) and `title` (short, shown on the chip). `description` and `url` are optional and surface in the hover popover. Chips only render for events that fall inside the currently-selected period (7d / 30d / 3m / 6m / 1y). When two chips would overlap, the later one auto-stacks onto a row below; if more than 3 rows are needed the oldest chips are dropped (check the browser console).", + "events": [ + { + "date": "2026-05-20", + "title": "Trade Revert Update", + "description": "Valve enabled merchant-led trade reversals for CS2, broadening the set of disputes that route through reverse.watch.", + "url": "" + }, + { + "date": "2026-03-08", + "title": "Anti-Cheat Wave", + "description": "Large VAC ban wave hit account-sharing rings; downstream effect on reversal volume as compromised accounts were flagged.", + "url": "" + }, + { + "date": "2025-10-22", + "title": "Retake Update", + "description": "New Retake game mode re-introduced + users can now use covert skins to trade up to a knife or gloves", + "url": "" + } + ] +} diff --git a/static/csfloat-icon.png b/static/csfloat-icon.png new file mode 100644 index 00000000..18405864 Binary files /dev/null and b/static/csfloat-icon.png differ diff --git a/static/csfloat-logo.png b/static/csfloat-logo.png new file mode 100644 index 00000000..dc2bf80c Binary files /dev/null and b/static/csfloat-logo.png differ diff --git a/static/index.html b/static/index.html index 95bfedd0..40d45053 100644 --- a/static/index.html +++ b/static/index.html @@ -3,23 +3,30 @@ + - - reverse.watch + + + reverse.watch — The open trade reversal database -
-
-
- reverse.watch +
+ +
+
+ Powered by + + +
-

Check if a Steam ID has a history of trade reversals

-
- -
-
-
- - +

The open trade
reversal database

+

+ Check if a Steam ID has a history of reversed trades, reported by + marketplaces, trading tools, and community platforms. +

+ +
+ +
+ + + +
+ + +
+
+
+
+
+ + + + + + + +
+ +
+
+
-

Enter a 64-bit Steam ID to check their reversal history

- + +

Enter a 64-bit Steam ID to check their reversal history.

+ + +
+ + +
+
+ +
Steam IDs Searched
+
+
+
+
Traders Flagged
+
+
+
+
Traders Flagged (24h)
+
+
-
-
-
-
+
+
+
+

Reversal Graph

+
+ + + + + +
+
+

+ Total reversals across the selected time period. +

+
+
+
+ +
+
+
+

Recent Reversals

+
+

+ The most recent reversals reported to the database. + Search by Steam ID above to look up a specific trader. +

+
+
+ + + + + + + + + + + +
TraderSteam IDDate Added
Loading reversals…
-
-
- Steam ID - +
+ +
+
+ + + +