1102 lines
38 KiB
JavaScript
1102 lines
38 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
const { PrismaClient } = require('./generated/prisma');
|
|
require('dotenv').config();
|
|
const bcrypt = require('bcryptjs');
|
|
const jwt = require('jsonwebtoken');
|
|
const http = require('http');
|
|
const { Server } = require('socket.io');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
|
|
const app = express();
|
|
const prisma = new PrismaClient();
|
|
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
|
|
const PORT = process.env.PORT || 4000;
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
|
|
|
|
// Serve static files from the React build
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// API routes
|
|
app.get('/api/health', (req, res) => {
|
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
// Serve React app for all non-API routes
|
|
app.get('*', (req, res) => {
|
|
// Don't serve React app for API routes
|
|
if (req.path.startsWith('/api/')) {
|
|
return res.status(404).json({ error: 'API endpoint not found' });
|
|
}
|
|
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
// Player registration
|
|
app.post('/api/players', async (req, res) => {
|
|
const { name, email } = req.body;
|
|
if (!name || !email) {
|
|
return res.status(400).json({ error: 'Name and email are required.' });
|
|
}
|
|
try {
|
|
const player = await prisma.player.create({
|
|
data: { name, email },
|
|
});
|
|
res.status(201).json(player);
|
|
} catch (err) {
|
|
if (err.code === 'P2002') {
|
|
// Unique constraint failed
|
|
return res.status(409).json({ error: 'Email already registered.' });
|
|
}
|
|
res.status(500).json({ error: 'Failed to register player.' });
|
|
}
|
|
});
|
|
|
|
// List all players
|
|
app.get('/api/players', async (req, res) => {
|
|
try {
|
|
const players = await prisma.player.findMany({ orderBy: { registeredAt: 'asc' } });
|
|
res.json(players);
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to fetch players.' });
|
|
}
|
|
});
|
|
|
|
// Admin registration (for initial setup, can be removed later)
|
|
app.post('/api/admin/register', async (req, res) => {
|
|
const { username, password } = req.body;
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password are required.' });
|
|
}
|
|
try {
|
|
const hash = await bcrypt.hash(password, 10);
|
|
const admin = await prisma.adminUser.create({
|
|
data: { username, passwordHash: hash },
|
|
});
|
|
res.status(201).json({ id: admin.id, username: admin.username });
|
|
} catch (err) {
|
|
if (err.code === 'P2002') {
|
|
return res.status(409).json({ error: 'Username already exists.' });
|
|
}
|
|
res.status(500).json({ error: 'Failed to register admin.' });
|
|
}
|
|
});
|
|
|
|
// Admin login
|
|
app.post('/api/admin/login', async (req, res) => {
|
|
const { username, password } = req.body;
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password are required.' });
|
|
}
|
|
|
|
// Use environment variables for admin credentials
|
|
if (
|
|
username === process.env.ADMIN_USERNAME &&
|
|
password === process.env.ADMIN_PASSWORD
|
|
) {
|
|
const token = jwt.sign({ admin: true, username, email: process.env.ADMIN_EMAIL }, JWT_SECRET, { expiresIn: '8h' });
|
|
return res.json({ token });
|
|
}
|
|
|
|
return res.status(401).json({ error: 'Invalid credentials.' });
|
|
});
|
|
|
|
// JWT authentication middleware
|
|
function requireAdmin(req, res, next) {
|
|
const auth = req.headers.authorization;
|
|
if (!auth || !auth.startsWith('Bearer ')) {
|
|
return res.status(401).json({ error: 'Missing or invalid Authorization header.' });
|
|
}
|
|
const token = auth.split(' ')[1];
|
|
try {
|
|
const payload = jwt.verify(token, JWT_SECRET);
|
|
req.admin = payload;
|
|
next();
|
|
} catch (err) {
|
|
return res.status(401).json({ error: 'Invalid or expired token.' });
|
|
}
|
|
}
|
|
|
|
// Example protected admin route
|
|
app.get('/api/admin/me', requireAdmin, async (req, res) => {
|
|
// Return admin info from env
|
|
res.json({ username: process.env.ADMIN_USERNAME, email: process.env.ADMIN_EMAIL });
|
|
});
|
|
|
|
// Player registration with username
|
|
app.post('/api/register', async (req, res) => {
|
|
const { name, username, password } = req.body;
|
|
if (!name || !username || !password) {
|
|
return res.status(400).json({ error: 'Name, username, and password are required.' });
|
|
}
|
|
try {
|
|
const hash = await bcrypt.hash(password, 10);
|
|
const player = await prisma.player.create({
|
|
data: { name, username, passwordHash: hash },
|
|
});
|
|
res.status(201).json({ id: player.id, name: player.name, username: player.username });
|
|
} catch (err) {
|
|
if (err.code === 'P2002') {
|
|
return res.status(409).json({ error: 'Username already registered.' });
|
|
}
|
|
res.status(500).json({ error: 'Failed to register player.' });
|
|
}
|
|
});
|
|
|
|
// Player login with username
|
|
app.post('/api/login', async (req, res) => {
|
|
const { username, password } = req.body;
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password are required.' });
|
|
}
|
|
try {
|
|
const player = await prisma.player.findUnique({ where: { username } });
|
|
if (!player || !player.passwordHash) {
|
|
return res.status(401).json({ error: 'Invalid credentials.' });
|
|
}
|
|
const valid = await bcrypt.compare(password, player.passwordHash);
|
|
if (!valid) {
|
|
return res.status(401).json({ error: 'Invalid credentials.' });
|
|
}
|
|
const token = jwt.sign({ playerId: player.id, name: player.name, username: player.username }, JWT_SECRET, { expiresIn: '8h' });
|
|
res.json({ token });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to login.' });
|
|
}
|
|
});
|
|
|
|
// JWT authentication middleware for players
|
|
function requirePlayer(req, res, next) {
|
|
const auth = req.headers.authorization;
|
|
if (!auth || !auth.startsWith('Bearer ')) {
|
|
return res.status(401).json({ error: 'Missing or invalid Authorization header.' });
|
|
}
|
|
const token = auth.split(' ')[1];
|
|
try {
|
|
const payload = jwt.verify(token, JWT_SECRET);
|
|
req.player = payload;
|
|
next();
|
|
} catch (err) {
|
|
return res.status(401).json({ error: 'Invalid or expired token.' });
|
|
}
|
|
}
|
|
|
|
// Example protected player route
|
|
app.get('/api/me', requirePlayer, async (req, res) => {
|
|
const player = await prisma.player.findUnique({ where: { id: req.player.playerId }, select: { id: true, name: true, email: true } });
|
|
if (!player) return res.status(404).json({ error: 'Player not found.' });
|
|
res.json(player);
|
|
});
|
|
|
|
// Utility to generate all unique pairs for round robin
|
|
function generateRoundRobinPairs(teams) {
|
|
const pairs = [];
|
|
for (let i = 0; i < teams.length; i++) {
|
|
for (let j = i + 1; j < teams.length; j++) {
|
|
pairs.push([teams[i], teams[j]]);
|
|
}
|
|
}
|
|
return pairs;
|
|
}
|
|
|
|
// Admin: Schedule round robin matches (teams)
|
|
app.post('/api/admin/schedule/roundrobin', requireAdmin, async (req, res) => {
|
|
try {
|
|
const existingStage = await prisma.tournamentStage.findFirst({ where: { type: 'ROUND_ROBIN' } });
|
|
if (existingStage) {
|
|
return res.status(400).json({ error: 'Round robin stage already scheduled.' });
|
|
}
|
|
// Get all teams
|
|
const teams = await prisma.team.findMany();
|
|
if (teams.length < 3) {
|
|
return res.status(400).json({ error: 'At least 3 teams required.' });
|
|
}
|
|
// Pool logic: min 3, max 5
|
|
const minTeamsPerPool = 3;
|
|
const maxTeamsPerPool = 5;
|
|
const numPools = Math.ceil(teams.length / maxTeamsPerPool);
|
|
if (numPools * minTeamsPerPool > teams.length) {
|
|
return res.status(400).json({ error: `Not enough teams to create ${numPools} pools with at least ${minTeamsPerPool} teams each.` });
|
|
}
|
|
// Shuffle teams
|
|
const shuffled = [...teams];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
// Distribute teams into pools as evenly as possible (no pool > 5, all pools >= 3)
|
|
const pools = Array.from({ length: numPools }, () => []);
|
|
let idx = 0;
|
|
for (const team of shuffled) {
|
|
pools[idx % numPools].push(team);
|
|
idx++;
|
|
}
|
|
// Debug log
|
|
console.log('Teams:', teams.length, 'Num pools:', numPools, 'Pool sizes:', pools.map(p => p.length));
|
|
if (pools.some(pool => pool.length < minTeamsPerPool || pool.length > maxTeamsPerPool)) {
|
|
return res.status(400).json({ error: `Unable to distribute teams into pools of min ${minTeamsPerPool} and max ${maxTeamsPerPool} teams.` });
|
|
}
|
|
// Get or create default tournament
|
|
let tournament = await prisma.tournament.findFirst();
|
|
if (!tournament) {
|
|
tournament = await prisma.tournament.create({
|
|
data: {
|
|
name: 'Spring Championship 2024',
|
|
date: new Date('2024-03-15'),
|
|
location: 'Main Arena'
|
|
}
|
|
});
|
|
}
|
|
// Create stage
|
|
const stage = await prisma.tournamentStage.create({
|
|
data: {
|
|
type: 'ROUND_ROBIN',
|
|
startedAt: new Date(),
|
|
tournamentId: tournament.id
|
|
}
|
|
});
|
|
// Generate matches for each pool
|
|
const now = new Date();
|
|
let matchesData = [];
|
|
pools.forEach((pool, poolIdx) => {
|
|
const pairs = generateRoundRobinPairs(pool);
|
|
matchesData.push(...pairs.map(([t1, t2]) => ({
|
|
stageId: stage.id,
|
|
team1Id: t1.id,
|
|
team2Id: t2.id,
|
|
scheduledAt: now,
|
|
pool: poolIdx + 1
|
|
})));
|
|
});
|
|
const matches = await prisma.match.createMany({ data: matchesData });
|
|
// Prepare pool assignments for response
|
|
const poolAssignments = pools.map((pool, i) => ({ pool: i + 1, teams: pool.map(t => ({ id: t.id, name: t.name })) }));
|
|
res.status(201).json({ stage, matchesCreated: matches.count, pools: poolAssignments });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to schedule round robin.' });
|
|
}
|
|
});
|
|
|
|
// List all matches with team names and results
|
|
app.get('/api/matches', async (req, res) => {
|
|
try {
|
|
const matches = await prisma.match.findMany({
|
|
orderBy: { scheduledAt: 'asc' },
|
|
include: {
|
|
team1: { select: { id: true, name: true, logo: true } },
|
|
team2: { select: { id: true, name: true, logo: true } },
|
|
result: true,
|
|
stage: { select: { id: true, type: true, tier: true } }
|
|
}
|
|
});
|
|
res.json(matches);
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to fetch matches.' });
|
|
}
|
|
});
|
|
|
|
// Admin: Enter match result (teams)
|
|
app.post('/api/admin/matches/:matchId/result', requireAdmin, async (req, res) => {
|
|
const matchId = parseInt(req.params.matchId, 10);
|
|
const { team1Score, team2Score } = req.body;
|
|
if (!Number.isInteger(matchId) || matchId <= 0) {
|
|
return res.status(400).json({ error: 'Invalid match ID.' });
|
|
}
|
|
if (typeof team1Score !== 'number' || typeof team2Score !== 'number') {
|
|
return res.status(400).json({ error: 'Scores must be numbers.' });
|
|
}
|
|
try {
|
|
const match = await prisma.match.findUnique({
|
|
where: { id: matchId },
|
|
include: { team1: true, team2: true, result: true }
|
|
});
|
|
if (!match) {
|
|
return res.status(404).json({ error: 'Match not found.' });
|
|
}
|
|
if (match.result) {
|
|
return res.status(409).json({ error: 'Result already entered for this match.' });
|
|
}
|
|
let winnerId = null;
|
|
if (team1Score > team2Score) winnerId = match.team1Id;
|
|
else if (team2Score > team1Score) winnerId = match.team2Id;
|
|
else return res.status(400).json({ error: 'Draws are not allowed.' });
|
|
const result = await prisma.result.create({
|
|
data: {
|
|
matchId: match.id,
|
|
team1Score,
|
|
team2Score,
|
|
winnerId
|
|
}
|
|
});
|
|
const updatedMatch = await prisma.match.findUnique({
|
|
where: { id: matchId },
|
|
include: {
|
|
team1: { select: { id: true, name: true, logo: true } },
|
|
team2: { select: { id: true, name: true, logo: true } },
|
|
result: true,
|
|
stage: { select: { id: true, type: true } }
|
|
}
|
|
});
|
|
res.status(201).json(updatedMatch);
|
|
io.emit('matchResultUpdated', { matchId, updatedMatch });
|
|
emitBracketUpdate();
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to enter match result.' });
|
|
}
|
|
});
|
|
|
|
// Compute and return round robin standings (teams)
|
|
app.get('/api/standings', async (req, res) => {
|
|
try {
|
|
// Get all teams
|
|
const teams = await prisma.team.findMany();
|
|
// Get all round robin matches with results
|
|
const rrStage = await prisma.tournamentStage.findFirst({ where: { type: 'ROUND_ROBIN' } });
|
|
if (!rrStage) return res.status(400).json({ error: 'No round robin stage found.' });
|
|
const matches = await prisma.match.findMany({
|
|
where: { stageId: rrStage.id },
|
|
include: { result: true }
|
|
});
|
|
// Compute stats
|
|
const stats = {};
|
|
teams.forEach(t => {
|
|
stats[t.id] = { id: t.id, name: t.name, logo: t.logo, wins: 0, losses: 0, played: 0, points: 0 };
|
|
});
|
|
matches.forEach(m => {
|
|
if (!m.result) return;
|
|
stats[m.team1Id].played++;
|
|
stats[m.team2Id].played++;
|
|
if (m.result.winnerId === m.team1Id) {
|
|
stats[m.team1Id].wins++;
|
|
stats[m.team2Id].losses++;
|
|
stats[m.team1Id].points += 2;
|
|
} else if (m.result.winnerId === m.team2Id) {
|
|
stats[m.team2Id].wins++;
|
|
stats[m.team1Id].losses++;
|
|
stats[m.team2Id].points += 2;
|
|
}
|
|
});
|
|
// Convert to array and sort
|
|
const standings = Object.values(stats).sort((a, b) =>
|
|
b.points - a.points || b.wins - a.wins || a.name.localeCompare(b.name)
|
|
);
|
|
res.json(standings);
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to compute standings.' });
|
|
}
|
|
});
|
|
|
|
// Utility: get next power of 2 <= n
|
|
function getBracketSize(n) {
|
|
if (n >= 8) return 8;
|
|
if (n >= 4) return 4;
|
|
return 2;
|
|
}
|
|
|
|
// Admin: Schedule single elimination bracket (teams)
|
|
app.post('/api/admin/schedule/singleelim', requireAdmin, async (req, res) => {
|
|
try {
|
|
// Check if any SINGLE_ELIM stage exists
|
|
const existingStage = await prisma.tournamentStage.findFirst({ where: { type: 'SINGLE_ELIM' } });
|
|
if (existingStage) {
|
|
return res.status(400).json({ error: 'Single elimination stage already scheduled.' });
|
|
}
|
|
// Get round robin standings
|
|
const rrStage = await prisma.tournamentStage.findFirst({ where: { type: 'ROUND_ROBIN' } });
|
|
if (!rrStage) return res.status(400).json({ error: 'No round robin stage found.' });
|
|
const rrMatches = await prisma.match.findMany({ where: { stageId: rrStage.id }, include: { result: true } });
|
|
if (rrMatches.some(m => !m.result)) {
|
|
return res.status(400).json({ error: 'Not all round robin matches have results.' });
|
|
}
|
|
// Compute standings
|
|
const stats = {};
|
|
rrMatches.forEach(m => {
|
|
if (!stats[m.team1Id]) stats[m.team1Id] = { id: m.team1Id, wins: 0, points: 0 };
|
|
if (!stats[m.team2Id]) stats[m.team2Id] = { id: m.team2Id, wins: 0, points: 0 };
|
|
if (m.result.winnerId === m.team1Id) {
|
|
stats[m.team1Id].wins++;
|
|
stats[m.team1Id].points += 2;
|
|
} else if (m.result.winnerId === m.team2Id) {
|
|
stats[m.team2Id].wins++;
|
|
stats[m.team2Id].points += 2;
|
|
}
|
|
});
|
|
// Get team info
|
|
const teams = await prisma.team.findMany();
|
|
const standings = Object.values(stats).map(s => ({ ...s, name: teams.find(t => t.id === s.id)?.name || '' }));
|
|
standings.sort((a, b) => b.points - a.points || b.wins - a.wins || a.name.localeCompare(b.name));
|
|
if (standings.length < 2) return res.status(400).json({ error: 'Not enough teams for single elimination.' });
|
|
|
|
// Split standings into tiers of 4
|
|
const tiers = [];
|
|
for (let i = 0; i < standings.length; i += 4) {
|
|
tiers.push(standings.slice(i, i + 4));
|
|
}
|
|
const now = new Date();
|
|
const allStages = [];
|
|
const allMatches = [];
|
|
for (let tierIdx = 0; tierIdx < tiers.length; tierIdx++) {
|
|
const tierTeams = tiers[tierIdx];
|
|
if (tierTeams.length < 2) continue; // Skip tiers with less than 2 teams
|
|
// Get or create default tournament
|
|
let tournament = await prisma.tournament.findFirst();
|
|
if (!tournament) {
|
|
tournament = await prisma.tournament.create({
|
|
data: {
|
|
name: 'Spring Championship 2024',
|
|
date: new Date('2024-03-15'),
|
|
location: 'Main Arena'
|
|
}
|
|
});
|
|
}
|
|
// Create stage for this tier
|
|
const stage = await prisma.tournamentStage.create({
|
|
data: {
|
|
type: 'SINGLE_ELIM',
|
|
startedAt: now,
|
|
tier: tierIdx + 1,
|
|
tournamentId: tournament.id
|
|
}
|
|
});
|
|
let matchesData = [];
|
|
if (tierTeams.length === 4) {
|
|
// 4 teams: 1 vs 4, 2 vs 3
|
|
const pairs = [
|
|
[tierTeams[0], tierTeams[3]],
|
|
[tierTeams[1], tierTeams[2]]
|
|
];
|
|
matchesData = pairs.map(([t1, t2]) => ({
|
|
stageId: stage.id,
|
|
team1Id: t1.id,
|
|
team2Id: t2.id,
|
|
scheduledAt: now,
|
|
pool: 0
|
|
}));
|
|
} else if (tierTeams.length === 3) {
|
|
// 3 teams: top seed gets a bye, 2 vs 3 play semifinal, winner faces 1 in final
|
|
// Semifinal: 2 vs 3
|
|
matchesData.push({
|
|
stageId: stage.id,
|
|
team1Id: tierTeams[1].id,
|
|
team2Id: tierTeams[2].id,
|
|
scheduledAt: now,
|
|
pool: 0
|
|
});
|
|
// Final: 1 vs winner of semifinal (to be scheduled after semifinal is played)
|
|
// We'll need to add logic elsewhere to schedule the final after the semifinal result is in.
|
|
} else if (tierTeams.length === 2) {
|
|
// 2 teams: single final
|
|
matchesData.push({
|
|
stageId: stage.id,
|
|
team1Id: tierTeams[0].id,
|
|
team2Id: tierTeams[1].id,
|
|
scheduledAt: now,
|
|
pool: 0
|
|
});
|
|
}
|
|
const matches = await prisma.match.createMany({ data: matchesData });
|
|
allStages.push(stage);
|
|
allMatches.push({ tier: tierIdx + 1, matchesCreated: matches.count, teams: tierTeams.map(t => t ? t.name : null) });
|
|
}
|
|
res.status(201).json({ stages: allStages, summary: allMatches });
|
|
} catch (err) {
|
|
console.error('Error scheduling single elimination:', err);
|
|
res.status(500).json({ error: 'Failed to schedule single elimination.' });
|
|
}
|
|
});
|
|
|
|
// View single elimination bracket
|
|
app.get('/api/bracket/singleelim', async (req, res) => {
|
|
try {
|
|
const stage = await prisma.tournamentStage.findFirst({ where: { type: 'SINGLE_ELIM' } });
|
|
if (!stage) return res.status(404).json({ error: 'Single elimination stage not found.' });
|
|
const matches = await prisma.match.findMany({
|
|
where: { stageId: stage.id },
|
|
orderBy: { scheduledAt: 'asc' },
|
|
include: {
|
|
team1: { select: { id: true, name: true, logo: true } },
|
|
team2: { select: { id: true, name: true, logo: true } },
|
|
result: true
|
|
}
|
|
});
|
|
res.json({ stage, matches });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to fetch single elimination bracket.' });
|
|
}
|
|
});
|
|
|
|
// Admin: Advance winners and schedule next round in single elimination (by tier)
|
|
app.post('/api/admin/schedule/singleelim/next', requireAdmin, async (req, res) => {
|
|
try {
|
|
const tier = parseInt(req.query.tier, 10);
|
|
if (!tier || isNaN(tier)) {
|
|
return res.status(400).json({ error: 'Tier parameter is required and must be a number.' });
|
|
}
|
|
const stage = await prisma.tournamentStage.findFirst({ where: { type: 'SINGLE_ELIM', tier } });
|
|
if (!stage) return res.status(404).json({ error: 'Single elimination stage for this tier not found.' });
|
|
// Get all matches for this stage
|
|
const matches = await prisma.match.findMany({
|
|
where: { stageId: stage.id },
|
|
orderBy: { scheduledAt: 'asc' },
|
|
include: { result: true }
|
|
});
|
|
// Group matches by scheduledAt (rounds)
|
|
const rounds = {};
|
|
matches.forEach(m => {
|
|
const key = m.scheduledAt.toISOString();
|
|
if (!rounds[key]) rounds[key] = [];
|
|
rounds[key].push(m);
|
|
});
|
|
// Find latest round (by scheduledAt)
|
|
const roundKeys = Object.keys(rounds).sort();
|
|
const latestKey = roundKeys[roundKeys.length - 1];
|
|
const latestRound = rounds[latestKey];
|
|
// Check all matches in latest round have results
|
|
if (latestRound.some(m => !m.result)) {
|
|
return res.status(400).json({ error: 'Not all matches in the current round have results.' });
|
|
}
|
|
// Get winners
|
|
const winners = latestRound.map(m => m.result.winnerId);
|
|
if (winners.length === 1) {
|
|
// Tournament complete for this tier
|
|
const champion = await prisma.team.findUnique({ where: { id: winners[0] } });
|
|
return res.json({ message: 'Tournament complete for this tier', champion });
|
|
}
|
|
// Pair winners for next round
|
|
const pairs = [];
|
|
for (let i = 0; i < winners.length; i += 2) {
|
|
if (i + 1 < winners.length) {
|
|
pairs.push([winners[i], winners[i + 1]]);
|
|
} else {
|
|
// Odd number: bye (auto-advance)
|
|
pairs.push([winners[i], null]);
|
|
}
|
|
}
|
|
// Schedule next round
|
|
const now = new Date();
|
|
const matchesData = pairs.filter(p => p[1] !== null).map(([t1Id, t2Id]) => ({
|
|
stageId: stage.id,
|
|
team1Id: t1Id,
|
|
team2Id: t2Id,
|
|
scheduledAt: now,
|
|
pool: 0
|
|
}));
|
|
const newMatches = await prisma.match.createMany({ data: matchesData });
|
|
res.status(201).json({ matchesCreated: newMatches.count, nextRoundPairs: pairs });
|
|
io.emit('roundAdvanced');
|
|
emitBracketUpdate();
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to schedule next round.' });
|
|
}
|
|
});
|
|
|
|
// Change admin password (requires authentication)
|
|
app.post('/api/admin/change-password', requireAdmin, async (req, res) => {
|
|
const { oldPassword, newPassword } = req.body;
|
|
if (!oldPassword || !newPassword) {
|
|
return res.status(400).json({ error: 'Old and new passwords are required.' });
|
|
}
|
|
try {
|
|
const admin = await prisma.adminUser.findUnique({ where: { id: req.admin.adminId } });
|
|
if (!admin) {
|
|
return res.status(404).json({ error: 'Admin not found.' });
|
|
}
|
|
const valid = await bcrypt.compare(oldPassword, admin.passwordHash);
|
|
if (!valid) {
|
|
return res.status(401).json({ error: 'Old password is incorrect.' });
|
|
}
|
|
const newHash = await bcrypt.hash(newPassword, 10);
|
|
await prisma.adminUser.update({
|
|
where: { id: admin.id },
|
|
data: { passwordHash: newHash }
|
|
});
|
|
res.json({ message: 'Password updated successfully.' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to update password.' });
|
|
}
|
|
});
|
|
|
|
// Change player password (requires authentication)
|
|
app.post('/api/player/change-password', requirePlayer, async (req, res) => {
|
|
const { oldPassword, newPassword } = req.body;
|
|
if (!oldPassword || !newPassword) {
|
|
return res.status(400).json({ error: 'Old and new passwords are required.' });
|
|
}
|
|
try {
|
|
const player = await prisma.player.findUnique({ where: { id: req.player.playerId } });
|
|
if (!player || !player.passwordHash) {
|
|
return res.status(404).json({ error: 'Player not found or password not set.' });
|
|
}
|
|
const valid = await bcrypt.compare(oldPassword, player.passwordHash);
|
|
if (!valid) {
|
|
return res.status(401).json({ error: 'Old password is incorrect.' });
|
|
}
|
|
const newHash = await bcrypt.hash(newPassword, 10);
|
|
await prisma.player.update({
|
|
where: { id: player.id },
|
|
data: { passwordHash: newHash }
|
|
});
|
|
res.json({ message: 'Password updated successfully.' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to update password.' });
|
|
}
|
|
});
|
|
|
|
// Set up multer storage for /uploads directory
|
|
const storage = multer.diskStorage({
|
|
destination: function (req, file, cb) {
|
|
cb(null, path.join(__dirname, 'uploads'));
|
|
},
|
|
filename: function (req, file, cb) {
|
|
const ext = path.extname(file.originalname);
|
|
const uniqueName = `player_${req.player.playerId}_${Date.now()}${ext}`;
|
|
cb(null, uniqueName);
|
|
}
|
|
});
|
|
const upload = multer({ storage });
|
|
|
|
// Serve /uploads as static files
|
|
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
|
|
|
// Player profile image upload endpoint
|
|
app.post('/api/player/upload-image', requirePlayer, upload.single('image'), async (req, res) => {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'No file uploaded.' });
|
|
}
|
|
try {
|
|
const imageUrl = `/uploads/${req.file.filename}`;
|
|
await prisma.player.update({
|
|
where: { id: req.player.playerId },
|
|
data: { imageUrl }
|
|
});
|
|
res.json({ imageUrl });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to update profile image.' });
|
|
}
|
|
});
|
|
|
|
// Update player name (requires authentication)
|
|
app.post('/api/player/update-name', requirePlayer, async (req, res) => {
|
|
const { name } = req.body;
|
|
if (!name) {
|
|
return res.status(400).json({ error: 'Name is required.' });
|
|
}
|
|
try {
|
|
const player = await prisma.player.update({
|
|
where: { id: req.player.playerId },
|
|
data: { name }
|
|
});
|
|
res.json({ id: player.id, name: player.name, email: player.email, imageUrl: player.imageUrl });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to update name.' });
|
|
}
|
|
});
|
|
|
|
// In-memory store for password reset tokens (for development only)
|
|
const passwordResetTokens = {};
|
|
|
|
// Request password reset: generate token and log link
|
|
app.post('/api/player/request-password-reset', async (req, res) => {
|
|
const { email } = req.body;
|
|
if (!email) return res.status(400).json({ error: 'Email is required.' });
|
|
try {
|
|
const player = await prisma.player.findUnique({ where: { email } });
|
|
if (!player) return res.status(404).json({ error: 'No user with that email.' });
|
|
// Generate a token (simple random string for dev)
|
|
const token = Math.random().toString(36).substr(2) + Date.now();
|
|
passwordResetTokens[token] = { playerId: player.id, expires: Date.now() + 1000 * 60 * 15 }; // 15 min expiry
|
|
const resetLink = `http://localhost:3000/reset-password?token=${token}`;
|
|
console.log(`Password reset link for ${email}: ${resetLink}`);
|
|
res.json({ message: 'Password reset link sent (check backend console).' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to process request.' });
|
|
}
|
|
});
|
|
|
|
// Reset password using token
|
|
app.post('/api/player/reset-password', async (req, res) => {
|
|
const { token, newPassword } = req.body;
|
|
if (!token || !newPassword) return res.status(400).json({ error: 'Token and new password are required.' });
|
|
const entry = passwordResetTokens[token];
|
|
if (!entry || entry.expires < Date.now()) {
|
|
return res.status(400).json({ error: 'Invalid or expired token.' });
|
|
}
|
|
try {
|
|
const hash = await bcrypt.hash(newPassword, 10);
|
|
await prisma.player.update({ where: { id: entry.playerId }, data: { passwordHash: hash } });
|
|
delete passwordResetTokens[token];
|
|
res.json({ message: 'Password has been reset.' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to reset password.' });
|
|
}
|
|
});
|
|
|
|
// Function to automatically create pools when teams are registered
|
|
async function createPoolsAutomatically() {
|
|
try {
|
|
// Get all teams
|
|
const teams = await prisma.team.findMany();
|
|
if (teams.length < 3) {
|
|
return { success: false, message: 'Need at least 3 teams to create pools.' };
|
|
}
|
|
|
|
// Check if pools already exist
|
|
const existingStage = await prisma.tournamentStage.findFirst({ where: { type: 'ROUND_ROBIN' } });
|
|
if (existingStage) {
|
|
return { success: false, message: 'Pools already exist.' };
|
|
}
|
|
|
|
// Pool logic: min 3, max 5
|
|
const minTeamsPerPool = 3;
|
|
const maxTeamsPerPool = 5;
|
|
const numPools = Math.ceil(teams.length / maxTeamsPerPool);
|
|
|
|
if (numPools * minTeamsPerPool > teams.length) {
|
|
return { success: false, message: `Not enough teams to create ${numPools} pools with at least ${minTeamsPerPool} teams each.` };
|
|
}
|
|
|
|
// Shuffle teams
|
|
const shuffled = [...teams];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
|
|
// Distribute teams into pools as evenly as possible (no pool > 5, all pools >= 3)
|
|
const pools = Array.from({ length: numPools }, () => []);
|
|
let idx = 0;
|
|
for (const team of shuffled) {
|
|
pools[idx % numPools].push(team);
|
|
idx++;
|
|
}
|
|
|
|
// Debug log
|
|
console.log('Teams:', teams.length, 'Num pools:', numPools, 'Pool sizes:', pools.map(p => p.length));
|
|
|
|
if (pools.some(pool => pool.length < minTeamsPerPool || pool.length > maxTeamsPerPool)) {
|
|
return { success: false, message: `Unable to distribute teams into pools of min ${minTeamsPerPool} and max ${maxTeamsPerPool} teams.` };
|
|
}
|
|
|
|
// Get or create default tournament
|
|
let tournament = await prisma.tournament.findFirst();
|
|
if (!tournament) {
|
|
tournament = await prisma.tournament.create({
|
|
data: {
|
|
name: 'Championship Tournament',
|
|
date: new Date(),
|
|
location: 'Main Arena'
|
|
}
|
|
});
|
|
}
|
|
|
|
// Create stage
|
|
const stage = await prisma.tournamentStage.create({
|
|
data: {
|
|
type: 'ROUND_ROBIN',
|
|
startedAt: new Date(),
|
|
tournamentId: tournament.id
|
|
}
|
|
});
|
|
|
|
// Generate matches for each pool
|
|
const now = new Date();
|
|
let matchesData = [];
|
|
pools.forEach((pool, poolIdx) => {
|
|
const pairs = generateRoundRobinPairs(pool);
|
|
matchesData.push(...pairs.map(([t1, t2]) => ({
|
|
stageId: stage.id,
|
|
team1Id: t1.id,
|
|
team2Id: t2.id,
|
|
scheduledAt: now,
|
|
pool: poolIdx + 1
|
|
})));
|
|
});
|
|
|
|
const matches = await prisma.match.createMany({ data: matchesData });
|
|
|
|
// Prepare pool assignments for response
|
|
const poolAssignments = pools.map((pool, i) => ({
|
|
pool: i + 1,
|
|
teams: pool.map(t => ({ id: t.id, name: t.name }))
|
|
}));
|
|
|
|
return {
|
|
success: true,
|
|
message: `Pools created successfully with ${matches.count} matches.`,
|
|
pools: poolAssignments,
|
|
matchesCreated: matches.count
|
|
};
|
|
} catch (err) {
|
|
console.error('Error creating pools automatically:', err);
|
|
return { success: false, message: 'Failed to create pools automatically.' };
|
|
}
|
|
}
|
|
|
|
// Create a team
|
|
app.post('/api/teams', async (req, res) => {
|
|
const { name, logo } = req.body;
|
|
if (!name) {
|
|
return res.status(400).json({ error: 'Team name is required.' });
|
|
}
|
|
|
|
// Generate SVG logo if not provided
|
|
let teamLogo = logo;
|
|
if (!teamLogo) {
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i++) {
|
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
|
|
const hue = Math.abs(hash) % 360;
|
|
const saturation = 60 + (Math.abs(hash) % 30);
|
|
const lightness = 40 + (Math.abs(hash) % 30);
|
|
const color = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
|
|
|
const secondaryHue = (hue + 180) % 360;
|
|
const secondaryColor = `hsl(${secondaryHue}, ${saturation}%, ${lightness}%)`;
|
|
|
|
const pattern = Math.abs(hash) % 4;
|
|
const initials = name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
|
|
|
switch (pattern) {
|
|
case 0: // Circle with initials
|
|
teamLogo = `<svg width="40" height="40" viewBox="0 0 40 40"><circle cx="20" cy="20" r="20" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="55%" text-anchor="middle" font-size="13.333333333333334" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
|
break;
|
|
case 1: // Square with diagonal
|
|
teamLogo = `<svg width="40" height="40" viewBox="0 0 40 40"><rect x="2" y="2" width="36" height="36" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><line x1="0" y1="0" x2="40" y2="40" stroke="${secondaryColor}" stroke-width="3"/><text x="50%" y="55%" text-anchor="middle" font-size="13.333333333333334" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
|
break;
|
|
case 2: // Triangle
|
|
teamLogo = `<svg width="40" height="40" viewBox="0 0 40 40"><polygon points="20,5 35,35 5,35" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="60%" text-anchor="middle" font-size="13.333333333333334" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
|
break;
|
|
case 3: // Diamond
|
|
teamLogo = `<svg width="40" height="40" viewBox="0 0 40 40"><polygon points="20,5 35,20 20,35 5,20" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="55%" text-anchor="middle" font-size="13.333333333333334" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
|
break;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const team = await prisma.team.create({ data: { name, logo: teamLogo } });
|
|
|
|
// Try to create pools automatically after team registration
|
|
const poolResult = await createPoolsAutomatically();
|
|
|
|
if (poolResult.success) {
|
|
res.status(201).json({
|
|
team,
|
|
poolsCreated: true,
|
|
poolMessage: poolResult.message,
|
|
pools: poolResult.pools,
|
|
matchesCreated: poolResult.matchesCreated
|
|
});
|
|
} else {
|
|
res.status(201).json({
|
|
team,
|
|
poolsCreated: false,
|
|
poolMessage: poolResult.message
|
|
});
|
|
}
|
|
} catch (err) {
|
|
if (err.code === 'P2002') {
|
|
return res.status(409).json({ error: 'Team name already exists.' });
|
|
}
|
|
res.status(500).json({ error: 'Failed to create team.' });
|
|
}
|
|
});
|
|
|
|
// List all teams
|
|
app.get('/api/teams', async (req, res) => {
|
|
try {
|
|
const teams = await prisma.team.findMany({ orderBy: { registeredAt: 'asc' } });
|
|
res.json(teams);
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to fetch teams.' });
|
|
}
|
|
});
|
|
|
|
// Create a tournament
|
|
app.post('/api/tournaments', requireAdmin, async (req, res) => {
|
|
const { name, date, location } = req.body;
|
|
if (!name || !date || !location) {
|
|
return res.status(400).json({ error: 'Name, date, and location are required.' });
|
|
}
|
|
try {
|
|
const tournament = await prisma.tournament.create({
|
|
data: {
|
|
name,
|
|
date: new Date(date),
|
|
location
|
|
}
|
|
});
|
|
res.status(201).json(tournament);
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to create tournament.' });
|
|
}
|
|
});
|
|
|
|
// Get available tournaments
|
|
app.get('/api/tournaments', async (req, res) => {
|
|
try {
|
|
// Fetch real tournaments from database
|
|
const tournaments = await prisma.tournament.findMany({
|
|
include: {
|
|
stages: {
|
|
include: {
|
|
matches: {
|
|
include: {
|
|
team1: true,
|
|
team2: true,
|
|
result: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
orderBy: { createdAt: 'desc' }
|
|
});
|
|
|
|
// If no tournaments exist, create a default one
|
|
if (tournaments.length === 0) {
|
|
const defaultTournament = await prisma.tournament.create({
|
|
data: {
|
|
name: 'Spring Championship 2024',
|
|
date: new Date('2024-03-15'),
|
|
location: 'Main Arena'
|
|
}
|
|
});
|
|
|
|
// Add some sample tournaments
|
|
await prisma.tournament.createMany({
|
|
data: [
|
|
{
|
|
name: 'Winter Cup 2024',
|
|
date: new Date('2024-01-20'),
|
|
location: 'Sports Complex'
|
|
},
|
|
{
|
|
name: 'Summer League 2024',
|
|
date: new Date('2024-06-10'),
|
|
location: 'City Stadium'
|
|
}
|
|
]
|
|
});
|
|
|
|
// Fetch the tournaments again after creating them
|
|
const updatedTournaments = await prisma.tournament.findMany({
|
|
include: {
|
|
stages: {
|
|
include: {
|
|
matches: {
|
|
include: {
|
|
team1: true,
|
|
team2: true,
|
|
result: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
orderBy: { createdAt: 'desc' }
|
|
});
|
|
|
|
// Transform the data for frontend
|
|
const teamCount = await prisma.team.count();
|
|
const transformedTournaments = updatedTournaments.map(tournament => {
|
|
const totalMatches = tournament.stages.reduce((sum, stage) => sum + stage.matches.length, 0);
|
|
const completedMatches = tournament.stages.reduce((sum, stage) =>
|
|
sum + stage.matches.filter(match => match.result).length, 0);
|
|
|
|
let status = 'upcoming';
|
|
if (totalMatches > 0) {
|
|
if (completedMatches === totalMatches) {
|
|
status = 'completed';
|
|
} else if (completedMatches > 0) {
|
|
status = 'active';
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: tournament.id,
|
|
name: tournament.name,
|
|
date: tournament.date.toISOString().split('T')[0],
|
|
location: tournament.location,
|
|
teams: teamCount, // Total teams in system
|
|
status: status,
|
|
totalMatches: totalMatches,
|
|
completedMatches: completedMatches
|
|
};
|
|
});
|
|
|
|
res.json(transformedTournaments);
|
|
} else {
|
|
// Transform existing tournaments
|
|
const transformedTournaments = await Promise.all(tournaments.map(async tournament => {
|
|
const totalMatches = tournament.stages.reduce((sum, stage) => sum + stage.matches.length, 0);
|
|
const completedMatches = tournament.stages.reduce((sum, stage) =>
|
|
sum + stage.matches.filter(match => match.result).length, 0);
|
|
|
|
let status = 'upcoming';
|
|
if (totalMatches > 0) {
|
|
if (completedMatches === totalMatches) {
|
|
status = 'completed';
|
|
} else if (completedMatches > 0) {
|
|
status = 'active';
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: tournament.id,
|
|
name: tournament.name,
|
|
date: tournament.date.toISOString().split('T')[0],
|
|
location: tournament.location,
|
|
teams: await prisma.team.count(), // Total teams in system
|
|
status: status,
|
|
totalMatches: totalMatches,
|
|
completedMatches: completedMatches
|
|
};
|
|
}));
|
|
|
|
res.json(transformedTournaments);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching tournaments:', err);
|
|
res.status(500).json({ error: 'Failed to fetch tournaments.' });
|
|
}
|
|
});
|
|
|
|
// Admin: Reset the entire tournament (delete all data)
|
|
app.post('/api/admin/reset-tournament', requireAdmin, async (req, res) => {
|
|
try {
|
|
await prisma.result.deleteMany();
|
|
await prisma.match.deleteMany();
|
|
await prisma.tournamentStage.deleteMany();
|
|
await prisma.team.deleteMany();
|
|
res.json({ message: 'Tournament reset.' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to reset tournament.' });
|
|
}
|
|
});
|
|
|
|
const server = http.createServer(app);
|
|
const io = new Server(server, { cors: { origin: '*' } });
|
|
|
|
// Socket.IO connection
|
|
io.on('connection', (socket) => {
|
|
console.log('A client connected:', socket.id);
|
|
socket.on('disconnect', () => {
|
|
console.log('Client disconnected:', socket.id);
|
|
});
|
|
});
|
|
|
|
// Helper to emit bracket update
|
|
function emitBracketUpdate() {
|
|
io.emit('bracketUpdated');
|
|
}
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`Server listening on port ${PORT}`);
|
|
});
|