Skip to content

Commit e3bd33c

Browse files
kronosapiensclaude
andcommitted
feat: implement pairwise voting judging system with React client
Add Express API server alongside Discord bot with Discord OAuth2 authentication, pairwise comparison judging UI, and spectral ranking algorithm for game jam entries. Includes Vite + React frontend, database migration for comparisons, and jam:rankings CLI script. Supports local dev with auth bypass mode. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent abece75 commit e3bd33c

33 files changed

Lines changed: 3494 additions & 6 deletions

backend/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,8 @@ OPENAI_API_KEY=sk-your-openai-key
4848
# Typefully (for draft publishing)
4949
TYPEFULLY_API_KEY=your-typefully-api-key
5050
TYPEFULLY_SOCIAL_SET_ID=your-social-set-id
51+
52+
# Game Jam Judging
53+
DISCORD_CLIENT_SECRET=your_discord_client_secret
54+
DISCORD_SESSION_SECRET=random-32-char-string-for-jwt-signing
55+
DEV_AUTH_BYPASS=false # true for local dev (skips OAuth)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Game jam judging schema migration
3+
* Creates table for pairwise comparisons
4+
*/
5+
6+
/**
7+
* @param {import('node-pg-migrate').MigrationBuilder} pgm
8+
*/
9+
exports.up = (pgm) => {
10+
// Pairwise comparisons table
11+
pgm.createTable('jam_comparisons', {
12+
id: { type: 'text', primaryKey: true },
13+
jam_slug: { type: 'text', notNull: true },
14+
judge_id: { type: 'text', notNull: true },
15+
entry_a_id: { type: 'text', notNull: true },
16+
entry_b_id: { type: 'text', notNull: true },
17+
score: { type: 'real' }, // NULL if skipped, 0.0-1.0 for preference
18+
timestamp: { type: 'bigint', notNull: true },
19+
});
20+
21+
// Unique constraint: one comparison per judge per pair per jam
22+
pgm.addConstraint('jam_comparisons', 'jam_comparisons_unique_judge_pair', {
23+
unique: ['jam_slug', 'judge_id', 'entry_a_id', 'entry_b_id'],
24+
});
25+
26+
// Indexes for common queries
27+
pgm.createIndex('jam_comparisons', 'jam_slug', { name: 'idx_jam_comparisons_jam' });
28+
pgm.createIndex('jam_comparisons', 'judge_id', { name: 'idx_jam_comparisons_judge' });
29+
pgm.createIndex('jam_comparisons', 'timestamp', { name: 'idx_jam_comparisons_timestamp' });
30+
};
31+
32+
/**
33+
* @param {import('node-pg-migrate').MigrationBuilder} pgm
34+
*/
35+
exports.down = (pgm) => {
36+
pgm.dropTable('jam_comparisons');
37+
};

backend/package.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"type": "module",
77
"scripts": {
88
"dev": "tsx watch src/index.ts",
9+
"dev:api": "tsx watch src/api-server.ts",
910
"build": "tsc -p tsconfig.build.json",
1011
"typecheck": "tsc --noEmit -p tsconfig.build.json",
1112
"start": "node dist/index.js",
@@ -22,7 +23,8 @@
2223
"test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
2324
"migrate": "node-pg-migrate up",
2425
"migrate:down": "node-pg-migrate down",
25-
"migrate:create": "node-pg-migrate create"
26+
"migrate:create": "node-pg-migrate create",
27+
"jam:rankings": "tsx src/scripts/jam-rankings.ts"
2628
},
2729
"lint-staged": {
2830
"*.{ts,js}": [
@@ -42,16 +44,26 @@
4244
"license": "MIT",
4345
"dependencies": {
4446
"@anthropic-ai/sdk": "^0.71.2",
47+
"cookie-parser": "^1.4.7",
48+
"cors": "^2.8.6",
4549
"discord.js": "^14.25.1",
4650
"dotenv": "^17.2.3",
51+
"express": "^5.2.1",
52+
"js-yaml": "^4.1.1",
53+
"jsonwebtoken": "^9.0.3",
4754
"node-cron": "^4.2.1",
4855
"node-pg-migrate": "^8.0.4",
4956
"openai": "^4.77.0",
5057
"postgres": "^3.4.8"
5158
},
5259
"devDependencies": {
5360
"@electric-sql/pglite": "^0.3.15",
61+
"@types/cookie-parser": "^1.4.10",
62+
"@types/cors": "^2.8.19",
63+
"@types/express": "^5.0.6",
5464
"@types/jest": "^30.0.0",
65+
"@types/js-yaml": "^4.0.9",
66+
"@types/jsonwebtoken": "^9.0.10",
5567
"@types/node": "^25.0.3",
5668
"@types/node-cron": "^3.0.11",
5769
"eslint": "^9.39.2",

backend/src/api-server.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Standalone API server for local development.
3+
* Runs Express without the Discord bot.
4+
*/
5+
import { config } from 'dotenv';
6+
import postgres from 'postgres';
7+
import { startApiServer } from './api/index.js';
8+
import { setSql } from './services/database.js';
9+
10+
// Load environment variables
11+
config();
12+
13+
async function main() {
14+
const databaseUrl = process.env.DATABASE_URL;
15+
if (!databaseUrl) {
16+
console.error('DATABASE_URL environment variable is required');
17+
process.exit(1);
18+
}
19+
20+
// Initialize database connection
21+
console.log('Connecting to database...');
22+
const sql = postgres(databaseUrl);
23+
24+
// Verify connection
25+
await sql`SELECT 1`;
26+
console.log('Database connected');
27+
28+
// Set the SQL instance for the database module
29+
setSql(sql);
30+
31+
// Start API server
32+
startApiServer();
33+
34+
// Handle graceful shutdown
35+
process.on('SIGINT', async () => {
36+
console.log('\nShutting down...');
37+
await sql.end();
38+
process.exit(0);
39+
});
40+
41+
process.on('SIGTERM', async () => {
42+
console.log('\nShutting down...');
43+
await sql.end();
44+
process.exit(0);
45+
});
46+
}
47+
48+
main().catch((err) => {
49+
console.error('Failed to start API server:', err);
50+
process.exit(1);
51+
});

backend/src/api/index.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import express, { Express } from 'express';
2+
import path from 'path';
3+
import { fileURLToPath } from 'url';
4+
import { existsSync } from 'fs';
5+
import cors from 'cors';
6+
import cookieParser from 'cookie-parser';
7+
import authRoutes from './routes/auth.js';
8+
import jamsRoutes from './routes/jams.js';
9+
10+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
11+
12+
export function createApiServer(): Express {
13+
const app = express();
14+
15+
// Middleware
16+
app.use(
17+
cors({
18+
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
19+
credentials: true,
20+
})
21+
);
22+
app.use(express.json());
23+
app.use(cookieParser());
24+
25+
// Health check
26+
app.get('/api/health', (_req, res) => {
27+
res.json({ status: 'ok' });
28+
});
29+
30+
// API routes
31+
app.use('/api/auth', authRoutes);
32+
app.use('/api/jams', jamsRoutes);
33+
34+
// Serve React SPA static files in production
35+
const clientDist = path.resolve(__dirname, '../../../client/dist');
36+
if (existsSync(clientDist)) {
37+
app.use(express.static(clientDist));
38+
39+
// SPA catch-all: serve index.html for non-API routes
40+
app.get('/{*splat}', (_req, res) => {
41+
res.sendFile(path.join(clientDist, 'index.html'));
42+
});
43+
44+
console.log(`Serving client from ${clientDist}`);
45+
}
46+
47+
return app;
48+
}
49+
50+
export function startApiServer(): Express {
51+
const app = createApiServer();
52+
const port = process.env.PORT || process.env.HTTP_PORT || 3000;
53+
54+
app.listen(port, () => {
55+
console.log(`API server listening on port ${port}`);
56+
});
57+
58+
return app;
59+
}

backend/src/api/middleware/auth.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Request, Response, NextFunction } from 'express';
2+
import jwt from 'jsonwebtoken';
3+
4+
export interface AuthUser {
5+
id: string;
6+
username: string;
7+
avatar?: string;
8+
}
9+
10+
declare global {
11+
namespace Express {
12+
interface Request {
13+
user?: AuthUser;
14+
}
15+
}
16+
}
17+
18+
// Dev bypass user for local testing
19+
const DEV_USER: AuthUser = {
20+
id: 'dev-user-123',
21+
username: 'DevSensei',
22+
avatar: undefined,
23+
};
24+
25+
function getSessionSecret(): string {
26+
return process.env.DISCORD_SESSION_SECRET || 'dev-secret-change-in-production';
27+
}
28+
29+
function isDevBypass(): boolean {
30+
return process.env.DEV_AUTH_BYPASS === 'true';
31+
}
32+
33+
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
34+
// Dev bypass mode - auto-authenticate as a mock user
35+
if (isDevBypass()) {
36+
req.user = DEV_USER;
37+
return next();
38+
}
39+
40+
// Check for session cookie
41+
const token = req.cookies?.session;
42+
if (!token) {
43+
return res.status(401).json({ error: 'Not authenticated' });
44+
}
45+
46+
try {
47+
const decoded = jwt.verify(token, getSessionSecret()) as AuthUser;
48+
req.user = decoded;
49+
next();
50+
} catch {
51+
return res.status(401).json({ error: 'Invalid session' });
52+
}
53+
}
54+
55+
export function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction) {
56+
// Dev bypass mode - auto-authenticate as a mock user
57+
if (isDevBypass()) {
58+
req.user = DEV_USER;
59+
return next();
60+
}
61+
62+
// Check for session cookie (optional - don't fail if missing)
63+
const token = req.cookies?.session;
64+
if (!token) {
65+
return next();
66+
}
67+
68+
try {
69+
const decoded = jwt.verify(token, getSessionSecret()) as AuthUser;
70+
req.user = decoded;
71+
} catch {
72+
// Invalid token - treat as unauthenticated
73+
}
74+
75+
next();
76+
}
77+
78+
export function createSessionToken(user: AuthUser): string {
79+
return jwt.sign(user, getSessionSecret(), { expiresIn: '7d' });
80+
}

0 commit comments

Comments
 (0)