Files
SuperSunday/backend/server.js
2025-08-24 14:49:43 +00:00

277 lines
14 KiB
JavaScript

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;i+=2) {
if (i+1 >= 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;i+=2) {
if (i+1 >= 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_a<score_b THEN 1 ELSE 0 END AS l
FROM matches WHERE tournament_id=$1 AND status='finished' AND team_a_id IS NOT NULL AND team_b_id IS NOT NULL
UNION ALL
SELECT team_b_id AS team_id, CASE WHEN score_b>score_a THEN 1 ELSE 0 END AS w, CASE WHEN score_b<score_a THEN 1 ELSE 0 END AS l
FROM matches WHERE tournament_id=$1 AND status='finished' AND team_a_id IS NOT NULL AND team_b_id IS NOT NULL
) x
GROUP BY team_id
ORDER BY wins DESC, losses ASC
`, [tid]);
res.json(rows);
});
app.listen(PORT, () => console.log(`API listening on :${PORT}`));