Implement backend and frontend foundation (#4-#8)#25
Conversation
Backend: - Chi router with versioned API routes, health check, SPA fallback - Middleware: structured JSON logging, panic recovery, CORS, rate limiting - PostgreSQL: pgx connection pool, golang-migrate migrations, initial schema (players, squads, squad_members, matches, player_stats) - Repository layer: PlayerRepo, SquadRepo, MatchRepo with full CRUD - CoD API client: go-resty based HTTP client with SSO auth, retry logic, custom error types (private profile, not found, rate limited, etc.) - Caching layer: in-process TTL cache wrapping CodClient with stale-while-revalidate support during API outages - Embedded frontend via go:embed for single-binary deployment Frontend: - Naive UI dark theme with Warzone-inspired color scheme (#00e5ff accent) - App shell with header navigation (Home, Compare, Squads) - Routes: /, /player/:platform/:gamertag, /compare, /squads, /squad/:id - Pinia stores: usePlayerStore, useSquadStore, useCompareStore - TypeScript types matching backend API response shapes - Home page with platform selector and gamertag search - Placeholder views for Player, Compare, Squads, Squad Closes #4, closes #5, closes #6, closes #7, closes #8 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove user-facing auth concerns — the backend uses a single SSO token to query any player's public stats (matching tracker.gg pattern). Add admin token refresh endpoint (POST /api/v1/admin/token) protected by ADMIN_API_KEY so expired tokens can be swapped at runtime without redeploying. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Establishes the initial backend + frontend scaffolding for the Warzone stats tracker, including a Go HTTP server, PostgreSQL schema/migrations, a CoD API client + cache wrapper, and a Vue 3 + Naive UI app shell, plus deployment/env wiring for admin token refresh.
Changes:
- Add Go server foundation: config, router, middleware (logging/recovery/rate limit), health + admin token refresh endpoint, embedded SPA serving.
- Add PostgreSQL schema + migration runner, plus initial repository implementations (players/squads/matches/stats snapshots).
- Add Vue app skeleton (routes/views, theme, Pinia stores/types) and Terraform/env updates for
ADMIN_API_KEY.
Reviewed changes
Copilot reviewed 52 out of 54 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/views/SquadsView.vue | Adds placeholder Squads view. |
| web/src/views/SquadView.vue | Adds placeholder Squad dashboard view driven by route param. |
| web/src/views/PlayerView.vue | Adds placeholder Player view driven by route params. |
| web/src/views/HomeView.vue | Replaces starter content with player search UI and navigation. |
| web/src/views/CompareView.vue | Adds placeholder Compare view. |
| web/src/views/AboutView.vue | Removes default About page scaffold. |
| web/src/types/index.ts | Introduces shared TS types for players/matches/squads. |
| web/src/stores/counter.ts | Replaces starter counter store with Player/Squad/Compare Pinia stores. |
| web/src/router/index.ts | Updates routes to app-specific pages (player/compare/squads). |
| web/src/assets/main.css | Replaces default CSS with app dark theme + utility classes. |
| web/src/assets/logo.svg | Removes default Vue logo asset. |
| web/src/assets/base.css | Removes default Vue base theme CSS. |
| web/src/App.vue | Adds Naive UI config provider, dark theme overrides, and app layout/nav. |
| web/embed.go | Adds go:embed for shipping built frontend assets in the server binary. |
| terraform/digitalocean/variables.tf | Adds admin_api_key variable for admin endpoint protection. |
| terraform/digitalocean/terraform.tfvars.example | Documents admin_api_key and admin token refresh usage. |
| terraform/digitalocean/main.tf | Wires ADMIN_API_KEY into DO App Platform environment. |
| migrations/000001_init.up.sql | Adds initial schema for players/squads/matches/player_stats. |
| migrations/000001_init.down.sql | Adds down migration dropping the initial schema. |
| internal/service/player.go | Updates placeholder comment. |
| internal/router/router.go | Adds chi router setup, middleware, API route stubs, admin route, SPA serving. |
| internal/repository/stats_repo.go | Replaces placeholder with note pointing to PlayerRepo snapshot methods. |
| internal/repository/squad_repo.go | Implements squad + membership repository methods. |
| internal/repository/player_repo.go | Implements player upsert/get + stats snapshot persistence helpers. |
| internal/repository/match_repo.go | Implements match upsert + retrieval. |
| internal/middleware/recovery.go | Adds panic recovery middleware. |
| internal/middleware/ratelimit.go | Adds in-memory rate limiter middleware. |
| internal/middleware/logging.go | Adds structured request logging + request ID header. |
| internal/middleware/adminauth.go | Adds admin bearer-token auth middleware based on ADMIN_API_KEY. |
| internal/handler/squad.go | Updates placeholder comments for squad handlers. |
| internal/handler/player.go | Updates placeholder comments for player handlers. |
| internal/handler/match.go | Updates placeholder comments for match handlers. |
| internal/handler/health.go | Adds a shared NotImplemented handler. |
| internal/handler/admin.go | Adds admin endpoint to update CoD SSO token at runtime. |
| internal/database/migrate.go | Adds golang-migrate runner. |
| internal/database/database.go | Adds pgxpool connection helper with pooling config + ping. |
| internal/config/config.go | Adds ADMIN_API_KEY to config. |
| internal/codclient/types.go | Expands CoD response mapping structs and adds additional stats fields. |
| internal/codclient/errors.go | Adds typed sentinel errors for common CoD API failure modes. |
| internal/codclient/client.go | Implements resty-based CoD client with retry, auth cookie, token update. |
| internal/cache/cache.go | Adds in-process TTL cache wrapper with stale serving behavior. |
| cmd/server/main.go | Wires config, logging, DB connect/migrate, CoD client+cache, router, static embed serving. |
| .env.example | Documents ADMIN_API_KEY and related admin endpoint behavior. |
| go.mod | Adds Go dependencies for chi/cors/pgx/migrate/resty/xid. |
| go.sum | Adds module checksums for new dependencies. |
| web/src/components/icons/IconTooling.vue | Removes starter icon component. |
| web/src/components/icons/IconSupport.vue | Removes starter icon component. |
| web/src/components/icons/IconEcosystem.vue | Removes starter icon component. |
| web/src/components/icons/IconDocumentation.vue | Removes starter icon component. |
| web/src/components/icons/IconCommunity.vue | Removes starter icon component. |
| web/src/components/tests/HelloWorld.spec.ts | Removes starter unit test. |
| web/src/components/WelcomeItem.vue | Removes starter component. |
| web/src/components/TheWelcome.vue | Removes starter component. |
| web/src/components/HelloWorld.vue | Removes starter component. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // UpdateToken replaces the SSO token at runtime (for admin token refresh). | ||
| func (c *client) UpdateToken(newToken string) { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
| c.http.SetCookie(&http.Cookie{ | ||
| Name: "ACT_SSO_COOKIE", | ||
| Value: newToken, | ||
| }) | ||
| slog.Info("cod api sso token updated") |
There was a problem hiding this comment.
UpdateToken locks c.mu but request methods don’t take the corresponding read lock, so the mutex currently doesn’t prevent concurrent mutation of the shared resty client while requests are in-flight (potential data race). Either acquire RLock around request creation/sending, or avoid mutating the shared client by storing the token separately (atomic/string) and setting the Cookie header per request.
| return nil, err | ||
| } | ||
| matches = append(matches, m) | ||
| } |
There was a problem hiding this comment.
rows.Err() isn’t checked after iterating. If the driver encounters an error mid-iteration, it won’t be surfaced. Add a if err := rows.Err(); err != nil { ... } after the loop before returning.
| } | |
| } | |
| if err := rows.Err(); err != nil { | |
| return nil, err | |
| } |
| if (!res.ok) { | ||
| const body = await res.json() | ||
| throw new Error(body.message || 'Failed to fetch stats') | ||
| } |
There was a problem hiding this comment.
Error handling assumes non-2xx responses always return JSON (await res.json()), which will itself throw if the backend returns an empty body or non-JSON error (e.g., proxies). Consider reading res.text() first and only JSON-parsing when it looks like JSON, so you can surface a useful error message reliably.
| stats, err := c.inner.GetPlayerStats(ctx, platform, gamertag, mode) | ||
| if err != nil { | ||
| // Serve stale data if available | ||
| if val, ok := c.getStale(key); ok { | ||
| slog.Warn("serving stale cache due to API error", "key", key, "error", err) | ||
| return val.(*codclient.PlayerStats), nil | ||
| } | ||
| return nil, err |
There was a problem hiding this comment.
Stale-while-revalidate currently triggers on any error from the inner client (including ErrTokenExpired / ErrPrivateProfile / ErrPlayerNotFound). That can mask important states (e.g., token expired) and return misleading data. Consider only serving stale on transient upstream failures (e.g., ErrAPIUnavailable / rate limit / 5xx) and propagating other errors.
| export const usePlayerStore = defineStore('player', () => { | ||
| const loading = ref(false) |
There was a problem hiding this comment.
This file is still named counter.ts but now defines usePlayerStore, useSquadStore, and useCompareStore. Renaming the file (and updating imports) to match its contents (e.g., player.ts / squad.ts / compare.ts or stores/index.ts) will prevent confusion as the codebase grows.
| func pgxConnString(databaseURL string) string { | ||
| return "pgx5://" + databaseURL[len("postgres://"):] | ||
| } |
There was a problem hiding this comment.
pgxConnString slices databaseURL[len("postgres://"):] without validating the prefix. If DATABASE_URL uses postgresql://, pgx5://, or is empty, this will panic or produce an invalid connection string. Parse the URL and replace the scheme safely (or handle both postgres:// and postgresql:// via strings.TrimPrefix checks).
internal/middleware/ratelimit.go
Outdated
| ip := r.RemoteAddr | ||
|
|
||
| rl.mu.Lock() | ||
| v, exists := rl.visitors[ip] | ||
| if !exists || time.Since(v.lastSeen) > rl.window { | ||
| rl.visitors[ip] = &visitor{count: 1, lastSeen: time.Now()} | ||
| rl.mu.Unlock() | ||
| next.ServeHTTP(w, r) |
There was a problem hiding this comment.
Using r.RemoteAddr as the rate-limit key includes the client port (e.g. ip:port) and will also be the reverse proxy address in many deployments, making rate limiting ineffective or overly strict. Split host/port with net.SplitHostPort and (if behind a trusted proxy) consider using X-Forwarded-For / X-Real-IP to derive the real client IP.
| if v.count > rl.limit { | ||
| rl.mu.Unlock() | ||
| http.Error(w, `{"error":"rate_limited","message":"Too many requests"}`, http.StatusTooManyRequests) | ||
| return |
There was a problem hiding this comment.
RateLimiter returns a JSON body via http.Error but doesn’t set Content-Type: application/json (and http.Error appends a newline). Consider writing the response with json.Encoder after setting the content type for consistent API error handling.
| // CacheInfo returns hit/miss status and age for use in response headers. | ||
| func (c *CachedClient) CacheInfo(key string) (hit bool, ageSeconds int, stale bool) { | ||
| c.mu.RLock() | ||
| defer c.mu.RUnlock() | ||
|
|
||
| e, exists := c.store[key] | ||
| if !exists { | ||
| return false, 0, false | ||
| } | ||
|
|
||
| age := int(time.Since(e.expiresAt.Add(-c.statsTTL)).Seconds()) | ||
| if age < 0 { | ||
| age = 0 | ||
| } | ||
|
|
||
| return true, age, e.isExpired() | ||
| } |
There was a problem hiding this comment.
CacheInfo computes age from expiresAt.Add(-c.statsTTL) and always uses statsTTL, which will report incorrect ages for match entries (and can be wrong if TTLs change). Store a createdAt/insertedAt timestamp (or store per-entry TTL) and compute age from that, using the appropriate TTL for the key type.
internal/repository/match_repo.go
Outdated
| damage_dealt, damage_taken, gulag_result, match_time, created_at | ||
| FROM matches | ||
| WHERE player_id = $1 | ||
| ORDER BY match_time DESC |
There was a problem hiding this comment.
This SELECT orders by match_time DESC without handling NULLs. In Postgres, NULLs sort first for DESC, and scanning a NULL match_time into time.Time will error. Consider making match_time NOT NULL at the schema level (preferred if always present), or update the query to ORDER BY match_time DESC NULLS LAST and scan into *time.Time if NULLs are allowed.
| ORDER BY match_time DESC | |
| ORDER BY match_time DESC NULLS LAST |
Backend: Use build tags for go:embed so `go build` works without web/dist/ in CI. The embed_dist tag is only used in the Dockerfile where the frontend is built first. Frontend: Fix eslint errors (no-explicit-any in catch blocks), add passWithNoTests to vitest config, run lint without --fix in CI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix data race in CodClient: store token separately, set cookie per-request with RLock instead of mutating shared resty client - Only serve stale cache on transient errors (API unavailable, rate limited), not on token expired / player not found / private profile - Add rows.Err() checks after row iteration in match and squad repos - Handle json.Marshal error in match batch upsert - Add NULLS LAST to match_time DESC ordering - Set Content-Type: application/json in recovery and rate limit middleware instead of using http.Error with JSON strings - Extract IP without port in rate limiter using net.SplitHostPort - Trim whitespace from comma-separated CORS origins - Parse DATABASE_URL with net/url instead of fragile string slicing - Store createdAt in cache entries for accurate age reporting - Rename stores/counter.ts to stores/index.ts to match contents - Use encodeURIComponent for gamertags in fetch URLs - Parse error response as text first, then attempt JSON parse - Add gen_random_uuid() compatibility comment in migration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 57 out of 59 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| endpoint := fmt.Sprintf("/stats/cod/v1/title/%s/platform/%s/gamer/%s/profile/type/%s", | ||
| defaultTitle, platform, encodedTag, mode) | ||
|
|
||
| resp, err := c.http.R(). |
There was a problem hiding this comment.
UpdateToken locks, but GetPlayerStats/GetRecentMatches don't take a read lock (and Resty’s internal cookie jar mutation is not guaranteed to be concurrent-safe). This can lead to a data race when the admin endpoint updates the token while requests are in flight. Consider storing the token in a concurrency-safe field and attaching Cookie/header per-request (or take an RLock around request creation/sending).
| func (rl *RateLimiter) Handler(next http.Handler) http.Handler { | ||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
There was a problem hiding this comment.
Rate limiting is keyed by r.RemoteAddr, which includes the client port (e.g. 1.2.3.4:54321). This causes the same client to appear under many keys, effectively bypassing the limiter. Use net.SplitHostPort(r.RemoteAddr) (and, if running behind a reverse proxy, consider honoring X-Forwarded-For/X-Real-IP with a trusted proxy list).
| "error", err, | ||
| "stack", string(debug.Stack()), | ||
| "path", r.URL.Path, | ||
| ) |
There was a problem hiding this comment.
http.Error defaults Content-Type to text/plain; charset=utf-8, but the body here is JSON. Set Content-Type: application/json and write the JSON payload directly so clients can reliably parse error responses.
| ) | |
| w.Header().Set("Content-Type", "application/json") | |
| w.WriteHeader(http.StatusInternalServerError) | |
| _, _ = w.Write([]byte(`{"error":"internal_server_error","message":"An unexpected error occurred"}`)) |
| } | ||
| s.Members = append(s.Members, p) | ||
| } | ||
| if err := rows.Err(); err != nil { |
There was a problem hiding this comment.
This method iterates through rows but never checks rows.Err() after the loop. Add a rows.Err() check before returning to surface any scan/iteration errors from pgx.
| if err := rows.Err(); err != nil { | |
| if err := rows.Err(); err != nil { | |
| return nil, err | |
| } |
| -- gen_random_uuid() is built-in since PostgreSQL 13. For older versions, uncomment: | ||
| -- CREATE EXTENSION IF NOT EXISTS pgcrypto; | ||
|
|
||
| CREATE TABLE players ( |
There was a problem hiding this comment.
This migration uses gen_random_uuid() but does not enable the extension that provides it. On a fresh Postgres instance, gen_random_uuid() will fail unless pgcrypto is installed. Add CREATE EXTENSION IF NOT EXISTS pgcrypto; (or switch to uuid-ossp/uuid_generate_v4() if that’s the project standard).
| next.ServeHTTP(w, r) | ||
| return | ||
| } | ||
|
|
There was a problem hiding this comment.
http.Error will send text/plain but the body is JSON. For API consumers (and your frontend store which calls res.json()), set Content-Type: application/json and write the JSON body explicitly when rate limiting triggers.
| w.Header().Set("Content-Type", "application/json") | |
| w.WriteHeader(http.StatusTooManyRequests) | |
| _, _ = w.Write([]byte(`{"error":"rate_limited","message":"Too many requests"}`)) |
| environment: 'jsdom', | ||
| exclude: [...configDefaults.exclude, 'e2e/**'], | ||
| root: fileURLToPath(new URL('./', import.meta.url)), | ||
| passWithNoTests: true, | ||
| }, |
There was a problem hiding this comment.
Setting passWithNoTests: true means CI will succeed even when there are zero unit tests (and this PR removes the only existing test). If the goal is to enforce at least a minimal test baseline, keep this false and/or add a small smoke test so regressions don’t slip through silently.
grovecj
left a comment
There was a problem hiding this comment.
All 16 review comments have been addressed in commit 489e7f8. Here's a summary of each fix:
1. CodClient data race (client.go) — Fixed. Token is now stored in a token field protected by RWMutex. authCookie() acquires RLock and returns a per-request cookie. UpdateToken() acquires write lock and updates only the token field, never mutating the shared resty client.
2. Missing rows.Err() in match repo — Fixed. Added rows.Err() check after the iteration loop.
3. res.json() on non-JSON error responses (stores) — Fixed. Now reads res.text() first, then attempts JSON.parse() with a try/catch fallback.
4. Stale cache on any error — Fixed. Added isTransientError() helper — only serves stale data for ErrAPIUnavailable and ErrRateLimited. Token expired, private profile, and player not found errors now propagate immediately.
5. File named counter.ts — Fixed. Renamed to stores/index.ts.
6. json.Marshal error ignored in match upsert — Fixed. Error is now checked and returned.
7. Missing rows.Err() in squad repo — Fixed. Added rows.Err() check after member iteration loop.
8. Missing encodeURIComponent for gamertags — Fixed. Both gamertag and mode are now encoded with encodeURIComponent().
9. gen_random_uuid() without extension — Added a compatibility comment. gen_random_uuid() is built-in since PostgreSQL 13, and we target PG 17 (docker-compose + DO managed PG). Added a commented-out CREATE EXTENSION IF NOT EXISTS pgcrypto for older version compatibility.
10. Recovery middleware Content-Type — Fixed. Now sets Content-Type: application/json and uses json.NewEncoder instead of http.Error.
11. CORS origins whitespace — Fixed. Origins are now trimmed with strings.TrimSpace() after splitting, and empty strings are filtered out.
12. pgxConnString fragile URL slicing — Fixed. Now uses net/url.Parse() to safely replace the scheme, handling postgres://, postgresql://, and any other scheme.
13. Rate limiter RemoteAddr includes port — Fixed. Now uses net.SplitHostPort() to extract the IP address without the port.
14. Rate limiter Content-Type — Fixed. Same pattern as recovery — uses json.NewEncoder with proper Content-Type header.
15. CacheInfo always uses statsTTL — Fixed. Cache entries now store createdAt timestamp, and CacheInfo computes age from that instead of back-calculating from expiresAt.
16. NULLS LAST for match_time DESC — Fixed. Added NULLS LAST to the ORDER BY clause.
Summary
go:embedTest plan
go build ./...compiles without errorsgo vet ./...passescd web && npm run buildbuilds frontend successfullycd web && npm run type-checkpasses TypeScript checks/api/v1/health🤖 Generated with Claude Code