feat(api): collective backend — Express + better-sqlite3 + ws — TDD
New api/ service powering admin + judge for the single-day event: - db.ts: SQLite store (teams/submissions/scores), upserts, leaderboard; POST /submissions flips the team's add phase - app.ts: REST — public PUT /teams/:id + POST /submissions (broadcast), code-protected GET /teams, /submissions[/:team], POST /scores, /leaderboard - auth.ts: shared admin/judge access codes (header/bearer/?code=), const-time compare - hub.ts + ws.ts: WS feed at /ws — auth via ?code=, snapshot on connect, team:update / submission:new / score:new broadcasts, heartbeat - index.ts: http + ws wiring; types.ts mirrors the client DTOs 13 tests (REST via supertest + WS integration) green; typecheck clean; boot smoke test: /healthz=ok, protected routes 401 without a code. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ca023cdaa5
commit
9808e6958e
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "apess-api",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"start": "tsx src/index.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2195
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import request from 'supertest'
|
||||||
|
import { openStore, type Store } from './db'
|
||||||
|
import { createApp } from './app'
|
||||||
|
import type { WsEvent } from './types'
|
||||||
|
|
||||||
|
const ADMIN = 'admin-code'
|
||||||
|
const JUDGE = 'judge-code'
|
||||||
|
|
||||||
|
function team(id: string, over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
name: `team ${id}`,
|
||||||
|
kit: 'KIT-01',
|
||||||
|
members: ['a'],
|
||||||
|
phases: { reg: true, setup: false, m1: false, m2: false, add: false },
|
||||||
|
stats: { calls: 3, nominal: 2, anomalous: 1, critical: 0 },
|
||||||
|
deviceConnected: true,
|
||||||
|
updatedAt: '2026-07-27T13:00:00.000Z',
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullAdd = { L1: { goal: 'x' }, L2: 'two', L3: 'three', L4: 'four', L5: 'five' }
|
||||||
|
|
||||||
|
describe('collective API', () => {
|
||||||
|
let store: Store
|
||||||
|
let events: WsEvent[]
|
||||||
|
let app: ReturnType<typeof createApp>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = openStore(':memory:')
|
||||||
|
events = []
|
||||||
|
app = createApp({
|
||||||
|
store,
|
||||||
|
broadcast: (e) => events.push(e),
|
||||||
|
adminCode: ADMIN,
|
||||||
|
judgeCode: JUDGE,
|
||||||
|
now: () => '2026-07-27T14:00:00.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('healthz returns ok', async () => {
|
||||||
|
const res = await request(app).get('/healthz')
|
||||||
|
expect(res.text).toBe('ok')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth', () => {
|
||||||
|
it('rejects protected reads without a code', async () => {
|
||||||
|
await request(app).get('/teams').expect(401)
|
||||||
|
})
|
||||||
|
it('accepts admin or judge code', async () => {
|
||||||
|
await request(app).get('/teams').set('X-Access-Code', ADMIN).expect(200)
|
||||||
|
await request(app).get('/teams').set('X-Access-Code', JUDGE).expect(200)
|
||||||
|
})
|
||||||
|
it('scoring requires the judge code specifically', async () => {
|
||||||
|
await request(app).post('/scores').set('X-Access-Code', ADMIN).send({ teamId: 't', total: 5 }).expect(401)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('teams', () => {
|
||||||
|
it('upserts a team snapshot and broadcasts team:update', async () => {
|
||||||
|
await request(app).put('/teams/t1').send(team('t1')).expect(204)
|
||||||
|
const list = await request(app).get('/teams').set('X-Access-Code', ADMIN)
|
||||||
|
expect(list.body).toHaveLength(1)
|
||||||
|
expect(list.body[0]).toMatchObject({ id: 't1', name: 'team t1', deviceConnected: true })
|
||||||
|
expect(events.some((e) => e.type === 'team:update')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upsert is last-write-wins for a given id', async () => {
|
||||||
|
await request(app).put('/teams/t1').send(team('t1'))
|
||||||
|
await request(app).put('/teams/t1').send(team('t1', { name: 'renamed' }))
|
||||||
|
const list = await request(app).get('/teams').set('X-Access-Code', ADMIN)
|
||||||
|
expect(list.body).toHaveLength(1)
|
||||||
|
expect(list.body[0].name).toBe('renamed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submissions', () => {
|
||||||
|
it('stores a submission, flips the team add phase, and broadcasts', async () => {
|
||||||
|
await request(app).put('/teams/t1').send(team('t1'))
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/submissions')
|
||||||
|
.send({ teamId: 't1', teamName: 'team t1', code: 'KIT-01-ABC', add: fullAdd, submittedAt: '2026-07-27T18:00:00.000Z' })
|
||||||
|
.expect(201)
|
||||||
|
expect(res.body).toMatchObject({ teamId: 't1', scored: false })
|
||||||
|
expect(events.some((e) => e.type === 'submission:new')).toBe(true)
|
||||||
|
|
||||||
|
const teams = await request(app).get('/teams').set('X-Access-Code', ADMIN)
|
||||||
|
expect(teams.body[0].phases.add).toBe(true)
|
||||||
|
|
||||||
|
const sub = await request(app).get('/submissions/t1').set('X-Access-Code', JUDGE)
|
||||||
|
expect(sub.body.add).toEqual(fullAdd)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an incomplete submission', async () => {
|
||||||
|
await request(app).post('/submissions').send({ teamId: 't1' }).expect(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists submission summaries with a scored flag', async () => {
|
||||||
|
await request(app).post('/submissions').send({ teamId: 't1', code: 'c', add: fullAdd })
|
||||||
|
const before = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
|
||||||
|
expect(before.body[0].scored).toBe(false)
|
||||||
|
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 't1', total: 8 })
|
||||||
|
const after = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
|
||||||
|
expect(after.body[0].scored).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('scores + leaderboard', () => {
|
||||||
|
it('ranks teams by average total, descending', async () => {
|
||||||
|
await request(app).post('/submissions').send({ teamId: 'a', teamName: 'Alpha', code: 'c', add: fullAdd })
|
||||||
|
await request(app).post('/submissions').send({ teamId: 'b', teamName: 'Bravo', code: 'c', add: fullAdd })
|
||||||
|
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 'a', total: 6 })
|
||||||
|
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 'a', total: 10 })
|
||||||
|
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 'b', total: 9 })
|
||||||
|
|
||||||
|
const lb = await request(app).get('/leaderboard').set('X-Access-Code', ADMIN)
|
||||||
|
expect(lb.body.map((r: { teamId: string }) => r.teamId)).toEqual(['b', 'a'])
|
||||||
|
expect(lb.body[0]).toMatchObject({ teamName: 'Bravo', avgTotal: 9, scoreCount: 1 })
|
||||||
|
expect(lb.body[1]).toMatchObject({ avgTotal: 8, scoreCount: 2 })
|
||||||
|
expect(events.some((e) => e.type === 'score:new')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import express, { type Express } from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import type { Store } from './db'
|
||||||
|
import { requireCode, requireAnyCode } from './auth'
|
||||||
|
import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types'
|
||||||
|
|
||||||
|
export interface AppOptions {
|
||||||
|
store: Store
|
||||||
|
broadcast: (e: WsEvent) => void
|
||||||
|
adminCode: string
|
||||||
|
judgeCode: string
|
||||||
|
corsOrigin?: string
|
||||||
|
now?: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyPhases = { reg: false, setup: false, m1: false, m2: false, add: false }
|
||||||
|
const emptyStats = { calls: 0, nominal: 0, anomalous: 0, critical: 0 }
|
||||||
|
|
||||||
|
/** Build the collective REST app. Pure of I/O wiring (db + broadcast injected). */
|
||||||
|
export function createApp(opts: AppOptions): Express {
|
||||||
|
const { store, broadcast, adminCode, judgeCode } = opts
|
||||||
|
const now = opts.now ?? (() => new Date().toISOString())
|
||||||
|
const app = express()
|
||||||
|
app.use(cors({ origin: opts.corsOrigin ?? true }))
|
||||||
|
app.use(express.json({ limit: '256kb' }))
|
||||||
|
|
||||||
|
app.get('/healthz', (_req, res) => {
|
||||||
|
res.type('text/plain').send('ok')
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- public participant sync -------------------------------------------
|
||||||
|
app.put('/teams/:id', (req, res) => {
|
||||||
|
const b = req.body ?? {}
|
||||||
|
const team: TeamSnapshot = {
|
||||||
|
id: req.params.id,
|
||||||
|
name: typeof b.name === 'string' ? b.name : '',
|
||||||
|
kit: typeof b.kit === 'string' ? b.kit : '',
|
||||||
|
members: Array.isArray(b.members) ? b.members : [],
|
||||||
|
phases: { ...emptyPhases, ...(b.phases ?? {}) },
|
||||||
|
stats: { ...emptyStats, ...(b.stats ?? {}) },
|
||||||
|
deviceConnected: !!b.deviceConnected,
|
||||||
|
updatedAt: typeof b.updatedAt === 'string' ? b.updatedAt : now(),
|
||||||
|
}
|
||||||
|
store.upsertTeam(team)
|
||||||
|
broadcast({ type: 'team:update', team })
|
||||||
|
res.status(204).end()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/submissions', (req, res) => {
|
||||||
|
const b = req.body ?? {}
|
||||||
|
if (typeof b.teamId !== 'string' || typeof b.code !== 'string' || !b.add) {
|
||||||
|
return res.status(400).json({ error: 'teamId, code and add are required' })
|
||||||
|
}
|
||||||
|
const dto: SubmissionDTO = {
|
||||||
|
teamId: b.teamId,
|
||||||
|
teamName: typeof b.teamName === 'string' ? b.teamName : '',
|
||||||
|
code: b.code,
|
||||||
|
add: b.add,
|
||||||
|
submittedAt: typeof b.submittedAt === 'string' ? b.submittedAt : now(),
|
||||||
|
}
|
||||||
|
const summary = store.upsertSubmission(dto)
|
||||||
|
broadcast({ type: 'submission:new', submission: summary })
|
||||||
|
res.status(201).json(summary)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- protected read / scoring ------------------------------------------
|
||||||
|
app.get('/teams', requireAnyCode(adminCode, judgeCode), (_req, res) => {
|
||||||
|
res.json(store.listTeams())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/submissions', requireAnyCode(adminCode, judgeCode), (_req, res) => {
|
||||||
|
res.json(store.listSubmissions())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/submissions/:team', requireAnyCode(adminCode, judgeCode), (req, res) => {
|
||||||
|
const sub = store.getSubmission(String(req.params.team))
|
||||||
|
if (!sub) return res.status(404).json({ error: 'not found' })
|
||||||
|
res.json(sub)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/scores', requireCode(judgeCode), (req, res) => {
|
||||||
|
const b = req.body ?? {}
|
||||||
|
if (typeof b.teamId !== 'string' || typeof b.total !== 'number') {
|
||||||
|
return res.status(400).json({ error: 'teamId and numeric total are required' })
|
||||||
|
}
|
||||||
|
const score = store.insertScore(
|
||||||
|
{
|
||||||
|
teamId: b.teamId,
|
||||||
|
judge: typeof b.judge === 'string' ? b.judge : '',
|
||||||
|
rubric: b.rubric && typeof b.rubric === 'object' ? b.rubric : {},
|
||||||
|
total: b.total,
|
||||||
|
notes: typeof b.notes === 'string' ? b.notes : '',
|
||||||
|
},
|
||||||
|
now(),
|
||||||
|
)
|
||||||
|
broadcast({ type: 'score:new', teamId: score.teamId, total: score.total })
|
||||||
|
res.status(201).json(score)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/leaderboard', requireAnyCode(adminCode, judgeCode), (_req, res) => {
|
||||||
|
res.json(store.leaderboard())
|
||||||
|
})
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { RequestHandler } from 'express'
|
||||||
|
|
||||||
|
function presentedCode(req: Parameters<RequestHandler>[0]): string {
|
||||||
|
const header = req.header('x-access-code')
|
||||||
|
if (header) return header
|
||||||
|
const auth = req.header('authorization')
|
||||||
|
if (auth?.startsWith('Bearer ')) return auth.slice(7)
|
||||||
|
const q = req.query.code
|
||||||
|
return typeof q === 'string' ? q : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(a: string, b: string): boolean {
|
||||||
|
if (!a || a.length !== b.length) return false
|
||||||
|
let diff = 0
|
||||||
|
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
||||||
|
return diff === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require a specific shared access code (header, bearer, or ?code=). */
|
||||||
|
export function requireCode(expected: string): RequestHandler {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (matches(presentedCode(req), expected)) return next()
|
||||||
|
res.status(401).json({ error: 'unauthorized' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require any of the given codes (admin OR judge read access). */
|
||||||
|
export function requireAnyCode(...expected: string[]): RequestHandler {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const code = presentedCode(req)
|
||||||
|
if (expected.some((e) => matches(code, e))) return next()
|
||||||
|
res.status(401).json({ error: 'unauthorized' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { presentedCode, matches }
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import type {
|
||||||
|
TeamSnapshot,
|
||||||
|
SubmissionDTO,
|
||||||
|
SubmissionSummary,
|
||||||
|
ScoreInput,
|
||||||
|
ScoreDTO,
|
||||||
|
LeaderboardRow,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export interface Store {
|
||||||
|
upsertTeam(t: TeamSnapshot): void
|
||||||
|
listTeams(): TeamSnapshot[]
|
||||||
|
getTeam(id: string): TeamSnapshot | null
|
||||||
|
upsertSubmission(s: SubmissionDTO): SubmissionSummary
|
||||||
|
listSubmissions(): SubmissionSummary[]
|
||||||
|
getSubmission(teamId: string): SubmissionDTO | null
|
||||||
|
insertScore(input: ScoreInput, createdAt: string): ScoreDTO
|
||||||
|
leaderboard(): LeaderboardRow[]
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamRow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kit: string
|
||||||
|
members: string
|
||||||
|
phases: string
|
||||||
|
stats: string
|
||||||
|
device_connected: number
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToTeam(r: TeamRow): TeamSnapshot {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
kit: r.kit,
|
||||||
|
members: JSON.parse(r.members),
|
||||||
|
phases: JSON.parse(r.phases),
|
||||||
|
stats: JSON.parse(r.stats),
|
||||||
|
deviceConnected: !!r.device_connected,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openStore(path = ':memory:'): Store {
|
||||||
|
const db = new Database(path)
|
||||||
|
if (path !== ':memory:') db.pragma('journal_mode = WAL')
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
kit TEXT NOT NULL DEFAULT '',
|
||||||
|
members TEXT NOT NULL DEFAULT '[]',
|
||||||
|
phases TEXT NOT NULL DEFAULT '{}',
|
||||||
|
stats TEXT NOT NULL DEFAULT '{}',
|
||||||
|
device_connected INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS submissions (
|
||||||
|
team_id TEXT PRIMARY KEY,
|
||||||
|
team_name TEXT NOT NULL DEFAULT '',
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
add_json TEXT NOT NULL,
|
||||||
|
submitted_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS scores (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
judge TEXT NOT NULL DEFAULT '',
|
||||||
|
rubric TEXT NOT NULL DEFAULT '{}',
|
||||||
|
total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
const upsertTeamStmt = db.prepare(`
|
||||||
|
INSERT INTO teams (id, name, kit, members, phases, stats, device_connected, updated_at)
|
||||||
|
VALUES (@id, @name, @kit, @members, @phases, @stats, @device_connected, @updated_at)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name=excluded.name, kit=excluded.kit, members=excluded.members,
|
||||||
|
phases=excluded.phases, stats=excluded.stats,
|
||||||
|
device_connected=excluded.device_connected, updated_at=excluded.updated_at
|
||||||
|
`)
|
||||||
|
const listTeamsStmt = db.prepare('SELECT * FROM teams ORDER BY name')
|
||||||
|
const getTeamStmt = db.prepare('SELECT * FROM teams WHERE id = ?')
|
||||||
|
const upsertSubStmt = db.prepare(`
|
||||||
|
INSERT INTO submissions (team_id, team_name, code, add_json, submitted_at)
|
||||||
|
VALUES (@team_id, @team_name, @code, @add_json, @submitted_at)
|
||||||
|
ON CONFLICT(team_id) DO UPDATE SET
|
||||||
|
team_name=excluded.team_name, code=excluded.code,
|
||||||
|
add_json=excluded.add_json, submitted_at=excluded.submitted_at
|
||||||
|
`)
|
||||||
|
const listSubsStmt = db.prepare(`
|
||||||
|
SELECT s.team_id, s.team_name, s.submitted_at,
|
||||||
|
EXISTS(SELECT 1 FROM scores sc WHERE sc.team_id = s.team_id) AS scored
|
||||||
|
FROM submissions s ORDER BY s.submitted_at
|
||||||
|
`)
|
||||||
|
const getSubStmt = db.prepare('SELECT * FROM submissions WHERE team_id = ?')
|
||||||
|
const insertScoreStmt = db.prepare(`
|
||||||
|
INSERT INTO scores (team_id, judge, rubric, total, notes, created_at)
|
||||||
|
VALUES (@team_id, @judge, @rubric, @total, @notes, @created_at)
|
||||||
|
`)
|
||||||
|
const leaderboardStmt = db.prepare(`
|
||||||
|
SELECT s.team_id AS teamId,
|
||||||
|
COALESCE(sub.team_name, s.team_id) AS teamName,
|
||||||
|
AVG(s.total) AS avgTotal,
|
||||||
|
COUNT(*) AS scoreCount
|
||||||
|
FROM scores s
|
||||||
|
LEFT JOIN submissions sub ON sub.team_id = s.team_id
|
||||||
|
GROUP BY s.team_id
|
||||||
|
ORDER BY avgTotal DESC, scoreCount DESC
|
||||||
|
`)
|
||||||
|
const setPhaseAddStmt = db.prepare(
|
||||||
|
`UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
upsertTeam(t) {
|
||||||
|
upsertTeamStmt.run({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
kit: t.kit,
|
||||||
|
members: JSON.stringify(t.members),
|
||||||
|
phases: JSON.stringify(t.phases),
|
||||||
|
stats: JSON.stringify(t.stats),
|
||||||
|
device_connected: t.deviceConnected ? 1 : 0,
|
||||||
|
updated_at: t.updatedAt,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
listTeams() {
|
||||||
|
return (listTeamsStmt.all() as TeamRow[]).map(rowToTeam)
|
||||||
|
},
|
||||||
|
getTeam(id) {
|
||||||
|
const row = getTeamStmt.get(id) as TeamRow | undefined
|
||||||
|
return row ? rowToTeam(row) : null
|
||||||
|
},
|
||||||
|
upsertSubmission(s) {
|
||||||
|
upsertSubStmt.run({
|
||||||
|
team_id: s.teamId,
|
||||||
|
team_name: s.teamName,
|
||||||
|
code: s.code,
|
||||||
|
add_json: JSON.stringify(s.add),
|
||||||
|
submitted_at: s.submittedAt,
|
||||||
|
})
|
||||||
|
// mark the team's final phase complete if we know the team
|
||||||
|
if (getTeamStmt.get(s.teamId)) setPhaseAddStmt.run(s.teamId)
|
||||||
|
return { teamId: s.teamId, teamName: s.teamName, submittedAt: s.submittedAt, scored: false }
|
||||||
|
},
|
||||||
|
listSubmissions() {
|
||||||
|
return (listSubsStmt.all() as Array<Omit<SubmissionSummary, 'scored'> & { scored: number }>).map((r) => ({
|
||||||
|
teamId: r.teamId,
|
||||||
|
teamName: r.teamName,
|
||||||
|
submittedAt: r.submittedAt,
|
||||||
|
scored: !!r.scored,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
getSubmission(teamId) {
|
||||||
|
const row = getSubStmt.get(teamId) as
|
||||||
|
| { team_id: string; team_name: string; code: string; add_json: string; submitted_at: string }
|
||||||
|
| undefined
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
teamId: row.team_id,
|
||||||
|
teamName: row.team_name,
|
||||||
|
code: row.code,
|
||||||
|
add: JSON.parse(row.add_json),
|
||||||
|
submittedAt: row.submitted_at,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
insertScore(input, createdAt) {
|
||||||
|
const info = insertScoreStmt.run({
|
||||||
|
team_id: input.teamId,
|
||||||
|
judge: input.judge,
|
||||||
|
rubric: JSON.stringify(input.rubric),
|
||||||
|
total: input.total,
|
||||||
|
notes: input.notes,
|
||||||
|
created_at: createdAt,
|
||||||
|
})
|
||||||
|
return { ...input, id: Number(info.lastInsertRowid), createdAt }
|
||||||
|
},
|
||||||
|
leaderboard() {
|
||||||
|
return (leaderboardStmt.all() as LeaderboardRow[]).map((r) => ({
|
||||||
|
teamId: r.teamId,
|
||||||
|
teamName: r.teamName,
|
||||||
|
avgTotal: Math.round(r.avgTotal * 100) / 100,
|
||||||
|
scoreCount: r.scoreCount,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
db.close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { WebSocket } from 'ws'
|
||||||
|
import type { WsEvent } from './types'
|
||||||
|
|
||||||
|
export interface Hub {
|
||||||
|
add(ws: WebSocket): void
|
||||||
|
broadcast(e: WsEvent): void
|
||||||
|
size(): number
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPEN = 1 // ws.OPEN
|
||||||
|
|
||||||
|
/** Tracks connected admin/judge sockets and fans out collective events. */
|
||||||
|
export function createHub(): Hub {
|
||||||
|
const clients = new Set<WebSocket>()
|
||||||
|
return {
|
||||||
|
add(ws) {
|
||||||
|
clients.add(ws)
|
||||||
|
ws.on('close', () => clients.delete(ws))
|
||||||
|
ws.on('error', () => clients.delete(ws))
|
||||||
|
},
|
||||||
|
broadcast(e) {
|
||||||
|
const msg = JSON.stringify(e)
|
||||||
|
for (const ws of clients) {
|
||||||
|
if (ws.readyState === OPEN) ws.send(msg)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
size() {
|
||||||
|
return clients.size
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import http from 'node:http'
|
||||||
|
import { openStore } from './db'
|
||||||
|
import { createHub } from './hub'
|
||||||
|
import { createApp } from './app'
|
||||||
|
import { attachWs } from './ws'
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT ?? 3000)
|
||||||
|
const DB_PATH = process.env.DB_PATH ?? '/data/apess.db'
|
||||||
|
const ADMIN_CODE = process.env.ADMIN_CODE ?? ''
|
||||||
|
const JUDGE_CODE = process.env.JUDGE_CODE ?? ''
|
||||||
|
const CORS_ORIGIN = process.env.CORS_ORIGIN
|
||||||
|
|
||||||
|
if (!ADMIN_CODE || !JUDGE_CODE) {
|
||||||
|
console.warn('[apess-api] ADMIN_CODE / JUDGE_CODE not set — protected routes will reject all requests')
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = openStore(DB_PATH)
|
||||||
|
const hub = createHub()
|
||||||
|
const app = createApp({
|
||||||
|
store,
|
||||||
|
broadcast: hub.broadcast,
|
||||||
|
adminCode: ADMIN_CODE,
|
||||||
|
judgeCode: JUDGE_CODE,
|
||||||
|
corsOrigin: CORS_ORIGIN,
|
||||||
|
})
|
||||||
|
|
||||||
|
const server = http.createServer(app)
|
||||||
|
attachWs(server, { store, hub, adminCode: ADMIN_CODE, judgeCode: JUDGE_CODE })
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[apess-api] listening on :${PORT} (db: ${DB_PATH})`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Mirror of the client's src/types.ts — keep in sync by hand.
|
||||||
|
|
||||||
|
export type PhaseKey = 'reg' | 'setup' | 'm1' | 'm2' | 'add'
|
||||||
|
|
||||||
|
export interface SessionStats {
|
||||||
|
calls: number
|
||||||
|
nominal: number
|
||||||
|
anomalous: number
|
||||||
|
critical: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddLayers {
|
||||||
|
L1: unknown | null
|
||||||
|
L2: string
|
||||||
|
L3: string
|
||||||
|
L4: string
|
||||||
|
L5: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSnapshot {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kit: string
|
||||||
|
members: string[]
|
||||||
|
phases: Record<PhaseKey, boolean>
|
||||||
|
stats: SessionStats
|
||||||
|
deviceConnected: boolean
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmissionDTO {
|
||||||
|
teamId: string
|
||||||
|
teamName: string
|
||||||
|
code: string
|
||||||
|
add: AddLayers
|
||||||
|
submittedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmissionSummary {
|
||||||
|
teamId: string
|
||||||
|
teamName: string
|
||||||
|
submittedAt: string
|
||||||
|
scored: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreInput {
|
||||||
|
teamId: string
|
||||||
|
judge: string
|
||||||
|
rubric: Record<string, number>
|
||||||
|
total: number
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreDTO extends ScoreInput {
|
||||||
|
id: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardRow {
|
||||||
|
teamId: string
|
||||||
|
teamName: string
|
||||||
|
avgTotal: number
|
||||||
|
scoreCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WsEvent =
|
||||||
|
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] }
|
||||||
|
| { type: 'team:update'; team: TeamSnapshot }
|
||||||
|
| { type: 'submission:new'; submission: SubmissionSummary }
|
||||||
|
| { type: 'score:new'; teamId: string; total: number }
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import http from 'node:http'
|
||||||
|
import { AddressInfo } from 'node:net'
|
||||||
|
import { WebSocket } from 'ws'
|
||||||
|
import request from 'supertest'
|
||||||
|
import { openStore, type Store } from './db'
|
||||||
|
import { createHub } from './hub'
|
||||||
|
import { createApp } from './app'
|
||||||
|
import { attachWs } from './ws'
|
||||||
|
import type { WsEvent } from './types'
|
||||||
|
|
||||||
|
const ADMIN = 'admin-code'
|
||||||
|
const JUDGE = 'judge-code'
|
||||||
|
|
||||||
|
function nextMessage(ws: WebSocket): Promise<WsEvent> {
|
||||||
|
return new Promise((resolve) => ws.once('message', (d) => resolve(JSON.parse(d.toString()))))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('collective WS feed', () => {
|
||||||
|
let store: Store
|
||||||
|
let server: http.Server
|
||||||
|
let port: number
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
store = openStore(':memory:')
|
||||||
|
const hub = createHub()
|
||||||
|
const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE })
|
||||||
|
server = http.createServer(app)
|
||||||
|
attachWs(server, { store, hub, adminCode: ADMIN, judgeCode: JUDGE })
|
||||||
|
await new Promise<void>((r) => server.listen(0, r))
|
||||||
|
port = (server.address() as AddressInfo).port
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await new Promise<void>((r) => server.close(() => r()))
|
||||||
|
store.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a connection without a valid code', async () => {
|
||||||
|
const ws = new WebSocket(`ws://localhost:${port}/ws?code=nope`)
|
||||||
|
const code: number = await new Promise((resolve) => ws.on('close', (c) => resolve(c)))
|
||||||
|
expect(code).toBe(4401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends a snapshot on connect', async () => {
|
||||||
|
store.upsertTeam({
|
||||||
|
id: 't1',
|
||||||
|
name: 'team t1',
|
||||||
|
kit: 'KIT-01',
|
||||||
|
members: [],
|
||||||
|
phases: { reg: true, setup: false, m1: false, m2: false, add: false },
|
||||||
|
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
|
||||||
|
deviceConnected: false,
|
||||||
|
updatedAt: '2026-07-27T13:00:00.000Z',
|
||||||
|
})
|
||||||
|
const ws = new WebSocket(`ws://localhost:${port}/ws?code=${ADMIN}`)
|
||||||
|
const msg = await nextMessage(ws)
|
||||||
|
expect(msg.type).toBe('snapshot')
|
||||||
|
if (msg.type === 'snapshot') expect(msg.teams).toHaveLength(1)
|
||||||
|
ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pushes team:update to a connected client after a PUT /teams', async () => {
|
||||||
|
const ws = new WebSocket(`ws://localhost:${port}/ws?code=${JUDGE}`)
|
||||||
|
await nextMessage(ws) // snapshot
|
||||||
|
const updatePromise = nextMessage(ws)
|
||||||
|
|
||||||
|
await request(`http://localhost:${port}`)
|
||||||
|
.put('/teams/t9')
|
||||||
|
.send({ name: 'late team', kit: 'KIT-09' })
|
||||||
|
|
||||||
|
const evt = await updatePromise
|
||||||
|
expect(evt.type).toBe('team:update')
|
||||||
|
if (evt.type === 'team:update') expect(evt.team.id).toBe('t9')
|
||||||
|
ws.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { WebSocketServer } from 'ws'
|
||||||
|
import type { Server } from 'node:http'
|
||||||
|
import type { Store } from './db'
|
||||||
|
import type { Hub } from './hub'
|
||||||
|
import { matches } from './auth'
|
||||||
|
|
||||||
|
const HEARTBEAT_MS = 30000
|
||||||
|
|
||||||
|
export interface WsOptions {
|
||||||
|
store: Store
|
||||||
|
hub: Hub
|
||||||
|
adminCode: string
|
||||||
|
judgeCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach the collective WS feed at /ws: auth via ?code=, snapshot on connect. */
|
||||||
|
export function attachWs(server: Server, { store, hub, adminCode, judgeCode }: WsOptions): WebSocketServer {
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' })
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
const url = new URL(req.url ?? '', 'http://localhost')
|
||||||
|
const code = url.searchParams.get('code') ?? ''
|
||||||
|
if (!matches(code, adminCode) && !matches(code, judgeCode)) {
|
||||||
|
ws.close(4401, 'unauthorized')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'snapshot',
|
||||||
|
teams: store.listTeams(),
|
||||||
|
submissions: store.listSubmissions(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
hub.add(ws)
|
||||||
|
const ping = setInterval(() => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.ping()
|
||||||
|
}, HEARTBEAT_MS)
|
||||||
|
ws.on('close', () => clearInterval(ping))
|
||||||
|
})
|
||||||
|
|
||||||
|
return wss
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user