From aa2d3dc0d2fbef261554efdbf01c98f0da809b7b Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Sun, 28 Jun 2026 17:02:50 +0100 Subject: [PATCH 1/7] Implement API for session management with WebSocket support and local development configuration - Added new API routes for session management including creation, retrieval, and updates. - Introduced WebSocket support for real-time updates in session rooms. - Configured local development environment variables in `.env.example`. - Updated package scripts for local API development. - Added middleware for request validation and body size limits. - Implemented pagination for session listing. - Created utility functions for database connection and error handling. - Added OpenAPI documentation for the new endpoints. - Included tests for session routes and pagination functionality. - Refactored existing code for improved structure and maintainability. --- .env.example | 4 + apps/api/src/__tests__/pagination.test.ts | 14 + apps/api/src/__tests__/scores.test.ts | 24 ++ apps/api/src/__tests__/session-room.test.ts | 35 +++ .../api/src/__tests__/sessions.routes.test.ts | 190 ++++++++++++++ apps/api/src/app.ts | 29 +++ apps/api/src/env.ts | 7 +- apps/api/src/index.ts | 156 +----------- apps/api/src/lib/db.ts | 15 ++ apps/api/src/lib/errors.ts | 18 ++ apps/api/src/lib/pagination.ts | 30 +++ apps/api/src/lib/scores.ts | 15 ++ apps/api/src/lib/serialize.ts | 82 ++++++ apps/api/src/middleware.ts | 10 + apps/api/src/openapi/spec.ts | 239 ++++++++++++++++++ apps/api/src/principal.ts | 3 +- apps/api/src/routes/sessions.ts | 73 ++++++ apps/api/src/services/sessions.ts | 165 ++++++++++++ apps/api/src/session-room.ts | 22 +- apps/api/wrangler.toml | 5 + apps/web/src/components/PlayerAvatar.tsx | 32 +++ apps/web/src/hooks/useLiveSession.ts | 25 +- apps/web/src/lib/api.ts | 2 +- apps/web/src/main.tsx | 41 ++- apps/web/src/routes/History.tsx | 42 ++- apps/web/src/routes/Overview.tsx | 58 ++++- apps/web/src/routes/SessionDetail.tsx | 23 +- apps/web/vite.config.d.ts.map | 2 +- apps/web/vite.config.js | 1 + apps/web/vite.config.ts | 1 + package.json | 2 +- .../db/migrations/meta/0000_snapshot.json | 58 ++--- packages/db/migrations/meta/_journal.json | 2 +- packages/db/migrations/zzzz_force_rls.sql | 8 + packages/db/src/client.ts | 2 + packages/db/src/force-rls.ts | 2 +- packages/db/{ => src}/load-env.ts | 2 +- packages/db/src/reset.ts | 2 +- packages/db/src/seed.ts | 2 +- scripts/wrangler-dev.mjs | 29 +++ 40 files changed, 1224 insertions(+), 248 deletions(-) create mode 100644 apps/api/src/__tests__/pagination.test.ts create mode 100644 apps/api/src/__tests__/scores.test.ts create mode 100644 apps/api/src/__tests__/session-room.test.ts create mode 100644 apps/api/src/__tests__/sessions.routes.test.ts create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/lib/db.ts create mode 100644 apps/api/src/lib/errors.ts create mode 100644 apps/api/src/lib/pagination.ts create mode 100644 apps/api/src/lib/scores.ts create mode 100644 apps/api/src/lib/serialize.ts create mode 100644 apps/api/src/openapi/spec.ts create mode 100644 apps/api/src/routes/sessions.ts create mode 100644 apps/api/src/services/sessions.ts create mode 100644 apps/web/src/components/PlayerAvatar.tsx rename packages/db/{ => src}/load-env.ts (96%) create mode 100644 scripts/wrangler-dev.mjs diff --git a/.env.example b/.env.example index 6f679c1..dfdf5de 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,7 @@ R2_PUBLIC_BASE_PROD="https://oche-media.r2.example" # --- Cloudflare (CI) --- CLOUDFLARE_ACCOUNT_ID="" CLOUDFLARE_API_TOKEN="" + +# --- Local dev (SPA + API) --- +VITE_API_BASE="http://localhost:8787" +APP_ORIGIN="http://localhost:5173" diff --git a/apps/api/src/__tests__/pagination.test.ts b/apps/api/src/__tests__/pagination.test.ts new file mode 100644 index 0000000..8f865be --- /dev/null +++ b/apps/api/src/__tests__/pagination.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { decodeCursor, encodeCursor } from '../lib/pagination.js'; + +describe('pagination cursor', () => { + it('round-trips a cursor', () => { + const cursor = { createdAt: '2026-01-01T12:00:00.000Z', id: '11111111-1111-1111-1111-111111111111' }; + const encoded = encodeCursor(cursor); + expect(decodeCursor(encoded)).toEqual(cursor); + }); + + it('returns null for invalid cursor', () => { + expect(decodeCursor('not-valid')).toBeNull(); + }); +}); diff --git a/apps/api/src/__tests__/scores.test.ts b/apps/api/src/__tests__/scores.test.ts new file mode 100644 index 0000000..176998a --- /dev/null +++ b/apps/api/src/__tests__/scores.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { applyScoreChange, SCORE_MAX, SCORE_MIN } from '../lib/scores.js'; + +describe('applyScoreChange', () => { + it('applies a positive delta', () => { + expect(applyScoreChange(40, { delta: 20 })).toEqual({ ok: true, next: 60, delta: 20 }); + }); + + it('rejects scores below minimum', () => { + const r = applyScoreChange(5, { delta: -10 }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain(String(SCORE_MIN)); + }); + + it('rejects scores above maximum', () => { + const r = applyScoreChange(SCORE_MAX, { delta: 1 }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain(String(SCORE_MAX)); + }); + + it('honours absolute set', () => { + expect(applyScoreChange(40, { set: 100 })).toEqual({ ok: true, next: 100, delta: 60 }); + }); +}); diff --git a/apps/api/src/__tests__/session-room.test.ts b/apps/api/src/__tests__/session-room.test.ts new file mode 100644 index 0000000..f37ae08 --- /dev/null +++ b/apps/api/src/__tests__/session-room.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import type { WsServerMessage } from '@oche/shared'; +import { SessionRoom } from '../session-room.js'; + +describe('SessionRoom', () => { + it('accepts POST /broadcast and fans out to websockets', async () => { + const sent: string[] = []; + const ws = { send: (msg: string) => sent.push(msg) } as unknown as WebSocket; + const ctx = { + getWebSockets: () => [ws], + acceptWebSocket: () => {}, + storage: { setAlarm: async () => {}, deleteAlarm: async () => {} }, + }; + + const room = new SessionRoom(ctx as never, {} as never); + const message: WsServerMessage = { + type: 'score', + playerId: '11111111-1111-1111-1111-111111111111', + newScore: 50, + delta: 10, + }; + + const res = await room.fetch( + new Request('https://session-room/broadcast', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(message), + }), + ); + + expect(res.status).toBe(200); + expect(sent).toHaveLength(1); + expect(JSON.parse(sent[0]!)).toEqual(message); + }); +}); diff --git a/apps/api/src/__tests__/sessions.routes.test.ts b/apps/api/src/__tests__/sessions.routes.test.ts new file mode 100644 index 0000000..874944a --- /dev/null +++ b/apps/api/src/__tests__/sessions.routes.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createApp } from '../app.js'; +import type { Env } from '../env.js'; + +const OWNER_A = '11111111-1111-1111-1111-111111111111'; +const OWNER_B = '22222222-2222-2222-2222-222222222222'; +const SESSION_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const PLAYER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + +const mockSession = { + id: SESSION_ID, + ownerId: OWNER_A, + status: 'active' as const, + title: 'Test Session', + videoUrl: null, + videoPoster: null, + hlsUrl: null, + createdAt: new Date('2026-06-01T12:00:00.000Z'), + updatedAt: new Date('2026-06-01T12:00:00.000Z'), +}; + +const mockPlayer = { + id: PLAYER_ID, + sessionId: SESSION_ID, + name: 'Alice', + score: 40, + photoUrl: null, + position: 0, +}; + +const mockTx = { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + execute: vi.fn(), +}; + +vi.mock('@oche/db', () => ({ + createDb: vi.fn(() => ({})), + withPrincipal: vi.fn(async (_db, ownerId, fn) => { + if (ownerId === OWNER_B) { + const emptyTx = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => ({ + limit: vi.fn(async () => []), + })), + })), + })), + })), + }; + return fn(emptyTx); + } + return fn(mockTx); + }), + sessions: { id: 'id', createdAt: 'createdAt', ownerId: 'ownerId' }, + players: { id: 'id', sessionId: 'sessionId', position: 'position' }, + scoreEvents: { playerId: 'playerId', createdAt: 'createdAt' }, +})); + +function chainSelect(rows: unknown[]) { + const limit = vi.fn(async () => rows); + const orderBy = vi.fn(() => ({ limit, orderBy: vi.fn(() => ({ limit })) })); + const where = vi.fn(() => ({ orderBy, limit, where: vi.fn(() => ({ orderBy, limit })) })); + const from = vi.fn(() => ({ where, orderBy, limit })); + mockTx.select.mockReturnValue({ from }); + return { where, orderBy, limit }; +} + +function mockEnv(): Env { + return { + DATABASE_URL: 'postgresql://test', + APP_ORIGIN: 'http://localhost:5173', + ENVIRONMENT: 'development', + SESSION_ROOM: { + idFromName: () => ({ fetch: vi.fn(async () => new Response(JSON.stringify({ ok: true }))) }), + } as unknown as Env['SESSION_ROOM'], + MEDIA: {} as Env['MEDIA'], + }; +} + +describe('sessions API routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('GET /health is public', async () => { + const app = createApp(); + const res = await app.request('/health', {}, mockEnv()); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ ok: true }); + }); + + it('GET /openapi.json is public', async () => { + const app = createApp(); + const res = await app.request('/openapi.json', {}, mockEnv()); + expect(res.status).toBe(200); + const body = (await res.json()) as { openapi: string }; + expect(body.openapi).toBe('3.1.0'); + }); + + it('returns 401 without principal header', async () => { + const app = createApp(); + const res = await app.request('/sessions', {}, mockEnv()); + expect(res.status).toBe(401); + }); + + it('GET /sessions returns paginated summaries', async () => { + chainSelect([mockSession]); + mockTx.select.mockImplementationOnce(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => ({ + limit: vi.fn(async () => [mockSession]), + })), + })), + })), + })); + mockTx.select.mockImplementationOnce(() => ({ + from: vi.fn(() => ({ + where: vi.fn(async () => [mockPlayer]), + })), + })); + + const app = createApp(); + const res = await app.request('/sessions', { headers: { 'x-oche-owner': 'demo-key-a' } }, mockEnv()); + expect(res.status).toBe(200); + expect(res.headers.get('Cache-Control')).toContain('max-age=15'); + const body = (await res.json()) as { data: unknown[]; nextCursor: string | null }; + expect(body.data).toHaveLength(1); + }); + + it('GET /sessions/:id returns 404 when hidden by RLS', async () => { + chainSelect([]); + mockTx.select.mockReturnValue({ + from: vi.fn(() => ({ + where: vi.fn(async () => []), + })), + }); + + const app = createApp(); + const res = await app.request( + `/sessions/${SESSION_ID}`, + { headers: { 'x-oche-owner': 'demo-key-a' } }, + mockEnv(), + ); + expect(res.status).toBe(404); + }); + + it('POST /sessions rejects unknown keys (strict Zod)', async () => { + const app = createApp(); + const res = await app.request( + '/sessions', + { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-oche-owner': 'demo-key-a' }, + body: JSON.stringify({ title: 'x', players: [{ name: 'A' }], extra: true }), + }, + mockEnv(), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string; issues?: unknown[] }; + expect(body.error).toBe('Invalid body'); + expect(body.issues?.length).toBeGreaterThan(0); + }); + + it('PATCH /sessions/:id rejects empty body', async () => { + const app = createApp(); + const res = await app.request( + `/sessions/${SESSION_ID}`, + { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-oche-owner': 'demo-key-a' }, + body: JSON.stringify({}), + }, + mockEnv(), + ); + expect(res.status).toBe(422); + }); + + it('RLS isolation: other owner sees empty list', async () => { + const app = createApp(); + const res = await app.request('/sessions', { headers: { 'x-oche-owner': 'demo-key-b' } }, mockEnv()); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: unknown[] }; + expect(body.data).toEqual([]); + }); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..734d850 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,29 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import type { Env } from './env.js'; +import { bodySizeLimit, rateLimit, securityHeaders } from './middleware.js'; +import { openApiDocument } from './openapi/spec.js'; +import { sessions } from './routes/sessions.js'; + +export function createApp() { + const app = new Hono<{ Bindings: Env }>(); + + app.use('*', securityHeaders); + app.use('*', bodySizeLimit()); + app.use('*', rateLimit()); + app.use('*', (c, next) => cors({ origin: c.env.APP_ORIGIN, credentials: true })(c, next)); + + app.get('/health', (c) => c.json({ ok: true, env: c.env.ENVIRONMENT })); + app.get('/openapi.json', (c) => c.json(openApiDocument)); + + app.route('/sessions', sessions); + + app.onError((err, c) => { + console.error(err); + return c.json({ error: 'Internal error' }, 500); + }); + + return app; +} + +export default createApp(); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 9122e39..fd7f8e5 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -1,9 +1,12 @@ import type { DurableObjectNamespace, Hyperdrive, R2Bucket } from '@cloudflare/workers-types'; export interface Env { - HYPERDRIVE: Hyperdrive; + /** Hyperdrive in staging/production; optional locally. */ + HYPERDRIVE?: Hyperdrive; + /** Direct Neon URL for local wrangler dev (.dev.vars). */ + DATABASE_URL?: string; SESSION_ROOM: DurableObjectNamespace; MEDIA: R2Bucket; APP_ORIGIN: string; - ENVIRONMENT: 'staging' | 'production'; + ENVIRONMENT: 'development' | 'staging' | 'production'; } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7a8d573..0d07d16 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,158 +1,8 @@ /** - * Oche API — Hono on Cloudflare Workers. - * Endpoints: GET /sessions, GET /sessions/:id, POST /sessions, PATCH /sessions/:id, WS /sessions/:id/live. - * Every DB call runs inside withPrincipal(), so RLS enforces per-owner isolation. + * Oche API — Cloudflare Worker entry (REST + SessionRoom Durable Object). */ -import { createDb, players, scoreEvents, sessions, withPrincipal } from '@oche/db'; -import { CreateSessionInput, PatchSessionInput } from '@oche/shared'; -import { and, desc, eq } from 'drizzle-orm'; -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import type { Env } from './env.js'; -import { rateLimit, securityHeaders } from './middleware.js'; -import { resolvePrincipal } from './principal.js'; +import { createApp } from './app.js'; export { SessionRoom } from './session-room.js'; -const app = new Hono<{ Bindings: Env; Variables: { ownerId: string } }>(); - -app.use('*', securityHeaders); -app.use('*', rateLimit()); -app.use('*', (c, next) => cors({ origin: c.env.APP_ORIGIN, credentials: true })(c, next)); - -// Resolve principal once per request. -app.use('/sessions/*', async (c, next) => { - const ownerId = resolvePrincipal(c); - if (!ownerId) return c.json({ error: 'Unauthorised' }, 401); - c.set('ownerId', ownerId); - await next(); -}); - -app.get('/health', (c) => c.json({ ok: true, env: c.env.ENVIRONMENT })); - -app.get('/sessions', async (c) => { - const db = createDb(c.env.HYPERDRIVE.connectionString); - const rows = await withPrincipal(db, c.get('ownerId'), async (tx) => { - const list = await tx.select().from(sessions).orderBy(desc(sessions.createdAt)).limit(50); - return Promise.all( - list.map(async (s) => { - const ps = await tx.select().from(players).where(eq(players.sessionId, s.id)); - return { ...s, playerCount: ps.length }; - }), - ); - }); - return c.json({ data: rows, nextCursor: null }); -}); - -app.get('/sessions/:id', async (c) => { - const id = c.req.param('id'); - const db = createDb(c.env.HYPERDRIVE.connectionString); - const result = await withPrincipal(db, c.get('ownerId'), async (tx) => { - const [s] = await tx.select().from(sessions).where(eq(sessions.id, id)); - if (!s) return null; - const ps = await tx.select().from(players).where(eq(players.sessionId, id)).orderBy(players.position); - const events = ps.length - ? await tx - .select() - .from(scoreEvents) - .where(eq(scoreEvents.playerId, ps[0]!.id)) - .orderBy(desc(scoreEvents.createdAt)) - .limit(20) - : []; - return { ...s, players: ps, scoreEvents: events }; - }); - return result ? c.json(result) : c.json({ error: 'Session not found' }, 404); -}); - -app.post('/sessions', async (c) => { - const parsed = CreateSessionInput.safeParse(await c.req.json().catch(() => null)); - if (!parsed.success) { - return c.json( - { - error: 'Invalid body', - issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), - }, - 422, - ); - } - const db = createDb(c.env.HYPERDRIVE.connectionString); - const created = await withPrincipal(db, c.get('ownerId'), async (tx) => { - const [s] = await tx - .insert(sessions) - .values({ - ownerId: c.get('ownerId'), - title: parsed.data.title, - status: parsed.data.status, - videoUrl: parsed.data.videoUrl, - videoPoster: parsed.data.videoPoster, - }) - .returning(); - await tx.insert(players).values(parsed.data.players.map((p) => ({ ...p, sessionId: s!.id }))); - return s; - }); - return c.json(created, 201); -}); - -app.patch('/sessions/:id', async (c) => { - const id = c.req.param('id'); - const parsed = PatchSessionInput.safeParse(await c.req.json().catch(() => null)); - if (!parsed.success) { - return c.json( - { - error: 'Invalid body', - issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), - }, - 422, - ); - } - const db = createDb(c.env.HYPERDRIVE.connectionString); - const updates = await withPrincipal(db, c.get('ownerId'), async (tx) => { - const [s] = await tx.select().from(sessions).where(eq(sessions.id, id)); - if (!s) return null; - if (parsed.data.status) { - await tx - .update(sessions) - .set({ status: parsed.data.status, updatedAt: new Date() }) - .where(eq(sessions.id, id)); - } - const broadcasts: Array<{ playerId: string; newScore: number; delta: number }> = []; - for (const change of parsed.data.scores ?? []) { - const [p] = await tx - .select() - .from(players) - .where(and(eq(players.id, change.playerId), eq(players.sessionId, id))); - if (!p) continue; - const newScore = change.set ?? p.score + (change.delta ?? 0); - const delta = newScore - p.score; - await tx.update(players).set({ score: newScore }).where(eq(players.id, p.id)); - await tx.insert(scoreEvents).values({ playerId: p.id, delta, newScore }); - broadcasts.push({ playerId: p.id, newScore, delta }); - } - return { status: parsed.data.status, broadcasts }; - }); - if (!updates) return c.json({ error: 'Session not found' }, 404); - - // Fan out live updates via the Durable Object. - const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(id)); - for (const b of updates.broadcasts) { - await stub.fetch('https://do/broadcast', { - method: 'POST', - body: JSON.stringify({ type: 'score', ...b }), - }); - } - return c.json({ ok: true }); -}); - -// WebSocket upgrade → routed to the per-session Durable Object. -app.get('/sessions/:id/live', (c) => { - const id = c.req.param('id'); - const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(id)); - return stub.fetch(c.req.raw); -}); - -app.onError((err, c) => { - console.error(err); // structured log; never returned to the client - return c.json({ error: 'Internal error' }, 500); -}); - -export default app; +export default createApp(); diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts new file mode 100644 index 0000000..b6465fe --- /dev/null +++ b/apps/api/src/lib/db.ts @@ -0,0 +1,15 @@ +import { createDb, type Db } from '@oche/db'; +import type { Env } from '../env.js'; + +/** Runtime DB URL: Hyperdrive in deployed envs, DATABASE_URL from .dev.vars locally. */ +export function getConnectionString(env: Env): string { + const url = env.HYPERDRIVE?.connectionString ?? env.DATABASE_URL; + if (!url) { + throw new Error('Database not configured (set HYPERDRIVE or DATABASE_URL in .dev.vars)'); + } + return url; +} + +export function getDb(env: Env): Db { + return createDb(getConnectionString(env)); +} diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts new file mode 100644 index 0000000..df0c3b2 --- /dev/null +++ b/apps/api/src/lib/errors.ts @@ -0,0 +1,18 @@ +import type { Context } from 'hono'; +import type { ZodError } from 'zod'; + +export function validationError(c: Context, error: ZodError) { + return c.json( + { + error: 'Invalid body', + issues: error.issues.map((i) => ({ path: i.path.join('.') || '(root)', message: i.message })), + }, + 422, + ); +} + +export async function readJsonBody(c: Context): Promise { + const ct = c.req.header('content-type') ?? ''; + if (!ct.includes('application/json')) return null; + return c.req.json().catch(() => null); +} diff --git a/apps/api/src/lib/pagination.ts b/apps/api/src/lib/pagination.ts new file mode 100644 index 0000000..6b672bd --- /dev/null +++ b/apps/api/src/lib/pagination.ts @@ -0,0 +1,30 @@ +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 50; + +export function parseListQuery(url: string): { limit: number; cursor: ListCursor | null } { + const params = new URL(url).searchParams; + const limitRaw = Number(params.get('limit') ?? DEFAULT_LIMIT); + const limit = Number.isFinite(limitRaw) + ? Math.min(MAX_LIMIT, Math.max(1, Math.floor(limitRaw))) + : DEFAULT_LIMIT; + + const cursorRaw = params.get('cursor'); + const cursor = cursorRaw ? decodeCursor(cursorRaw) : null; + return { limit, cursor }; +} + +export type ListCursor = { createdAt: string; id: string }; + +export function encodeCursor(cursor: ListCursor): string { + return Buffer.from(JSON.stringify(cursor)).toString('base64url'); +} + +export function decodeCursor(raw: string): ListCursor | null { + try { + const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as ListCursor; + if (typeof parsed.createdAt === 'string' && typeof parsed.id === 'string') return parsed; + return null; + } catch { + return null; + } +} diff --git a/apps/api/src/lib/scores.ts b/apps/api/src/lib/scores.ts new file mode 100644 index 0000000..d7c9447 --- /dev/null +++ b/apps/api/src/lib/scores.ts @@ -0,0 +1,15 @@ +/** Score bounds for PATCH updates (501-style cumulative scoring). */ +export const SCORE_MIN = 0; +export const SCORE_MAX = 999; + +export function applyScoreChange( + current: number, + change: { delta?: number; set?: number }, +): { ok: true; next: number; delta: number } | { ok: false; error: string } { + const next = change.set ?? current + (change.delta ?? 0); + if (!Number.isInteger(next)) return { ok: false, error: 'Score must be an integer' }; + if (next < SCORE_MIN || next > SCORE_MAX) { + return { ok: false, error: `Score must be between ${SCORE_MIN} and ${SCORE_MAX}` }; + } + return { ok: true, next, delta: next - current }; +} diff --git a/apps/api/src/lib/serialize.ts b/apps/api/src/lib/serialize.ts new file mode 100644 index 0000000..ed27f98 --- /dev/null +++ b/apps/api/src/lib/serialize.ts @@ -0,0 +1,82 @@ +import type { Player, ScoreEvent, Session, SessionSummary } from '@oche/shared'; + +type SessionRow = { + id: string; + status: 'active' | 'completed'; + title: string; + videoUrl: string | null; + videoPoster: string | null; + hlsUrl: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type PlayerRow = { + id: string; + name: string; + score: number; + photoUrl: string | null; + position: number; +}; + +type ScoreEventRow = { + id: string; + playerId: string; + delta: number; + newScore: number; + createdAt: Date; +}; + +function toIso(d: Date): string { + return d.toISOString(); +} + +export function serializePlayer(p: PlayerRow): Player { + return { + id: p.id, + name: p.name, + score: p.score, + photoUrl: p.photoUrl ?? undefined, + position: p.position, + }; +} + +export function serializeScoreEvent(e: ScoreEventRow): ScoreEvent { + return { + id: e.id, + playerId: e.playerId, + delta: e.delta, + newScore: e.newScore, + createdAt: toIso(e.createdAt), + }; +} + +export function serializeSession( + s: SessionRow, + players: PlayerRow[], + scoreEvents: ScoreEventRow[] = [], +): Session { + return { + id: s.id, + status: s.status, + title: s.title, + videoUrl: s.videoUrl ?? undefined, + videoPoster: s.videoPoster ?? undefined, + hlsUrl: s.hlsUrl ?? undefined, + players: players.map(serializePlayer), + scoreEvents: scoreEvents.map(serializeScoreEvent), + createdAt: toIso(s.createdAt), + updatedAt: toIso(s.updatedAt), + }; +} + +export function serializeSessionSummary(s: SessionRow, playerCount: number): SessionSummary { + return { + id: s.id, + status: s.status, + title: s.title, + videoPoster: s.videoPoster ?? undefined, + createdAt: toIso(s.createdAt), + playerCount, + }; +} diff --git a/apps/api/src/middleware.ts b/apps/api/src/middleware.ts index 62082a2..1d4535a 100644 --- a/apps/api/src/middleware.ts +++ b/apps/api/src/middleware.ts @@ -6,8 +6,18 @@ export const securityHeaders: MiddlewareHandler = async (c, next) => { c.header('X-Frame-Options', 'DENY'); c.header('Referrer-Policy', 'strict-origin-when-cross-origin'); c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + c.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"); }; +/** Reject oversized JSON bodies before parsing. */ +export const bodySizeLimit = + (maxBytes = 32_768): MiddlewareHandler => + async (c, next) => { + const len = Number(c.req.header('content-length') ?? 0); + if (len > maxBytes) return c.json({ error: 'Request body too large' }, 413); + await next(); + }; + /** Tiny in-memory token bucket per colo. Production: use a Durable Object or KV. */ const buckets = new Map(); export const rateLimit = diff --git a/apps/api/src/openapi/spec.ts b/apps/api/src/openapi/spec.ts new file mode 100644 index 0000000..6b1d38b --- /dev/null +++ b/apps/api/src/openapi/spec.ts @@ -0,0 +1,239 @@ +/** OpenAPI 3.1 document — kept in sync with @oche/shared Zod schemas. */ +export const openApiDocument = { + openapi: '3.1.0', + info: { + title: 'Oche API', + version: '1.0.0', + description: + 'Game Session Dashboard API. All /sessions routes require header `x-oche-owner` (take-home stand-in for JWT auth).', + }, + servers: [{ url: 'http://localhost:8787', description: 'Local wrangler dev' }], + tags: [{ name: 'sessions' }], + components: { + securitySchemes: { + OcheOwner: { type: 'apiKey', in: 'header', name: 'x-oche-owner' }, + }, + schemas: { + ApiError: { + type: 'object', + required: ['error'], + properties: { + error: { type: 'string' }, + issues: { + type: 'array', + items: { + type: 'object', + properties: { path: { type: 'string' }, message: { type: 'string' } }, + }, + }, + }, + }, + SessionSummary: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + status: { type: 'string', enum: ['active', 'completed'] }, + title: { type: 'string' }, + videoPoster: { type: 'string', format: 'uri' }, + createdAt: { type: 'string', format: 'date-time' }, + playerCount: { type: 'integer' }, + }, + }, + Session: { + allOf: [ + { $ref: '#/components/schemas/SessionSummary' }, + { + type: 'object', + properties: { + videoUrl: { type: 'string', format: 'uri' }, + hlsUrl: { type: 'string', format: 'uri' }, + updatedAt: { type: 'string', format: 'date-time' }, + players: { + type: 'array', + items: { $ref: '#/components/schemas/Player' }, + }, + scoreEvents: { + type: 'array', + items: { $ref: '#/components/schemas/ScoreEvent' }, + }, + }, + }, + ], + }, + Player: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + score: { type: 'integer' }, + photoUrl: { type: 'string', format: 'uri' }, + position: { type: 'integer' }, + }, + }, + ScoreEvent: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + playerId: { type: 'string', format: 'uuid' }, + delta: { type: 'integer' }, + newScore: { type: 'integer' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + CreateSessionInput: { + type: 'object', + required: ['title', 'players'], + additionalProperties: false, + properties: { + title: { type: 'string', minLength: 1, maxLength: 160 }, + status: { type: 'string', enum: ['active', 'completed'], default: 'active' }, + videoUrl: { type: 'string', format: 'uri' }, + videoPoster: { type: 'string', format: 'uri' }, + players: { + type: 'array', + minItems: 1, + maxItems: 12, + items: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + score: { type: 'integer', default: 0 }, + photoUrl: { type: 'string', format: 'uri' }, + position: { type: 'integer', default: 0 }, + }, + }, + }, + }, + }, + PatchSessionInput: { + type: 'object', + additionalProperties: false, + properties: { + status: { type: 'string', enum: ['active', 'completed'] }, + scores: { + type: 'array', + maxItems: 12, + items: { + type: 'object', + required: ['playerId'], + properties: { + playerId: { type: 'string', format: 'uuid' }, + delta: { type: 'integer' }, + set: { type: 'integer' }, + }, + }, + }, + }, + }, + }, + }, + security: [{ OcheOwner: [] }], + paths: { + '/health': { + get: { + security: [], + responses: { + '200': { + description: 'Liveness probe', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + }, + }, + '/sessions': { + get: { + tags: ['sessions'], + parameters: [ + { name: 'limit', in: 'query', schema: { type: 'integer', default: 20, maximum: 50 } }, + { name: 'cursor', in: 'query', schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'Paginated session list (owner-scoped via RLS)', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/SessionSummary' } }, + nextCursor: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + '401': { + description: 'Missing/invalid principal', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } }, + }, + }, + }, + post: { + tags: ['sessions'], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateSessionInput' } } }, + }, + responses: { + '201': { + description: 'Created session', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Session' } } }, + }, + '422': { + description: 'Validation error', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } }, + }, + }, + }, + }, + '/sessions/{id}': { + get: { + tags: ['sessions'], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Session detail', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Session' } } }, + }, + '404': { + description: 'Not found or not visible (RLS)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } }, + }, + }, + }, + patch: { + tags: ['sessions'], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/PatchSessionInput' } } }, + }, + responses: { + '200': { + description: 'Updated', + content: { + 'application/json': { schema: { type: 'object', properties: { ok: { type: 'boolean' } } } }, + }, + }, + '404': { + description: 'Not found', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } }, + }, + '422': { + description: 'Validation error', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } }, + }, + }, + }, + }, + '/sessions/{id}/live': { + get: { + tags: ['sessions'], + summary: 'WebSocket upgrade for live score/status stream', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { '101': { description: 'Switching Protocols' } }, + }, + }, + }, +} as const; diff --git a/apps/api/src/principal.ts b/apps/api/src/principal.ts index eb0a659..2c50793 100644 --- a/apps/api/src/principal.ts +++ b/apps/api/src/principal.ts @@ -13,6 +13,7 @@ const DEMO_OWNERS: Record = { }; export function resolvePrincipal(c: Context): string | null { - const key = c.req.header('x-oche-owner') ?? 'demo-key-a'; + const key = c.req.header('x-oche-owner') ?? c.req.query('key'); + if (!key) return null; return DEMO_OWNERS[key] ?? null; } diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts new file mode 100644 index 0000000..e39ec44 --- /dev/null +++ b/apps/api/src/routes/sessions.ts @@ -0,0 +1,73 @@ +import { CreateSessionInput, PatchSessionInput } from '@oche/shared'; +import { Hono } from 'hono'; +import type { Env } from '../env.js'; +import { readJsonBody, validationError } from '../lib/errors.js'; +import { getDb } from '../lib/db.js'; +import { parseListQuery } from '../lib/pagination.js'; +import { + createSession, + getSession, + listSessions, + notifySessionRoom, + patchSession, +} from '../services/sessions.js'; +import { resolvePrincipal } from '../principal.js'; + +export type ApiVariables = { ownerId: string }; + +const sessions = new Hono<{ Bindings: Env; Variables: ApiVariables }>(); + +sessions.use('*', async (c, next) => { + const ownerId = resolvePrincipal(c); + if (!ownerId) return c.json({ error: 'Unauthorised' }, 401); + c.set('ownerId', ownerId); + await next(); +}); + +sessions.get('/', async (c) => { + const { limit, cursor } = parseListQuery(c.req.url); + const db = getDb(c.env); + const result = await listSessions(db, c.get('ownerId'), { limit, cursor }); + c.header('Cache-Control', 'private, max-age=15'); + return c.json(result); +}); + +/** Must be registered before `/:id` so `live` is not captured as an id. */ +sessions.get('/:id/live', (c) => { + const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(c.req.param('id'))); + return stub.fetch(c.req.raw); +}); + +sessions.get('/:id', async (c) => { + const db = getDb(c.env); + const session = await getSession(db, c.get('ownerId'), c.req.param('id')); + return session ? c.json(session) : c.json({ error: 'Session not found' }, 404); +}); + +sessions.post('/', async (c) => { + const parsed = CreateSessionInput.safeParse(await readJsonBody(c)); + if (!parsed.success) return validationError(c, parsed.error); + + const db = getDb(c.env); + const created = await createSession(db, c.get('ownerId'), parsed.data); + return c.json(created, 201); +}); + +sessions.patch('/:id', async (c) => { + const parsed = PatchSessionInput.safeParse(await readJsonBody(c)); + if (!parsed.success) return validationError(c, parsed.error); + + const id = c.req.param('id'); + const db = getDb(c.env); + const result = await patchSession(db, c.get('ownerId'), id, parsed.data); + + if (result === null) return c.json({ error: 'Session not found' }, 404); + if ('error' in result) return c.json({ error: result.error }, result.status); + + const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(id)); + await notifySessionRoom(stub, result.broadcasts); + + return c.json({ ok: true as const }); +}); + +export { sessions }; diff --git a/apps/api/src/services/sessions.ts b/apps/api/src/services/sessions.ts new file mode 100644 index 0000000..8f8f9ad --- /dev/null +++ b/apps/api/src/services/sessions.ts @@ -0,0 +1,165 @@ +import { players, scoreEvents, sessions, withPrincipal, type Db } from '@oche/db'; +import type { CreateSessionInput, PatchSessionInput, WsServerMessage } from '@oche/shared'; +import { and, desc, eq, inArray, lt, or } from 'drizzle-orm'; +import { encodeCursor, type ListCursor } from '../lib/pagination.js'; +import { applyScoreChange } from '../lib/scores.js'; +import { serializeSession, serializeSessionSummary } from '../lib/serialize.js'; + +export async function listSessions( + db: Db, + ownerId: string, + opts: { limit: number; cursor: ListCursor | null }, +) { + return withPrincipal(db, ownerId, async (tx) => { + const cursorDate = opts.cursor ? new Date(opts.cursor.createdAt) : null; + const cursorFilter = + opts.cursor && cursorDate + ? or( + lt(sessions.createdAt, cursorDate), + and(eq(sessions.createdAt, cursorDate), lt(sessions.id, opts.cursor!.id)), + ) + : undefined; + + const list = await tx + .select() + .from(sessions) + .where(cursorFilter) + .orderBy(desc(sessions.createdAt), desc(sessions.id)) + .limit(opts.limit + 1); + + const page = list.slice(0, opts.limit); + const hasMore = list.length > opts.limit; + + const data = await Promise.all( + page.map(async (s) => { + const ps = await tx.select().from(players).where(eq(players.sessionId, s.id)); + return serializeSessionSummary(s, ps.length); + }), + ); + + const last = page.at(-1); + const nextCursor = + hasMore && last ? encodeCursor({ createdAt: last.createdAt.toISOString(), id: last.id }) : null; + + return { data, nextCursor }; + }); +} + +export async function getSession(db: Db, ownerId: string, sessionId: string) { + return withPrincipal(db, ownerId, async (tx) => { + const [s] = await tx.select().from(sessions).where(eq(sessions.id, sessionId)); + if (!s) return null; + + const ps = await tx + .select() + .from(players) + .where(eq(players.sessionId, sessionId)) + .orderBy(players.position); + + const playerIds = ps.map((p) => p.id); + const events = + playerIds.length > 0 + ? await tx + .select() + .from(scoreEvents) + .where(inArray(scoreEvents.playerId, playerIds)) + .orderBy(desc(scoreEvents.createdAt)) + .limit(50) + : []; + + return serializeSession(s, ps, events); + }); +} + +export async function createSession(db: Db, ownerId: string, input: CreateSessionInput) { + return withPrincipal(db, ownerId, async (tx) => { + const [s] = await tx + .insert(sessions) + .values({ + ownerId, + title: input.title, + status: input.status, + videoUrl: input.videoUrl ?? null, + videoPoster: input.videoPoster ?? null, + }) + .returning(); + + const inserted = await tx + .insert(players) + .values(input.players.map((p) => ({ ...p, sessionId: s!.id, photoUrl: p.photoUrl ?? null }))) + .returning(); + + return serializeSession(s!, inserted, []); + }); +} + +export type PatchResult = { + broadcasts: WsServerMessage[]; +}; + +export async function patchSession( + db: Db, + ownerId: string, + sessionId: string, + input: PatchSessionInput, +): Promise { + return withPrincipal(db, ownerId, async (tx) => { + const [s] = await tx.select().from(sessions).where(eq(sessions.id, sessionId)); + if (!s) return null; + + const broadcasts: WsServerMessage[] = []; + + if (input.status && input.status !== s.status) { + await tx + .update(sessions) + .set({ status: input.status, updatedAt: new Date() }) + .where(eq(sessions.id, sessionId)); + broadcasts.push({ type: 'status', status: input.status }); + } + + for (const change of input.scores ?? []) { + const [p] = await tx + .select() + .from(players) + .where(and(eq(players.id, change.playerId), eq(players.sessionId, sessionId))); + if (!p) continue; + + const result = applyScoreChange(p.score, change); + if (!result.ok) return { error: result.error, status: 422 as const }; + + if (result.delta === 0) continue; + + await tx.update(players).set({ score: result.next }).where(eq(players.id, p.id)); + await tx.insert(scoreEvents).values({ + playerId: p.id, + delta: result.delta, + newScore: result.next, + }); + broadcasts.push({ + type: 'score', + playerId: p.id, + newScore: result.next, + delta: result.delta, + }); + } + + if (input.scores?.length || input.status) { + await tx.update(sessions).set({ updatedAt: new Date() }).where(eq(sessions.id, sessionId)); + } + + return { broadcasts }; + }); +} + +export async function notifySessionRoom( + stub: { fetch(input: RequestInfo, init?: RequestInit): Promise }, + messages: WsServerMessage[], +): Promise { + for (const message of messages) { + await stub.fetch('https://session-room/broadcast', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(message), + }); + } +} diff --git a/apps/api/src/session-room.ts b/apps/api/src/session-room.ts index 1670cc0..d6ca2ba 100644 --- a/apps/api/src/session-room.ts +++ b/apps/api/src/session-room.ts @@ -1,10 +1,9 @@ /** - * SessionRoom — a SQLite-backed Durable Object (free-tier eligible) using - * hibernatable WebSockets. One instance per session id. Broadcasts typed score/status - * messages and can self-drive a "simulate" mode so reviewers see live updates. + * SessionRoom — SQLite-backed Durable Object (free-tier) with hibernatable WebSockets. + * One instance per session id. Broadcasts typed score/status messages; supports simulate mode. */ import type { DurableObjectState } from '@cloudflare/workers-types'; -import type { WsServerMessage } from '@oche/shared'; +import { WsServerMessage } from '@oche/shared'; import type { Env } from './env.js'; export class SessionRoom { @@ -14,23 +13,32 @@ export class SessionRoom { ) {} async fetch(req: Request): Promise { + const url = new URL(req.url); + + if (req.method === 'POST' && url.pathname === '/broadcast') { + const raw = await req.json().catch(() => null); + const parsed = WsServerMessage.safeParse(raw); + if (!parsed.success) return Response.json({ error: 'Invalid broadcast payload' }, { status: 422 }); + await this.broadcast(parsed.data); + return Response.json({ ok: true }); + } + if (req.headers.get('Upgrade') !== 'websocket') { return new Response('Expected WebSocket', { status: 426 }); } + const pair = new WebSocketPair(); const [client, server] = [pair[0], pair[1]]; - // Hibernation API: accept on the DO context, not server.accept(). this.ctx.acceptWebSocket(server); return new Response(null, { status: 101, webSocket: client }); } - /** Called by the Worker (via RPC/fetch) after a score PATCH. */ async broadcast(message: WsServerMessage): Promise { const payload = JSON.stringify(message); for (const ws of this.ctx.getWebSockets()) ws.send(payload); } - async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise { + async webSocketMessage(_ws: WebSocket, raw: string | ArrayBuffer): Promise { if (typeof raw !== 'string') return; try { const msg = JSON.parse(raw) as { type: string; enabled?: boolean }; diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index f74c341..db77663 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -3,6 +3,11 @@ main = "src/index.ts" compatibility_date = "2025-01-01" compatibility_flags = ["nodejs_compat"] +# Local dev defaults — DATABASE_URL goes in apps/api/.dev.vars (see scripts/wrangler-dev.mjs) +[vars] +ENVIRONMENT = "development" +APP_ORIGIN = "http://localhost:5173" + # Durable Object: SQLite backend is required for the free plan. [[durable_objects.bindings]] name = "SESSION_ROOM" diff --git a/apps/web/src/components/PlayerAvatar.tsx b/apps/web/src/components/PlayerAvatar.tsx new file mode 100644 index 0000000..935db55 --- /dev/null +++ b/apps/web/src/components/PlayerAvatar.tsx @@ -0,0 +1,32 @@ +/** Player photo with responsive sizing, lazy load, and initials fallback. */ +export function PlayerAvatar({ name, photoUrl }: { name: string; photoUrl?: string | null }) { + const initials = name + .split(/\s+/) + .map((w) => w[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + if (photoUrl) { + return ( + + ); + } + + return ( + + {initials} + + ); +} diff --git a/apps/web/src/hooks/useLiveSession.ts b/apps/web/src/hooks/useLiveSession.ts index 8c03bfc..6539a1c 100644 --- a/apps/web/src/hooks/useLiveSession.ts +++ b/apps/web/src/hooks/useLiveSession.ts @@ -1,14 +1,16 @@ -import { WsServerMessage } from '@oche/shared'; -import { useEffect, useRef } from 'react'; +import { WsServerMessage, type WsServerMessage as WsMessage } from '@oche/shared'; +import { useEffect, useRef, useState } from 'react'; import { api } from '@/lib/api'; /** * Subscribe to a session's live updates via WebSocket, with auto-reconnect. - * Falls back to TanStack Query polling if the socket can't connect (handled by caller). + * Returns `connected` so callers can enable TanStack Query polling as a fallback + * when the socket is down (see docs/PERFORMANCE.md). */ -export function useLiveSession(sessionId: string, onMessage: (m: WsServerMessage) => void) { +export function useLiveSession(sessionId: string, onMessage: (m: WsMessage) => void) { const onMsg = useRef(onMessage); onMsg.current = onMessage; + const [connected, setConnected] = useState(false); useEffect(() => { let ws: WebSocket | null = null; @@ -17,21 +19,30 @@ export function useLiveSession(sessionId: string, onMessage: (m: WsServerMessage const connect = () => { ws = new WebSocket(api.wsUrl(sessionId)); - ws.onopen = () => (retry = 0); + ws.onopen = () => { + retry = 0; + setConnected(true); + }; ws.onmessage = (e) => { - const parsed = WsServerMessage.safeParse(JSON.parse(e.data)); + const parsed = WsServerMessage.safeParse(JSON.parse(String(e.data))); if (parsed.success) onMsg.current(parsed.data); }; ws.onclose = () => { + setConnected(false); if (closed) return; retry = Math.min(retry + 1, 5); - setTimeout(connect, retry * 1000); // backoff; caller polls in the meantime + setTimeout(connect, retry * 1000); }; + ws.onerror = () => ws?.close(); }; + connect(); return () => { closed = true; + setConnected(false); ws?.close(); }; }, [sessionId]); + + return { connected }; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 42d043f..6505fa4 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -19,5 +19,5 @@ export const api = { getSession: (id: string) => req(`/sessions/${id}`), patchScores: (id: string, scores: Array<{ playerId: string; delta?: number; set?: number }>) => req<{ ok: true }>(`/sessions/${id}`, { method: 'PATCH', body: JSON.stringify({ scores }) }), - wsUrl: (id: string) => `${BASE.replace(/^http/, 'ws')}/sessions/${id}/live`, + wsUrl: (id: string) => `${BASE.replace(/^http/, 'ws')}/sessions/${id}/live?key=demo-key-a`, }; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c4e6c62..a451717 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,22 +1,51 @@ import { QueryClientProvider } from '@tanstack/react-query'; +import { lazy, Suspense } from 'react'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import App from './App'; import './index.css'; import { queryClient } from './lib/query'; -import { History } from './routes/History'; -import { Overview } from './routes/Overview'; -import { SessionDetail } from './routes/SessionDetail'; + +const Overview = lazy(() => import('./routes/Overview').then((m) => ({ default: m.Overview }))); +const History = lazy(() => import('./routes/History').then((m) => ({ default: m.History }))); +const SessionDetail = lazy(() => + import('./routes/SessionDetail').then((m) => ({ default: m.SessionDetail })), +); + +function Page({ children }: { children: React.ReactNode }) { + return Loading…

}>{children}
; +} const router = createBrowserRouter([ { path: '/', element: , children: [ - { index: true, element: }, - { path: 'history', element: }, - { path: 'history/:id', element: }, + { + index: true, + element: ( + + + + ), + }, + { + path: 'history', + element: ( + + + + ), + }, + { + path: 'history/:id', + element: ( + + + + ), + }, ], }, ]); diff --git a/apps/web/src/routes/History.tsx b/apps/web/src/routes/History.tsx index d58dd8b..45071c7 100644 --- a/apps/web/src/routes/History.tsx +++ b/apps/web/src/routes/History.tsx @@ -2,23 +2,53 @@ import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { api } from '@/lib/api'; -/** Match history: a list of past sessions; click loads detail + plays the video. */ +function formatWhen(iso: string) { + return new Date(iso).toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); +} + +/** Match history: paginated list; each row links to detail + video. */ export function History() { const { data, isLoading, error } = useQuery({ queryKey: ['sessions'], queryFn: api.listSessions }); + if (isLoading) return

Loading history…

; if (error) return

Couldn’t load history.

; + if (!data?.data.length) return

No completed sessions yet.

; return (
    - {data!.data.map((s) => ( + {data.data.map((s) => (
  • - {s.title} - - {s.playerCount} players · {s.status} + {s.videoPoster ? ( + + ) : ( + + No video + + )} + + {s.title} + + {formatWhen(s.createdAt)} · {s.playerCount} players · {s.status} +
  • diff --git a/apps/web/src/routes/Overview.tsx b/apps/web/src/routes/Overview.tsx index d40fa42..d2e1abb 100644 --- a/apps/web/src/routes/Overview.tsx +++ b/apps/web/src/routes/Overview.tsx @@ -1,6 +1,7 @@ import type { Session } from '@oche/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; +import { PlayerAvatar } from '@/components/PlayerAvatar'; import { useLiveSession } from '@/hooks/useLiveSession'; import { api } from '@/lib/api'; @@ -20,15 +21,26 @@ export function Overview() { function SessionPanel({ id }: { id: string }) { const qc = useQueryClient(); - const { data: session } = useQuery({ queryKey: ['session', id], queryFn: () => api.getSession(id) }); const [pulse, setPulse] = useState(null); + const [saveError, setSaveError] = useState(null); - useLiveSession(id, (m) => { + const { connected } = useLiveSession(id, (m) => { if (m.type === 'score') { setPulse(m.playerId); qc.invalidateQueries({ queryKey: ['session', id] }); } + if (m.type === 'status') { + qc.invalidateQueries({ queryKey: ['session', id] }); + qc.invalidateQueries({ queryKey: ['sessions'] }); + } }); + + const { data: session } = useQuery({ + queryKey: ['session', id], + queryFn: () => api.getSession(id), + refetchInterval: connected ? false : 5_000, + }); + useEffect(() => { if (!pulse) return; const t = setTimeout(() => setPulse(null), 600); @@ -36,7 +48,7 @@ function SessionPanel({ id }: { id: string }) { }, [pulse]); async function bump(playerId: string, delta: number, current: Session) { - // optimistic + setSaveError(null); qc.setQueryData(['session', id], { ...current, players: current.players.map((p) => (p.id === playerId ? { ...p, score: p.score + delta } : p)), @@ -45,33 +57,54 @@ function SessionPanel({ id }: { id: string }) { await api.patchScores(id, [{ playerId, delta }]); } catch { qc.invalidateQueries({ queryKey: ['session', id] }); + setSaveError('Score update failed — reverted.'); } } if (!session) return null; + return (
    -
    -

    {session.title}

    +
    +
    +

    {session.title}

    +

    + {connected ? 'Live' : 'Polling every 5s (WebSocket reconnecting…)'} +

    +
    {session.status}
    -
      + + {saveError ? ( +

      + {saveError} +

      + ) : null} + +
        {session.players.map((p) => (
      • - {p.name} + + + {p.name} + - {p.score} + + {p.score} +