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';
// API routes
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// 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;
}
// Helper to reorder matches so no team plays two games consecutively
function reorderMatchesNoConsecutiveGames(pairs) {
const result = [];
const used = new Array(pairs.length).fill(false);
let lastTeams = new Set();
for (let i = 0; i < pairs.length; i++) {
let found = false;
for (let j = 0; j < pairs.length; j++) {
if (used[j]) continue;
const [a, b] = pairs[j];
if (!lastTeams.has(a.id) && !lastTeams.has(b.id)) {
result.push(pairs[j]);
used[j] = true;
lastTeams = new Set([a.id, b.id]);
found = true;
break;
}
}
if (!found) {
// If not possible, just pick the next unused
for (let j = 0; j < pairs.length; j++) {
if (!used[j]) {
result.push(pairs[j]);
used[j] = true;
lastTeams = new Set([pairs[j][0].id, pairs[j][1].id]);
break;
}
}
}
}
return result;
}
// Robust round robin scheduler (circle method)
function generateRoundRobinSchedule(pool) {
const teams = [...pool];
const n = teams.length;
const rounds = [];
// If odd, add a dummy team (bye)
let isOdd = false;
if (n % 2 === 1) {
teams.push({ id: -1, name: 'BYE' });
isOdd = true;
}
const numRounds = teams.length - 1;
const half = teams.length / 2;
for (let round = 0; round < numRounds; round++) {
const matches = [];
for (let i = 0; i < half; i++) {
const t1 = teams[i];
const t2 = teams[teams.length - 1 - i];
if (t1.id !== -1 && t2.id !== -1) {
matches.push([t1, t2]);
}
}
rounds.push(matches);
// Rotate teams (except the first)
teams.splice(1, 0, teams.pop());
}
return rounds;
}
// Interleave matches from rounds to avoid consecutive games for any team
function interleaveRounds(rounds) {
const interleaved = [];
const maxLen = Math.max(...rounds.map(r => r.length));
for (let i = 0; i < maxLen; i++) {
for (let j = 0; j < rounds.length; j++) {
if (rounds[j][i]) {
interleaved.push(rounds[j][i]);
}
}
}
return interleaved;
}
// Simple and working match ordering
function orderMatchesNoConsecutiveGames(pairs) {
console.log("orderMatchesNoConsecutiveGames called with", pairs.length, "pairs");
const n = pairs.length;
const result = [];
const used = new Array(n).fill(false);
// Keep track of teams that played in the last match
let lastMatchTeams = new Set();
// Simple approach: always pick a match where neither team played in the last match
while (result.length < n) {
let foundGoodMatch = false;
// First pass: look for matches with no consecutive games
for (let i = 0; i < n; i++) {
if (used[i]) continue;
const [team1, team2] = pairs[i];
// Check if either team played in the last match
if (lastMatchTeams.has(team1.id) || lastMatchTeams.has(team2.id)) {
continue; // Skip this match
}
// Found a good match
used[i] = true;
result.push(pairs[i]);
lastMatchTeams = new Set([team1.id, team2.id]);
foundGoodMatch = true;
break;
}
// If no good match found, pick any available match
if (!foundGoodMatch) {
for (let i = 0; i < n; i++) {
if (used[i]) continue;
used[i] = true;
result.push(pairs[i]);
const [team1, team2] = pairs[i];
lastMatchTeams = new Set([team1.id, team2.id]);
break;
}
}
}
console.log("orderMatchesNoConsecutiveGames returning", result.length, "matches");
return result;
}
// Simple greedy ordering for other cases
function simpleGreedyOrder(pairs) {
const n = pairs.length;
const result = [];
const used = new Array(n).fill(false);
let lastMatchTeams = new Set();
while (result.length < n) {
let foundGoodMatch = false;
// Look for matches with no consecutive games
for (let i = 0; i < n; i++) {
if (used[i]) continue;
const [team1, team2] = pairs[i];
if (lastMatchTeams.has(team1.id) || lastMatchTeams.has(team2.id)) {
continue;
}
used[i] = true;
result.push(pairs[i]);
lastMatchTeams = new Set([team1.id, team2.id]);
foundGoodMatch = true;
break;
}
// If no good match found, pick any available match
if (!foundGoodMatch) {
for (let i = 0; i < n; i++) {
if (used[i]) continue;
used[i] = true;
result.push(pairs[i]);
const [team1, team2] = pairs[i];
lastMatchTeams = new Set([team1.id, team2.id]);
break;
}
}
}
return result;
}
// 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 rounds = generateRoundRobinSchedule(pool);
const pairs = rounds.flat();
const orderedMatches = orderMatchesNoConsecutiveGames(pairs);
matchesData.push(...orderedMatches.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 rounds = generateRoundRobinSchedule(pool);
const pairs = rounds.flat();
const orderedMatches = orderMatchesNoConsecutiveGames(pairs);
matchesData.push(...orderedMatches.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 = ``;
break;
case 1: // Square with diagonal
teamLogo = ``;
break;
case 2: // Triangle
teamLogo = ``;
break;
case 3: // Diamond
teamLogo = ``;
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.' });
}
});
app.use(express.static(path.join(__dirname, 'public')));
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'));
});
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}`);
});