✅ 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:
291
backend/src/controllers/league.controller.ts
Normal file
291
backend/src/controllers/league.controller.ts
Normal 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;
|
||||
156
backend/src/controllers/leagueMatch.controller.ts
Normal file
156
backend/src/controllers/leagueMatch.controller.ts
Normal 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;
|
||||
155
backend/src/controllers/leagueSchedule.controller.ts
Normal file
155
backend/src/controllers/leagueSchedule.controller.ts
Normal 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;
|
||||
139
backend/src/controllers/leagueStanding.controller.ts
Normal file
139
backend/src/controllers/leagueStanding.controller.ts
Normal 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;
|
||||
269
backend/src/controllers/leagueTeam.controller.ts
Normal file
269
backend/src/controllers/leagueTeam.controller.ts
Normal 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;
|
||||
298
backend/src/controllers/tournament.controller.ts
Normal file
298
backend/src/controllers/tournament.controller.ts
Normal 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;
|
||||
149
backend/src/controllers/tournamentDraw.controller.ts
Normal file
149
backend/src/controllers/tournamentDraw.controller.ts
Normal 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;
|
||||
317
backend/src/controllers/tournamentMatch.controller.ts
Normal file
317
backend/src/controllers/tournamentMatch.controller.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
78
backend/src/routes/league.routes.ts
Normal file
78
backend/src/routes/league.routes.ts
Normal 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;
|
||||
88
backend/src/routes/leagueMatch.routes.ts
Normal file
88
backend/src/routes/leagueMatch.routes.ts
Normal 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;
|
||||
86
backend/src/routes/leagueSchedule.routes.ts
Normal file
86
backend/src/routes/leagueSchedule.routes.ts
Normal 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;
|
||||
69
backend/src/routes/leagueStanding.routes.ts
Normal file
69
backend/src/routes/leagueStanding.routes.ts
Normal 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;
|
||||
102
backend/src/routes/leagueTeam.routes.ts
Normal file
102
backend/src/routes/leagueTeam.routes.ts
Normal 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;
|
||||
67
backend/src/routes/tournament.routes.ts
Normal file
67
backend/src/routes/tournament.routes.ts
Normal 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;
|
||||
68
backend/src/routes/tournamentDraw.routes.ts
Normal file
68
backend/src/routes/tournamentDraw.routes.ts
Normal 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;
|
||||
146
backend/src/routes/tournamentMatch.routes.ts
Normal file
146
backend/src/routes/tournamentMatch.routes.ts
Normal 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;
|
||||
502
backend/src/services/league.service.ts
Normal file
502
backend/src/services/league.service.ts
Normal 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;
|
||||
442
backend/src/services/leagueMatch.service.ts
Normal file
442
backend/src/services/leagueMatch.service.ts
Normal 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;
|
||||
553
backend/src/services/leagueSchedule.service.ts
Normal file
553
backend/src/services/leagueSchedule.service.ts
Normal 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;
|
||||
533
backend/src/services/leagueStanding.service.ts
Normal file
533
backend/src/services/leagueStanding.service.ts
Normal 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;
|
||||
641
backend/src/services/leagueTeam.service.ts
Normal file
641
backend/src/services/leagueTeam.service.ts
Normal 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;
|
||||
799
backend/src/services/tournament.service.ts
Normal file
799
backend/src/services/tournament.service.ts
Normal 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;
|
||||
788
backend/src/services/tournamentDraw.service.ts
Normal file
788
backend/src/services/tournamentDraw.service.ts
Normal 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;
|
||||
690
backend/src/services/tournamentMatch.service.ts
Normal file
690
backend/src/services/tournamentMatch.service.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
284
backend/src/utils/tournamentDraw.ts
Normal file
284
backend/src/utils/tournamentDraw.ts
Normal 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' };
|
||||
}
|
||||
86
backend/src/validators/league.validator.ts
Normal file
86
backend/src/validators/league.validator.ts
Normal 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>;
|
||||
104
backend/src/validators/tournament.validator.ts
Normal file
104
backend/src/validators/tournament.validator.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user