Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
66f75c5
add dashboard plan and public stats endpoints
byskov May 25, 2026
2f6bb7e
capture local-commit cadence rule in ABOUT-MORTEN
byskov May 25, 2026
311fe96
add local dev seed (98-row Sheet CSV -> reversals)
byskov May 25, 2026
cdb2458
expand ABOUT-MORTEN with review workflow + private-repo notes
byskov May 26, 2026
f3fd493
serve static files in-memory; avoid sendfile truncation
byskov May 26, 2026
56a3fbc
rewrite static/index.html for v1 dashboard
byskov May 26, 2026
5c53975
update README for v1 dashboard (seed, public API, test setup)
byskov May 26, 2026
33053c5
log Session #2 in SESSION-LOG (PR #1 + seed + PR #2 + README)
byskov May 26, 2026
9ef639b
seed: add deterministic 6-month synthetic dataset
byskov May 26, 2026
6b8d708
api/v1/stats: accept 7, 180, 365 days
byskov May 26, 2026
cfac9e8
dashboard: KPI rename + chart period picker
byskov May 26, 2026
5a715e0
docs/dashboard: log Session #3 (KPI rename, period picker, synthetic …
byskov May 26, 2026
01ca15d
dashboard: chart axis fixes + Trader column shows display name
byskov May 26, 2026
99b4b86
dashboard: editorial CS2 event chips on the chart
byskov May 26, 2026
e28bebc
dashboard: defer chip render to next frame; real CS2 event
byskov May 26, 2026
5f8951f
dashboard: pre-review cleanup pass
byskov May 27, 2026
d44dbd4
dashboard: redesign result chip with avatar + profile links
byskov May 27, 2026
71324f7
dashboard: design polish (search card, alignments, copy, footer discl…
byskov May 27, 2026
25b7e29
docs/dashboard: log Session #6 (chip redesign, bg revert, design polish)
byskov May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 50 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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?

Expand All @@ -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

Expand Down
49 changes: 49 additions & 0 deletions api/v1/reversals/reversals.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
240 changes: 240 additions & 0 deletions api/v1/reversals/reversals_recent_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading