277 lines
14 KiB
JavaScript
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}`)); |