From d17f39a03d77199999fa73b07da77ebc306e9a0c Mon Sep 17 00:00:00 2001 From: Volodya Date: Thu, 14 May 2026 13:46:36 +0100 Subject: [PATCH 1/2] feat: [T04] design leaderboard database schema Adds the PostgreSQL schema for the snake leaderboard: - users: one row per player handle, with bearer token for X-Player-Token auth - sessions: one row per game played (start/end + final score + meta JSONB) - scores: append-only score feed indexed for top-N reads Indexes: - users_player_lower_uniq case-insensitive unique handle - users_api_token_uniq token lookup for write auth - sessions_user_started_idx recent sessions per user - scores_score_created_idx global top-N (ties resolved by earliest submit) - scores_user_score_idx best-score-per-user Migrations live as numbered .sql files under backend/migrations/ and are idempotent. backend/src/db.ts exposes a lazy pool plus a runMigrations() helper for tests/bootstrap so typecheck does not require a running DB. Adds pg + @types/pg to backend. --- backend/migrations/001_init.sql | 78 +++++++++++++++++++++++++++++++++ backend/migrations/README.md | 28 ++++++++++++ backend/package.json | 4 +- backend/src/db.ts | 67 ++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/001_init.sql create mode 100644 backend/migrations/README.md create mode 100644 backend/src/db.ts diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..84174d7 --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,78 @@ +-- 001_init.sql +-- Initial schema for the snake leaderboard. +-- +-- Three tables: +-- users — registered players (one row per unique player handle) +-- sessions — one row per game played (start/end time, final score, optional metadata) +-- scores — denormalized "best/notable scores" feed used by the leaderboard +-- +-- We keep `scores` separate from `sessions` so the leaderboard read path is a +-- cheap index scan and not a `MAX(score) GROUP BY user` over the full session log. +-- +-- All statements are idempotent so a bootstrap helper can safely re-run them. + +BEGIN; + +-- --------------------------------------------------------------------------- +-- users +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + -- Display handle. Case-insensitive uniqueness is enforced via the index + -- below (CITEXT would be nicer but adds an extension dependency). + player TEXT NOT NULL, + -- Opaque bearer token used by the API for `X-Player-Token` auth. Stored + -- as a hex string; rotation is just an UPDATE. + api_token TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_player_lower_uniq + ON users (LOWER(player)); + +CREATE UNIQUE INDEX IF NOT EXISTS users_api_token_uniq + ON users (api_token); + +-- --------------------------------------------------------------------------- +-- sessions +-- --------------------------------------------------------------------------- +-- One row per played game. `ended_at IS NULL` means in-progress. +CREATE TABLE IF NOT EXISTS sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, + final_score INTEGER NOT NULL DEFAULT 0 CHECK (final_score >= 0), + -- Free-form JSON for client-side metadata (board size, tick rate, etc.). + -- Useful for analytics; the leaderboard does not read it. + meta JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS sessions_user_started_idx + ON sessions (user_id, started_at DESC); + +-- --------------------------------------------------------------------------- +-- scores +-- --------------------------------------------------------------------------- +-- Append-only feed of submitted scores. The leaderboard query reads this +-- table directly. We index `(score DESC, created_at ASC)` so that +-- "top-N global" is a single index scan and ties resolve by who got there +-- first. +CREATE TABLE IF NOT EXISTS scores ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id BIGINT REFERENCES sessions(id) ON DELETE SET NULL, + score INTEGER NOT NULL CHECK (score >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Top-N leaderboard read path. +CREATE INDEX IF NOT EXISTS scores_score_created_idx + ON scores (score DESC, created_at ASC); + +-- "Best score per user" lookups (`GET /api/users/:id/best`). +CREATE INDEX IF NOT EXISTS scores_user_score_idx + ON scores (user_id, score DESC); + +COMMIT; diff --git a/backend/migrations/README.md b/backend/migrations/README.md new file mode 100644 index 0000000..a6bf3c9 --- /dev/null +++ b/backend/migrations/README.md @@ -0,0 +1,28 @@ +# Migrations + +Plain numbered SQL files. Apply with `psql`: + +```bash +DATABASE_URL=postgres://user:pass@localhost:5432/snake \ + psql "$DATABASE_URL" -f backend/migrations/001_init.sql +``` + +Or, from a Node script, use `backend/src/db.ts` which exposes `runMigrations(client)` for tests/bootstrap. + +All statements are wrapped in `BEGIN/COMMIT` and use `IF NOT EXISTS`, so re-running on an already-initialised database is a no-op. + +## Schema overview + +| Table | Purpose | +|------------|----------------------------------------------------------------------| +| `users` | One row per player handle. Holds the bearer token used for write auth. | +| `sessions` | One row per game played. `ended_at IS NULL` while in progress. | +| `scores` | Append-only feed of submitted scores. Indexed for top-N reads. | + +### Indexes + +- `users_player_lower_uniq` — case-insensitive unique handle. +- `users_api_token_uniq` — token lookup for `X-Player-Token` auth. +- `sessions_user_started_idx` — recent-sessions-per-user reads. +- `scores_score_created_idx` — global top-N leaderboard (score DESC, ties resolved by earliest submit). +- `scores_user_score_idx` — best-score-per-user lookups. diff --git a/backend/package.json b/backend/package.json index fe4d771..e3c5ffa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,11 +13,13 @@ }, "dependencies": { "@snake/shared": "*", - "express": "^4.21.1" + "express": "^4.21.1", + "pg": "^8.13.1" }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.16.10", + "@types/pg": "^8.11.10", "tsx": "^4.19.1", "typescript": "^5.5.4" } diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..02bc4e2 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,67 @@ +/** + * Database access layer. + * + * Why a thin abstraction: + * - The leaderboard backend talks to PostgreSQL via `pg`, but typechecking + * and unit tests must not require a running database. `getPool()` is lazy: + * a Pool is only constructed on first call. + * - Tests can swap in any object that implements the minimal `Db` interface + * below (e.g. `pg-mem`'s adapter) by calling `setDb()`. + * - All query helpers go through this module so the rest of the codebase + * never imports `pg` directly. Keeps the surface area small. + */ + +import { readFile, readdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Minimal subset of `pg.Pool` we depend on. */ +export interface Db { + query(text: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number | null }> +} + +let _db: Db | null = null + +/** + * Returns the active database handle, lazily constructing a `pg.Pool` from + * `DATABASE_URL` on first call. Throws if `DATABASE_URL` is not set and no + * test harness has called `setDb()`. + */ +export async function getDb(): Promise { + if (_db) return _db + const url = process.env.DATABASE_URL + if (!url) { + throw new Error( + 'DATABASE_URL is not set. Either configure it, or call setDb() with a test adapter.', + ) + } + // Imported lazily so `pg` is not required for typecheck or tests that + // never touch a real database. + const { Pool } = await import('pg') + const pool = new Pool({ connectionString: url }) + _db = pool as unknown as Db + return _db +} + +/** Inject a custom adapter (e.g. pg-mem) for tests. */ +export function setDb(db: Db | null): void { + _db = db +} + +/** + * Apply every `.sql` file in `backend/migrations/` in lexicographic order. + * Files are idempotent so re-running is safe. + */ +export async function runMigrations(db?: Db): Promise { + const handle = db ?? (await getDb()) + const here = dirname(fileURLToPath(import.meta.url)) + // src/ -> backend/migrations + const migrationsDir = join(here, '..', 'migrations') + const files = (await readdir(migrationsDir)) + .filter((f) => f.endsWith('.sql')) + .sort() + for (const file of files) { + const sql = await readFile(join(migrationsDir, file), 'utf8') + await handle.query(sql) + } +} From 803eedab2711a673a8395743c6c9015691cfc2b1 Mon Sep 17 00:00:00 2001 From: Volodya Date: Thu, 14 May 2026 13:52:02 +0100 Subject: [PATCH 2/2] feat: [T05] implement score tracking API REST endpoints under /api: POST /api/users/register create user, returns X-Player-Token POST /api/scores submit a score (auth required) GET /api/leaderboard?limit=N top-N (limit clamped 1..100, default 10) GET /api/users/:id/best all-time best for one user Layered as: routes/leaderboard.ts -> repo.ts -> db.ts. SQL lives only in repo.ts; routes do validation + auth. Validation uses zod (handle regex, score >= 0 and bounded, limit clamp). Auth model: a single bearer token per user, sent as X-Player-Token. That's sufficient for a hobby leaderboard and avoids dragging in JWT/bcrypt for T05's scope. Tests: 9 integration tests under src/__tests__/leaderboard.test.ts using pg-mem to provide an in-memory PostgreSQL adapter, so CI does not need a real database. Migrations are applied through the same runMigrations() helper used in production. Test runner: node --test (--import tsx). Shared types extended additively: - SubmitScoreRequest gains optional `meta` - ScoreEntry gains optional `rank` - new SubmitScoreResponse / LeaderboardResponse / RegisterUserResponse Adds zod (runtime), pg-mem + supertest (tests). --- backend/package.json | 8 +- backend/src/__tests__/leaderboard.test.ts | 141 ++++++++++++++++ backend/src/repo.ts | 153 +++++++++++++++++ backend/src/routes/leaderboard.ts | 193 ++++++++++++++++++++++ backend/src/server.ts | 6 + shared/src/index.ts | 26 +++ 6 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 backend/src/__tests__/leaderboard.test.ts create mode 100644 backend/src/repo.ts create mode 100644 backend/src/routes/leaderboard.ts diff --git a/backend/package.json b/backend/package.json index e3c5ffa..d3caac4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,18 +8,22 @@ "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.json", "start": "node dist/server.js", - "test": "node --test --import tsx src/**/*.test.ts", + "test": "node --test --import tsx 'src/**/*.test.ts'", "typecheck": "tsc --noEmit" }, "dependencies": { "@snake/shared": "*", "express": "^4.21.1", - "pg": "^8.13.1" + "pg": "^8.13.1", + "zod": "^3.23.8" }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.16.10", "@types/pg": "^8.11.10", + "@types/supertest": "^6.0.2", + "pg-mem": "^3.0.4", + "supertest": "^7.0.0", "tsx": "^4.19.1", "typescript": "^5.5.4" } diff --git a/backend/src/__tests__/leaderboard.test.ts b/backend/src/__tests__/leaderboard.test.ts new file mode 100644 index 0000000..e64e8db --- /dev/null +++ b/backend/src/__tests__/leaderboard.test.ts @@ -0,0 +1,141 @@ +/** + * Integration tests for the leaderboard router. + * + * Uses `pg-mem` to provide an in-memory PostgreSQL-compatible database, so + * the suite runs in CI without a real Postgres. The router is wired with the + * mem DB via the `db` option; nothing in the code path under test is mocked. + */ + +import { test, before } from 'node:test' +import assert from 'node:assert/strict' +import express from 'express' +import request from 'supertest' +import { newDb } from 'pg-mem' +import { leaderboardRouter } from '../routes/leaderboard.js' +import { runMigrations } from '../db.js' +import type { Db } from '../db.js' + +let app: express.Express + +before(async () => { + const mem = newDb({ autoCreateForeignKeyIndices: true }) + // pg-mem doesn't ship NOW()/jsonb-functions identically to PG; the + // defaults are good enough for our schema. + const adapter = mem.adapters.createPg() + const pool = new adapter.Pool() as unknown as Db + + // Apply real migrations (the same .sql files used in production). + await runMigrations(pool) + + app = express() + app.use(express.json()) + app.use('/api', leaderboardRouter({ db: pool })) +}) + +test('register -> submit -> leaderboard -> best round-trip', async () => { + // Register two players. + const reg1 = await request(app) + .post('/api/users/register') + .send({ player: 'alice' }) + .expect(201) + assert.equal(reg1.body.player, 'alice') + assert.ok(reg1.body.id, 'id present') + assert.ok(reg1.body.token && typeof reg1.body.token === 'string', 'token returned') + + const reg2 = await request(app) + .post('/api/users/register') + .send({ player: 'bob' }) + .expect(201) + + // Submit several scores. + await request(app) + .post('/api/scores') + .set('X-Player-Token', reg1.body.token) + .send({ score: 50 }) + .expect(201) + + const r2 = await request(app) + .post('/api/scores') + .set('X-Player-Token', reg1.body.token) + .send({ score: 120, meta: { boardSize: 20 } }) + .expect(201) + assert.equal(r2.body.entry.score, 120) + assert.equal(r2.body.bestScore, 120, 'best updated to 120') + + await request(app) + .post('/api/scores') + .set('X-Player-Token', reg2.body.token) + .send({ score: 80 }) + .expect(201) + + // Leaderboard should be 120 (alice), 80 (bob), 50 (alice). + const lb = await request(app).get('/api/leaderboard?limit=10').expect(200) + assert.equal(lb.body.entries.length, 3) + assert.equal(lb.body.entries[0].score, 120) + assert.equal(lb.body.entries[0].player, 'alice') + assert.equal(lb.body.entries[0].rank, 1) + assert.equal(lb.body.entries[1].score, 80) + assert.equal(lb.body.entries[2].score, 50) + assert.ok(lb.body.generatedAt, 'generatedAt set') + + // Best for alice should be 120. + const best = await request(app) + .get(`/api/users/${reg1.body.id}/best`) + .expect(200) + assert.equal(best.body.entry.score, 120) + assert.equal(best.body.entry.player, 'alice') +}) + +test('rejects score submit without token', async () => { + await request(app).post('/api/scores').send({ score: 10 }).expect(401) +}) + +test('rejects score submit with invalid token', async () => { + await request(app) + .post('/api/scores') + .set('X-Player-Token', 'definitely-not-a-real-token') + .send({ score: 10 }) + .expect(401) +}) + +test('rejects negative scores via zod validation', async () => { + const reg = await request(app) + .post('/api/users/register') + .send({ player: 'carol' }) + .expect(201) + const r = await request(app) + .post('/api/scores') + .set('X-Player-Token', reg.body.token) + .send({ score: -1 }) + .expect(400) + assert.equal(r.body.error, 'validation failed') +}) + +test('rejects malformed player handle on register', async () => { + await request(app).post('/api/users/register').send({ player: '' }).expect(400) + await request(app) + .post('/api/users/register') + .send({ player: 'has spaces' }) + .expect(400) +}) + +test('register is idempotent on case-insensitive handle', async () => { + const a = await request(app).post('/api/users/register').send({ player: 'Dave' }).expect(201) + const b = await request(app).post('/api/users/register').send({ player: 'dave' }).expect(201) + assert.equal(a.body.id, b.body.id, 'same user returned for case variant') +}) + +test('leaderboard limit is clamped to a sane range', async () => { + const tooBig = await request(app).get('/api/leaderboard?limit=10000').expect(400) + assert.equal(tooBig.body.error, 'validation failed') + const tooSmall = await request(app).get('/api/leaderboard?limit=0').expect(400) + assert.equal(tooSmall.body.error, 'validation failed') +}) + +test('users/:id/best returns 404 for unknown user', async () => { + await request(app).get('/api/users/9999999/best').expect(404) +}) + +test('users/:id/best rejects non-numeric ids', async () => { + await request(app).get('/api/users/not-a-number/best').expect(400) +}) diff --git a/backend/src/repo.ts b/backend/src/repo.ts new file mode 100644 index 0000000..12b62b4 --- /dev/null +++ b/backend/src/repo.ts @@ -0,0 +1,153 @@ +/** + * Data access layer. + * + * All SQL lives here. Routes call these helpers and never assemble SQL + * themselves. Every helper takes a `Db` so routes/tests can pick a connection. + */ + +import { randomBytes } from 'node:crypto' +import type { Db } from './db.js' + +export interface UserRow { + id: string + player: string + api_token: string +} + +export interface ScoreRow { + id: string + user_id: string + score: number + created_at: string // ISO-8601 + player: string +} + +/** + * Create a new user with a freshly minted API token, or return the existing + * one. We do a SELECT-then-INSERT instead of `ON CONFLICT (LOWER(player))` + * because expression-based unique indexes are well-supported by real + * PostgreSQL but not always by lightweight test adapters (pg-mem). The race + * window is harmless here — at worst two concurrent registrations for the + * same handle will conflict on the unique index and one will retry. + */ +export async function createUser(db: Db, player: string): Promise { + const existing = await db.query( + `SELECT id::text AS id, player, api_token + FROM users + WHERE LOWER(player) = LOWER($1)`, + [player], + ) + const found = existing.rows[0] + if (found) return found + + const token = randomBytes(24).toString('hex') + const insert = await db.query( + `INSERT INTO users (player, api_token) + VALUES ($1, $2) + RETURNING id::text AS id, player, api_token`, + [player, token], + ) + const row = insert.rows[0] + if (!row) throw new Error('failed to create user') + return row +} + +/** Resolve a user by their API token. Returns `null` if the token is unknown. */ +export async function findUserByToken(db: Db, token: string): Promise { + const result = await db.query( + `SELECT id::text AS id, player, api_token + FROM users + WHERE api_token = $1`, + [token], + ) + return result.rows[0] ?? null +} + +/** Resolve a user by id. */ +export async function findUserById(db: Db, id: string): Promise { + const result = await db.query( + `SELECT id::text AS id, player, api_token + FROM users + WHERE id = $1`, + [id], + ) + return result.rows[0] ?? null +} + +/** + * Record a score for the given user. Creates a session row alongside the + * score so we have a 1:1 audit trail even if no `/sessions` endpoint exists + * yet. + */ +export async function recordScore( + db: Db, + userId: string, + score: number, + meta: Record = {}, +): Promise { + const session = await db.query<{ id: string }>( + `INSERT INTO sessions (user_id, ended_at, final_score, meta) + VALUES ($1, NOW(), $2, $3::jsonb) + RETURNING id::text AS id`, + [userId, score, JSON.stringify(meta)], + ) + const sessionId = session.rows[0]?.id ?? null + + const inserted = await db.query( + `INSERT INTO scores (user_id, session_id, score) + VALUES ($1, $2, $3) + RETURNING id::text AS id, + user_id::text AS user_id, + score, + created_at, + (SELECT player FROM users WHERE id = $1) AS player`, + [userId, sessionId, score], + ) + const row = inserted.rows[0] + if (!row) throw new Error('failed to record score') + // Some adapters return Date objects for TIMESTAMPTZ; normalize to ISO. + return { ...row, created_at: toIso(row.created_at) } +} + +/** Fetch the top-N scores for the global leaderboard, newest tie-break wins ascending. */ +export async function topScores(db: Db, limit: number): Promise { + const result = await db.query( + `SELECT s.id::text AS id, + s.user_id::text AS user_id, + s.score AS score, + s.created_at AS created_at, + u.player AS player + FROM scores s + JOIN users u ON u.id = s.user_id + ORDER BY s.score DESC, s.created_at ASC + LIMIT $1`, + [limit], + ) + return result.rows.map((r) => ({ ...r, created_at: toIso(r.created_at) })) +} + +/** Fetch the all-time best score for a single user. Returns `null` if no scores. */ +export async function bestScoreForUser(db: Db, userId: string): Promise { + const result = await db.query( + `SELECT s.id::text AS id, + s.user_id::text AS user_id, + s.score AS score, + s.created_at AS created_at, + u.player AS player + FROM scores s + JOIN users u ON u.id = s.user_id + WHERE s.user_id = $1 + ORDER BY s.score DESC, s.created_at ASC + LIMIT 1`, + [userId], + ) + const row = result.rows[0] + if (!row) return null + return { ...row, created_at: toIso(row.created_at) } +} + +function toIso(value: unknown): string { + if (value instanceof Date) return value.toISOString() + if (typeof value === 'string') return value + return new Date(String(value)).toISOString() +} diff --git a/backend/src/routes/leaderboard.ts b/backend/src/routes/leaderboard.ts new file mode 100644 index 0000000..d63f2e3 --- /dev/null +++ b/backend/src/routes/leaderboard.ts @@ -0,0 +1,193 @@ +/** + * Score / leaderboard / user routes. + * + * POST /api/users/register — create a user, returns API token + * POST /api/scores — submit a score (requires X-Player-Token) + * GET /api/leaderboard?limit=N — top-N scores (limit clamped 1..100, default 10) + * GET /api/users/:id/best — that user's best score + * + * Auth model: a single bearer token per user, sent as `X-Player-Token`. + * That's enough for a hobby leaderboard; promoting to JWT/refresh tokens + * is out of scope for T05. + */ + +import { Router, type Request, type Response, type NextFunction } from 'express' +import { z } from 'zod' +import type { + LeaderboardResponse, + RegisterUserResponse, + ScoreEntry, + SubmitScoreResponse, +} from '@snake/shared' +import { getDb, type Db } from '../db.js' +import { + bestScoreForUser, + createUser, + findUserById, + findUserByToken, + recordScore, + topScores, + type ScoreRow, +} from '../repo.js' + +// ---------- Validation schemas --------------------------------------------- + +// Player handle: 1-32 chars, alphanumeric + ._- . Keeps the leaderboard +// readable and avoids zero-width / control chars sneaking in. +const PlayerHandle = z + .string() + .trim() + .min(1, 'player handle must not be empty') + .max(32, 'player handle too long') + .regex(/^[A-Za-z0-9._-]+$/, 'player handle must be alphanumeric (._- allowed)') + +const RegisterBody = z.object({ player: PlayerHandle }) + +const SubmitBody = z.object({ + // `player` is accepted for parity with `SubmitScoreRequest` from + // @snake/shared but is *advisory* — the authoritative identity comes + // from the X-Player-Token header. We just sanity-check that it matches + // the token's user when both are supplied. + player: PlayerHandle.optional(), + score: z.number().int().nonnegative().max(1_000_000), + meta: z.record(z.unknown()).optional(), +}) + +const LeaderboardQuery = z.object({ + limit: z + .preprocess((v) => (v === undefined ? undefined : Number(v)), z.number().int().min(1).max(100)) + .optional(), +}) + +// ---------- Helpers --------------------------------------------------------- + +function rowToEntry(row: ScoreRow, rank?: number): ScoreEntry { + return { + id: row.id, + player: row.player, + score: row.score, + createdAt: row.created_at, + ...(rank !== undefined ? { rank } : {}), + } +} + +/** + * Resolve the authenticated user from the `X-Player-Token` header. + * Throws a 401-flavoured error if the header is missing or invalid. + */ +async function authenticate(db: Db, req: Request) { + const raw = req.header('x-player-token') + if (!raw || typeof raw !== 'string') { + const err: Error & { status?: number } = new Error('missing X-Player-Token header') + err.status = 401 + throw err + } + const user = await findUserByToken(db, raw.trim()) + if (!user) { + const err: Error & { status?: number } = new Error('invalid X-Player-Token') + err.status = 401 + throw err + } + return user +} + +// ---------- Router factory -------------------------------------------------- + +export interface LeaderboardRouterOptions { + /** Optional DB override (used by tests). Falls back to `getDb()`. */ + db?: Db +} + +export function leaderboardRouter(opts: LeaderboardRouterOptions = {}): Router { + const router = Router() + const db = async (): Promise => opts.db ?? (await getDb()) + + router.post('/users/register', async (req, res, next) => { + try { + const body = RegisterBody.parse(req.body) + const user = await createUser(await db(), body.player) + const out: RegisterUserResponse = { + id: user.id, + player: user.player, + token: user.api_token, + } + res.status(201).json(out) + } catch (e) { + next(e) + } + }) + + router.post('/scores', async (req, res, next) => { + try { + const body = SubmitBody.parse(req.body) + const conn = await db() + const user = await authenticate(conn, req) + if (body.player && body.player.toLowerCase() !== user.player.toLowerCase()) { + res.status(403).json({ error: 'player handle does not match token' }) + return + } + const row = await recordScore(conn, user.id, body.score, body.meta ?? {}) + const best = await bestScoreForUser(conn, user.id) + const out: SubmitScoreResponse = { + entry: rowToEntry(row), + bestScore: best?.score ?? row.score, + } + res.status(201).json(out) + } catch (e) { + next(e) + } + }) + + router.get('/leaderboard', async (req, res, next) => { + try { + const q = LeaderboardQuery.parse(req.query) + const limit = q.limit ?? 10 + const rows = await topScores(await db(), limit) + const out: LeaderboardResponse = { + entries: rows.map((r, i) => rowToEntry(r, i + 1)), + generatedAt: new Date().toISOString(), + } + res.json(out) + } catch (e) { + next(e) + } + }) + + router.get('/users/:id/best', async (req, res, next) => { + try { + const conn = await db() + const id = req.params.id + if (!id || !/^\d+$/.test(id)) { + res.status(400).json({ error: 'invalid user id' }) + return + } + const user = await findUserById(conn, id) + if (!user) { + res.status(404).json({ error: 'user not found' }) + return + } + const best = await bestScoreForUser(conn, id) + if (!best) { + res.status(404).json({ error: 'no scores recorded for user' }) + return + } + res.json({ entry: rowToEntry(best) }) + } catch (e) { + next(e) + } + }) + + // Centralised error handler for this router. Validation failures from + // zod become 400s; explicit `.status` on thrown errors is honoured. + router.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'validation failed', issues: err.issues }) + return + } + const status = (err as { status?: number })?.status ?? 500 + const message = err instanceof Error ? err.message : 'internal error' + res.status(status).json({ error: message }) + }) + + return router +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 1620a79..e738be9 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,4 +1,5 @@ import express from 'express' +import { leaderboardRouter } from './routes/leaderboard.js' const app = express() app.use(express.json()) @@ -7,6 +8,11 @@ app.get('/api/health', (_req, res) => { res.json({ status: 'ok', service: 'snake-backend' }) }) +// Score / leaderboard / user routes. Mounted under `/api` so the dev proxy +// in the frontend Vite config (`/api -> http://localhost:8787`) hits them +// without extra rewriting. +app.use('/api', leaderboardRouter()) + const port = Number(process.env.PORT ?? 8787) if (process.env.NODE_ENV !== 'test') { diff --git a/shared/src/index.ts b/shared/src/index.ts index 3b11c69..1c5a425 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -13,9 +13,35 @@ export interface ScoreEntry { player: string score: number createdAt: string + /** 1-based position in the leaderboard response (optional, server-supplied). */ + rank?: number } export interface SubmitScoreRequest { player: string score: number + /** Optional client metadata persisted on the session row. */ + meta?: Record +} + +/** Response shape of `POST /api/scores`. */ +export interface SubmitScoreResponse { + entry: ScoreEntry + /** Player's all-time best score after this submission. */ + bestScore: number +} + +/** Response shape of `GET /api/leaderboard?limit=N`. */ +export interface LeaderboardResponse { + entries: ScoreEntry[] + /** When the server generated this response, ISO-8601. */ + generatedAt: string +} + +/** Response shape of `POST /api/users/register`. */ +export interface RegisterUserResponse { + id: string + player: string + /** Bearer token for `X-Player-Token` header on subsequent writes. */ + token: string }