FASE 3 COMPLETADA: Torneos y Ligas

Implementados 3 módulos con agent swarm:

1. SISTEMA DE TORNEOS
   - Tipos: Eliminación, Round Robin, Suizo, Consolación
   - Categorías: Masculina, Femenina, Mixta
   - Inscripciones con validación de niveles
   - Gestión de pagos y estados

2. CUADROS Y PARTIDOS
   - Generación automática de cuadros
   - Algoritmos: Circle method (Round Robin), Swiss pairing
   - Avance automático de ganadores
   - Asignación de canchas y horarios
   - Registro y confirmación de resultados

3. LIGAS POR EQUIPOS
   - Creación de equipos con capitán
   - Calendario round-robin automático
   - Tabla de clasificación con desempates
   - Estadísticas por equipo

Modelos DB:
- Tournament, TournamentParticipant, TournamentMatch
- League, LeagueTeam, LeagueTeamMember, LeagueMatch, LeagueStanding

Nuevos endpoints:
- /tournaments/* - Gestión de torneos
- /tournaments/:id/draw/* - Cuadros
- /tournaments/:id/matches/* - Partidos de torneo
- /leagues/* - Ligas
- /league-teams/* - Equipos
- /league-schedule/* - Calendario
- /league-standings/* - Clasificación
- /league-matches/* - Partidos de liga

Datos de prueba:
- Torneo de Verano 2024 (Eliminatoria)
- Liga de Invierno (Round Robin)
- Liga de Club 2024
This commit is contained in:
2026-01-31 08:38:54 +00:00
parent e20c5b956b
commit 6494e2b38b
34 changed files with 9036 additions and 3 deletions

View File

@@ -0,0 +1,291 @@
import { Request, Response, NextFunction } from 'express';
import { LeagueService } from '../services/league.service';
import { ApiError } from '../middleware/errorHandler';
export class LeagueController {
/**
* Crear nueva liga
*/
static async createLeague(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { name, description, format, matchesPerMatchday, startDate, endDate } = req.body;
const league = await LeagueService.createLeague(req.user.userId, {
name,
description,
format,
matchesPerMatchday,
startDate,
endDate,
});
res.status(201).json({
success: true,
message: 'Liga creada exitosamente',
data: league,
});
} catch (error) {
next(error);
}
}
/**
* Obtener lista de ligas
*/
static async getLeagues(req: Request, res: Response, next: NextFunction) {
try {
const { status, type, createdById } = req.query;
const leagues = await LeagueService.getLeagues({
status: status as string,
type: type as string,
createdById: createdById as string,
});
res.status(200).json({
success: true,
count: leagues.length,
data: leagues,
});
} catch (error) {
next(error);
}
}
/**
* Obtener mis ligas (ligas donde el usuario ha creado equipos o es creador)
*/
static async getMyLeagues(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
// Obtener ligas creadas por el usuario
const createdLeagues = await LeagueService.getLeagues({
createdById: req.user.userId,
});
// Obtener ligas donde el usuario es capitán de un equipo
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const captainLeagues = await prisma.leagueTeam.findMany({
where: { captainId: req.user.userId },
include: {
league: {
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
teams: true,
matches: true,
},
},
},
},
},
});
// Obtener ligas donde el usuario es miembro de un equipo
const memberLeagues = await prisma.leagueTeamMember.findMany({
where: {
userId: req.user.userId,
isActive: true,
},
include: {
team: {
include: {
league: {
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
teams: true,
matches: true,
},
},
},
},
},
},
},
});
// Combinar y eliminar duplicados
const allLeagues = [
...createdLeagues,
...captainLeagues.map((cl: any) => cl.league),
...memberLeagues.map((ml: any) => ml.team.league),
];
const uniqueLeagues = allLeagues.filter(
(league, index, self) =>
index === self.findIndex((l) => l.id === league.id)
);
res.status(200).json({
success: true,
count: uniqueLeagues.length,
data: uniqueLeagues,
});
} catch (error) {
next(error);
}
}
/**
* Obtener liga por ID
*/
static async getLeagueById(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const league = await LeagueService.getLeagueById(id);
res.status(200).json({
success: true,
data: league,
});
} catch (error) {
next(error);
}
}
/**
* Actualizar liga
*/
static async updateLeague(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const { name, description, format, matchesPerMatchday, startDate, endDate } = req.body;
const league = await LeagueService.updateLeague(id, req.user.userId, {
name,
description,
format,
matchesPerMatchday,
startDate,
endDate,
});
res.status(200).json({
success: true,
message: 'Liga actualizada exitosamente',
data: league,
});
} catch (error) {
next(error);
}
}
/**
* Eliminar liga
*/
static async deleteLeague(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const result = await LeagueService.deleteLeague(id, req.user.userId);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
}
/**
* Iniciar liga
*/
static async startLeague(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const league = await LeagueService.startLeague(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Liga iniciada exitosamente',
data: league,
});
} catch (error) {
next(error);
}
}
/**
* Finalizar liga
*/
static async finishLeague(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const league = await LeagueService.finishLeague(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Liga finalizada exitosamente',
data: league,
});
} catch (error) {
next(error);
}
}
/**
* Cancelar liga
*/
static async cancelLeague(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const league = await LeagueService.cancelLeague(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Liga cancelada exitosamente',
data: league,
});
} catch (error) {
next(error);
}
}
}
export default LeagueController;

View File

@@ -0,0 +1,156 @@
import { Request, Response, NextFunction } from 'express';
import { LeagueMatchService } from '../services/leagueMatch.service';
import { ApiError } from '../middleware/errorHandler';
export class LeagueMatchController {
/**
* Obtener todos los partidos de una liga
*/
static async getMatches(req: Request, res: Response, next: NextFunction) {
try {
const { leagueId } = req.params;
const { status, matchday } = req.query;
const matches = await LeagueMatchService.getMatches(leagueId, {
status: status as string,
matchday: matchday ? parseInt(matchday as string, 10) : undefined,
});
res.status(200).json({
success: true,
count: matches.length,
data: matches,
});
} catch (error) {
next(error);
}
}
/**
* Obtener partido por ID
*/
static async getMatchById(req: Request, res: Response, next: NextFunction) {
try {
const { matchId } = req.params;
const match = await LeagueMatchService.getMatchById(matchId);
res.status(200).json({
success: true,
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Actualizar resultado de un partido
*/
static async updateMatchResult(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const { team1Score, team2Score, setDetails, winner, notes } = req.body;
const match = await LeagueMatchService.updateMatchResult(
matchId,
req.user.userId,
{
team1Score,
team2Score,
setDetails,
winner,
notes,
}
);
res.status(200).json({
success: true,
message: 'Resultado registrado exitosamente',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Actualizar estado de un partido
*/
static async updateMatchStatus(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const { status, scheduledDate, scheduledTime, courtId } = req.body;
const match = await LeagueMatchService.updateMatchStatus(
matchId,
req.user.userId,
{
status,
scheduledDate,
scheduledTime,
courtId,
}
);
res.status(200).json({
success: true,
message: 'Estado actualizado exitosamente',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Anular resultado de un partido
*/
static async voidMatchResult(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const match = await LeagueMatchService.voidMatchResult(
matchId,
req.user.userId
);
res.status(200).json({
success: true,
message: 'Resultado anulado exitosamente',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Obtener resumen de partidos de una liga
*/
static async getMatchSummary(req: Request, res: Response, next: NextFunction) {
try {
const { leagueId } = req.params;
const summary = await LeagueMatchService.getMatchSummary(leagueId);
res.status(200).json({
success: true,
data: summary,
});
} catch (error) {
next(error);
}
}
}
export default LeagueMatchController;

View File

@@ -0,0 +1,155 @@
import { Request, Response, NextFunction } from 'express';
import { LeagueScheduleService } from '../services/leagueSchedule.service';
import { ApiError } from '../middleware/errorHandler';
export class LeagueScheduleController {
/**
* Generar calendario de la liga
*/
static async generateSchedule(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { leagueId } = req.params;
const schedule = await LeagueScheduleService.generateSchedule(leagueId);
res.status(201).json({
success: true,
message: 'Calendario generado exitosamente',
data: schedule,
});
} catch (error) {
next(error);
}
}
/**
* Obtener calendario completo
*/
static async getSchedule(req: Request, res: Response, next: NextFunction) {
try {
const { leagueId } = req.params;
const schedule = await LeagueScheduleService.getSchedule(leagueId);
res.status(200).json({
success: true,
data: schedule,
});
} catch (error) {
next(error);
}
}
/**
* Obtener jornada específica
*/
static async getMatchday(req: Request, res: Response, next: NextFunction) {
try {
const { leagueId, matchday } = req.params;
const matchdayData = await LeagueScheduleService.getMatchday(
leagueId,
parseInt(matchday, 10)
);
res.status(200).json({
success: true,
data: matchdayData,
});
} catch (error) {
next(error);
}
}
/**
* Actualizar fecha/hora/cancha de un partido
*/
static async updateMatchDate(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const { scheduledDate, scheduledTime, courtId } = req.body;
const match = await LeagueScheduleService.updateMatchDate(
matchId,
req.user.userId,
{
scheduledDate,
scheduledTime,
courtId,
}
);
res.status(200).json({
success: true,
message: 'Partido actualizado exitosamente',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Obtener partidos de un equipo
*/
static async getTeamMatches(req: Request, res: Response, next: NextFunction) {
try {
const { teamId } = req.params;
const matches = await LeagueScheduleService.getTeamMatches(teamId);
res.status(200).json({
success: true,
count: matches.length,
data: matches,
});
} catch (error) {
next(error);
}
}
/**
* Obtener partidos pendientes de programar
*/
static async getUnscheduledMatches(req: Request, res: Response, next: NextFunction) {
try {
const { leagueId } = req.params;
const matches = await LeagueScheduleService.getUnscheduledMatches(leagueId);
res.status(200).json({
success: true,
count: matches.length,
data: matches,
});
} catch (error) {
next(error);
}
}
/**
* Eliminar calendario
*/
static async deleteSchedule(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { leagueId } = req.params;
const result = await LeagueScheduleService.deleteSchedule(leagueId, req.user.userId);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
}
}
export default LeagueScheduleController;

View File

@@ -0,0 +1,139 @@
import { Request, Response, NextFunction } from 'express';
import { LeagueStandingService } from '../services/leagueStanding.service';
import { ApiError } from '../middleware/errorHandler';
export class LeagueStandingController {
/**
* Calcular y obtener clasificación
*/
static async calculateStandings(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { leagueId } = req.params;
const standings = await LeagueStandingService.calculateStandings(leagueId);
res.status(200).json({
success: true,
message: 'Clasificación recalculada exitosamente',
data: standings,
});
} catch (error) {
next(error);
}
}
/**
* Obtener clasificación
*/
static async getStandings(req: Request, res: Response, next: NextFunction) {
try {
const { leagueId } = req.params;
const standings = await LeagueStandingService.getStandings(leagueId);
res.status(200).json({
success: true,
data: standings,
});
} catch (error) {
next(error);
}
}
/**
* Actualizar clasificación tras un partido
*/
static async updateStandingsAfterMatch(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const standings = await LeagueStandingService.updateStandingsAfterMatch(matchId);
res.status(200).json({
success: true,
message: 'Clasificación actualizada exitosamente',
data: standings,
});
} catch (error) {
next(error);
}
}
/**
* Obtener goleadores / mejores jugadores
*/
static async getTopScorers(req: Request, res: Response, next: NextFunction) {
try {
const { leagueId } = req.params;
const { limit } = req.query;
const topScorers = await LeagueStandingService.getTopScorers(
leagueId,
limit ? parseInt(limit as string, 10) : 10
);
res.status(200).json({
success: true,
count: topScorers.length,
data: topScorers,
});
} catch (error) {
next(error);
}
}
/**
* Reiniciar clasificación
*/
static async resetStandings(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { leagueId } = req.params;
const result = await LeagueStandingService.resetStandings(leagueId, req.user.userId);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
}
/**
* Comparar dos equipos
*/
static async getTeamComparison(req: Request, res: Response, next: NextFunction) {
try {
const { leagueId } = req.params;
const { team1Id, team2Id } = req.query;
if (!team1Id || !team2Id) {
throw new ApiError('Se requieren los IDs de ambos equipos', 400);
}
const comparison = await LeagueStandingService.getTeamComparison(
leagueId,
team1Id as string,
team2Id as string
);
res.status(200).json({
success: true,
data: comparison,
});
} catch (error) {
next(error);
}
}
}
export default LeagueStandingController;

View File

@@ -0,0 +1,269 @@
import { Request, Response, NextFunction } from 'express';
import { LeagueTeamService } from '../services/leagueTeam.service';
import { ApiError } from '../middleware/errorHandler';
export class LeagueTeamController {
/**
* Crear equipo en una liga
*/
static async createTeam(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { leagueId } = req.params;
const { name, description } = req.body;
const team = await LeagueTeamService.createTeam(leagueId, req.user.userId, {
name,
description,
});
res.status(201).json({
success: true,
message: 'Equipo creado exitosamente',
data: team,
});
} catch (error) {
next(error);
}
}
/**
* Obtener equipos de una liga
*/
static async getTeams(req: Request, res: Response, next: NextFunction) {
try {
const { leagueId } = req.params;
const teams = await LeagueTeamService.getTeams(leagueId);
res.status(200).json({
success: true,
count: teams.length,
data: teams,
});
} catch (error) {
next(error);
}
}
/**
* Obtener equipo por ID
*/
static async getTeamById(req: Request, res: Response, next: NextFunction) {
try {
const { teamId } = req.params;
const team = await LeagueTeamService.getTeamById(teamId);
res.status(200).json({
success: true,
data: team,
});
} catch (error) {
next(error);
}
}
/**
* Actualizar equipo
*/
static async updateTeam(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { teamId } = req.params;
const { name, description } = req.body;
const team = await LeagueTeamService.updateTeam(teamId, req.user.userId, {
name,
description,
});
res.status(200).json({
success: true,
message: 'Equipo actualizado exitosamente',
data: team,
});
} catch (error) {
next(error);
}
}
/**
* Eliminar equipo
*/
static async deleteTeam(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { teamId } = req.params;
const result = await LeagueTeamService.deleteTeam(teamId, req.user.userId);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
}
/**
* Agregar miembro al equipo
*/
static async addMember(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { teamId } = req.params;
const { userId } = req.body;
const member = await LeagueTeamService.addMember(teamId, req.user.userId, userId);
res.status(201).json({
success: true,
message: 'Miembro agregado exitosamente',
data: member,
});
} catch (error) {
next(error);
}
}
/**
* Quitar miembro del equipo
*/
static async removeMember(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { teamId, userId } = req.params;
const result = await LeagueTeamService.removeMember(teamId, req.user.userId, userId);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
}
/**
* Abandonar equipo
*/
static async leaveTeam(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { teamId } = req.params;
const result = await LeagueTeamService.leaveTeam(teamId, req.user.userId);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
}
/**
* Obtener mis equipos (equipos donde el usuario es capitán o miembro)
*/
static async getMyTeams(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
// Obtener equipos donde es capitán
const captainTeams = await prisma.leagueTeam.findMany({
where: { captainId: req.user.userId },
include: {
league: {
select: {
id: true,
name: true,
status: true,
},
},
captain: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
_count: {
select: {
members: true,
},
},
},
});
// Obtener equipos donde es miembro (pero no capitán)
const memberTeams = await prisma.leagueTeam.findMany({
where: {
members: {
some: {
userId: req.user.userId,
isActive: true,
},
},
captainId: {
not: req.user.userId,
},
},
include: {
league: {
select: {
id: true,
name: true,
status: true,
},
},
captain: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
_count: {
select: {
members: true,
},
},
},
});
res.status(200).json({
success: true,
data: {
asCaptain: captainTeams,
asMember: memberTeams,
},
});
} catch (error) {
next(error);
}
}
}
export default LeagueTeamController;

View File

@@ -0,0 +1,298 @@
import { Request, Response, NextFunction } from 'express';
import { TournamentService } from '../services/tournament.service';
import { ApiError } from '../middleware/errorHandler';
import { UserRole } from '../utils/constants';
export class TournamentController {
// Crear un torneo
static async create(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const {
name,
description,
type,
category,
allowedLevels,
maxParticipants,
registrationStartDate,
registrationEndDate,
startDate,
endDate,
courtIds,
price,
} = req.body;
const tournament = await TournamentService.createTournament(req.user.userId, {
name,
description,
type,
category,
allowedLevels,
maxParticipants,
registrationStartDate: new Date(registrationStartDate),
registrationEndDate: new Date(registrationEndDate),
startDate: new Date(startDate),
endDate: new Date(endDate),
courtIds,
price,
});
res.status(201).json({
success: true,
message: 'Torneo creado exitosamente',
data: tournament,
});
} catch (error) {
next(error);
}
}
// Obtener todos los torneos
static async getAll(req: Request, res: Response, next: NextFunction) {
try {
const filters = {
status: req.query.status as string,
type: req.query.type as string,
category: req.query.category as string,
upcoming: req.query.upcoming === 'true',
open: req.query.open === 'true',
};
const tournaments = await TournamentService.getTournaments(filters);
res.status(200).json({
success: true,
count: tournaments.length,
data: tournaments,
});
} catch (error) {
next(error);
}
}
// Obtener un torneo por ID
static async getById(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const tournament = await TournamentService.getTournamentById(id);
res.status(200).json({
success: true,
data: tournament,
});
} catch (error) {
next(error);
}
}
// Actualizar un torneo
static async update(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const {
name,
description,
type,
category,
allowedLevels,
maxParticipants,
registrationStartDate,
registrationEndDate,
startDate,
endDate,
courtIds,
price,
status,
} = req.body;
const updateData: any = {
name,
description,
type,
category,
allowedLevels,
maxParticipants,
courtIds,
price,
status,
};
// Convertir fechas si se proporcionan
if (registrationStartDate) {
updateData.registrationStartDate = new Date(registrationStartDate);
}
if (registrationEndDate) {
updateData.registrationEndDate = new Date(registrationEndDate);
}
if (startDate) {
updateData.startDate = new Date(startDate);
}
if (endDate) {
updateData.endDate = new Date(endDate);
}
const tournament = await TournamentService.updateTournament(
id,
req.user.userId,
updateData
);
res.status(200).json({
success: true,
message: 'Torneo actualizado exitosamente',
data: tournament,
});
} catch (error) {
next(error);
}
}
// Eliminar (cancelar) un torneo
static async delete(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const tournament = await TournamentService.deleteTournament(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Torneo cancelado exitosamente',
data: tournament,
});
} catch (error) {
next(error);
}
}
// Abrir inscripciones
static async openRegistration(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const tournament = await TournamentService.openRegistration(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Inscripciones abiertas exitosamente',
data: tournament,
});
} catch (error) {
next(error);
}
}
// Cerrar inscripciones
static async closeRegistration(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const tournament = await TournamentService.closeRegistration(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Inscripciones cerradas exitosamente',
data: tournament,
});
} catch (error) {
next(error);
}
}
// Inscribirse a un torneo
static async register(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const participant = await TournamentService.registerParticipant(id, req.user.userId);
res.status(201).json({
success: true,
message: 'Inscripción realizada exitosamente',
data: participant,
});
} catch (error) {
next(error);
}
}
// Desinscribirse de un torneo
static async unregister(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const participant = await TournamentService.unregisterParticipant(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Inscripción cancelada exitosamente',
data: participant,
});
} catch (error) {
next(error);
}
}
// Confirmar pago de inscripción
static async confirmPayment(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { participantId } = req.params;
const participant = await TournamentService.confirmPayment(
participantId,
req.user.userId
);
res.status(200).json({
success: true,
message: 'Pago confirmado exitosamente',
data: participant,
});
} catch (error) {
next(error);
}
}
// Obtener participantes de un torneo
static async getParticipants(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const participants = await TournamentService.getParticipants(id);
res.status(200).json({
success: true,
count: participants.length,
data: participants,
});
} catch (error) {
next(error);
}
}
}
export default TournamentController;

View File

@@ -0,0 +1,149 @@
import { Request, Response, NextFunction } from 'express';
import { TournamentDrawService } from '../services/tournamentDraw.service';
import { ApiError } from '../middleware/errorHandler';
export class TournamentDrawController {
/**
* Generar cuadro de torneo
* POST /tournaments/:id/draw/generate
*/
static async generateDraw(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const { shuffle = false, respectSeeds = true } = req.body;
const result = await TournamentDrawService.generateDraw(id, {
shuffle,
respectSeeds,
});
res.status(201).json({
success: true,
message: 'Cuadro generado exitosamente',
data: result,
});
} catch (error) {
next(error);
}
}
/**
* Obtener cuadro completo de un torneo
* GET /tournaments/:id/draw
*/
static async getDraw(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const draw = await TournamentDrawService.getDraw(id);
res.status(200).json({
success: true,
data: draw,
});
} catch (error) {
next(error);
}
}
/**
* Programar un partido
* PUT /tournaments/:id/matches/:matchId/schedule
*/
static async scheduleMatch(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const { courtId, date, time } = req.body;
if (!courtId || !date || !time) {
throw new ApiError('Cancha, fecha y hora son requeridos', 400);
}
const match = await TournamentDrawService.scheduleMatch(matchId, {
courtId,
date: new Date(date),
time,
});
res.status(200).json({
success: true,
message: 'Partido programado exitosamente',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Generar siguiente ronda de sistema suizo
* POST /tournaments/:id/draw/swiss-next-round
*/
static async generateNextRoundSwiss(
req: Request,
res: Response,
next: NextFunction
) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const result = await TournamentDrawService.generateNextRoundSwiss(id);
res.status(201).json({
success: true,
message: `Ronda ${result.round} generada exitosamente`,
data: result,
});
} catch (error) {
next(error);
}
}
/**
* Registrar resultado de un partido
* PUT /tournaments/:id/matches/:matchId/result
*/
static async recordResult(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const { team1Score, team2Score } = req.body;
if (team1Score === undefined || team2Score === undefined) {
throw new ApiError('Los puntajes de ambos equipos son requeridos', 400);
}
const match = await TournamentDrawService.recordMatchResult(matchId, {
team1Score: parseInt(team1Score),
team2Score: parseInt(team2Score),
});
res.status(200).json({
success: true,
message: 'Resultado registrado exitosamente',
data: match,
});
} catch (error) {
next(error);
}
}
}
export default TournamentDrawController;

View File

@@ -0,0 +1,317 @@
import { Request, Response, NextFunction } from 'express';
import { TournamentMatchService } from '../services/tournamentMatch.service';
import { ApiError } from '../middleware/errorHandler';
export class TournamentMatchController {
/**
* Listar partidos de un torneo
* GET /tournaments/:id/matches
*/
static async getMatches(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const filters = {
round: req.query.round ? parseInt(req.query.round as string) : undefined,
status: req.query.status as string,
courtId: req.query.courtId as string,
playerId: req.query.playerId as string,
fromDate: req.query.fromDate
? new Date(req.query.fromDate as string)
: undefined,
toDate: req.query.toDate
? new Date(req.query.toDate as string)
: undefined,
};
const matches = await TournamentMatchService.getMatches(id, filters);
res.status(200).json({
success: true,
count: matches.length,
data: matches,
});
} catch (error) {
next(error);
}
}
/**
* Obtener un partido específico
* GET /tournaments/:id/matches/:matchId
*/
static async getMatch(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const match = await TournamentMatchService.getMatchById(matchId);
res.status(200).json({
success: true,
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Actualizar un partido
* PUT /tournaments/:id/matches/:matchId
*/
static async updateMatch(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const { courtId, scheduledDate, scheduledTime, status, notes } = req.body;
const match = await TournamentMatchService.updateMatch(matchId, {
courtId,
scheduledDate: scheduledDate ? new Date(scheduledDate) : undefined,
scheduledTime,
status,
notes,
});
res.status(200).json({
success: true,
message: 'Partido actualizado exitosamente',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Asignar cancha a un partido
* PUT /tournaments/:id/matches/:matchId/assign-court
*/
static async assignCourt(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const { courtId, date, time } = req.body;
if (!courtId || !date || !time) {
throw new ApiError('Cancha, fecha y hora son requeridos', 400);
}
const match = await TournamentMatchService.assignCourt(
matchId,
courtId,
new Date(date),
time
);
res.status(200).json({
success: true,
message: 'Cancha asignada exitosamente',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Registrar resultado de un partido
* PUT /tournaments/:id/matches/:matchId/result
*/
static async recordResult(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const { team1Score, team2Score } = req.body;
if (team1Score === undefined || team2Score === undefined) {
throw new ApiError('Los puntajes de ambos equipos son requeridos', 400);
}
const match = await TournamentMatchService.recordResult(
matchId,
{
team1Score: parseInt(team1Score),
team2Score: parseInt(team2Score),
},
req.user.userId
);
res.status(200).json({
success: true,
message: match.isConfirmed
? 'Resultado registrado y confirmado'
: 'Resultado registrado. Esperando confirmación del oponente.',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Confirmar resultado de un partido
* PUT /tournaments/:id/matches/:matchId/confirm
*/
static async confirmResult(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const match = await TournamentMatchService.confirmResult(
matchId,
req.user.userId
);
res.status(200).json({
success: true,
message: match.isConfirmed
? 'Resultado confirmado. El partido es válido.'
: 'Confirmación registrada. Se necesita otra confirmación para validar.',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Iniciar partido (cambiar estado a IN_PROGRESS)
* PUT /tournaments/:id/matches/:matchId/start
*/
static async startMatch(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const match = await TournamentMatchService.startMatch(matchId);
res.status(200).json({
success: true,
message: 'Partido iniciado',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Cancelar partido
* PUT /tournaments/:id/matches/:matchId/cancel
*/
static async cancelMatch(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { matchId } = req.params;
const { reason } = req.body;
const match = await TournamentMatchService.cancelMatch(matchId, reason);
res.status(200).json({
success: true,
message: 'Partido cancelado',
data: match,
});
} catch (error) {
next(error);
}
}
/**
* Obtener partidos de un participante específico
* GET /tournaments/:id/participants/:participantId/matches
*/
static async getParticipantMatches(
req: Request,
res: Response,
next: NextFunction
) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id, participantId } = req.params;
const matches = await TournamentMatchService.getParticipantMatches(
id,
participantId
);
res.status(200).json({
success: true,
count: matches.length,
data: matches,
});
} catch (error) {
next(error);
}
}
/**
* Obtener mis partidos en un torneo
* GET /tournaments/:id/my-matches
*/
static async getMyMatches(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
// Buscar el participante asociado al usuario
const participant = await prisma.tournamentParticipant.findFirst({
where: {
tournamentId: id,
userId: req.user.userId,
status: { in: ['REGISTERED', 'CONFIRMED'] },
},
});
if (!participant) {
throw new ApiError('No estás registrado en este torneo', 403);
}
const matches = await TournamentMatchService.getParticipantMatches(
id,
participant.id
);
res.status(200).json({
success: true,
count: matches.length,
data: matches,
});
} catch (error) {
next(error);
}
}
}
// Importación necesaria para getMyMatches
import prisma from '../config/database';
export default TournamentMatchController;

View File

@@ -5,6 +5,16 @@ import bookingRoutes from './booking.routes';
import matchRoutes from './match.routes';
import rankingRoutes from './ranking.routes';
import statsRoutes from './stats.routes';
import tournamentRoutes from './tournament.routes';
import tournamentDrawRoutes from './tournamentDraw.routes';
import tournamentMatchRoutes from './tournamentMatch.routes';
// Rutas de Ligas por Equipos (Fase 3.3)
import leagueRoutes from './league.routes';
import leagueTeamRoutes from './leagueTeam.routes';
import leagueScheduleRoutes from './leagueSchedule.routes';
import leagueStandingRoutes from './leagueStanding.routes';
import leagueMatchRoutes from './leagueMatch.routes';
const router = Router();
@@ -35,4 +45,32 @@ router.use('/ranking', rankingRoutes);
// Rutas de estadísticas
router.use('/stats', statsRoutes);
// Rutas de torneos (base)
router.use('/tournaments', tournamentRoutes);
// Rutas de cuadro de torneo (sub-rutas de /tournaments/:id)
router.use('/tournaments', tournamentDrawRoutes);
// Rutas de partidos de torneo (sub-rutas de /tournaments/:id)
router.use('/tournaments', tournamentMatchRoutes);
// ============================================
// Rutas de Ligas por Equipos (Fase 3.3)
// ============================================
// Rutas de ligas
router.use('/leagues', leagueRoutes);
// Rutas de equipos de liga
router.use('/league-teams', leagueTeamRoutes);
// Rutas de calendario de liga
router.use('/league-schedule', leagueScheduleRoutes);
// Rutas de clasificación de liga
router.use('/league-standings', leagueStandingRoutes);
// Rutas de partidos de liga
router.use('/league-matches', leagueMatchRoutes);
export default router;

View File

@@ -0,0 +1,78 @@
import { Router } from 'express';
import { LeagueController } from '../controllers/league.controller';
import { authenticate } from '../middleware/auth';
import { validate, validateParams } from '../middleware/validate';
import { z } from 'zod';
import { LeagueStatus, LeagueFormat, LeagueType } from '../utils/constants';
const router = Router();
// Esquemas de validación
const leagueIdSchema = z.object({
id: z.string().uuid('ID de liga inválido'),
});
const createLeagueSchema = z.object({
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres'),
description: z.string().max(1000, 'La descripción no puede exceder 1000 caracteres').optional(),
format: z.enum([LeagueFormat.SINGLE_ROUND_ROBIN, LeagueFormat.DOUBLE_ROUND_ROBIN], {
errorMap: () => ({ message: 'Formato inválido' }),
}).optional(),
matchesPerMatchday: z.number().int().min(1).max(10).optional(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(),
});
const updateLeagueSchema = z.object({
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres').optional(),
description: z.string().max(1000, 'La descripción no puede exceder 1000 caracteres').optional(),
format: z.enum([LeagueFormat.SINGLE_ROUND_ROBIN, LeagueFormat.DOUBLE_ROUND_ROBIN], {
errorMap: () => ({ message: 'Formato inválido' }),
}).optional(),
matchesPerMatchday: z.number().int().min(1).max(10).optional(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
});
const getLeaguesQuerySchema = z.object({
status: z.enum([LeagueStatus.DRAFT, LeagueStatus.ACTIVE, LeagueStatus.FINISHED, LeagueStatus.CANCELLED]).optional(),
type: z.enum([LeagueType.TEAM_LEAGUE, LeagueType.INDIVIDUAL_LEAGUE]).optional(),
createdById: z.string().uuid().optional(),
});
// Todas las rutas requieren autenticación
router.use(authenticate);
// POST /api/v1/leagues - Crear liga
router.post('/', validate(createLeagueSchema), LeagueController.createLeague);
// GET /api/v1/leagues - Listar ligas
router.get('/', validate(getLeaguesQuerySchema), LeagueController.getLeagues);
// GET /api/v1/leagues/my-leagues - Mis ligas
router.get('/my-leagues', LeagueController.getMyLeagues);
// GET /api/v1/leagues/:id - Obtener liga por ID
router.get('/:id', validateParams(leagueIdSchema), LeagueController.getLeagueById);
// PUT /api/v1/leagues/:id - Actualizar liga
router.put(
'/:id',
validateParams(leagueIdSchema),
validate(updateLeagueSchema),
LeagueController.updateLeague
);
// DELETE /api/v1/leagues/:id - Eliminar liga
router.delete('/:id', validateParams(leagueIdSchema), LeagueController.deleteLeague);
// POST /api/v1/leagues/:id/start - Iniciar liga
router.post('/:id/start', validateParams(leagueIdSchema), LeagueController.startLeague);
// POST /api/v1/leagues/:id/finish - Finalizar liga
router.post('/:id/finish', validateParams(leagueIdSchema), LeagueController.finishLeague);
// POST /api/v1/leagues/:id/cancel - Cancelar liga
router.post('/:id/cancel', validateParams(leagueIdSchema), LeagueController.cancelLeague);
export default router;

View File

@@ -0,0 +1,88 @@
import { Router } from 'express';
import { LeagueMatchController } from '../controllers/leagueMatch.controller';
import { authenticate } from '../middleware/auth';
import { validate, validateParams } from '../middleware/validate';
import { z } from 'zod';
import { LeagueMatchStatus, MatchWinner } from '../utils/constants';
const router = Router();
// Esquemas de validación
const leagueIdSchema = z.object({
leagueId: z.string().uuid('ID de liga inválido'),
});
const matchIdSchema = z.object({
matchId: z.string().uuid('ID de partido inválido'),
});
const updateMatchResultSchema = z.object({
team1Score: z.number().int().min(0).max(9, 'Máximo 9 sets'),
team2Score: z.number().int().min(0).max(9, 'Máximo 9 sets'),
setDetails: z.array(z.object({
team1Games: z.number().int().min(0).max(7, 'Máximo 7 games'),
team2Games: z.number().int().min(0).max(7, 'Máximo 7 games'),
})).optional(),
winner: z.enum([MatchWinner.TEAM1, MatchWinner.TEAM2, MatchWinner.DRAW], {
errorMap: () => ({ message: 'Ganador inválido' }),
}),
notes: z.string().max(500, 'Las notas no pueden exceder 500 caracteres').optional(),
});
const updateMatchStatusSchema = z.object({
status: z.enum([LeagueMatchStatus.SCHEDULED, LeagueMatchStatus.CONFIRMED, LeagueMatchStatus.IN_PROGRESS, LeagueMatchStatus.CANCELLED, LeagueMatchStatus.POSTPONED, LeagueMatchStatus.WALKOVER], {
errorMap: () => ({ message: 'Estado inválido' }),
}),
scheduledDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
scheduledTime: z.string().regex(/^\d{2}:\d{2}$/, 'Hora debe estar en formato HH:mm').optional().nullable(),
courtId: z.string().uuid('ID de cancha inválido').optional().nullable(),
});
// Todas las rutas requieren autenticación
router.use(authenticate);
// GET /api/v1/league-matches/league/:leagueId - Listar partidos
router.get(
'/league/:leagueId',
validateParams(leagueIdSchema),
LeagueMatchController.getMatches
);
// GET /api/v1/league-matches/league/:leagueId/summary - Resumen de partidos
router.get(
'/league/:leagueId/summary',
validateParams(leagueIdSchema),
LeagueMatchController.getMatchSummary
);
// GET /api/v1/league-matches/:matchId - Obtener partido por ID
router.get(
'/:matchId',
validateParams(matchIdSchema),
LeagueMatchController.getMatchById
);
// PUT /api/v1/league-matches/:matchId/result - Actualizar resultado
router.put(
'/:matchId/result',
validateParams(matchIdSchema),
validate(updateMatchResultSchema),
LeagueMatchController.updateMatchResult
);
// PUT /api/v1/league-matches/:matchId/status - Actualizar estado
router.put(
'/:matchId/status',
validateParams(matchIdSchema),
validate(updateMatchStatusSchema),
LeagueMatchController.updateMatchStatus
);
// POST /api/v1/league-matches/:matchId/void - Anular resultado
router.post(
'/:matchId/void',
validateParams(matchIdSchema),
LeagueMatchController.voidMatchResult
);
export default router;

View File

@@ -0,0 +1,86 @@
import { Router } from 'express';
import { LeagueScheduleController } from '../controllers/leagueSchedule.controller';
import { authenticate } from '../middleware/auth';
import { validate, validateParams } from '../middleware/validate';
import { z } from 'zod';
const router = Router();
// Esquemas de validación
const leagueIdSchema = z.object({
leagueId: z.string().uuid('ID de liga inválido'),
});
const matchdaySchema = z.object({
leagueId: z.string().uuid('ID de liga inválido'),
matchday: z.string().regex(/^\d+$/, 'La jornada debe ser un número').transform(Number),
});
const matchIdSchema = z.object({
matchId: z.string().uuid('ID de partido inválido'),
});
const teamIdSchema = z.object({
teamId: z.string().uuid('ID de equipo inválido'),
});
const updateMatchSchema = z.object({
scheduledDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
scheduledTime: z.string().regex(/^\d{2}:\d{2}$/, 'Hora debe estar en formato HH:mm').optional().nullable(),
courtId: z.string().uuid('ID de cancha inválido').optional().nullable(),
});
// Todas las rutas requieren autenticación
router.use(authenticate);
// POST /api/v1/league-schedule/league/:leagueId/generate - Generar calendario
router.post(
'/league/:leagueId/generate',
validateParams(leagueIdSchema),
LeagueScheduleController.generateSchedule
);
// GET /api/v1/league-schedule/league/:leagueId - Obtener calendario
router.get(
'/league/:leagueId',
validateParams(leagueIdSchema),
LeagueScheduleController.getSchedule
);
// GET /api/v1/league-schedule/league/:leagueId/matchday/:matchday - Obtener jornada
router.get(
'/league/:leagueId/matchday/:matchday',
validateParams(matchdaySchema),
LeagueScheduleController.getMatchday
);
// GET /api/v1/league-schedule/league/:leagueId/unscheduled - Partidos pendientes
router.get(
'/league/:leagueId/unscheduled',
validateParams(leagueIdSchema),
LeagueScheduleController.getUnscheduledMatches
);
// GET /api/v1/league-schedule/team/:teamId - Partidos de un equipo
router.get(
'/team/:teamId',
validateParams(teamIdSchema),
LeagueScheduleController.getTeamMatches
);
// PUT /api/v1/league-schedule/match/:matchId - Actualizar partido
router.put(
'/match/:matchId',
validateParams(matchIdSchema),
validate(updateMatchSchema),
LeagueScheduleController.updateMatchDate
);
// DELETE /api/v1/league-schedule/league/:leagueId - Eliminar calendario
router.delete(
'/league/:leagueId',
validateParams(leagueIdSchema),
LeagueScheduleController.deleteSchedule
);
export default router;

View File

@@ -0,0 +1,69 @@
import { Router } from 'express';
import { LeagueStandingController } from '../controllers/leagueStanding.controller';
import { authenticate } from '../middleware/auth';
import { validate, validateParams } from '../middleware/validate';
import { z } from 'zod';
const router = Router();
// Esquemas de validación
const leagueIdSchema = z.object({
leagueId: z.string().uuid('ID de liga inválido'),
});
const matchIdSchema = z.object({
matchId: z.string().uuid('ID de partido inválido'),
});
const teamComparisonSchema = z.object({
leagueId: z.string().uuid('ID de liga inválido'),
team1Id: z.string().uuid('ID de equipo 1 inválido'),
team2Id: z.string().uuid('ID de equipo 2 inválido'),
});
// Todas las rutas requieren autenticación
router.use(authenticate);
// GET /api/v1/league-standings/league/:leagueId - Obtener clasificación
router.get(
'/league/:leagueId',
validateParams(leagueIdSchema),
LeagueStandingController.getStandings
);
// POST /api/v1/league-standings/league/:leagueId/calculate - Recalcular clasificación
router.post(
'/league/:leagueId/calculate',
validateParams(leagueIdSchema),
LeagueStandingController.calculateStandings
);
// POST /api/v1/league-standings/match/:matchId/update - Actualizar tras partido
router.post(
'/match/:matchId/update',
validateParams(matchIdSchema),
LeagueStandingController.updateStandingsAfterMatch
);
// GET /api/v1/league-standings/league/:leagueId/top-scorers - Goleadores
router.get(
'/league/:leagueId/top-scorers',
validateParams(leagueIdSchema),
LeagueStandingController.getTopScorers
);
// POST /api/v1/league-standings/league/:leagueId/reset - Reiniciar clasificación
router.post(
'/league/:leagueId/reset',
validateParams(leagueIdSchema),
LeagueStandingController.resetStandings
);
// GET /api/v1/league-standings/league/:leagueId/compare - Comparar equipos
router.get(
'/league/:leagueId/compare',
validateParams(teamComparisonSchema),
LeagueStandingController.getTeamComparison
);
export default router;

View File

@@ -0,0 +1,102 @@
import { Router } from 'express';
import { LeagueTeamController } from '../controllers/leagueTeam.controller';
import { authenticate } from '../middleware/auth';
import { validate, validateParams } from '../middleware/validate';
import { z } from 'zod';
const router = Router();
// Esquemas de validación
const leagueIdSchema = z.object({
leagueId: z.string().uuid('ID de liga inválido'),
});
const teamIdSchema = z.object({
teamId: z.string().uuid('ID de equipo inválido'),
});
const teamMemberSchema = z.object({
teamId: z.string().uuid('ID de equipo inválido'),
userId: z.string().uuid('ID de usuario inválido'),
});
const createTeamSchema = z.object({
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
description: z.string().max(500, 'La descripción no puede exceder 500 caracteres').optional(),
});
const updateTeamSchema = z.object({
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(),
description: z.string().max(500, 'La descripción no puede exceder 500 caracteres').optional(),
});
const addMemberSchema = z.object({
userId: z.string().uuid('ID de usuario inválido'),
});
// Todas las rutas requieren autenticación
router.use(authenticate);
// GET /api/v1/league-teams/my-teams - Mis equipos
router.get('/my-teams', LeagueTeamController.getMyTeams);
// GET /api/v1/league-teams/league/:leagueId - Listar equipos de una liga
router.get(
'/league/:leagueId',
validateParams(leagueIdSchema),
LeagueTeamController.getTeams
);
// POST /api/v1/league-teams/league/:leagueId - Crear equipo
router.post(
'/league/:leagueId',
validateParams(leagueIdSchema),
validate(createTeamSchema),
LeagueTeamController.createTeam
);
// GET /api/v1/league-teams/:teamId - Obtener equipo por ID
router.get(
'/:teamId',
validateParams(teamIdSchema),
LeagueTeamController.getTeamById
);
// PUT /api/v1/league-teams/:teamId - Actualizar equipo
router.put(
'/:teamId',
validateParams(teamIdSchema),
validate(updateTeamSchema),
LeagueTeamController.updateTeam
);
// DELETE /api/v1/league-teams/:teamId - Eliminar equipo
router.delete(
'/:teamId',
validateParams(teamIdSchema),
LeagueTeamController.deleteTeam
);
// POST /api/v1/league-teams/:teamId/members - Agregar miembro
router.post(
'/:teamId/members',
validateParams(teamIdSchema),
validate(addMemberSchema),
LeagueTeamController.addMember
);
// DELETE /api/v1/league-teams/:teamId/members/:userId - Quitar miembro
router.delete(
'/:teamId/members/:userId',
validateParams(teamMemberSchema),
LeagueTeamController.removeMember
);
// POST /api/v1/league-teams/:teamId/leave - Abandonar equipo
router.post(
'/:teamId/leave',
validateParams(teamIdSchema),
LeagueTeamController.leaveTeam
);
export default router;

View File

@@ -0,0 +1,67 @@
import { Router } from 'express';
import { TournamentController } from '../controllers/tournament.controller';
import { authenticate, authorize } from '../middleware/auth';
import { validate } from '../middleware/validate';
import { UserRole } from '../utils/constants';
import {
createTournamentSchema,
updateTournamentSchema,
} from '../validators/tournament.validator';
const router = Router();
// Rutas públicas (lectura)
router.get('/', TournamentController.getAll);
router.get('/:id', TournamentController.getById);
router.get('/:id/participants', TournamentController.getParticipants);
// Rutas protegidas para usuarios autenticados (inscripciones)
router.post('/:id/register', authenticate, TournamentController.register);
router.delete('/:id/register', authenticate, TournamentController.unregister);
// Rutas de admin (creación y gestión)
router.post(
'/',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validate(createTournamentSchema),
TournamentController.create
);
router.put(
'/:id',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validate(updateTournamentSchema),
TournamentController.update
);
router.delete(
'/:id',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
TournamentController.delete
);
router.post(
'/:id/open',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
TournamentController.openRegistration
);
router.post(
'/:id/close',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
TournamentController.closeRegistration
);
router.put(
'/participants/:participantId/pay',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
TournamentController.confirmPayment
);
export default router;

View File

@@ -0,0 +1,68 @@
import { Router } from 'express';
import { TournamentDrawController } from '../controllers/tournamentDraw.controller';
import { authenticate, authorize } from '../middleware/auth';
import { validate, validateQuery } from '../middleware/validate';
import { UserRole } from '../utils/constants';
import { z } from 'zod';
const router = Router({ mergeParams: true });
// Schema para generar cuadro
const generateDrawSchema = z.object({
shuffle: z.boolean().optional().default(false),
respectSeeds: z.boolean().optional().default(true),
});
// Schema para programar partido
const scheduleMatchSchema = z.object({
courtId: z.string().uuid('ID de cancha inválido'),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe ser YYYY-MM-DD'),
time: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Hora debe ser HH:MM'),
});
// Schema para registrar resultado
const recordResultSchema = z.object({
team1Score: z.number().int().min(0, 'El puntaje no puede ser negativo'),
team2Score: z.number().int().min(0, 'El puntaje no puede ser negativo'),
});
// Rutas de cuadro (solo admins)
router.post(
'/draw/generate',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validate(generateDrawSchema),
TournamentDrawController.generateDraw
);
router.get(
'/draw',
authenticate,
TournamentDrawController.getDraw
);
router.post(
'/draw/swiss-next-round',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
TournamentDrawController.generateNextRoundSwiss
);
// Programar partido (solo admins)
router.put(
'/matches/:matchId/schedule',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validate(scheduleMatchSchema),
TournamentDrawController.scheduleMatch
);
// Registrar resultado (jugadores o admins)
router.put(
'/matches/:matchId/result',
authenticate,
validate(recordResultSchema),
TournamentDrawController.recordResult
);
export default router;

View File

@@ -0,0 +1,146 @@
import { Router } from 'express';
import { TournamentMatchController } from '../controllers/tournamentMatch.controller';
import { authenticate, authorize } from '../middleware/auth';
import { validate, validateQuery } from '../middleware/validate';
import { UserRole, TournamentMatchStatus } from '../utils/constants';
import { z } from 'zod';
const router = Router({ mergeParams: true });
// Schema para query params de filtros
const matchFiltersSchema = z.object({
round: z.string().regex(/^\d+$/).optional().transform(Number),
status: z.enum([
TournamentMatchStatus.PENDING,
TournamentMatchStatus.SCHEDULED,
TournamentMatchStatus.IN_PROGRESS,
TournamentMatchStatus.FINISHED,
TournamentMatchStatus.CANCELLED,
TournamentMatchStatus.BYE,
]).optional(),
courtId: z.string().uuid().optional(),
playerId: z.string().uuid().optional(),
fromDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
});
// Schema para actualizar partido
const updateMatchSchema = z.object({
courtId: z.string().uuid().optional(),
scheduledDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
scheduledTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/).optional(),
status: z.enum([
TournamentMatchStatus.PENDING,
TournamentMatchStatus.SCHEDULED,
TournamentMatchStatus.IN_PROGRESS,
TournamentMatchStatus.CANCELLED,
]).optional(),
notes: z.string().optional(),
});
// Schema para asignar cancha
const assignCourtSchema = z.object({
courtId: z.string().uuid('ID de cancha inválido'),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe ser YYYY-MM-DD'),
time: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Hora debe ser HH:MM'),
});
// Schema para registrar resultado
const recordResultSchema = z.object({
team1Score: z.number().int().min(0, 'El puntaje no puede ser negativo'),
team2Score: z.number().int().min(0, 'El puntaje no puede ser negativo'),
});
// Schema para cancelar partido
const cancelMatchSchema = z.object({
reason: z.string().optional(),
});
// Schema para params de IDs
const matchIdSchema = z.object({
matchId: z.string().uuid('ID de partido inválido'),
});
// Listar partidos del torneo
router.get(
'/matches',
authenticate,
validateQuery(matchFiltersSchema),
TournamentMatchController.getMatches
);
// Obtener mis partidos en el torneo
router.get(
'/my-matches',
authenticate,
TournamentMatchController.getMyMatches
);
// Obtener partidos de un participante específico
router.get(
'/participants/:participantId/matches',
authenticate,
TournamentMatchController.getParticipantMatches
);
// Obtener un partido específico
router.get(
'/matches/:matchId',
authenticate,
validate(z.object({ matchId: z.string().uuid() })),
TournamentMatchController.getMatch
);
// Actualizar partido (solo admins)
router.put(
'/matches/:matchId',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validate(updateMatchSchema),
TournamentMatchController.updateMatch
);
// Asignar cancha (solo admins)
router.put(
'/matches/:matchId/assign-court',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validate(assignCourtSchema),
TournamentMatchController.assignCourt
);
// Iniciar partido (solo admins)
router.put(
'/matches/:matchId/start',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validate(matchIdSchema),
TournamentMatchController.startMatch
);
// Cancelar partido (solo admins)
router.put(
'/matches/:matchId/cancel',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validate(cancelMatchSchema),
TournamentMatchController.cancelMatch
);
// Registrar resultado (jugadores o admins)
router.put(
'/matches/:matchId/result',
authenticate,
validate(recordResultSchema),
TournamentMatchController.recordResult
);
// Confirmar resultado (jugadores)
router.put(
'/matches/:matchId/confirm',
authenticate,
validate(matchIdSchema),
TournamentMatchController.confirmResult
);
export default router;

View File

@@ -0,0 +1,502 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import { LeagueStatus, LeagueType, LeagueFormat } from '../utils/constants';
// Interfaces
export interface CreateLeagueInput {
name: string;
description?: string;
format?: string;
matchesPerMatchday?: number;
startDate?: string;
endDate?: string;
}
export interface UpdateLeagueInput {
name?: string;
description?: string;
format?: string;
matchesPerMatchday?: number;
startDate?: string | null;
endDate?: string | null;
}
export interface LeagueFilters {
status?: string;
type?: string;
createdById?: string;
}
export class LeagueService {
/**
* Crear una nueva liga
*/
static async createLeague(adminId: string, data: CreateLeagueInput) {
// Validar fechas si se proporcionan
let startDate: Date | undefined;
let endDate: Date | undefined;
if (data.startDate) {
startDate = new Date(data.startDate);
if (isNaN(startDate.getTime())) {
throw new ApiError('Fecha de inicio inválida', 400);
}
}
if (data.endDate) {
endDate = new Date(data.endDate);
if (isNaN(endDate.getTime())) {
throw new ApiError('Fecha de fin inválida', 400);
}
}
if (startDate && endDate && endDate <= startDate) {
throw new ApiError('La fecha de fin debe ser posterior a la fecha de inicio', 400);
}
const league = await prisma.league.create({
data: {
name: data.name,
description: data.description,
type: LeagueType.TEAM_LEAGUE,
format: data.format || LeagueFormat.DOUBLE_ROUND_ROBIN,
matchesPerMatchday: data.matchesPerMatchday || 2,
startDate,
endDate,
status: LeagueStatus.DRAFT,
createdById: adminId,
},
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
teams: true,
matches: true,
},
},
},
});
return league;
}
/**
* Obtener lista de ligas con filtros
*/
static async getLeagues(filters: LeagueFilters = {}) {
const where: any = {};
if (filters.status) {
where.status = filters.status;
}
if (filters.type) {
where.type = filters.type;
}
if (filters.createdById) {
where.createdById = filters.createdById;
}
const leagues = await prisma.league.findMany({
where,
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
teams: true,
matches: true,
},
},
},
orderBy: { updatedAt: 'desc' },
});
return leagues;
}
/**
* Obtener liga por ID con detalles completos
*/
static async getLeagueById(id: string) {
const league = await prisma.league.findUnique({
where: { id },
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
teams: {
include: {
captain: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
},
},
members: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
playerLevel: true,
},
},
},
where: { isActive: true },
},
_count: {
select: {
members: true,
},
},
},
orderBy: { name: 'asc' },
},
standings: {
include: {
team: true,
},
orderBy: [
{ position: 'asc' },
{ points: 'desc' },
],
},
_count: {
select: {
teams: true,
matches: true,
},
},
},
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
return league;
}
/**
* Actualizar liga (solo si está en estado DRAFT o por el creador/admin)
*/
static async updateLeague(id: string, adminId: string, data: UpdateLeagueInput) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Solo el creador puede actualizar
if (league.createdById !== adminId) {
throw new ApiError('No tienes permisos para actualizar esta liga', 403);
}
// No se puede modificar si ya está finalizada o cancelada
if (league.status === LeagueStatus.FINISHED || league.status === LeagueStatus.CANCELLED) {
throw new ApiError('No se puede modificar una liga finalizada o cancelada', 400);
}
// Validar fechas
let startDate: Date | undefined | null = data.startDate === null ? null : undefined;
let endDate: Date | undefined | null = data.endDate === null ? null : undefined;
if (data.startDate && data.startDate !== null) {
startDate = new Date(data.startDate);
if (isNaN(startDate.getTime())) {
throw new ApiError('Fecha de inicio inválida', 400);
}
}
if (data.endDate && data.endDate !== null) {
endDate = new Date(data.endDate);
if (isNaN(endDate.getTime())) {
throw new ApiError('Fecha de fin inválida', 400);
}
}
const finalStartDate = startDate !== undefined ? startDate : league.startDate;
const finalEndDate = endDate !== undefined ? endDate : league.endDate;
if (finalStartDate && finalEndDate && finalEndDate <= finalStartDate) {
throw new ApiError('La fecha de fin debe ser posterior a la fecha de inicio', 400);
}
const updated = await prisma.league.update({
where: { id },
data: {
name: data.name,
description: data.description,
format: data.format,
matchesPerMatchday: data.matchesPerMatchday,
startDate,
endDate,
},
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
teams: true,
matches: true,
},
},
},
});
return updated;
}
/**
* Eliminar liga (solo si está en estado DRAFT)
*/
static async deleteLeague(id: string, adminId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Solo el creador puede eliminar
if (league.createdById !== adminId) {
throw new ApiError('No tienes permisos para eliminar esta liga', 403);
}
// Solo se puede eliminar si está en DRAFT
if (league.status !== LeagueStatus.DRAFT) {
throw new ApiError('Solo se pueden eliminar ligas en estado borrador', 400);
}
await prisma.league.delete({
where: { id },
});
return { message: 'Liga eliminada exitosamente' };
}
/**
* Iniciar liga (cambiar estado de DRAFT a ACTIVE)
* Requiere mínimo 3 equipos
*/
static async startLeague(id: string, adminId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id },
include: {
_count: {
select: {
teams: true,
},
},
},
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Solo el creador puede iniciar
if (league.createdById !== adminId) {
throw new ApiError('No tienes permisos para iniciar esta liga', 403);
}
// Solo se puede iniciar si está en DRAFT
if (league.status !== LeagueStatus.DRAFT) {
throw new ApiError('Solo se pueden iniciar ligas en estado borrador', 400);
}
// Mínimo 3 equipos
if (league._count.teams < 3) {
throw new ApiError('Se requieren al menos 3 equipos para iniciar la liga', 400);
}
const updated = await prisma.league.update({
where: { id },
data: {
status: LeagueStatus.ACTIVE,
startDate: league.startDate || new Date(),
},
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
teams: true,
matches: true,
},
},
},
});
return updated;
}
/**
* Finalizar liga (cambiar estado a FINISHED)
*/
static async finishLeague(id: string, adminId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Solo el creador puede finalizar
if (league.createdById !== adminId) {
throw new ApiError('No tienes permisos para finalizar esta liga', 403);
}
// Solo se puede finalizar si está en ACTIVE
if (league.status !== LeagueStatus.ACTIVE) {
throw new ApiError('Solo se pueden finalizar ligas activas', 400);
}
const updated = await prisma.league.update({
where: { id },
data: {
status: LeagueStatus.FINISHED,
endDate: new Date(),
},
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
teams: true,
matches: true,
},
},
},
});
return updated;
}
/**
* Cancelar liga
*/
static async cancelLeague(id: string, adminId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Solo el creador puede cancelar
if (league.createdById !== adminId) {
throw new ApiError('No tienes permisos para cancelar esta liga', 403);
}
// No se puede cancelar si ya está finalizada
if (league.status === LeagueStatus.FINISHED) {
throw new ApiError('No se puede cancelar una liga finalizada', 400);
}
const updated = await prisma.league.update({
where: { id },
data: {
status: LeagueStatus.CANCELLED,
},
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
teams: true,
matches: true,
},
},
},
});
return updated;
}
/**
* Verificar si el usuario es el creador de la liga
*/
static async isLeagueCreator(leagueId: string, userId: string): Promise<boolean> {
const league = await prisma.league.findUnique({
where: { id: leagueId },
select: { createdById: true },
});
return league?.createdById === userId;
}
/**
* Verificar si la liga está en estado editable (DRAFT)
*/
static async isLeagueEditable(leagueId: string): Promise<boolean> {
const league = await prisma.league.findUnique({
where: { id: leagueId },
select: { status: true },
});
return league?.status === LeagueStatus.DRAFT;
}
}
export default LeagueService;

View File

@@ -0,0 +1,442 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import { LeagueMatchStatus, LeagueStatus } from '../utils/constants';
// Interfaces
export interface UpdateMatchResultInput {
team1Score: number;
team2Score: number;
setDetails?: { team1Games: number; team2Games: number }[];
winner: 'TEAM1' | 'TEAM2' | 'DRAW';
notes?: string;
}
export interface UpdateMatchStatusInput {
status: string;
scheduledDate?: string;
scheduledTime?: string;
courtId?: string;
}
export class LeagueMatchService {
/**
* Obtener todos los partidos de una liga
*/
static async getMatches(leagueId: string, filters?: { status?: string; matchday?: number }) {
const where: any = { leagueId };
if (filters?.status) {
where.status = filters.status;
}
if (filters?.matchday !== undefined) {
where.matchday = filters.matchday;
}
const matches = await prisma.leagueMatch.findMany({
where,
include: {
team1: {
select: {
id: true,
name: true,
captain: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
},
team2: {
select: {
id: true,
name: true,
captain: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
},
court: {
select: {
id: true,
name: true,
type: true,
},
},
},
orderBy: [
{ matchday: 'asc' },
{ scheduledDate: 'asc' },
{ scheduledTime: 'asc' },
],
});
return matches;
}
/**
* Obtener partido por ID
*/
static async getMatchById(matchId: string) {
const match = await prisma.leagueMatch.findUnique({
where: { id: matchId },
include: {
league: {
select: {
id: true,
name: true,
status: true,
createdById: true,
},
},
team1: {
include: {
captain: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
members: {
where: { isActive: true },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
},
},
},
team2: {
include: {
captain: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
members: {
where: { isActive: true },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
},
},
},
court: true,
},
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
return match;
}
/**
* Actualizar resultado de un partido
*/
static async updateMatchResult(
matchId: string,
userId: string,
data: UpdateMatchResultInput
) {
// Verificar que el partido existe
const match = await prisma.leagueMatch.findUnique({
where: { id: matchId },
include: {
league: {
select: {
id: true,
status: true,
createdById: true,
},
},
team1: {
select: {
captainId: true,
},
},
team2: {
select: {
captainId: true,
},
},
},
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
// Verificar permisos (creador de liga, capitán de equipo 1 o capitán de equipo 2)
const isLeagueCreator = match.league.createdById === userId;
const isTeam1Captain = match.team1.captainId === userId;
const isTeam2Captain = match.team2.captainId === userId;
if (!isLeagueCreator && !isTeam1Captain && !isTeam2Captain) {
throw new ApiError('No tienes permisos para actualizar este partido', 403);
}
// Verificar que la liga esté activa
if (match.league.status !== LeagueStatus.ACTIVE) {
throw new ApiError('No se pueden actualizar resultados en una liga que no está activa', 400);
}
// Validar el resultado
if (data.team1Score < 0 || data.team2Score < 0) {
throw new ApiError('El resultado no puede ser negativo', 400);
}
// Validar consistencia del ganador
if (data.winner === 'TEAM1' && data.team1Score <= data.team2Score) {
throw new ApiError('El ganador TEAM1 debe tener más sets que TEAM2', 400);
}
if (data.winner === 'TEAM2' && data.team2Score <= data.team1Score) {
throw new ApiError('El ganador TEAM2 debe tener más sets que TEAM1', 400);
}
if (data.winner === 'DRAW' && data.team1Score !== data.team2Score) {
throw new ApiError('En empate ambos equipos deben tener el mismo número de sets', 400);
}
// Validar detalle de sets si se proporciona
if (data.setDetails && data.setDetails.length > 0) {
const setsTeam1 = data.setDetails.filter(s => (s.team1Games || 0) > (s.team2Games || 0)).length;
const setsTeam2 = data.setDetails.filter(s => (s.team2Games || 0) > (s.team1Games || 0)).length;
if (setsTeam1 !== data.team1Score || setsTeam2 !== data.team2Score) {
throw new ApiError('El detalle de sets no coincide con el resultado', 400);
}
}
const updated = await prisma.leagueMatch.update({
where: { id: matchId },
data: {
team1Score: data.team1Score,
team2Score: data.team2Score,
setDetails: data.setDetails ? JSON.stringify(data.setDetails) : undefined,
winner: data.winner,
status: LeagueMatchStatus.COMPLETED,
completedAt: new Date(),
notes: data.notes,
},
include: {
team1: {
select: {
id: true,
name: true,
},
},
team2: {
select: {
id: true,
name: true,
},
},
},
});
return updated;
}
/**
* Actualizar estado de un partido
*/
static async updateMatchStatus(
matchId: string,
userId: string,
data: UpdateMatchStatusInput
) {
// Verificar que el partido existe
const match = await prisma.leagueMatch.findUnique({
where: { id: matchId },
include: {
league: {
select: {
createdById: true,
},
},
team1: {
select: {
captainId: true,
},
},
team2: {
select: {
captainId: true,
},
},
},
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
// Verificar permisos
const isLeagueCreator = match.league.createdById === userId;
const isTeam1Captain = match.team1.captainId === userId;
const isTeam2Captain = match.team2.captainId === userId;
if (!isLeagueCreator && !isTeam1Captain && !isTeam2Captain) {
throw new ApiError('No tienes permisos para actualizar este partido', 403);
}
// No se puede modificar si ya está completado (excepto el propio estado)
if (match.status === LeagueMatchStatus.COMPLETED && data.status !== LeagueMatchStatus.COMPLETED) {
throw new ApiError('No se puede cambiar el estado de un partido completado', 400);
}
const updateData: any = { status: data.status };
if (data.scheduledDate !== undefined) {
updateData.scheduledDate = data.scheduledDate ? new Date(data.scheduledDate) : null;
}
if (data.scheduledTime !== undefined) {
updateData.scheduledTime = data.scheduledTime;
}
if (data.courtId !== undefined) {
updateData.courtId = data.courtId;
}
const updated = await prisma.leagueMatch.update({
where: { id: matchId },
data: updateData,
include: {
team1: {
select: {
id: true,
name: true,
},
},
team2: {
select: {
id: true,
name: true,
},
},
court: true,
},
});
return updated;
}
/**
* Anular resultado de un partido (volver a programado)
*/
static async voidMatchResult(matchId: string, userId: string) {
// Verificar que el partido existe
const match = await prisma.leagueMatch.findUnique({
where: { id: matchId },
include: {
league: {
select: {
createdById: true,
status: true,
},
},
},
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
// Solo el creador de la liga puede anular resultados
if (match.league.createdById !== userId) {
throw new ApiError('Solo el creador de la liga puede anular resultados', 403);
}
// Solo se puede anular si está completado
if (match.status !== LeagueMatchStatus.COMPLETED) {
throw new ApiError('Solo se pueden anular partidos completados', 400);
}
const updated = await prisma.leagueMatch.update({
where: { id: matchId },
data: {
status: LeagueMatchStatus.SCHEDULED,
team1Score: null,
team2Score: null,
setDetails: null,
winner: null,
completedAt: null,
},
include: {
team1: {
select: {
id: true,
name: true,
},
},
team2: {
select: {
id: true,
name: true,
},
},
},
});
return updated;
}
/**
* Obtener resumen de partidos de una liga
*/
static async getMatchSummary(leagueId: string) {
const matches = await prisma.leagueMatch.groupBy({
by: ['status'],
where: { leagueId },
_count: {
status: true,
},
});
const totalMatches = await prisma.leagueMatch.count({
where: { leagueId },
});
const completedMatches = await prisma.leagueMatch.count({
where: {
leagueId,
status: LeagueMatchStatus.COMPLETED,
},
});
return {
total: totalMatches,
completed: completedMatches,
pending: totalMatches - completedMatches,
byStatus: matches,
};
}
}
export default LeagueMatchService;

View File

@@ -0,0 +1,553 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import { LeagueStatus, LeagueFormat, LeagueMatchStatus } from '../utils/constants';
// Interfaces
export interface MatchScheduleInput {
matchId: string;
scheduledDate?: string;
scheduledTime?: string;
courtId?: string;
}
export interface RoundRobinMatch {
team1Id: string;
team2Id: string;
}
export interface Matchday {
matchday: number;
matches: RoundRobinMatch[];
}
export class LeagueScheduleService {
/**
* Generar calendario completo de la liga (todos vs todos)
*/
static async generateSchedule(leagueId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id: leagueId },
include: {
teams: {
select: {
id: true,
},
},
matches: {
select: {
id: true,
},
},
},
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Verificar que la liga está en estado DRAFT
if (league.status !== LeagueStatus.DRAFT) {
throw new ApiError('Solo se puede generar el calendario en ligas en estado borrador', 400);
}
// Verificar que hay al menos 3 equipos
if (league.teams.length < 3) {
throw new ApiError('Se requieren al menos 3 equipos para generar el calendario', 400);
}
// Verificar que no hay partidos existentes
if (league.matches.length > 0) {
throw new ApiError('Ya existe un calendario generado para esta liga', 400);
}
const teamIds = league.teams.map((t) => t.id);
const isDoubleRoundRobin = league.format === LeagueFormat.DOUBLE_ROUND_ROBIN ||
league.format === LeagueFormat.DOUBLE_MATCHDAY;
// Generar jornadas (ida)
const firstRoundMatchdays = this.generateRoundRobin(teamIds);
// Generar jornadas de vuelta si es doble round robin
let allMatchdays: Matchday[];
if (isDoubleRoundRobin) {
const secondRoundMatchdays = firstRoundMatchdays.map((matchday) => ({
matchday: matchday.matchday + firstRoundMatchdays.length,
matches: matchday.matches.map((match) => ({
team1Id: match.team2Id,
team2Id: match.team1Id,
})),
}));
allMatchdays = [...firstRoundMatchdays, ...secondRoundMatchdays];
} else {
allMatchdays = firstRoundMatchdays;
}
// Crear los partidos en la base de datos
const createdMatches = [];
for (const matchday of allMatchdays) {
for (const match of matchday.matches) {
const createdMatch = await prisma.leagueMatch.create({
data: {
leagueId,
matchday: matchday.matchday,
team1Id: match.team1Id,
team2Id: match.team2Id,
status: LeagueMatchStatus.SCHEDULED,
},
include: {
team1: {
select: {
id: true,
name: true,
},
},
team2: {
select: {
id: true,
name: true,
},
},
},
});
createdMatches.push(createdMatch);
}
}
return {
leagueId,
totalMatchdays: allMatchdays.length,
totalMatches: createdMatches.length,
matches: createdMatches,
};
}
/**
* Algoritmo de round-robin (todos vs todos)
* Usa el algoritmo de "circle method"
*/
static generateRoundRobin(teamIds: string[]): Matchday[] {
const numTeams = teamIds.length;
// Si es número impar, agregar un "bye"
const teams = [...teamIds];
if (numTeams % 2 === 1) {
teams.push('BYE');
}
const n = teams.length;
const numRounds = n - 1;
const matchesPerRound = n / 2;
const matchdays: Matchday[] = [];
// Crear array mutable para rotar (el primer equipo se queda fijo)
let rotatingTeams = teams.slice(1);
for (let round = 0; round < numRounds; round++) {
const matches: RoundRobinMatch[] = [];
// El primer equipo juega contra el último de los rotantes
if (teams[0] !== 'BYE' && rotatingTeams[rotatingTeams.length - 1] !== 'BYE') {
matches.push({
team1Id: teams[0],
team2Id: rotatingTeams[rotatingTeams.length - 1],
});
}
// Los demás equipos se emparejan simétricamente
for (let i = 0; i < matchesPerRound - 1; i++) {
const team1 = rotatingTeams[i];
const team2 = rotatingTeams[rotatingTeams.length - 2 - i];
if (team1 !== 'BYE' && team2 !== 'BYE') {
matches.push({
team1Id: team1,
team2Id: team2,
});
}
}
matchdays.push({
matchday: round + 1,
matches,
});
// Rotar los equipos (excepto el primero)
rotatingTeams = [
rotatingTeams[rotatingTeams.length - 1],
...rotatingTeams.slice(0, rotatingTeams.length - 1),
];
}
return matchdays;
}
/**
* Obtener calendario completo de la liga
*/
static async getSchedule(leagueId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
const matches = await prisma.leagueMatch.findMany({
where: { leagueId },
include: {
team1: {
select: {
id: true,
name: true,
captain: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
},
team2: {
select: {
id: true,
name: true,
captain: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
},
court: {
select: {
id: true,
name: true,
},
},
},
orderBy: [
{ matchday: 'asc' },
{ scheduledDate: 'asc' },
{ scheduledTime: 'asc' },
],
});
// Agrupar por jornada
const matchdays = new Map<number, typeof matches>();
for (const match of matches) {
if (!matchdays.has(match.matchday)) {
matchdays.set(match.matchday, []);
}
matchdays.get(match.matchday)!.push(match);
}
return {
leagueId,
totalMatchdays: matchdays.size,
matchdays: Array.from(matchdays.entries()).map(([matchday, matches]) => ({
matchday,
matches,
})),
};
}
/**
* Obtener jornada específica
*/
static async getMatchday(leagueId: string, matchday: number) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
const matches = await prisma.leagueMatch.findMany({
where: {
leagueId,
matchday,
},
include: {
team1: {
select: {
id: true,
name: true,
captain: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
},
team2: {
select: {
id: true,
name: true,
captain: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
},
court: {
select: {
id: true,
name: true,
},
},
},
orderBy: [
{ scheduledDate: 'asc' },
{ scheduledTime: 'asc' },
],
});
if (matches.length === 0) {
throw new ApiError('Jornada no encontrada', 404);
}
return {
leagueId,
matchday,
matches,
};
}
/**
* Actualizar fecha/hora/cancha de un partido
*/
static async updateMatchDate(
matchId: string,
userId: string,
data: {
scheduledDate?: string;
scheduledTime?: string;
courtId?: string;
}
) {
// Verificar que el partido existe
const match = await prisma.leagueMatch.findUnique({
where: { id: matchId },
include: {
league: {
select: {
createdById: true,
},
},
team1: {
select: {
captainId: true,
},
},
team2: {
select: {
captainId: true,
},
},
},
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
// Verificar permisos (creador de liga, capitán de equipo 1 o capitán de equipo 2)
const isLeagueCreator = match.league.createdById === userId;
const isTeam1Captain = match.team1.captainId === userId;
const isTeam2Captain = match.team2.captainId === userId;
if (!isLeagueCreator && !isTeam1Captain && !isTeam2Captain) {
throw new ApiError('No tienes permisos para modificar este partido', 403);
}
// No se puede modificar si ya está completado
if (match.status === 'COMPLETED' || match.status === 'CANCELLED') {
throw new ApiError('No se puede modificar un partido finalizado o cancelado', 400);
}
// Validar cancha si se proporciona
if (data.courtId) {
const court = await prisma.court.findUnique({
where: { id: data.courtId },
});
if (!court) {
throw new ApiError('Cancha no encontrada', 404);
}
}
// Validar fecha
let scheduledDate: Date | null = null;
if (data.scheduledDate !== undefined) {
if (data.scheduledDate === null) {
scheduledDate = null;
} else {
scheduledDate = new Date(data.scheduledDate);
if (isNaN(scheduledDate.getTime())) {
throw new ApiError('Fecha inválida', 400);
}
}
}
// Validar hora
if (data.scheduledTime !== undefined && data.scheduledTime !== null) {
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (!timeRegex.test(data.scheduledTime)) {
throw new ApiError('Hora inválida. Use formato HH:mm', 400);
}
}
const updated = await prisma.leagueMatch.update({
where: { id: matchId },
data: {
scheduledDate: scheduledDate !== undefined ? scheduledDate : match.scheduledDate,
scheduledTime: data.scheduledTime !== undefined ? data.scheduledTime : match.scheduledTime,
courtId: data.courtId !== undefined ? data.courtId : match.courtId,
},
include: {
team1: {
select: {
id: true,
name: true,
},
},
team2: {
select: {
id: true,
name: true,
},
},
court: {
select: {
id: true,
name: true,
},
},
},
});
return updated;
}
/**
* Obtener partidos de un equipo específico
*/
static async getTeamMatches(teamId: string) {
const matches = await prisma.leagueMatch.findMany({
where: {
OR: [
{ team1Id: teamId },
{ team2Id: teamId },
],
},
include: {
team1: {
select: {
id: true,
name: true,
},
},
team2: {
select: {
id: true,
name: true,
},
},
court: {
select: {
id: true,
name: true,
},
},
},
orderBy: [
{ matchday: 'asc' },
{ scheduledDate: 'asc' },
],
});
return matches;
}
/**
* Obtener partidos pendientes de programar
*/
static async getUnscheduledMatches(leagueId: string) {
const matches = await prisma.leagueMatch.findMany({
where: {
leagueId,
scheduledDate: null,
status: {
notIn: ['CANCELLED', 'COMPLETED'],
},
},
include: {
team1: {
select: {
id: true,
name: true,
},
},
team2: {
select: {
id: true,
name: true,
},
},
},
orderBy: [
{ matchday: 'asc' },
],
});
return matches;
}
/**
* Eliminar calendario (solo si la liga está en DRAFT)
*/
static async deleteSchedule(leagueId: string, userId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Solo el creador puede eliminar
if (league.createdById !== userId) {
throw new ApiError('No tienes permisos para eliminar el calendario', 403);
}
// Solo se puede eliminar si está en DRAFT
if (league.status !== LeagueStatus.DRAFT) {
throw new ApiError('No se puede eliminar el calendario una vez iniciada la liga', 400);
}
// Eliminar todos los partidos
await prisma.leagueMatch.deleteMany({
where: { leagueId },
});
return { message: 'Calendario eliminado exitosamente' };
}
}
export default LeagueScheduleService;

View File

@@ -0,0 +1,533 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import { LeaguePoints, DEFAULT_TIEBREAKER_ORDER, TiebreakerCriteria } from '../utils/constants';
// Interfaces
export interface StandingTeam {
teamId: string;
matchesPlayed: number;
matchesWon: number;
matchesLost: number;
matchesDrawn: number;
setsFor: number;
setsAgainst: number;
gamesFor: number;
gamesAgainst: number;
points: number;
}
export interface TopScorer {
userId: string;
firstName: string;
lastName: string;
teamId: string;
teamName: string;
matchesPlayed: number;
setsWon: number;
gamesWon: number;
}
export class LeagueStandingService {
/**
* Calcular y actualizar clasificación completa de una liga
*/
static async calculateStandings(leagueId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id: leagueId },
include: {
teams: {
select: {
id: true,
},
},
matches: {
where: {
status: 'COMPLETED',
winner: { not: null },
},
},
},
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Inicializar estadísticas para todos los equipos
const standingsMap = new Map<string, StandingTeam>();
for (const team of league.teams) {
standingsMap.set(team.id, {
teamId: team.id,
matchesPlayed: 0,
matchesWon: 0,
matchesLost: 0,
matchesDrawn: 0,
setsFor: 0,
setsAgainst: 0,
gamesFor: 0,
gamesAgainst: 0,
points: 0,
});
}
// Procesar todos los partidos completados
for (const match of league.matches) {
const team1 = standingsMap.get(match.team1Id);
const team2 = standingsMap.get(match.team2Id);
if (!team1 || !team2) continue;
// Parsear detalle de sets si existe
let setDetails: { team1Games: number; team2Games: number }[] = [];
if (match.setDetails) {
try {
setDetails = JSON.parse(match.setDetails);
} catch {
setDetails = [];
}
}
// Calcular games totales
let team1Games = 0;
let team2Games = 0;
for (const set of setDetails) {
team1Games += set.team1Games || 0;
team2Games += set.team2Games || 0;
}
// Actualizar estadísticas
team1.matchesPlayed++;
team2.matchesPlayed++;
team1.setsFor += match.team1Score || 0;
team1.setsAgainst += match.team2Score || 0;
team2.setsFor += match.team2Score || 0;
team2.setsAgainst += match.team1Score || 0;
team1.gamesFor += team1Games;
team1.gamesAgainst += team2Games;
team2.gamesFor += team2Games;
team2.gamesAgainst += team1Games;
if (match.winner === 'TEAM1') {
team1.matchesWon++;
team1.points += LeaguePoints.WIN;
team2.matchesLost++;
team2.points += LeaguePoints.LOSS;
} else if (match.winner === 'TEAM2') {
team2.matchesWon++;
team2.points += LeaguePoints.WIN;
team1.matchesLost++;
team1.points += LeaguePoints.LOSS;
} else if (match.winner === 'DRAW') {
team1.matchesDrawn++;
team1.points += LeaguePoints.DRAW;
team2.matchesDrawn++;
team2.points += LeaguePoints.DRAW;
}
}
// Convertir a array y ordenar según criterios de desempate
let standings = Array.from(standingsMap.values());
standings = this.applyTiebreakers(standings);
// Guardar en la base de datos
await prisma.$transaction(async (tx) => {
for (let i = 0; i < standings.length; i++) {
const standing = standings[i];
await tx.leagueStanding.updateMany({
where: {
leagueId,
teamId: standing.teamId,
},
data: {
matchesPlayed: standing.matchesPlayed,
matchesWon: standing.matchesWon,
matchesLost: standing.matchesLost,
matchesDrawn: standing.matchesDrawn,
setsFor: standing.setsFor,
setsAgainst: standing.setsAgainst,
gamesFor: standing.gamesFor,
gamesAgainst: standing.gamesAgainst,
points: standing.points,
position: i + 1,
},
});
}
});
return this.getStandings(leagueId);
}
/**
* Actualizar clasificación después de un partido específico
*/
static async updateStandingsAfterMatch(matchId: string) {
const match = await prisma.leagueMatch.findUnique({
where: { id: matchId },
select: {
leagueId: true,
team1Id: true,
team2Id: true,
team1Score: true,
team2Score: true,
winner: true,
setDetails: true,
status: true,
},
});
if (!match || match.status !== 'COMPLETED' || !match.winner) {
throw new ApiError('El partido no está completado o no tiene resultado', 400);
}
// Recalcular toda la clasificación
return this.calculateStandings(match.leagueId);
}
/**
* Obtener clasificación de una liga ordenada por posición
*/
static async getStandings(leagueId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
const standings = await prisma.leagueStanding.findMany({
where: { leagueId },
include: {
team: {
select: {
id: true,
name: true,
captain: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
members: {
where: { isActive: true },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
},
},
},
},
orderBy: [
{ position: 'asc' },
{ points: 'desc' },
],
});
// Agregar estadísticas adicionales
return standings.map((standing) => ({
...standing,
setsDifference: standing.setsFor - standing.setsAgainst,
gamesDifference: standing.gamesFor - standing.gamesAgainst,
}));
}
/**
* Aplicar criterios de desempate
* Orden por defecto: Puntos -> Diferencia de sets -> Diferencia de games -> Enfrentamiento directo
*/
static applyTiebreakers(standings: StandingTeam[]): StandingTeam[] {
return standings.sort((a, b) => {
for (const criteria of DEFAULT_TIEBREAKER_ORDER) {
const comparison = this.compareByCriteria(a, b, criteria);
if (comparison !== 0) {
return comparison;
}
}
return 0;
});
}
/**
* Comparar dos equipos por un criterio específico
*/
private static compareByCriteria(
a: StandingTeam,
b: StandingTeam,
criteria: string
): number {
switch (criteria) {
case TiebreakerCriteria.POINTS:
return b.points - a.points;
case TiebreakerCriteria.SETS_DIFFERENCE:
const setDiffA = a.setsFor - a.setsAgainst;
const setDiffB = b.setsFor - b.setsAgainst;
return setDiffB - setDiffA;
case TiebreakerCriteria.GAMES_DIFFERENCE:
const gameDiffA = a.gamesFor - a.gamesAgainst;
const gameDiffB = b.gamesFor - b.gamesAgainst;
return gameDiffB - gameDiffA;
case TiebreakerCriteria.WINS:
return b.matchesWon - a.matchesWon;
case TiebreakerCriteria.DIRECT_ENCOUNTER:
// Para implementar completamente necesitaría consultar los resultados directos
// Por ahora, no afecta el ordenamiento (retorna 0)
return 0;
default:
return 0;
}
}
/**
* Obtener mejores jugadores (goleadores) de la liga
* Basado en sets y games ganados
*/
static async getTopScorers(leagueId: string, limit: number = 10) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Obtener todos los partidos completados con detalles
const matches = await prisma.leagueMatch.findMany({
where: {
leagueId,
status: 'COMPLETED',
},
include: {
team1: {
include: {
members: {
where: { isActive: true },
include: {
user: true,
},
},
},
},
team2: {
include: {
members: {
where: { isActive: true },
include: {
user: true,
},
},
},
},
},
});
// Mapa para acumular estadísticas de jugadores
const playerStats = new Map<string, TopScorer>();
for (const match of matches) {
// Parsear detalle de sets
let setDetails: { team1Games: number; team2Games: number }[] = [];
if (match.setDetails) {
try {
setDetails = JSON.parse(match.setDetails);
} catch {
setDetails = [];
}
}
// Calcular games totales
const team1Games = setDetails.reduce((sum, set) => sum + (set.team1Games || 0), 0);
const team2Games = setDetails.reduce((sum, set) => sum + (set.team2Games || 0), 0);
// Procesar jugadores del equipo 1
for (const member of match.team1.members) {
const userId = member.userId;
const existing = playerStats.get(userId);
if (existing) {
existing.matchesPlayed++;
existing.setsWon += match.team1Score || 0;
existing.gamesWon += team1Games;
} else {
playerStats.set(userId, {
userId,
firstName: member.user.firstName,
lastName: member.user.lastName,
teamId: match.team1.id,
teamName: match.team1.name,
matchesPlayed: 1,
setsWon: match.team1Score || 0,
gamesWon: team1Games,
});
}
}
// Procesar jugadores del equipo 2
for (const member of match.team2.members) {
const userId = member.userId;
const existing = playerStats.get(userId);
if (existing) {
existing.matchesPlayed++;
existing.setsWon += match.team2Score || 0;
existing.gamesWon += team2Games;
} else {
playerStats.set(userId, {
userId,
firstName: member.user.firstName,
lastName: member.user.lastName,
teamId: match.team2.id,
teamName: match.team2.name,
matchesPlayed: 1,
setsWon: match.team2Score || 0,
gamesWon: team2Games,
});
}
}
}
// Convertir a array y ordenar por sets ganados, luego por games
const topScorers = Array.from(playerStats.values())
.sort((a, b) => {
if (b.setsWon !== a.setsWon) {
return b.setsWon - a.setsWon;
}
return b.gamesWon - a.gamesWon;
})
.slice(0, limit);
return topScorers;
}
/**
* Reiniciar clasificación de una liga
*/
static async resetStandings(leagueId: string, userId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Solo el creador puede reiniciar
if (league.createdById !== userId) {
throw new ApiError('No tienes permisos para reiniciar la clasificación', 403);
}
// Reiniciar todas las estadísticas
await prisma.leagueStanding.updateMany({
where: { leagueId },
data: {
matchesPlayed: 0,
matchesWon: 0,
matchesLost: 0,
matchesDrawn: 0,
setsFor: 0,
setsAgainst: 0,
gamesFor: 0,
gamesAgainst: 0,
points: 0,
position: 0,
},
});
return { message: 'Clasificación reiniciada exitosamente' };
}
/**
* Obtener estadísticas comparativas entre equipos
*/
static async getTeamComparison(leagueId: string, team1Id: string, team2Id: string) {
// Verificar que ambos equipos existen y pertenecen a la liga
const teams = await prisma.leagueTeam.findMany({
where: {
id: { in: [team1Id, team2Id] },
leagueId,
},
include: {
standing: true,
},
});
if (teams.length !== 2) {
throw new ApiError('Uno o ambos equipos no encontrados en esta liga', 404);
}
// Obtener enfrentamientos directos
const directMatches = await prisma.leagueMatch.findMany({
where: {
leagueId,
status: 'COMPLETED',
OR: [
{
team1Id,
team2Id,
},
{
team1Id: team2Id,
team2Id: team1Id,
},
],
},
orderBy: {
completedAt: 'desc',
},
});
// Calcular estadísticas de enfrentamientos directos
let team1Wins = 0;
let team2Wins = 0;
let draws = 0;
for (const match of directMatches) {
if (match.winner === 'DRAW') {
draws++;
} else if (
(match.team1Id === team1Id && match.winner === 'TEAM1') ||
(match.team2Id === team1Id && match.winner === 'TEAM2')
) {
team1Wins++;
} else {
team2Wins++;
}
}
return {
team1: teams.find((t) => t.id === team1Id)?.standing,
team2: teams.find((t) => t.id === team2Id)?.standing,
directMatches: {
total: directMatches.length,
team1Wins,
team2Wins,
draws,
matches: directMatches,
},
};
}
}
export default LeagueStandingService;

View File

@@ -0,0 +1,641 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import { LeagueStatus } from '../utils/constants';
import LeagueService from './league.service';
// Interfaces
export interface CreateTeamInput {
name: string;
description?: string;
}
export interface UpdateTeamInput {
name?: string;
description?: string;
}
export class LeagueTeamService {
/**
* Crear un nuevo equipo en una liga
*/
static async createTeam(
leagueId: string,
captainId: string,
data: CreateTeamInput
) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id: leagueId },
include: {
teams: {
select: {
id: true,
},
},
},
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
// Solo se pueden agregar equipos si la liga está en DRAFT
if (league.status !== LeagueStatus.DRAFT) {
throw new ApiError('No se pueden agregar equipos una vez iniciada la liga', 400);
}
// Verificar que el nombre no exista ya en la liga
const existingTeam = await prisma.leagueTeam.findFirst({
where: {
leagueId,
name: data.name,
},
});
if (existingTeam) {
throw new ApiError('Ya existe un equipo con este nombre en la liga', 409);
}
// Verificar que el usuario no sea capitán de otro equipo en esta liga
const existingCaptain = await prisma.leagueTeam.findFirst({
where: {
leagueId,
captainId,
},
});
if (existingCaptain) {
throw new ApiError('Ya eres capitán de otro equipo en esta liga', 409);
}
// Verificar que el usuario no sea miembro de otro equipo en esta liga
const existingMembership = await prisma.leagueTeamMember.findFirst({
where: {
userId: captainId,
team: {
leagueId,
},
isActive: true,
},
});
if (existingMembership) {
throw new ApiError('Ya eres miembro de otro equipo en esta liga', 409);
}
// Crear el equipo con el capitán como primer miembro
const team = await prisma.leagueTeam.create({
data: {
leagueId,
captainId,
name: data.name,
description: data.description,
members: {
create: {
userId: captainId,
isActive: true,
},
},
// Inicializar standing vacío
standing: {
create: {
leagueId,
matchesPlayed: 0,
matchesWon: 0,
matchesLost: 0,
matchesDrawn: 0,
setsFor: 0,
setsAgainst: 0,
gamesFor: 0,
gamesAgainst: 0,
points: 0,
position: 0,
},
},
},
include: {
captain: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
},
},
members: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
},
},
},
},
league: {
select: {
id: true,
name: true,
status: true,
},
},
standing: true,
},
});
return team;
}
/**
* Obtener todos los equipos de una liga
*/
static async getTeams(leagueId: string) {
// Verificar que la liga existe
const league = await prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) {
throw new ApiError('Liga no encontrada', 404);
}
const teams = await prisma.leagueTeam.findMany({
where: { leagueId },
include: {
captain: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
},
},
members: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
playerLevel: true,
},
},
},
where: { isActive: true },
},
_count: {
select: {
members: true,
},
},
standing: true,
},
orderBy: { name: 'asc' },
});
return teams;
}
/**
* Obtener equipo por ID con detalles completos
*/
static async getTeamById(teamId: string) {
const team = await prisma.leagueTeam.findUnique({
where: { id: teamId },
include: {
captain: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
},
},
members: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
playerLevel: true,
},
},
},
orderBy: { joinedAt: 'asc' },
},
league: {
select: {
id: true,
name: true,
status: true,
},
},
matchesAsTeam1: {
include: {
team2: true,
court: {
select: {
id: true,
name: true,
},
},
},
orderBy: [
{ matchday: 'asc' },
{ scheduledDate: 'asc' },
],
},
matchesAsTeam2: {
include: {
team1: true,
court: {
select: {
id: true,
name: true,
},
},
},
orderBy: [
{ matchday: 'asc' },
{ scheduledDate: 'asc' },
],
},
standing: true,
},
});
if (!team) {
throw new ApiError('Equipo no encontrado', 404);
}
return team;
}
/**
* Actualizar equipo (solo capitán o admin de liga)
*/
static async updateTeam(
teamId: string,
userId: string,
data: UpdateTeamInput
) {
// Verificar que el equipo existe
const team = await prisma.leagueTeam.findUnique({
where: { id: teamId },
include: {
league: {
select: {
createdById: true,
},
},
},
});
if (!team) {
throw new ApiError('Equipo no encontrado', 404);
}
// Solo capitán o creador de liga pueden actualizar
const isCaptain = team.captainId === userId;
const isLeagueCreator = team.league.createdById === userId;
if (!isCaptain && !isLeagueCreator) {
throw new ApiError('No tienes permisos para actualizar este equipo', 403);
}
// Verificar nombre único si se está cambiando
if (data.name && data.name !== team.name) {
const existingTeam = await prisma.leagueTeam.findFirst({
where: {
leagueId: team.leagueId,
name: data.name,
id: { not: teamId },
},
});
if (existingTeam) {
throw new ApiError('Ya existe un equipo con este nombre en la liga', 409);
}
}
const updated = await prisma.leagueTeam.update({
where: { id: teamId },
data: {
name: data.name,
description: data.description,
},
include: {
captain: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
},
},
members: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
},
},
},
},
standing: true,
},
});
return updated;
}
/**
* Eliminar equipo (solo capitán o admin de liga, y solo si la liga está en DRAFT)
*/
static async deleteTeam(teamId: string, userId: string) {
// Verificar que el equipo existe
const team = await prisma.leagueTeam.findUnique({
where: { id: teamId },
include: {
league: {
select: {
status: true,
createdById: true,
},
},
},
});
if (!team) {
throw new ApiError('Equipo no encontrado', 404);
}
// Solo se puede eliminar si la liga está en DRAFT
if (team.league.status !== LeagueStatus.DRAFT) {
throw new ApiError('No se pueden eliminar equipos una vez iniciada la liga', 400);
}
// Solo capitán o creador de liga pueden eliminar
const isCaptain = team.captainId === userId;
const isLeagueCreator = team.league.createdById === userId;
if (!isCaptain && !isLeagueCreator) {
throw new ApiError('No tienes permisos para eliminar este equipo', 403);
}
await prisma.leagueTeam.delete({
where: { id: teamId },
});
return { message: 'Equipo eliminado exitosamente' };
}
/**
* Agregar miembro al equipo (solo capitán)
*/
static async addMember(teamId: string, captainId: string, userId: string) {
// Verificar que el equipo existe
const team = await prisma.leagueTeam.findUnique({
where: { id: teamId },
include: {
league: {
select: {
id: true,
status: true,
},
},
members: {
where: { isActive: true },
select: { userId: true },
},
},
});
if (!team) {
throw new ApiError('Equipo no encontrado', 404);
}
// Verificar que es el capitán
if (team.captainId !== captainId) {
throw new ApiError('Solo el capitán puede agregar miembros', 403);
}
// Solo se pueden agregar miembros si la liga está en DRAFT
if (team.league.status !== LeagueStatus.DRAFT) {
throw new ApiError('No se pueden agregar miembros una vez iniciada la liga', 400);
}
// Verificar que el usuario existe
const user = await prisma.user.findUnique({
where: { id: userId, isActive: true },
});
if (!user) {
throw new ApiError('Usuario no encontrado', 404);
}
// Verificar que el usuario no es ya miembro del equipo
const existingMember = team.members.find((m) => m.userId === userId);
if (existingMember) {
throw new ApiError('El usuario ya es miembro del equipo', 409);
}
// Verificar que el usuario no es miembro de otro equipo en esta liga
const existingMembership = await prisma.leagueTeamMember.findFirst({
where: {
userId,
team: {
leagueId: team.league.id,
},
isActive: true,
},
});
if (existingMembership) {
throw new ApiError('El usuario ya es miembro de otro equipo en esta liga', 409);
}
// Crear membresía
const member = await prisma.leagueTeamMember.create({
data: {
teamId,
userId,
isActive: true,
},
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
playerLevel: true,
},
},
},
});
return member;
}
/**
* Quitar miembro del equipo (solo capitán)
*/
static async removeMember(teamId: string, captainId: string, userId: string) {
// Verificar que el equipo existe
const team = await prisma.leagueTeam.findUnique({
where: { id: teamId },
include: {
league: {
select: {
status: true,
createdById: true,
},
},
},
});
if (!team) {
throw new ApiError('Equipo no encontrado', 404);
}
// Verificar que es el capitán o el propio usuario
const isCaptain = team.captainId === captainId;
const isLeagueCreator = team.league.createdById === captainId;
const isSelf = captainId === userId;
if (!isCaptain && !isSelf && !isLeagueCreator) {
throw new ApiError('No tienes permisos para quitar este miembro', 403);
}
// No se puede quitar al capitán
if (userId === team.captainId && !isSelf) {
throw new ApiError('No se puede quitar al capitán del equipo', 400);
}
// Verificar que el miembro existe
const member = await prisma.leagueTeamMember.findUnique({
where: {
teamId_userId: {
teamId,
userId,
},
},
});
if (!member || !member.isActive) {
throw new ApiError('El usuario no es miembro activo del equipo', 404);
}
// Eliminar membresía (física, no soft delete)
await prisma.leagueTeamMember.delete({
where: {
teamId_userId: {
teamId,
userId,
},
},
});
return { message: 'Miembro eliminado exitosamente' };
}
/**
* Abandonar equipo (el propio usuario)
*/
static async leaveTeam(teamId: string, userId: string) {
// Verificar que el equipo existe
const team = await prisma.leagueTeam.findUnique({
where: { id: teamId },
include: {
league: {
select: {
status: true,
},
},
},
});
if (!team) {
throw new ApiError('Equipo no encontrado', 404);
}
// El capitán no puede abandonar
if (userId === team.captainId) {
throw new ApiError('El capitán no puede abandonar el equipo. Transfiere el liderazgo primero o elimina el equipo.', 400);
}
// Verificar que el miembro existe
const member = await prisma.leagueTeamMember.findUnique({
where: {
teamId_userId: {
teamId,
userId,
},
},
});
if (!member || !member.isActive) {
throw new ApiError('No eres miembro de este equipo', 404);
}
// Eliminar membresía
await prisma.leagueTeamMember.delete({
where: {
teamId_userId: {
teamId,
userId,
},
},
});
return { message: 'Has abandonado el equipo exitosamente' };
}
/**
* Verificar si el usuario es capitán del equipo
*/
static async isTeamCaptain(teamId: string, userId: string): Promise<boolean> {
const team = await prisma.leagueTeam.findUnique({
where: { id: teamId },
select: { captainId: true },
});
return team?.captainId === userId;
}
/**
* Verificar si el usuario es miembro del equipo
*/
static async isTeamMember(teamId: string, userId: string): Promise<boolean> {
const member = await prisma.leagueTeamMember.findUnique({
where: {
teamId_userId: {
teamId,
userId,
},
},
});
return !!member && member.isActive;
}
}
export default LeagueTeamService;

View File

@@ -0,0 +1,799 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import {
TournamentType,
TournamentCategory,
TournamentStatus,
ParticipantStatus,
PaymentStatus,
PlayerLevel,
UserRole,
} from '../utils/constants';
import logger from '../config/logger';
// Interfaces
export interface CreateTournamentInput {
name: string;
description?: string;
type: string;
category: string;
allowedLevels: string[];
maxParticipants: number;
registrationStartDate: Date;
registrationEndDate: Date;
startDate: Date;
endDate: Date;
courtIds: string[];
price: number;
}
export interface UpdateTournamentInput {
name?: string;
description?: string;
type?: string;
category?: string;
allowedLevels?: string[];
maxParticipants?: number;
registrationStartDate?: Date;
registrationEndDate?: Date;
startDate?: Date;
endDate?: Date;
courtIds?: string[];
price?: number;
status?: string;
}
export class TournamentService {
// Crear un torneo
static async createTournament(adminId: string, data: CreateTournamentInput) {
// Verificar que el usuario sea admin
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || (admin.role !== UserRole.ADMIN && admin.role !== UserRole.SUPERADMIN)) {
throw new ApiError('No tienes permisos para crear torneos', 403);
}
// Validar tipo de torneo
if (!Object.values(TournamentType).includes(data.type as any)) {
throw new ApiError('Tipo de torneo inválido', 400);
}
// Validar categoría
if (!Object.values(TournamentCategory).includes(data.category as any)) {
throw new ApiError('Categoría de torneo inválida', 400);
}
// Validar niveles permitidos
for (const level of data.allowedLevels) {
if (!Object.values(PlayerLevel).includes(level as any)) {
throw new ApiError(`Nivel inválido: ${level}`, 400);
}
}
// Validar fechas
const now = new Date();
if (data.registrationStartDate < now) {
throw new ApiError('La fecha de inicio de inscripción no puede ser en el pasado', 400);
}
if (data.registrationEndDate <= data.registrationStartDate) {
throw new ApiError('La fecha de fin de inscripción debe ser posterior a la de inicio', 400);
}
if (data.startDate <= data.registrationEndDate) {
throw new ApiError('La fecha de inicio del torneo debe ser posterior al cierre de inscripciones', 400);
}
if (data.endDate <= data.startDate) {
throw new ApiError('La fecha de fin del torneo debe ser posterior a la de inicio', 400);
}
// Validar que las canchas existan
if (data.courtIds.length > 0) {
const courts = await prisma.court.findMany({
where: { id: { in: data.courtIds } },
});
if (courts.length !== data.courtIds.length) {
throw new ApiError('Una o más canchas no existen', 404);
}
}
// Validar cupo máximo
if (data.maxParticipants < 2) {
throw new ApiError('El torneo debe permitir al menos 2 participantes', 400);
}
// Crear torneo
const tournament = await prisma.tournament.create({
data: {
name: data.name,
description: data.description,
type: data.type,
category: data.category,
allowedLevels: JSON.stringify(data.allowedLevels),
maxParticipants: data.maxParticipants,
registrationStartDate: data.registrationStartDate,
registrationEndDate: data.registrationEndDate,
startDate: data.startDate,
endDate: data.endDate,
courtIds: JSON.stringify(data.courtIds),
price: data.price,
status: TournamentStatus.DRAFT,
createdById: adminId,
},
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
});
return {
...tournament,
allowedLevels: data.allowedLevels,
courtIds: data.courtIds,
};
}
// Obtener todos los torneos (con filtros)
static async getTournaments(filters: {
status?: string;
type?: string;
category?: string;
upcoming?: boolean;
open?: boolean;
}) {
const where: any = {};
if (filters.status) where.status = filters.status;
if (filters.type) where.type = filters.type;
if (filters.category) where.category = filters.category;
if (filters.upcoming) {
where.startDate = { gte: new Date() };
}
if (filters.open) {
where.status = TournamentStatus.OPEN;
}
const tournaments = await prisma.tournament.findMany({
where,
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
_count: {
select: {
participants: true,
},
},
},
orderBy: [{ startDate: 'asc' }],
});
return tournaments.map((t) => ({
...t,
allowedLevels: JSON.parse(t.allowedLevels),
courtIds: JSON.parse(t.courtIds),
}));
}
// Obtener torneo por ID
static async getTournamentById(id: string) {
const tournament = await prisma.tournament.findUnique({
where: { id },
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
participants: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
playerLevel: true,
avatarUrl: true,
},
},
},
orderBy: [{ seed: 'asc' }, { registrationDate: 'asc' }],
},
},
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
return {
...tournament,
allowedLevels: JSON.parse(tournament.allowedLevels),
courtIds: JSON.parse(tournament.courtIds),
};
}
// Actualizar torneo
static async updateTournament(
id: string,
adminId: string,
data: UpdateTournamentInput
) {
// Verificar que el torneo existe
const tournament = await prisma.tournament.findUnique({
where: { id },
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
// Verificar permisos (creador o admin)
if (tournament.createdById !== adminId) {
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || admin.role !== UserRole.SUPERADMIN) {
throw new ApiError('No tienes permisos para modificar este torneo', 403);
}
}
// No permitir modificar si ya está en progreso o finalizado
if (
tournament.status === TournamentStatus.IN_PROGRESS ||
tournament.status === TournamentStatus.FINISHED
) {
throw new ApiError('No se puede modificar un torneo en progreso o finalizado', 400);
}
// Validar tipo si se proporciona
if (data.type && !Object.values(TournamentType).includes(data.type as any)) {
throw new ApiError('Tipo de torneo inválido', 400);
}
// Validar categoría si se proporciona
if (data.category && !Object.values(TournamentCategory).includes(data.category as any)) {
throw new ApiError('Categoría de torneo inválida', 400);
}
// Validar niveles si se proporcionan
if (data.allowedLevels) {
for (const level of data.allowedLevels) {
if (!Object.values(PlayerLevel).includes(level as any)) {
throw new ApiError(`Nivel inválido: ${level}`, 400);
}
}
}
// Validar fechas si se proporcionan
if (data.registrationStartDate && data.registrationEndDate) {
if (data.registrationEndDate <= data.registrationStartDate) {
throw new ApiError('La fecha de fin de inscripción debe ser posterior a la de inicio', 400);
}
}
// Actualizar torneo
const updated = await prisma.tournament.update({
where: { id },
data: {
name: data.name,
description: data.description,
type: data.type,
category: data.category,
allowedLevels: data.allowedLevels ? JSON.stringify(data.allowedLevels) : undefined,
maxParticipants: data.maxParticipants,
registrationStartDate: data.registrationStartDate,
registrationEndDate: data.registrationEndDate,
startDate: data.startDate,
endDate: data.endDate,
courtIds: data.courtIds ? JSON.stringify(data.courtIds) : undefined,
price: data.price,
status: data.status,
},
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
_count: {
select: {
participants: true,
},
},
},
});
return {
...updated,
allowedLevels: data.allowedLevels
? data.allowedLevels
: JSON.parse(updated.allowedLevels),
courtIds: data.courtIds ? data.courtIds : JSON.parse(updated.courtIds),
};
}
// Eliminar (cancelar) torneo
static async deleteTournament(id: string, adminId: string) {
// Verificar que el torneo existe
const tournament = await prisma.tournament.findUnique({
where: { id },
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
// Verificar permisos
if (tournament.createdById !== adminId) {
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || admin.role !== UserRole.SUPERADMIN) {
throw new ApiError('No tienes permisos para cancelar este torneo', 403);
}
}
// No permitir cancelar si ya está finalizado
if (tournament.status === TournamentStatus.FINISHED) {
throw new ApiError('No se puede cancelar un torneo finalizado', 400);
}
// Cancelar torneo (soft delete cambiando estado)
const cancelled = await prisma.tournament.update({
where: { id },
data: { status: TournamentStatus.CANCELLED },
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
});
// Actualizar participantes como retirados
await prisma.tournamentParticipant.updateMany({
where: { tournamentId: id },
data: { status: ParticipantStatus.WITHDRAWN },
});
logger.info(`Torneo ${id} cancelado por admin ${adminId}`);
return {
...cancelled,
allowedLevels: JSON.parse(cancelled.allowedLevels),
courtIds: JSON.parse(cancelled.courtIds),
};
}
// Abrir inscripciones
static async openRegistration(id: string, adminId: string) {
// Verificar que el torneo existe
const tournament = await prisma.tournament.findUnique({
where: { id },
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
// Verificar permisos
if (tournament.createdById !== adminId) {
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || admin.role !== UserRole.SUPERADMIN) {
throw new ApiError('No tienes permisos para modificar este torneo', 403);
}
}
// Solo se puede abrir desde DRAFT
if (tournament.status !== TournamentStatus.DRAFT) {
throw new ApiError('Solo se pueden abrir inscripciones de torneos en borrador', 400);
}
const updated = await prisma.tournament.update({
where: { id },
data: { status: TournamentStatus.OPEN },
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
_count: {
select: {
participants: true,
},
},
},
});
logger.info(`Inscripciones abiertas para torneo ${id}`);
return {
...updated,
allowedLevels: JSON.parse(updated.allowedLevels),
courtIds: JSON.parse(updated.courtIds),
};
}
// Cerrar inscripciones
static async closeRegistration(id: string, adminId: string) {
// Verificar que el torneo existe
const tournament = await prisma.tournament.findUnique({
where: { id },
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
// Verificar permisos
if (tournament.createdById !== adminId) {
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || admin.role !== UserRole.SUPERADMIN) {
throw new ApiError('No tienes permisos para modificar este torneo', 403);
}
}
// Solo se puede cerrar desde OPEN
if (tournament.status !== TournamentStatus.OPEN) {
throw new ApiError('Solo se pueden cerrar inscripciones de torneos abiertos', 400);
}
const updated = await prisma.tournament.update({
where: { id },
data: { status: TournamentStatus.CLOSED },
include: {
createdBy: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
_count: {
select: {
participants: true,
},
},
},
});
logger.info(`Inscripciones cerradas para torneo ${id}`);
return {
...updated,
allowedLevels: JSON.parse(updated.allowedLevels),
courtIds: JSON.parse(updated.courtIds),
};
}
// Inscribir participante
static async registerParticipant(tournamentId: string, userId: string) {
// Verificar que el torneo existe y está abierto
const tournament = await prisma.tournament.findUnique({
where: { id: tournamentId },
include: {
_count: {
select: {
participants: {
where: {
status: {
in: [ParticipantStatus.REGISTERED, ParticipantStatus.CONFIRMED],
},
},
},
},
},
},
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
if (tournament.status !== TournamentStatus.OPEN) {
throw new ApiError('Las inscripciones no están abiertas', 400);
}
// Verificar fechas de inscripción
const now = new Date();
if (now < tournament.registrationStartDate) {
throw new ApiError('Las inscripciones aún no han comenzado', 400);
}
if (now > tournament.registrationEndDate) {
throw new ApiError('El período de inscripciones ha finalizado', 400);
}
// Verificar cupo
if (tournament._count.participants >= tournament.maxParticipants) {
throw new ApiError('El torneo ha alcanzado el máximo de participantes', 409);
}
// Verificar que el usuario existe
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
playerLevel: true,
isActive: true,
},
});
if (!user) {
throw new ApiError('Usuario no encontrado', 404);
}
if (!user.isActive) {
throw new ApiError('Usuario no está activo', 400);
}
// Verificar que el usuario tiene el nivel requerido
const allowedLevels = JSON.parse(tournament.allowedLevels) as string[];
if (!allowedLevels.includes(user.playerLevel)) {
throw new ApiError(
`Tu nivel (${user.playerLevel}) no está permitido en este torneo. Niveles permitidos: ${allowedLevels.join(', ')}`,
403
);
}
// Verificar que no esté ya inscrito
const existingRegistration = await prisma.tournamentParticipant.findFirst({
where: {
tournamentId,
userId,
status: {
in: [ParticipantStatus.REGISTERED, ParticipantStatus.CONFIRMED],
},
},
});
if (existingRegistration) {
throw new ApiError('Ya estás inscrito en este torneo', 409);
}
// Crear inscripción
const participant = await prisma.tournamentParticipant.create({
data: {
tournamentId,
userId,
paymentStatus: tournament.price > 0 ? PaymentStatus.PENDING : PaymentStatus.PAID,
status: ParticipantStatus.REGISTERED,
},
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
playerLevel: true,
avatarUrl: true,
},
},
tournament: {
select: {
id: true,
name: true,
price: true,
},
},
},
});
logger.info(`Usuario ${userId} inscrito en torneo ${tournamentId}`);
return participant;
}
// Desinscribir participante
static async unregisterParticipant(tournamentId: string, userId: string) {
// Verificar que el torneo existe
const tournament = await prisma.tournament.findUnique({
where: { id: tournamentId },
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
// No permitir desinscribir si el torneo ya empezó
if (tournament.status === TournamentStatus.IN_PROGRESS ||
tournament.status === TournamentStatus.FINISHED) {
throw new ApiError('No puedes desinscribirte de un torneo en progreso o finalizado', 400);
}
// Buscar la inscripción
const participant = await prisma.tournamentParticipant.findFirst({
where: {
tournamentId,
userId,
status: {
in: [ParticipantStatus.REGISTERED, ParticipantStatus.CONFIRMED],
},
},
});
if (!participant) {
throw new ApiError('No estás inscrito en este torneo', 404);
}
// Actualizar estado a retirado
const updated = await prisma.tournamentParticipant.update({
where: { id: participant.id },
data: {
status: ParticipantStatus.WITHDRAWN,
paymentStatus:
participant.paymentStatus === PaymentStatus.PAID
? PaymentStatus.REFUNDED
: participant.paymentStatus,
},
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
tournament: {
select: {
id: true,
name: true,
},
},
},
});
logger.info(`Usuario ${userId} desinscrito del torneo ${tournamentId}`);
return updated;
}
// Confirmar pago de inscripción
static async confirmPayment(participantId: string, adminId: string) {
// Verificar que el admin tiene permisos
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || (admin.role !== UserRole.ADMIN && admin.role !== UserRole.SUPERADMIN)) {
throw new ApiError('No tienes permisos para confirmar pagos', 403);
}
// Verificar que la inscripción existe
const participant = await prisma.tournamentParticipant.findUnique({
where: { id: participantId },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
tournament: {
select: {
id: true,
name: true,
},
},
},
});
if (!participant) {
throw new ApiError('Inscripción no encontrada', 404);
}
if (participant.paymentStatus !== PaymentStatus.PENDING) {
throw new ApiError('El pago ya fue procesado o no está pendiente', 400);
}
// Actualizar pago y estado
const updated = await prisma.tournamentParticipant.update({
where: { id: participantId },
data: {
paymentStatus: PaymentStatus.PAID,
status: ParticipantStatus.CONFIRMED,
},
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
playerLevel: true,
avatarUrl: true,
},
},
tournament: {
select: {
id: true,
name: true,
price: true,
},
},
},
});
logger.info(`Pago confirmado para participante ${participantId} por admin ${adminId}`);
return updated;
}
// Obtener participantes de un torneo
static async getParticipants(tournamentId: string) {
// Verificar que el torneo existe
const tournament = await prisma.tournament.findUnique({
where: { id: tournamentId },
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
const participants = await prisma.tournamentParticipant.findMany({
where: { tournamentId },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
playerLevel: true,
avatarUrl: true,
},
},
},
orderBy: [{ seed: 'asc' }, { registrationDate: 'asc' }],
});
return participants;
}
}
export default TournamentService;

View File

@@ -0,0 +1,788 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import logger from '../config/logger';
import {
TournamentType,
TournamentStatus,
TournamentMatchStatus,
} from '../utils/constants';
import {
shuffleArray,
calculateRounds,
seedParticipants,
generateBracketPositions,
nextPowerOfTwo,
calculateByes,
generateRoundRobinPairings,
generateSwissPairings,
validateDrawGeneration,
} from '../utils/tournamentDraw';
export interface GenerateDrawInput {
shuffle?: boolean; // Mezclar participantes aleatoriamente
respectSeeds?: boolean; // Respetar cabezas de serie
}
export interface ScheduleMatchInput {
courtId: string;
date: Date;
time: string;
}
export interface MatchResultInput {
team1Score: number;
team2Score: number;
}
export class TournamentDrawService {
/**
* Generar cuadro de torneo según el tipo
*/
static async generateDraw(
tournamentId: string,
input: GenerateDrawInput = {}
) {
const tournament = await prisma.tournament.findUnique({
where: { id: tournamentId },
include: {
participants: {
where: { status: { in: ['REGISTERED', 'CONFIRMED'] } },
include: { user: true },
},
},
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
// Validar estado del torneo
if (tournament.status === TournamentStatus.DRAFT) {
throw new ApiError(
'El torneo debe estar abierto o cerrado para generar el cuadro',
400
);
}
if (tournament.status === TournamentStatus.IN_PROGRESS) {
throw new ApiError(
'El torneo ya está en progreso, no se puede regenerar el cuadro',
400
);
}
if (tournament.status === TournamentStatus.FINISHED) {
throw new ApiError('El torneo ya ha finalizado', 400);
}
// Validar participantes
const participants = tournament.participants;
const validation = validateDrawGeneration(
participants.length,
tournament.type
);
if (!validation.valid) {
throw new ApiError(validation.error || 'Error de validación', 400);
}
// Eliminar cuadro existente si hay
await prisma.tournamentMatch.deleteMany({
where: { tournamentId },
});
// Generar cuadro según tipo
let matches;
switch (tournament.type) {
case TournamentType.ELIMINATION:
matches = await this.generateEliminationDraw(
tournamentId,
participants,
input
);
break;
case TournamentType.CONSOLATION:
matches = await this.generateConsolationDraw(
tournamentId,
participants,
input
);
break;
case TournamentType.ROUND_ROBIN:
matches = await this.generateRoundRobin(
tournamentId,
participants,
input
);
break;
case TournamentType.SWISS:
matches = await this.generateSwiss(tournamentId, participants, input);
break;
default:
throw new ApiError('Tipo de torneo no soportado', 400);
}
// Actualizar estado del torneo
await prisma.tournament.update({
where: { id: tournamentId },
data: { status: TournamentStatus.IN_PROGRESS },
});
logger.info(
`Cuadro generado para torneo ${tournamentId}: ${matches.length} partidos`
);
return {
tournamentId,
type: tournament.type,
participantsCount: participants.length,
matchesCount: matches.length,
matches,
};
}
/**
* Generar cuadro de eliminatoria simple
*/
private static async generateEliminationDraw(
tournamentId: string,
participants: any[],
input: GenerateDrawInput
) {
const { shuffle = false, respectSeeds = true } = input;
const participantCount = participants.length;
const bracketSize = nextPowerOfTwo(participantCount);
const rounds = calculateRounds(participantCount);
// Ordenar participantes
let orderedParticipants = respectSeeds
? seedParticipants(participants)
: shuffle
? shuffleArray(participants)
: participants;
// Generar posiciones del cuadro
const positions = generateBracketPositions(participantCount);
// Asignar participantes a posiciones
const positionedParticipants: (typeof participants[0] | null)[] = new Array(
bracketSize
).fill(null);
for (let i = 0; i < participantCount; i++) {
const pos = positions[i] % bracketSize;
positionedParticipants[pos] = orderedParticipants[i];
}
// Crear partidos por ronda
const createdMatches: any[] = [];
const matchMap = new Map<string, Map<number, any>>(); // round -> position -> match
// Primera ronda (ronda más alta = primera ronda)
const firstRound = rounds;
const matchesInFirstRound = bracketSize / 2;
for (let i = 0; i < matchesInFirstRound; i++) {
const team1 = positionedParticipants[i * 2];
const team2 = positionedParticipants[i * 2 + 1];
// Si hay bye, el equipo avanza automáticamente
const isBye = !team1 || !team2;
const status = isBye
? TournamentMatchStatus.BYE
: TournamentMatchStatus.PENDING;
const match = await prisma.tournamentMatch.create({
data: {
tournamentId,
round: firstRound,
matchNumber: i + 1,
position: i,
team1Player1Id: team1?.id,
team1Player2Id: null, // Para individuales
team2Player1Id: team2?.id,
team2Player2Id: null,
status,
winner: isBye ? (team1 ? 'TEAM1' : 'TEAM2') : null,
},
});
createdMatches.push(match);
if (!matchMap.has(firstRound.toString())) {
matchMap.set(firstRound.toString(), new Map());
}
matchMap.get(firstRound.toString())!.set(i, match);
}
// Crear partidos de rondas siguientes
for (let round = firstRound - 1; round >= 1; round--) {
const matchesInRound = Math.pow(2, round - 1);
for (let i = 0; i < matchesInRound; i++) {
// Buscar partidos padre
const parentRound = round + 1;
const parentPosition1 = i * 2;
const parentPosition2 = i * 2 + 1;
const parent1 = matchMap.get(parentRound.toString())?.get(parentPosition1);
const parent2 = matchMap.get(parentRound.toString())?.get(parentPosition2);
const match = await prisma.tournamentMatch.create({
data: {
tournamentId,
round,
matchNumber: i + 1,
position: i,
status: TournamentMatchStatus.PENDING,
parentMatches: {
connect: [
...(parent1 ? [{ id: parent1.id }] : []),
...(parent2 ? [{ id: parent2.id }] : []),
],
},
},
});
createdMatches.push(match);
if (!matchMap.has(round.toString())) {
matchMap.set(round.toString(), new Map());
}
matchMap.get(round.toString())!.set(i, match);
// Actualizar nextMatchId de los padres
if (parent1) {
await prisma.tournamentMatch.update({
where: { id: parent1.id },
data: { nextMatchId: match.id },
});
}
if (parent2) {
await prisma.tournamentMatch.update({
where: { id: parent2.id },
data: { nextMatchId: match.id },
});
}
}
}
return createdMatches;
}
/**
* Generar cuadro de consolación (los perdedores de 1ra ronda juegan cuadro paralelo)
*/
private static async generateConsolationDraw(
tournamentId: string,
participants: any[],
input: GenerateDrawInput
) {
// Primero generar el cuadro principal
const mainMatches = await this.generateEliminationDraw(
tournamentId,
participants,
input
);
// Identificar partidos de primera ronda
const maxRound = Math.max(...mainMatches.map(m => m.round));
const firstRoundMatches = mainMatches.filter(m => m.round === maxRound);
// Crear cuadro de consolación con los perdedores
// Por simplicidad, hacemos un round robin entre los perdedores de 1ra ronda
const consolationMatches: any[] = [];
// Marcar partidos que alimentan consolación
for (const match of firstRoundMatches) {
await prisma.tournamentMatch.update({
where: { id: match.id },
data: {
metadata: JSON.stringify({ feedsConsolation: true }),
},
});
}
logger.info(
`Cuadro de consolación marcado para ${firstRoundMatches.length} partidos de primera ronda`
);
return [...mainMatches, ...consolationMatches];
}
/**
* Generar round robin (todos contra todos)
*/
private static async generateRoundRobin(
tournamentId: string,
participants: any[],
input: GenerateDrawInput
) {
const { shuffle = true } = input;
// Mezclar o mantener orden
const orderedParticipants = shuffle
? shuffleArray(participants)
: participants;
// Generar emparejamientos
const pairings = generateRoundRobinPairings(orderedParticipants);
// Crear partidos (una ronda por cada conjunto de emparejamientos)
const createdMatches: any[] = [];
const matchesPerRound = Math.floor(participants.length / 2);
for (let i = 0; i < pairings.length; i++) {
const [player1, player2] = pairings[i];
const round = Math.floor(i / matchesPerRound) + 1;
const matchNumber = (i % matchesPerRound) + 1;
const match = await prisma.tournamentMatch.create({
data: {
tournamentId,
round,
matchNumber,
position: i,
team1Player1Id: player1.id,
team1Player2Id: null,
team2Player1Id: player2.id,
team2Player2Id: null,
status: TournamentMatchStatus.PENDING,
metadata: JSON.stringify({
type: 'ROUND_ROBIN',
matchIndex: i,
}),
},
});
createdMatches.push(match);
}
return createdMatches;
}
/**
* Generar primera ronda de sistema suizo
*/
private static async generateSwiss(
tournamentId: string,
participants: any[],
input: GenerateDrawInput
) {
const { shuffle = true } = input;
// En la primera ronda, emparejar aleatoriamente o por seed
let orderedParticipants = shuffle
? shuffleArray(participants)
: seedParticipants(participants);
// Preparar jugadores para emparejamiento
const swissPlayers = orderedParticipants.map((p, index) => ({
id: p.id,
points: 0,
playedAgainst: [],
seed: p.seed || index + 1,
}));
// Generar emparejamientos
const pairings = generateSwissPairings(swissPlayers);
// Crear partidos de primera ronda
const createdMatches: any[] = [];
for (let i = 0; i < pairings.length; i++) {
const [player1Id, player2Id] = pairings[i];
const match = await prisma.tournamentMatch.create({
data: {
tournamentId,
round: 1,
matchNumber: i + 1,
position: i,
team1Player1Id: player1Id,
team1Player2Id: null,
team2Player1Id: player2Id,
team2Player2Id: null,
status: TournamentMatchStatus.PENDING,
metadata: JSON.stringify({
type: 'SWISS',
swissRound: 1,
}),
},
});
createdMatches.push(match);
}
return createdMatches;
}
/**
* Generar siguiente ronda de sistema suizo
*/
static async generateNextRoundSwiss(tournamentId: string) {
const tournament = await prisma.tournament.findUnique({
where: { id: tournamentId },
include: {
participants: {
where: { status: { in: ['REGISTERED', 'CONFIRMED'] } },
},
matches: {
where: { status: TournamentMatchStatus.FINISHED },
},
},
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
if (tournament.type !== TournamentType.SWISS) {
throw new ApiError('Esta función es solo para torneos suizo', 400);
}
// Calcular puntos de cada jugador
const playerPoints = new Map<string, number>();
const playedAgainst = new Map<string, string[]>();
for (const participant of tournament.participants) {
playerPoints.set(participant.id, 0);
playedAgainst.set(participant.id, []);
}
// Sumar puntos de partidos terminados
for (const match of tournament.matches) {
if (match.winner === 'TEAM1' && match.team1Player1Id) {
playerPoints.set(
match.team1Player1Id,
(playerPoints.get(match.team1Player1Id) || 0) + 1
);
} else if (match.winner === 'TEAM2' && match.team2Player1Id) {
playerPoints.set(
match.team2Player1Id,
(playerPoints.get(match.team2Player1Id) || 0) + 1
);
} else if (match.winner === 'DRAW') {
if (match.team1Player1Id) {
playerPoints.set(
match.team1Player1Id,
(playerPoints.get(match.team1Player1Id) || 0) + 0.5
);
}
if (match.team2Player1Id) {
playerPoints.set(
match.team2Player1Id,
(playerPoints.get(match.team2Player1Id) || 0) + 0.5
);
}
}
// Registrar enfrentamientos
if (match.team1Player1Id && match.team2Player1Id) {
playedAgainst.get(match.team1Player1Id)?.push(match.team2Player1Id);
playedAgainst.get(match.team2Player1Id)?.push(match.team1Player1Id);
}
}
// Determinar número de siguiente ronda
const currentRound = Math.max(...tournament.matches.map(m => m.round), 0);
const nextRound = currentRound + 1;
const totalRounds = tournament.participants.length - 1;
if (nextRound > totalRounds) {
throw new ApiError('Todas las rondas del sistema suizo han sido jugadas', 400);
}
// Preparar jugadores para emparejamiento
const swissPlayers = tournament.participants.map(p => ({
id: p.id,
points: playerPoints.get(p.id) || 0,
playedAgainst: playedAgainst.get(p.id) || [],
}));
// Generar emparejamientos
const pairings = generateSwissPairings(swissPlayers);
// Crear partidos
const createdMatches: any[] = [];
for (let i = 0; i < pairings.length; i++) {
const [player1Id, player2Id] = pairings[i];
const match = await prisma.tournamentMatch.create({
data: {
tournamentId,
round: nextRound,
matchNumber: i + 1,
position: i,
team1Player1Id: player1Id,
team1Player2Id: null,
team2Player1Id: player2Id,
team2Player2Id: null,
status: TournamentMatchStatus.PENDING,
metadata: JSON.stringify({
type: 'SWISS',
swissRound: nextRound,
}),
},
});
createdMatches.push(match);
}
logger.info(
`Ronda ${nextRound} de suizo generada para torneo ${tournamentId}: ${createdMatches.length} partidos`
);
return {
round: nextRound,
matches: createdMatches,
};
}
/**
* Obtener cuadro completo de un torneo
*/
static async getDraw(tournamentId: string) {
const tournament = await prisma.tournament.findUnique({
where: { id: tournamentId },
include: {
participants: {
where: { status: { in: ['REGISTERED', 'CONFIRMED'] } },
include: { user: true },
},
},
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
const matches = await prisma.tournamentMatch.findMany({
where: { tournamentId },
include: {
team1Player1: { include: { user: true } },
team1Player2: { include: { user: true } },
team2Player1: { include: { user: true } },
team2Player2: { include: { user: true } },
court: true,
nextMatch: true,
parentMatches: true,
},
orderBy: [{ round: 'desc' }, { matchNumber: 'asc' }],
});
// Agrupar por ronda
const rounds = matches.reduce((acc, match) => {
if (!acc[match.round]) {
acc[match.round] = [];
}
acc[match.round].push(match);
return acc;
}, {} as Record<number, typeof matches>);
return {
tournamentId,
type: tournament.type,
status: tournament.status,
participantsCount: tournament.participants.length,
matchesCount: matches.length,
rounds,
matches,
};
}
/**
* Programar un partido (asignar cancha y fecha)
*/
static async scheduleMatch(
matchId: string,
input: ScheduleMatchInput
) {
const { courtId, date, time } = input;
const match = await prisma.tournamentMatch.findUnique({
where: { id: matchId },
include: { tournament: true },
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
if (match.status === TournamentMatchStatus.FINISHED) {
throw new ApiError('No se puede reprogramar un partido finalizado', 400);
}
if (match.status === TournamentMatchStatus.CANCELLED) {
throw new ApiError('No se puede programar un partido cancelado', 400);
}
// Verificar que la cancha exista
const court = await prisma.court.findUnique({
where: { id: courtId },
});
if (!court) {
throw new ApiError('Cancha no encontrada', 404);
}
// Verificar que la cancha esté asignada al torneo
const courtIds = JSON.parse(match.tournament.courtIds) as string[];
if (!courtIds.includes(courtId)) {
throw new ApiError('La cancha no está asignada a este torneo', 400);
}
// Verificar disponibilidad de la cancha
const conflictingMatch = await prisma.tournamentMatch.findFirst({
where: {
courtId,
scheduledDate: date,
scheduledTime: time,
status: { not: TournamentMatchStatus.CANCELLED },
id: { not: matchId },
},
});
if (conflictingMatch) {
throw new ApiError('La cancha no está disponible en esa fecha y hora', 409);
}
const updatedMatch = await prisma.tournamentMatch.update({
where: { id: matchId },
data: {
courtId,
scheduledDate: date,
scheduledTime: time,
status: TournamentMatchStatus.SCHEDULED,
},
include: {
court: true,
team1Player1: { include: { user: true } },
team2Player1: { include: { user: true } },
},
});
logger.info(`Partido ${matchId} programado para ${date.toISOString()} en cancha ${courtId}`);
return updatedMatch;
}
/**
* Registrar resultado de un partido de torneo
*/
static async recordMatchResult(
matchId: string,
input: MatchResultInput
) {
const { team1Score, team2Score } = input;
const match = await prisma.tournamentMatch.findUnique({
where: { id: matchId },
include: {
tournament: true,
nextMatch: true,
},
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
if (match.status === TournamentMatchStatus.FINISHED) {
throw new ApiError('El partido ya ha finalizado', 400);
}
if (match.status === TournamentMatchStatus.CANCELLED) {
throw new ApiError('El partido está cancelado', 400);
}
if (match.status === TournamentMatchStatus.BYE) {
throw new ApiError('No se puede registrar resultado en un bye', 400);
}
// Validar puntajes
if (team1Score < 0 || team2Score < 0) {
throw new ApiError('Los puntajes no pueden ser negativos', 400);
}
// Determinar ganador
let winner: string;
if (team1Score > team2Score) {
winner = 'TEAM1';
} else if (team2Score > team1Score) {
winner = 'TEAM2';
} else {
winner = 'DRAW';
}
// Actualizar partido
const updatedMatch = await prisma.tournamentMatch.update({
where: { id: matchId },
data: {
team1Score,
team2Score,
winner,
status: TournamentMatchStatus.FINISHED,
},
include: {
team1Player1: { include: { user: true } },
team2Player1: { include: { user: true } },
},
});
logger.info(`Resultado registrado para partido ${matchId}: ${team1Score}-${team2Score}`);
// Avanzar ganador si es eliminatoria
if (match.tournament.type === TournamentType.ELIMINATION && match.nextMatchId) {
await this.advanceWinner(match, winner);
}
return updatedMatch;
}
/**
* Avanzar ganador a siguiente ronda
*/
private static async advanceWinner(
match: any,
winner: string
) {
if (!match.nextMatchId) return;
const nextMatch = await prisma.tournamentMatch.findUnique({
where: { id: match.nextMatchId },
});
if (!nextMatch) return;
// Determinar si va a team1 o team2 del siguiente partido
// basado en la posición del partido actual
const isTeam1Slot = match.position % 2 === 0;
const winnerId =
winner === 'TEAM1'
? match.team1Player1Id
: winner === 'TEAM2'
? match.team2Player1Id
: null;
if (!winnerId) return;
const updateData = isTeam1Slot
? { team1Player1Id: winnerId }
: { team2Player1Id: winnerId };
await prisma.tournamentMatch.update({
where: { id: match.nextMatchId },
data: updateData,
});
logger.info(
`Ganador ${winnerId} avanzado a partido ${match.nextMatchId} (${
isTeam1Slot ? 'team1' : 'team2'
})`
);
}
}
export default TournamentDrawService;

View File

@@ -0,0 +1,690 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import logger from '../config/logger';
import { TournamentMatchStatus, TournamentType } from '../utils/constants';
export interface MatchFilters {
round?: number;
status?: string;
courtId?: string;
playerId?: string;
fromDate?: Date;
toDate?: Date;
}
export interface UpdateMatchInput {
courtId?: string;
scheduledDate?: Date;
scheduledTime?: string;
status?: string;
notes?: string;
}
export interface RecordResultInput {
team1Score: number;
team2Score: number;
}
export class TournamentMatchService {
/**
* Listar partidos de un torneo con filtros
*/
static async getMatches(tournamentId: string, filters: MatchFilters = {}) {
const tournament = await prisma.tournament.findUnique({
where: { id: tournamentId },
});
if (!tournament) {
throw new ApiError('Torneo no encontrado', 404);
}
const where: any = { tournamentId };
if (filters.round !== undefined) {
where.round = filters.round;
}
if (filters.status) {
where.status = filters.status;
}
if (filters.courtId) {
where.courtId = filters.courtId;
}
if (filters.playerId) {
where.OR = [
{ team1Player1Id: filters.playerId },
{ team1Player2Id: filters.playerId },
{ team2Player1Id: filters.playerId },
{ team2Player2Id: filters.playerId },
];
}
if (filters.fromDate || filters.toDate) {
where.scheduledDate = {};
if (filters.fromDate) where.scheduledDate.gte = filters.fromDate;
if (filters.toDate) where.scheduledDate.lte = filters.toDate;
}
const matches = await prisma.tournamentMatch.findMany({
where,
include: {
team1Player1: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
playerLevel: true,
},
},
},
},
team1Player2: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
playerLevel: true,
},
},
},
},
team2Player1: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
playerLevel: true,
},
},
},
},
team2Player2: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
playerLevel: true,
},
},
},
},
court: {
select: {
id: true,
name: true,
type: true,
},
},
nextMatch: {
select: {
id: true,
round: true,
},
},
},
orderBy: [
{ round: 'desc' },
{ matchNumber: 'asc' },
],
});
// Añadir información de confirmaciones
return matches.map(match => {
const confirmedBy = JSON.parse(match.confirmedBy) as string[];
return {
...match,
confirmations: confirmedBy.length,
isConfirmed: confirmedBy.length >= 2,
confirmedBy,
};
});
}
/**
* Obtener un partido por ID
*/
static async getMatchById(matchId: string) {
const match = await prisma.tournamentMatch.findUnique({
where: { id: matchId },
include: {
tournament: {
select: {
id: true,
name: true,
type: true,
status: true,
},
},
team1Player1: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
playerLevel: true,
},
},
},
},
team1Player2: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
playerLevel: true,
},
},
},
},
team2Player1: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
playerLevel: true,
},
},
},
},
team2Player2: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
playerLevel: true,
},
},
},
},
court: true,
nextMatch: {
select: {
id: true,
round: true,
matchNumber: true,
},
},
parentMatches: {
select: {
id: true,
round: true,
matchNumber: true,
winner: true,
},
},
},
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
const confirmedBy = JSON.parse(match.confirmedBy) as string[];
return {
...match,
confirmations: confirmedBy.length,
isConfirmed: confirmedBy.length >= 2,
confirmedBy,
};
}
/**
* Actualizar un partido
*/
static async updateMatch(matchId: string, data: UpdateMatchInput) {
const match = await prisma.tournamentMatch.findUnique({
where: { id: matchId },
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
if (match.status === TournamentMatchStatus.FINISHED) {
throw new ApiError('No se puede editar un partido finalizado', 400);
}
// Verificar cancha si se proporciona
if (data.courtId) {
const court = await prisma.court.findUnique({
where: { id: data.courtId },
});
if (!court) {
throw new ApiError('Cancha no encontrada', 404);
}
}
const updatedMatch = await prisma.tournamentMatch.update({
where: { id: matchId },
data: {
...(data.courtId && { courtId: data.courtId }),
...(data.scheduledDate && { scheduledDate: data.scheduledDate }),
...(data.scheduledTime && { scheduledTime: data.scheduledTime }),
...(data.status && { status: data.status }),
...(data.notes && {
metadata: JSON.stringify({
...JSON.parse(match.metadata || '{}'),
notes: data.notes,
}),
}),
},
include: {
court: true,
team1Player1: { include: { user: true } },
team2Player1: { include: { user: true } },
},
});
logger.info(`Partido ${matchId} actualizado`);
return updatedMatch;
}
/**
* Asignar cancha a un partido
*/
static async assignCourt(
matchId: string,
courtId: string,
date: Date,
time: string
) {
const match = await prisma.tournamentMatch.findUnique({
where: { id: matchId },
include: { tournament: true },
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
if (match.status === TournamentMatchStatus.FINISHED) {
throw new ApiError('No se puede reasignar cancha a un partido finalizado', 400);
}
// Verificar cancha
const court = await prisma.court.findUnique({
where: { id: courtId },
});
if (!court) {
throw new ApiError('Cancha no encontrada', 404);
}
// Verificar disponibilidad
const conflictingMatch = await prisma.tournamentMatch.findFirst({
where: {
courtId,
scheduledDate: date,
scheduledTime: time,
status: { not: TournamentMatchStatus.CANCELLED },
id: { not: matchId },
},
});
if (conflictingMatch) {
throw new ApiError('La cancha no está disponible en esa fecha y hora', 409);
}
const updatedMatch = await prisma.tournamentMatch.update({
where: { id: matchId },
data: {
courtId,
scheduledDate: date,
scheduledTime: time,
status: TournamentMatchStatus.SCHEDULED,
},
include: {
court: true,
team1Player1: { include: { user: true } },
team2Player1: { include: { user: true } },
},
});
logger.info(`Cancha asignada a partido ${matchId}: ${courtId}`);
return updatedMatch;
}
/**
* Registrar resultado de un partido con lógica de avance
*/
static async recordResult(
matchId: string,
input: RecordResultInput,
recordedBy: string
) {
const { team1Score, team2Score } = input;
const match = await prisma.tournamentMatch.findUnique({
where: { id: matchId },
include: {
tournament: true,
nextMatch: true,
team1Player1: true,
team2Player1: true,
},
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
if (match.status === TournamentMatchStatus.FINISHED) {
throw new ApiError('El partido ya ha finalizado', 400);
}
if (match.status === TournamentMatchStatus.CANCELLED) {
throw new ApiError('El partido está cancelado', 400);
}
if (match.status === TournamentMatchStatus.BYE) {
throw new ApiError('No se puede registrar resultado en un bye', 400);
}
// Validar que ambos equipos estén asignados
if (!match.team1Player1Id || !match.team2Player1Id) {
throw new ApiError('Ambos equipos deben estar asignados', 400);
}
// Validar puntajes
if (team1Score < 0 || team2Score < 0) {
throw new ApiError('Los puntajes no pueden ser negativos', 400);
}
// Determinar ganador
let winner: string;
if (team1Score > team2Score) {
winner = 'TEAM1';
} else if (team2Score > team1Score) {
winner = 'TEAM2';
} else {
winner = 'DRAW';
}
// Actualizar partido
const updatedMatch = await prisma.tournamentMatch.update({
where: { id: matchId },
data: {
team1Score,
team2Score,
winner,
status: TournamentMatchStatus.FINISHED,
confirmedBy: JSON.stringify([recordedBy]),
},
include: {
team1Player1: { include: { user: true } },
team2Player1: { include: { user: true } },
court: true,
},
});
logger.info(`Resultado registrado para partido ${matchId}: ${team1Score}-${team2Score}`);
// Avanzar ganador en eliminatoria
if (match.tournament.type === TournamentType.ELIMINATION && match.nextMatchId && winner !== 'DRAW') {
await this.advanceWinnerToNextRound(match, winner);
}
return {
...updatedMatch,
confirmations: 1,
isConfirmed: false,
confirmedBy: [recordedBy],
};
}
/**
* Avanzar ganador a siguiente ronda (para eliminatoria)
*/
private static async advanceWinnerToNextRound(
match: any,
winner: string
) {
if (!match.nextMatchId) return;
const winnerId = winner === 'TEAM1' ? match.team1Player1Id : match.team2Player1Id;
if (!winnerId) return;
// Determinar posición en el siguiente partido
const isTeam1Slot = match.position % 2 === 0;
const updateData = isTeam1Slot
? { team1Player1Id: winnerId }
: { team2Player1Id: winnerId };
await prisma.tournamentMatch.update({
where: { id: match.nextMatchId },
data: updateData,
});
logger.info(
`Ganador ${winnerId} avanzado a partido ${match.nextMatchId}`
);
}
/**
* Confirmar resultado de un partido (requiere 2 confirmaciones)
*/
static async confirmResult(matchId: string, userId: string) {
const match = await prisma.tournamentMatch.findUnique({
where: { id: matchId },
include: {
team1Player1: true,
team1Player2: true,
team2Player1: true,
team2Player2: true,
},
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
if (match.status !== TournamentMatchStatus.FINISHED) {
throw new ApiError('El partido no ha finalizado', 400);
}
// Verificar que el usuario sea participante del partido
const playerIds = [
match.team1Player1?.userId,
match.team1Player2?.userId,
match.team2Player1?.userId,
match.team2Player2?.userId,
].filter(Boolean);
if (!playerIds.includes(userId)) {
throw new ApiError('Solo los participantes pueden confirmar el resultado', 403);
}
const confirmedBy = JSON.parse(match.confirmedBy) as string[];
// Verificar que no haya confirmado ya
if (confirmedBy.includes(userId)) {
throw new ApiError('Ya has confirmado este resultado', 400);
}
// Añadir confirmación
confirmedBy.push(userId);
const updatedMatch = await prisma.tournamentMatch.update({
where: { id: matchId },
data: {
confirmedBy: JSON.stringify(confirmedBy),
},
include: {
team1Player1: { include: { user: true } },
team2Player1: { include: { user: true } },
},
});
const isNowConfirmed = confirmedBy.length >= 2;
logger.info(
`Partido ${matchId} confirmado por ${userId}. Confirmaciones: ${confirmedBy.length}`
);
// Si está confirmado, actualizar estadísticas
if (isNowConfirmed) {
await this.updateStatsAfterMatch(match);
}
return {
...updatedMatch,
confirmations: confirmedBy.length,
isConfirmed: isNowConfirmed,
confirmedBy,
};
}
/**
* Actualizar estadísticas después de un partido confirmado
*/
private static async updateStatsAfterMatch(match: any) {
try {
// Actualizar estadísticas de participantes si es necesario
// Esto puede incluir estadísticas específicas del torneo
logger.info(`Estadísticas actualizadas para partido ${match.id}`);
} catch (error) {
logger.error(`Error actualizando estadísticas: ${error}`);
}
}
/**
* Iniciar partido (cambiar estado a IN_PROGRESS)
*/
static async startMatch(matchId: string) {
const match = await prisma.tournamentMatch.findUnique({
where: { id: matchId },
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
if (match.status !== TournamentMatchStatus.SCHEDULED) {
throw new ApiError('El partido debe estar programado para iniciar', 400);
}
const updatedMatch = await prisma.tournamentMatch.update({
where: { id: matchId },
data: { status: TournamentMatchStatus.IN_PROGRESS },
include: {
team1Player1: { include: { user: true } },
team2Player1: { include: { user: true } },
court: true,
},
});
logger.info(`Partido ${matchId} iniciado`);
return updatedMatch;
}
/**
* Cancelar partido
*/
static async cancelMatch(matchId: string, reason?: string) {
const match = await prisma.tournamentMatch.findUnique({
where: { id: matchId },
});
if (!match) {
throw new ApiError('Partido no encontrado', 404);
}
if (match.status === TournamentMatchStatus.FINISHED) {
throw new ApiError('No se puede cancelar un partido finalizado', 400);
}
const updatedMatch = await prisma.tournamentMatch.update({
where: { id: matchId },
data: {
status: TournamentMatchStatus.CANCELLED,
metadata: JSON.stringify({
...JSON.parse(match.metadata || '{}'),
cancellationReason: reason || 'Cancelado por administrador',
cancelledAt: new Date().toISOString(),
}),
},
include: {
team1Player1: { include: { user: true } },
team2Player1: { include: { user: true } },
},
});
logger.info(`Partido ${matchId} cancelado`);
return updatedMatch;
}
/**
* Obtener partidos de un participante específico
*/
static async getParticipantMatches(tournamentId: string, participantId: string) {
const matches = await prisma.tournamentMatch.findMany({
where: {
tournamentId,
OR: [
{ team1Player1Id: participantId },
{ team1Player2Id: participantId },
{ team2Player1Id: participantId },
{ team2Player2Id: participantId },
],
},
include: {
team1Player1: { include: { user: true } },
team2Player1: { include: { user: true } },
court: true,
},
orderBy: [{ round: 'desc' }, { matchNumber: 'asc' }],
});
return matches.map(match => {
const confirmedBy = JSON.parse(match.confirmedBy) as string[];
const isUserTeam1 =
match.team1Player1Id === participantId || match.team1Player2Id === participantId;
const isWinner =
(match.winner === 'TEAM1' && isUserTeam1) ||
(match.winner === 'TEAM2' && !isUserTeam1);
return {
...match,
confirmations: confirmedBy.length,
isConfirmed: confirmedBy.length >= 2,
confirmedBy,
isUserTeam1,
isWinner,
};
});
}
}
export default TournamentMatchService;

View File

@@ -89,3 +89,135 @@ export const GroupRole = {
} as const;
export type GroupRoleType = typeof GroupRole[keyof typeof GroupRole];
// Tipos de torneo
export const TournamentType = {
ELIMINATION: 'ELIMINATION',
ROUND_ROBIN: 'ROUND_ROBIN',
SWISS: 'SWISS',
CONSOLATION: 'CONSOLATION',
} as const;
export type TournamentTypeType = typeof TournamentType[keyof typeof TournamentType];
// Estados de torneo
export const TournamentStatus = {
DRAFT: 'DRAFT',
OPEN: 'OPEN',
CLOSED: 'CLOSED',
IN_PROGRESS: 'IN_PROGRESS',
FINISHED: 'FINISHED',
CANCELLED: 'CANCELLED',
} as const;
export type TournamentStatusType = typeof TournamentStatus[keyof typeof TournamentStatus];
// Categorías de torneo
export const TournamentCategory = {
MEN: 'MEN',
WOMEN: 'WOMEN',
MIXED: 'MIXED',
} as const;
export type TournamentCategoryType = typeof TournamentCategory[keyof typeof TournamentCategory];
// Estados de partido de torneo
export const TournamentMatchStatus = {
PENDING: 'PENDING',
SCHEDULED: 'SCHEDULED',
IN_PROGRESS: 'IN_PROGRESS',
FINISHED: 'FINISHED',
CANCELLED: 'CANCELLED',
BYE: 'BYE',
} as const;
export type TournamentMatchStatusType = typeof TournamentMatchStatus[keyof typeof TournamentMatchStatus];
// Estados de participante en torneo
export const ParticipantStatus = {
REGISTERED: 'REGISTERED',
CONFIRMED: 'CONFIRMED',
WITHDRAWN: 'WITHDRAWN',
} as const;
export type ParticipantStatusType = typeof ParticipantStatus[keyof typeof ParticipantStatus];
// Estado de pago
export const PaymentStatus = {
PENDING: 'PENDING',
PAID: 'PAID',
REFUNDED: 'REFUNDED',
} as const;
export type PaymentStatusType = typeof PaymentStatus[keyof typeof PaymentStatus];
// ============================================
// Constantes de Liga (Fase 3.3)
// ============================================
// Tipos de liga
export const LeagueType = {
TEAM_LEAGUE: 'TEAM_LEAGUE',
INDIVIDUAL_LEAGUE: 'INDIVIDUAL_LEAGUE',
} as const;
export type LeagueTypeType = typeof LeagueType[keyof typeof LeagueType];
// Formatos de liga
export const LeagueFormat = {
SINGLE_ROUND_ROBIN: 'SINGLE_ROUND_ROBIN', // Todos vs todos (ida)
DOUBLE_ROUND_ROBIN: 'DOUBLE_ROUND_ROBIN', // Todos vs todos (ida y vuelta)
SINGLE_MATCHDAY: 'SINGLE_MATCHDAY', // Una jornada por equipo
DOUBLE_MATCHDAY: 'DOUBLE_MATCHDAY', // Dos jornadas por equipo
} as const;
export type LeagueFormatType = typeof LeagueFormat[keyof typeof LeagueFormat];
// Estados de liga
export const LeagueStatus = {
DRAFT: 'DRAFT', // En creación, se pueden agregar/quitar equipos
ACTIVE: 'ACTIVE', // En curso
FINISHED: 'FINISHED', // Finalizada
CANCELLED: 'CANCELLED', // Cancelada
} as const;
export type LeagueStatusType = typeof LeagueStatus[keyof typeof LeagueStatus];
// Estados de partido de liga
export const LeagueMatchStatus = {
SCHEDULED: 'SCHEDULED', // Programado
CONFIRMED: 'CONFIRMED', // Confirmado
IN_PROGRESS: 'IN_PROGRESS', // En juego
COMPLETED: 'COMPLETED', // Completado
CANCELLED: 'CANCELLED', // Cancelado
POSTPONED: 'POSTPONED', // Aplazado
WALKOVER: 'WALKOVER', // Walkover
} as const;
export type LeagueMatchStatusType = typeof LeagueMatchStatus[keyof typeof LeagueMatchStatus];
// Criterios de desempate
export const TiebreakerCriteria = {
POINTS: 'POINTS', // Puntos
SETS_DIFFERENCE: 'SETS_DIFFERENCE', // Diferencia de sets
GAMES_DIFFERENCE: 'GAMES_DIFFERENCE', // Diferencia de games
DIRECT_ENCOUNTER: 'DIRECT_ENCOUNTER', // Enfrentamiento directo
WINS: 'WINS', // Victorias
} as const;
export type TiebreakerCriteriaType = typeof TiebreakerCriteria[keyof typeof TiebreakerCriteria];
// Orden de aplicación de desempates por defecto
export const DEFAULT_TIEBREAKER_ORDER = [
TiebreakerCriteria.POINTS,
TiebreakerCriteria.SETS_DIFFERENCE,
TiebreakerCriteria.GAMES_DIFFERENCE,
TiebreakerCriteria.DIRECT_ENCOUNTER,
];
// Puntos por resultado
export const LeaguePoints = {
WIN: 3, // Victoria
DRAW: 1, // Empate
LOSS: 0, // Derrota
} as const;

View File

@@ -0,0 +1,284 @@
/**
* Utilidades para generación de cuadros de torneo
*/
/**
* Mezcla un array aleatoriamente (algoritmo Fisher-Yates)
*/
export function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
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]];
}
return shuffled;
}
/**
* Calcula el número de rondas necesarias para una eliminatoria
*/
export function calculateRounds(participantCount: number): number {
if (participantCount <= 1) return 0;
return Math.ceil(Math.log2(participantCount));
}
/**
* Ordena participantes por seed (cabeza de serie)
* Los seeds más bajos (1, 2, 3...) se distribuyen estratégicamente
*/
export function seedParticipants<T extends { seed?: number | null; id: string }>(
participants: T[]
): T[] {
// Separar seeds y no seeds
const withSeed = participants.filter(p => p.seed !== null && p.seed !== undefined);
const withoutSeed = participants.filter(p => p.seed === null || p.seed === undefined);
// Ordenar seeds de menor a mayor
withSeed.sort((a, b) => (a.seed as number) - (b.seed as number));
// Mezclar no seeds
const shuffledNoSeed = shuffleArray(withoutSeed);
// Combinar: seeds primero, luego no seeds mezclados
return [...withSeed, ...shuffledNoSeed];
}
/**
* Genera las posiciones en el cuadro para una eliminatoria
* Distribuye los seeds estratégicamente
*/
export function generateBracketPositions(count: number): number[] {
const positions: number[] = [];
if (count <= 0) return positions;
// Encontrar la siguiente potencia de 2
const bracketSize = nextPowerOfTwo(count);
// Crear array de posiciones
for (let i = 0; i < bracketSize; i++) {
positions.push(i);
}
// Reordenar usando el algoritmo de distribución de seeds
return distributeSeeds(positions);
}
/**
* Distribuye los seeds en el cuadro para evitar enfrentamientos tempranos
* entre favoritos
*/
function distributeSeeds(positions: number[]): number[] {
if (positions.length <= 2) return positions;
const result: number[] = new Array(positions.length);
const seeds = positions.map((_, i) => i + 1); // 1, 2, 3, 4...
// Algoritmo de distribución de seeds
// Seed 1 -> posición 0
// Seed 2 -> última posición
// Seeds 3-4 -> cuartos opuestos
// Seeds 5-8 -> octavos opuestos, etc.
const distribute = (start: number, end: number, seedStart: number, seedEnd: number) => {
if (start > end || seedStart > seedEnd) return;
const mid = Math.floor((start + end) / 2);
const seedMid = Math.floor((seedStart + seedEnd) / 2);
result[start] = seeds[seedStart - 1]; // Mejor seed del grupo
result[end] = seeds[seedEnd - 1]; // Peor seed del grupo
if (start < end - 1) {
distribute(start + 1, mid, seedStart + 1, seedMid);
distribute(mid + 1, end - 1, seedMid + 1, seedEnd - 1);
}
};
distribute(0, positions.length - 1, 1, positions.length);
return result.map(pos => pos - 1); // Convertir a índices 0-based
}
/**
* Verifica si un número es potencia de 2
*/
export function isPowerOfTwo(n: number): boolean {
if (n <= 0) return false;
return (n & (n - 1)) === 0;
}
/**
* Encuentra la siguiente potencia de 2 mayor o igual a n
*/
export function nextPowerOfTwo(n: number): number {
if (n <= 0) return 1;
if (isPowerOfTwo(n)) return n;
return Math.pow(2, Math.ceil(Math.log2(n)));
}
/**
* Calcula el número de byes necesarios para completar una potencia de 2
*/
export function calculateByes(participantCount: number): number {
const bracketSize = nextPowerOfTwo(participantCount);
return bracketSize - participantCount;
}
/**
* Genera emparejamientos para round robin (todos vs todos)
* Usa el algoritmo de circle method
*/
export function generateRoundRobinPairings<T>(participants: T[]): Array<[T, T]> {
const n = participants.length;
const pairings: Array<[T, T]> = [];
if (n < 2) return pairings;
// Si es impar, añadir un "descanso"
const players = [...participants];
if (players.length % 2 !== 0) {
players.push(null as any); // bye
}
const count = players.length;
const rounds = count - 1;
// Fijar el primer jugador, rotar el resto
for (let round = 0; round < rounds; round++) {
for (let i = 0; i < count / 2; i++) {
const player1 = players[i];
const player2 = players[count - 1 - i];
if (player1 !== null && player2 !== null) {
pairings.push([player1, player2]);
}
}
// Rotar (excepto el primero)
const last = players.pop()!;
players.splice(1, 0, last);
}
return pairings;
}
/**
* Genera emparejamientos para sistema suizo
* Empareja jugadores con puntajes similares
*/
export interface SwissPlayer {
id: string;
points: number;
playedAgainst: string[]; // IDs de oponentes ya enfrentados
}
export function generateSwissPairings(players: SwissPlayer[]): Array<[string, string]> {
const pairings: Array<[string, string]> = [];
const unpaired = [...players].sort((a, b) => b.points - a.points);
const paired = new Set<string>();
while (unpaired.length >= 2) {
const player1 = unpaired.shift()!;
if (paired.has(player1.id)) continue;
// Buscar oponente con puntaje similar que no haya jugado contra
let opponentIndex = -1;
for (let i = 0; i < unpaired.length; i++) {
const candidate = unpaired[i];
if (paired.has(candidate.id)) continue;
// Verificar que no hayan jugado antes
if (!player1.playedAgainst.includes(candidate.id)) {
opponentIndex = i;
break;
}
}
// Si no hay oponente nuevo, tomar el primero disponible
if (opponentIndex === -1) {
for (let i = 0; i < unpaired.length; i++) {
if (!paired.has(unpaired[i].id)) {
opponentIndex = i;
break;
}
}
}
if (opponentIndex !== -1) {
const player2 = unpaired.splice(opponentIndex, 1)[0];
pairings.push([player1.id, player2.id]);
paired.add(player1.id);
paired.add(player2.id);
}
}
return pairings;
}
/**
* Calcula puntos para sistema suizo
*/
export function calculateSwissPoints(wins: number, draws: number = 0): number {
return wins * 3 + draws * 1;
}
/**
* Determina la siguiente ronda para un cuadro de eliminatoria
*/
export function getNextRoundMatch(currentRound: number, currentPosition: number): {
round: number;
position: number;
} {
return {
round: currentRound - 1, // 1 es la final, 2 semifinal, etc.
position: Math.floor(currentPosition / 2),
};
}
/**
* Calcula el número total de partidos en un cuadro de eliminatoria
*/
export function calculateTotalMatchesElimination(participantCount: number): number {
const bracketSize = nextPowerOfTwo(participantCount);
return bracketSize - 1;
}
/**
* Calcula el número total de partidos en round robin
*/
export function calculateTotalMatchesRoundRobin(participantCount: number): number {
return (participantCount * (participantCount - 1)) / 2;
}
/**
* Valida si un cuadro puede generarse
*/
export function validateDrawGeneration(
participantCount: number,
type: string
): { valid: boolean; error?: string } {
if (participantCount < 2) {
return { valid: false, error: 'Se necesitan al menos 2 participantes' };
}
if (type === 'ELIMINATION' || type === 'CONSOLATION') {
// Eliminatoria puede generarse con cualquier número (se usan byes)
return { valid: true };
}
if (type === 'ROUND_ROBIN') {
// Round robin puede generarse con cualquier número >= 2
return { valid: true };
}
if (type === 'SWISS') {
// Suizo necesita al menos 2 jugadores
return { valid: true };
}
return { valid: false, error: 'Tipo de torneo no soportado' };
}

View File

@@ -0,0 +1,86 @@
import { z } from 'zod';
import { LeagueStatus, LeagueFormat, LeagueType, LeagueMatchStatus } from '../utils/constants';
// ============================================
// Esquemas de Liga
// ============================================
// Crear liga
export const createLeagueSchema = z.object({
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres'),
description: z.string().max(1000, 'La descripción no puede exceder 1000 caracteres').optional(),
format: z.enum([LeagueFormat.SINGLE_ROUND_ROBIN, LeagueFormat.DOUBLE_ROUND_ROBIN], {
errorMap: () => ({ message: 'Formato inválido' }),
}).optional(),
matchesPerMatchday: z.number().int().min(1).max(10).optional(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(),
});
// Actualizar liga
export const updateLeagueSchema = z.object({
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres').optional(),
description: z.string().max(1000, 'La descripción no puede exceder 1000 caracteres').optional(),
format: z.enum([LeagueFormat.SINGLE_ROUND_ROBIN, LeagueFormat.DOUBLE_ROUND_ROBIN], {
errorMap: () => ({ message: 'Formato inválido' }),
}).optional(),
matchesPerMatchday: z.number().int().min(1).max(10).optional(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
});
// ============================================
// Esquemas de Equipos de Liga
// ============================================
// Crear equipo
export const createLeagueTeamSchema = z.object({
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
description: z.string().max(500, 'La descripción no puede exceder 500 caracteres').optional(),
});
// Actualizar equipo
export const updateLeagueTeamSchema = z.object({
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(),
description: z.string().max(500, 'La descripción no puede exceder 500 caracteres').optional(),
});
// Agregar miembro
export const addLeagueTeamMemberSchema = z.object({
userId: z.string().uuid('ID de usuario inválido'),
});
// ============================================
// Esquemas de Calendario
// ============================================
// Actualizar partido
export const updateLeagueMatchSchema = z.object({
scheduledDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
scheduledTime: z.string().regex(/^\d{2}:\d{2}$/, 'Hora debe estar en formato HH:mm').optional().nullable(),
courtId: z.string().uuid('ID de cancha inválido').optional().nullable(),
});
// Registrar resultado de partido
export const updateLeagueMatchResultSchema = z.object({
team1Score: z.number().int().min(0).max(9),
team2Score: z.number().int().min(0).max(9),
setDetails: z.array(z.object({
team1Games: z.number().int().min(0).max(7),
team2Games: z.number().int().min(0).max(7),
})).optional(),
winner: z.enum(['TEAM1', 'TEAM2', 'DRAW']),
notes: z.string().max(500).optional(),
});
// ============================================
// Tipos inferidos
// ============================================
export type CreateLeagueInput = z.infer<typeof createLeagueSchema>;
export type UpdateLeagueInput = z.infer<typeof updateLeagueSchema>;
export type CreateLeagueTeamInput = z.infer<typeof createLeagueTeamSchema>;
export type UpdateLeagueTeamInput = z.infer<typeof updateLeagueTeamSchema>;
export type AddLeagueTeamMemberInput = z.infer<typeof addLeagueTeamMemberSchema>;
export type UpdateLeagueMatchInput = z.infer<typeof updateLeagueMatchSchema>;
export type UpdateLeagueMatchResultInput = z.infer<typeof updateLeagueMatchResultSchema>;

View File

@@ -0,0 +1,104 @@
import { z } from 'zod';
import {
TournamentType,
TournamentCategory,
TournamentStatus,
PlayerLevel,
} from '../utils/constants';
// Esquema para crear torneo
export const createTournamentSchema = z.object({
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres'),
description: z.string().optional(),
type: z.enum([
TournamentType.ELIMINATION,
TournamentType.ROUND_ROBIN,
TournamentType.SWISS,
TournamentType.CONSOLATION,
], {
errorMap: () => ({ message: 'Tipo de torneo inválido' }),
}),
category: z.enum([
TournamentCategory.MEN,
TournamentCategory.WOMEN,
TournamentCategory.MIXED,
], {
errorMap: () => ({ message: 'Categoría inválida' }),
}),
allowedLevels: z
.array(
z.enum([
PlayerLevel.BEGINNER,
PlayerLevel.ELEMENTARY,
PlayerLevel.INTERMEDIATE,
PlayerLevel.ADVANCED,
PlayerLevel.COMPETITION,
PlayerLevel.PROFESSIONAL,
])
)
.min(1, 'Debe especificar al menos un nivel permitido'),
maxParticipants: z.number().int().min(2, 'Mínimo 2 participantes'),
registrationStartDate: z.string().datetime('Fecha inválida'),
registrationEndDate: z.string().datetime('Fecha inválida'),
startDate: z.string().datetime('Fecha inválida'),
endDate: z.string().datetime('Fecha inválida'),
courtIds: z.array(z.string().uuid('ID de cancha inválido')),
price: z.number().int().min(0, 'El precio no puede ser negativo').default(0),
});
// Esquema para actualizar torneo
export const updateTournamentSchema = z.object({
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres').optional(),
description: z.string().optional(),
type: z
.enum([
TournamentType.ELIMINATION,
TournamentType.ROUND_ROBIN,
TournamentType.SWISS,
TournamentType.CONSOLATION,
])
.optional(),
category: z
.enum([
TournamentCategory.MEN,
TournamentCategory.WOMEN,
TournamentCategory.MIXED,
])
.optional(),
allowedLevels: z
.array(
z.enum([
PlayerLevel.BEGINNER,
PlayerLevel.ELEMENTARY,
PlayerLevel.INTERMEDIATE,
PlayerLevel.ADVANCED,
PlayerLevel.COMPETITION,
PlayerLevel.PROFESSIONAL,
])
)
.optional(),
maxParticipants: z.number().int().min(2).optional(),
registrationStartDate: z.string().datetime('Fecha inválida').optional(),
registrationEndDate: z.string().datetime('Fecha inválida').optional(),
startDate: z.string().datetime('Fecha inválida').optional(),
endDate: z.string().datetime('Fecha inválida').optional(),
courtIds: z.array(z.string().uuid('ID de cancha inválido')).optional(),
price: z.number().int().min(0).optional(),
status: z
.enum([
TournamentStatus.DRAFT,
TournamentStatus.OPEN,
TournamentStatus.CLOSED,
TournamentStatus.IN_PROGRESS,
TournamentStatus.FINISHED,
TournamentStatus.CANCELLED,
])
.optional(),
});
// Esquema para registro de participante (solo valida que el cuerpo esté vacío o tenga datos opcionales)
export const registerSchema = z.object({}).optional();
// Tipos inferidos
export type CreateTournamentInput = z.infer<typeof createTournamentSchema>;
export type UpdateTournamentInput = z.infer<typeof updateTournamentSchema>;