import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import morgan from 'morgan'; import dotenv from 'dotenv'; import jwt from 'jsonwebtoken'; import pkg from 'pg'; dotenv.config(); const { Pool } = pkg; const PORT = process.env.PORT || 8080; const DATABASE_URL = process.env.DATABASE_URL || 'postgres://supersunday:supersunday@db:5432/supersunday'; const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret'; const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@supersunday.local'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; const app = express(); app.use(helmet()); app.use(cors({ origin: process.env.CORS_ORIGIN?.split(',') || '*' })); app.use(express.json({ limit: '2mb' })); app.use(morgan('tiny')); const pool = new Pool({ connectionString: DATABASE_URL }); async function q(text, params=[]) { const { rows } = await pool.query(text, params); return rows; } function auth(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) return res.status(401).json({ error: 'missing token' }); try { req.user = jwt.verify(token, JWT_SECRET); next(); } catch { res.status(401).json({ error: 'invalid token' }); } } /* Health + Auth */ app.get('/api/health', async (req, res) => { try { const { rows } = await pool.query('SELECT 1 as ok'); res.json({ status: 'ok', db: rows[0].ok === 1 }); } catch (e) { res.status(500).json({ status: 'error', error: String(e) }); } }); app.post('/api/auth/login', (req, res) => { const { email, password } = req.body || {}; if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) { const token = jwt.sign({ email, role: 'admin' }, JWT_SECRET, { expiresIn: '12h' }); return res.json({ token }); } res.status(401).json({ error: 'invalid credentials' }); }); /* Players */ app.get('/api/players', async (req, res) => { res.json(await q('SELECT * FROM players ORDER BY id DESC')); }); app.get('/api/players/:id', async (req, res) => { const rows = await q('SELECT * FROM players WHERE id=$1', [+req.params.id]); if (!rows[0]) return res.status(404).json({ error: 'not found' }); res.json(rows[0]); }); app.post('/api/players', auth, async (req, res) => { const { first_name, last_name, email=null, ranking=0 } = req.body||{}; if (!first_name || !last_name) return res.status(400).json({ error: 'first_name & last_name required' }); const rows = await q('INSERT INTO players(first_name,last_name,email,ranking) VALUES ($1,$2,$3,$4) RETURNING *', [first_name, last_name, email, ranking]); res.status(201).json(rows[0]); }); app.put('/api/players/:id', auth, async (req, res) => { const { first_name, last_name, email, ranking } = req.body||{}; const rows = await q('UPDATE players SET first_name=$1,last_name=$2,email=$3,ranking=$4 WHERE id=$5 RETURNING *', [first_name, last_name, email, ranking, +req.params.id]); res.json(rows[0]); }); app.delete('/api/players/:id', auth, async (req, res) => { await q('DELETE FROM players WHERE id=$1', [+req.params.id]); res.json({ ok: true }); }); /* Tournaments */ app.get('/api/tournaments', async (req, res) => { res.json(await q('SELECT * FROM tournaments ORDER BY start_date DESC, id DESC')); }); app.get('/api/tournaments/upcoming', async (req, res) => { res.json(await q('SELECT * FROM tournaments WHERE start_date >= CURRENT_DATE ORDER BY start_date ASC LIMIT 10')); }); app.get('/api/tournaments/:id', async (req, res) => { const id = +req.params.id; const [t] = await q('SELECT * FROM tournaments WHERE id=$1', [id]); if (!t) return res.status(404).json({ error: 'tournament not found' }); const [{ enrollment_count }] = await q('SELECT COUNT(*)::int AS enrollment_count FROM enrollments WHERE tournament_id=$1', [id]); const [{ team_count }] = await q('SELECT COUNT(*)::int AS team_count FROM teams WHERE tournament_id=$1', [id]); const [{ match_count }] = await q('SELECT COUNT(*)::int AS match_count FROM matches WHERE tournament_id=$1', [id]); const nextRows = await q(`SELECT * FROM matches WHERE tournament_id=$1 AND status IN ('scheduled','playing') AND scheduled_at IS NOT NULL ORDER BY scheduled_at ASC LIMIT 1`, [id]); res.json({ ...t, enrollment_count, team_count, match_count, next_match: nextRows[0] || null }); }); app.get('/api/tournaments/:id/summary', async (req, res) => { const id = +req.params.id; const [t] = await q('SELECT * FROM tournaments WHERE id=$1', [id]); if (!t) return res.status(404).json({ error: 'tournament not found' }); const enroll = await q(` SELECT p.id, p.first_name, p.last_name, p.ranking FROM enrollments e JOIN players p ON p.id=e.player_id WHERE e.tournament_id=$1 ORDER BY p.ranking ASC NULLS LAST, p.last_name ASC `, [id]); const matches = await q('SELECT * FROM matches WHERE tournament_id=$1 ORDER BY scheduled_at NULLS LAST, id ASC', [id]); res.json({ tournament: t, enrollments: enroll, matches }); }); app.post('/api/tournaments', auth, async (req, res) => { const { name, location=null, start_date, end_date } = req.body||{}; if (!name || !start_date || !end_date) return res.status(400).json({ error: 'name, start_date, end_date required' }); const rows = await q('INSERT INTO tournaments(name,location,start_date,end_date) VALUES ($1,$2,$3,$4) RETURNING *', [name, location, start_date, end_date]); res.status(201).json(rows[0]); }); app.put('/api/tournaments/:id', auth, async (req, res) => { const { name, location, start_date, end_date } = req.body||{}; const rows = await q('UPDATE tournaments SET name=$1,location=$2,start_date=$3,end_date=$4 WHERE id=$5 RETURNING *', [name, location, start_date, end_date, +req.params.id]); res.json(rows[0]); }); app.delete('/api/tournaments/:id', auth, async (req, res) => { await q('DELETE FROM tournaments WHERE id=$1', [+req.params.id]); res.json({ ok: true }); }); /* Enrollments */ app.get('/api/enrollments', async (req, res) => { const tid = req.query.tournament_id ? +req.query.tournament_id : null; if (tid) { return res.json(await q(` SELECT e.player_id, p.first_name, p.last_name, p.ranking FROM enrollments e JOIN players p ON p.id=e.player_id WHERE e.tournament_id=$1 ORDER BY p.ranking ASC, p.last_name ASC `, [tid])); } res.json(await q('SELECT * FROM enrollments ORDER BY tournament_id, player_id')); }); app.post('/api/enrollments', auth, async (req, res) => { const { player_id, tournament_id } = req.body||{}; if (!player_id || !tournament_id) return res.status(400).json({ error: 'player_id & tournament_id required' }); await q('INSERT INTO enrollments(player_id,tournament_id) VALUES($1,$2) ON CONFLICT DO NOTHING', [player_id, tournament_id]); res.status(201).json({ ok: true }); }); app.post('/api/enrollments/bulk', auth, async (req, res) => { const { player_ids = [], tournament_id } = req.body||{}; if (!tournament_id || !Array.isArray(player_ids)) return res.status(400).json({ error: 'player_ids[] & tournament_id required' }); for (const pid of player_ids) { await q('INSERT INTO enrollments(player_id,tournament_id) VALUES($1,$2) ON CONFLICT DO NOTHING', [pid, tournament_id]); } res.json({ ok: true, count: player_ids.length }); }); app.delete('/api/enrollments', auth, async (req, res) => { const { player_id, tournament_id } = req.body||{}; await q('DELETE FROM enrollments WHERE player_id=$1 AND tournament_id=$2', [player_id, tournament_id]); res.json({ ok: true }); }); /* Teams */ app.get('/api/teams', async (req, res) => { const tid = req.query.tournament_id ? +req.query.tournament_id : null; if (tid) { return res.json(await q(` SELECT t.*, p1.first_name AS p1_first, p1.last_name AS p1_last, p2.first_name AS p2_first, p2.last_name AS p2_last FROM teams t JOIN players p1 ON p1.id=t.player1_id JOIN players p2 ON p2.id=t.player2_id WHERE t.tournament_id=$1 ORDER BY t.id DESC `, [tid])); } res.json(await q('SELECT * FROM teams ORDER BY id DESC')); }); app.get('/api/teams/:id', async (req, res) => { const rows = await q('SELECT * FROM teams WHERE id=$1', [+req.params.id]); if (!rows[0]) return res.status(404).json({ error: 'not found' }); res.json(rows[0]); }); app.post('/api/teams', auth, async (req, res) => { const { player1_id, player2_id, tournament_id=null } = req.body||{}; if (!player1_id || !player2_id) return res.status(400).json({ error: 'player1_id & player2_id required' }); const rows = await q('INSERT INTO teams(player1_id,player2_id,tournament_id) VALUES ($1,$2,$3) RETURNING *', [Math.min(player1_id, player2_id), Math.max(player1_id, player2_id), tournament_id]); res.status(201).json(rows[0]); }); app.post('/api/teams/auto', auth, async (req, res) => { const { tournament_id } = req.body||{}; if (!tournament_id) return res.status(400).json({ error: 'tournament_id required' }); const players = await q(` SELECT p.* FROM enrollments e JOIN players p ON p.id=e.player_id WHERE e.tournament_id=$1 ORDER BY p.ranking ASC NULLS LAST `, [tournament_id]); const created = []; for (let i=0;i= players.length) break; const a = players[i].id, b = players[i+1].id; const { rows } = await pool.query( 'INSERT INTO teams(player1_id,player2_id,tournament_id) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING *', [Math.min(a,b), Math.max(a,b), tournament_id] ); if (rows[0]) created.push(rows[0]); } res.json({ created_count: created.length, created }); }); app.put('/api/teams/:id', auth, async (req, res) => { const { player1_id, player2_id, tournament_id } = req.body||{}; const rows = await q('UPDATE teams SET player1_id=$1,player2_id=$2,tournament_id=$3 WHERE id=$4 RETURNING *', [Math.min(player1_id, player2_id), Math.max(player1_id, player2_id), tournament_id, +req.params.id]); res.json(rows[0]); }); app.delete('/api/teams/:id', auth, async (req, res) => { await q('DELETE FROM teams WHERE id=$1', [+req.params.id]); res.json({ ok: true }); }); /* Matches */ app.get('/api/matches', async (req, res) => { const tid = req.query.tournament_id ? +req.query.tournament_id : null; if (tid) return res.json(await q('SELECT * FROM matches WHERE tournament_id=$1 ORDER BY scheduled_at NULLS LAST, id DESC', [tid])); res.json(await q('SELECT * FROM matches ORDER BY scheduled_at NULLS LAST, id DESC')); }); app.get('/api/matches/:id', async (req, res) => { const rows = await q('SELECT * FROM matches WHERE id=$1', [+req.params.id]); if (!rows[0]) return res.status(404).json({ error: 'not found' }); res.json(rows[0]); }); app.post('/api/matches', auth, async (req, res) => { const { tournament_id, court=null, round=null, scheduled_at=null, team_a_id=null, team_b_id=null } = req.body||{}; if (!tournament_id) return res.status(400).json({ error: 'tournament_id required' }); const rows = await q('INSERT INTO matches(tournament_id,court,round,scheduled_at,team_a_id,team_b_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *', [tournament_id, court, round, scheduled_at, team_a_id, team_b_id]); res.status(201).json(rows[0]); }); app.put('/api/matches/:id', auth, async (req, res) => { const { court, round, scheduled_at, team_a_id, team_b_id, status } = req.body||{}; const rows = await q('UPDATE matches SET court=$1, round=$2, scheduled_at=$3, team_a_id=$4, team_b_id=$5, status=$6 WHERE id=$7 RETURNING *', [court, round, scheduled_at, team_a_id, team_b_id, status, +req.params.id]); res.json(rows[0]); }); app.post('/api/matches/:id/score', auth, async (req, res) => { const { score_a, score_b, status='finished' } = req.body||{}; const rows = await q('UPDATE matches SET score_a=$1, score_b=$2, status=$3 WHERE id=$4 RETURNING *', [score_a, score_b, status, +req.params.id]); res.json(rows[0]); }); app.post('/api/matches/generate', auth, async (req, res) => { const { tournament_id, courts=['Court 1'], start_at=null, interval_minutes=20 } = req.body||{}; if (!tournament_id) return res.status(400).json({ error: 'tournament_id required' }); const teams = await q('SELECT id FROM teams WHERE tournament_id=$1 ORDER BY id ASC', [tournament_id]); if (teams.length < 2) return res.status(400).json({ error: 'not enough teams' }); const now = start_at ? new Date(start_at) : new Date(Date.now() + 5*60*1000); let t = new Date(now); let courtIdx = 0; const created = []; for (let i=0;i= teams.length) break; const a = teams[i].id, b = teams[i+1].id; const court = courts[courtIdx % courts.length]; courtIdx++; const { rows } = await pool.query( 'INSERT INTO matches(tournament_id, court, round, scheduled_at, team_a_id, team_b_id, status) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *', [tournament_id, court, 'Americano', t.toISOString(), a, b, 'scheduled'] ); created.push(rows[0]); t = new Date(t.getTime() + interval_minutes*60*1000); } res.json({ created }); }); /* Leaderboard (simple): sums wins/losses by team in a tournament */ app.get('/api/leaderboard', async (req, res) => { const tid = req.query.tournament_id ? +req.query.tournament_id : null; if (!tid) return res.status(400).json({ error: 'tournament_id required' }); const rows = await q(` SELECT team_id, SUM(w) AS wins, SUM(l) AS losses FROM ( SELECT team_a_id AS team_id, CASE WHEN score_a>score_b THEN 1 ELSE 0 END AS w, CASE WHEN score_ascore_a THEN 1 ELSE 0 END AS w, CASE WHEN score_b console.log(`API listening on :${PORT}`));