FASE 3 COMPLETADA: Torneos y Ligas

Implementados 3 módulos con agent swarm:

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

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

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

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

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

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

Binary file not shown.

View File

@@ -0,0 +1,258 @@
-- CreateTable
CREATE TABLE "tournaments" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL,
"category" TEXT NOT NULL,
"allowedLevels" TEXT NOT NULL,
"maxParticipants" INTEGER NOT NULL,
"registrationStartDate" DATETIME NOT NULL,
"registrationEndDate" DATETIME NOT NULL,
"startDate" DATETIME NOT NULL,
"endDate" DATETIME NOT NULL,
"courtIds" TEXT NOT NULL,
"price" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"createdById" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "tournaments_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "tournament_participants" (
"id" TEXT NOT NULL PRIMARY KEY,
"tournamentId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"registrationDate" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"paymentStatus" TEXT NOT NULL DEFAULT 'PENDING',
"seed" INTEGER,
"status" TEXT NOT NULL DEFAULT 'REGISTERED',
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "tournament_participants_tournamentId_fkey" FOREIGN KEY ("tournamentId") REFERENCES "tournaments" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "tournament_participants_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "tournament_matches" (
"id" TEXT NOT NULL PRIMARY KEY,
"tournamentId" TEXT NOT NULL,
"round" INTEGER NOT NULL,
"matchNumber" INTEGER NOT NULL,
"position" INTEGER NOT NULL,
"team1Player1Id" TEXT,
"team1Player2Id" TEXT,
"team2Player1Id" TEXT,
"team2Player2Id" TEXT,
"courtId" TEXT,
"scheduledDate" DATETIME,
"scheduledTime" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"team1Score" INTEGER,
"team2Score" INTEGER,
"winner" TEXT,
"nextMatchId" TEXT,
"confirmedBy" TEXT NOT NULL DEFAULT '[]',
"metadata" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "tournament_matches_tournamentId_fkey" FOREIGN KEY ("tournamentId") REFERENCES "tournaments" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "tournament_matches_team1Player1Id_fkey" FOREIGN KEY ("team1Player1Id") REFERENCES "tournament_participants" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "tournament_matches_team1Player2Id_fkey" FOREIGN KEY ("team1Player2Id") REFERENCES "tournament_participants" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "tournament_matches_team2Player1Id_fkey" FOREIGN KEY ("team2Player1Id") REFERENCES "tournament_participants" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "tournament_matches_team2Player2Id_fkey" FOREIGN KEY ("team2Player2Id") REFERENCES "tournament_participants" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "tournament_matches_courtId_fkey" FOREIGN KEY ("courtId") REFERENCES "courts" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "tournament_matches_nextMatchId_fkey" FOREIGN KEY ("nextMatchId") REFERENCES "tournament_matches" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "leagues" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL DEFAULT 'TEAM_LEAGUE',
"format" TEXT NOT NULL DEFAULT 'DOUBLE_ROUND_ROBIN',
"matchesPerMatchday" INTEGER NOT NULL DEFAULT 2,
"startDate" DATETIME,
"endDate" DATETIME,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"createdById" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "leagues_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "league_teams" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"leagueId" TEXT NOT NULL,
"captainId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "league_teams_leagueId_fkey" FOREIGN KEY ("leagueId") REFERENCES "leagues" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "league_teams_captainId_fkey" FOREIGN KEY ("captainId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "league_team_members" (
"id" TEXT NOT NULL PRIMARY KEY,
"teamId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "league_team_members_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "league_teams" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "league_team_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "league_matches" (
"id" TEXT NOT NULL PRIMARY KEY,
"leagueId" TEXT NOT NULL,
"matchday" INTEGER NOT NULL,
"team1Id" TEXT NOT NULL,
"team2Id" TEXT NOT NULL,
"courtId" TEXT,
"scheduledDate" DATETIME,
"scheduledTime" TEXT,
"status" TEXT NOT NULL DEFAULT 'SCHEDULED',
"team1Score" INTEGER,
"team2Score" INTEGER,
"setDetails" TEXT,
"winner" TEXT,
"completedAt" DATETIME,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "league_matches_leagueId_fkey" FOREIGN KEY ("leagueId") REFERENCES "leagues" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "league_matches_team1Id_fkey" FOREIGN KEY ("team1Id") REFERENCES "league_teams" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "league_matches_team2Id_fkey" FOREIGN KEY ("team2Id") REFERENCES "league_teams" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "league_matches_courtId_fkey" FOREIGN KEY ("courtId") REFERENCES "courts" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "league_standings" (
"id" TEXT NOT NULL PRIMARY KEY,
"leagueId" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"matchesPlayed" INTEGER NOT NULL DEFAULT 0,
"matchesWon" INTEGER NOT NULL DEFAULT 0,
"matchesLost" INTEGER NOT NULL DEFAULT 0,
"matchesDrawn" INTEGER NOT NULL DEFAULT 0,
"setsFor" INTEGER NOT NULL DEFAULT 0,
"setsAgainst" INTEGER NOT NULL DEFAULT 0,
"gamesFor" INTEGER NOT NULL DEFAULT 0,
"gamesAgainst" INTEGER NOT NULL DEFAULT 0,
"points" INTEGER NOT NULL DEFAULT 0,
"position" INTEGER NOT NULL DEFAULT 0,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "league_standings_leagueId_fkey" FOREIGN KEY ("leagueId") REFERENCES "leagues" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "league_standings_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "league_teams" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "tournaments_status_idx" ON "tournaments"("status");
-- CreateIndex
CREATE INDEX "tournaments_createdById_idx" ON "tournaments"("createdById");
-- CreateIndex
CREATE INDEX "tournaments_startDate_idx" ON "tournaments"("startDate");
-- CreateIndex
CREATE INDEX "tournaments_registrationStartDate_idx" ON "tournaments"("registrationStartDate");
-- CreateIndex
CREATE INDEX "tournament_participants_tournamentId_idx" ON "tournament_participants"("tournamentId");
-- CreateIndex
CREATE INDEX "tournament_participants_userId_idx" ON "tournament_participants"("userId");
-- CreateIndex
CREATE INDEX "tournament_participants_paymentStatus_idx" ON "tournament_participants"("paymentStatus");
-- CreateIndex
CREATE INDEX "tournament_participants_status_idx" ON "tournament_participants"("status");
-- CreateIndex
CREATE UNIQUE INDEX "tournament_participants_tournamentId_userId_key" ON "tournament_participants"("tournamentId", "userId");
-- CreateIndex
CREATE INDEX "tournament_matches_tournamentId_idx" ON "tournament_matches"("tournamentId");
-- CreateIndex
CREATE INDEX "tournament_matches_round_idx" ON "tournament_matches"("round");
-- CreateIndex
CREATE INDEX "tournament_matches_status_idx" ON "tournament_matches"("status");
-- CreateIndex
CREATE INDEX "tournament_matches_courtId_idx" ON "tournament_matches"("courtId");
-- CreateIndex
CREATE INDEX "tournament_matches_nextMatchId_idx" ON "tournament_matches"("nextMatchId");
-- CreateIndex
CREATE INDEX "tournament_matches_tournamentId_round_idx" ON "tournament_matches"("tournamentId", "round");
-- CreateIndex
CREATE INDEX "leagues_status_idx" ON "leagues"("status");
-- CreateIndex
CREATE INDEX "leagues_createdById_idx" ON "leagues"("createdById");
-- CreateIndex
CREATE INDEX "leagues_startDate_idx" ON "leagues"("startDate");
-- CreateIndex
CREATE INDEX "league_teams_leagueId_idx" ON "league_teams"("leagueId");
-- CreateIndex
CREATE INDEX "league_teams_captainId_idx" ON "league_teams"("captainId");
-- CreateIndex
CREATE UNIQUE INDEX "league_teams_leagueId_name_key" ON "league_teams"("leagueId", "name");
-- CreateIndex
CREATE INDEX "league_team_members_teamId_idx" ON "league_team_members"("teamId");
-- CreateIndex
CREATE INDEX "league_team_members_userId_idx" ON "league_team_members"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "league_team_members_teamId_userId_key" ON "league_team_members"("teamId", "userId");
-- CreateIndex
CREATE INDEX "league_matches_leagueId_idx" ON "league_matches"("leagueId");
-- CreateIndex
CREATE INDEX "league_matches_matchday_idx" ON "league_matches"("matchday");
-- CreateIndex
CREATE INDEX "league_matches_team1Id_idx" ON "league_matches"("team1Id");
-- CreateIndex
CREATE INDEX "league_matches_team2Id_idx" ON "league_matches"("team2Id");
-- CreateIndex
CREATE INDEX "league_matches_status_idx" ON "league_matches"("status");
-- CreateIndex
CREATE INDEX "league_matches_scheduledDate_idx" ON "league_matches"("scheduledDate");
-- CreateIndex
CREATE UNIQUE INDEX "league_standings_teamId_key" ON "league_standings"("teamId");
-- CreateIndex
CREATE INDEX "league_standings_leagueId_idx" ON "league_standings"("leagueId");
-- CreateIndex
CREATE INDEX "league_standings_position_idx" ON "league_standings"("position");
-- CreateIndex
CREATE INDEX "league_standings_points_idx" ON "league_standings"("points");
-- CreateIndex
CREATE UNIQUE INDEX "league_standings_leagueId_teamId_key" ON "league_standings"("leagueId", "teamId");

View File

@@ -64,9 +64,18 @@ model User {
groupsCreated Group[] @relation("GroupsCreated") groupsCreated Group[] @relation("GroupsCreated")
groupMembers GroupMember[] @relation("GroupMemberships") groupMembers GroupMember[] @relation("GroupMemberships")
// Ligas
leaguesCreated League[] @relation("LeaguesCreated") // Ligas creadas por el usuario
teamCaptain LeagueTeam[] @relation("TeamCaptain") // Equipos donde es capitán
leagueTeamMembers LeagueTeamMember[] // Membresías de equipo en liga
// Reservas recurrentes // Reservas recurrentes
recurringBookings RecurringBooking[] recurringBookings RecurringBooking[]
// Torneos
tournamentsCreated Tournament[] @relation("TournamentsCreated")
tournamentParticipations TournamentParticipant[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -121,6 +130,8 @@ model Court {
bookings Booking[] bookings Booking[]
schedules CourtSchedule[] schedules CourtSchedule[]
recurringBookings RecurringBooking[] recurringBookings RecurringBooking[]
leagueMatches LeagueMatch[]
tournamentMatches TournamentMatch[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -381,3 +392,368 @@ model UserStats {
@@index([points]) @@index([points])
@@map("user_stats") @@map("user_stats")
} }
// Modelo de Torneo
model Tournament {
id String @id @default(uuid())
name String
description String?
// Tipo de torneo
type String // ELIMINATION, ROUND_ROBIN, SWISS, CONSOLATION
// Categoría
category String // MEN, WOMEN, MIXED
// Niveles permitidos (almacenados como JSON string)
allowedLevels String
// Capacidad
maxParticipants Int
// Fechas importantes
registrationStartDate DateTime
registrationEndDate DateTime
startDate DateTime
endDate DateTime
// Canchas asignadas (almacenadas como JSON string de IDs)
courtIds String
// Precio de inscripción (en centavos)
price Int @default(0)
// Estado del torneo
status String @default("DRAFT") // DRAFT, OPEN, CLOSED, IN_PROGRESS, FINISHED, CANCELLED
// Creador (admin)
createdBy User @relation("TournamentsCreated", fields: [createdById], references: [id])
createdById String
// Relaciones
participants TournamentParticipant[] @relation("TournamentParticipants")
matches TournamentMatch[]
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([createdById])
@@index([startDate])
@@index([registrationStartDate])
@@map("tournaments")
}
// Modelo de Participante de Torneo
model TournamentParticipant {
id String @id @default(uuid())
// Torneo
tournament Tournament @relation("TournamentParticipants", fields: [tournamentId], references: [id], onDelete: Cascade)
tournamentId String
// Usuario (jugador individual)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
// Fecha de inscripción
registrationDate DateTime @default(now())
// Estado del pago
paymentStatus String @default("PENDING") // PENDING, PAID, REFUNDED
// Número de cabeza de serie (opcional)
seed Int?
// Estado de la inscripción
status String @default("REGISTERED") // REGISTERED, CONFIRMED, WITHDRAWN
// Timestamps
updatedAt DateTime @updatedAt
// Relaciones con partidos (como jugador individual)
team1Player1Matches TournamentMatch[] @relation("T1P1")
team1Player2Matches TournamentMatch[] @relation("T1P2")
team2Player1Matches TournamentMatch[] @relation("T2P1")
team2Player2Matches TournamentMatch[] @relation("T2P2")
@@unique([tournamentId, userId])
@@index([tournamentId])
@@index([userId])
@@index([paymentStatus])
@@index([status])
@@map("tournament_participants")
}
// Modelo de Partido de Torneo
model TournamentMatch {
id String @id @default(uuid())
// Torneo
tournament Tournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade)
tournamentId String
// Ronda (1=final, 2=semifinal, etc. o número de ronda en liga)
round Int
// Número de partido en la ronda
matchNumber Int
// Posición en el cuadro
position Int
// Equipo 1 (pareja o individual)
team1Player1 TournamentParticipant? @relation("T1P1", fields: [team1Player1Id], references: [id], onDelete: SetNull)
team1Player1Id String?
team1Player2 TournamentParticipant? @relation("T1P2", fields: [team1Player2Id], references: [id], onDelete: SetNull)
team1Player2Id String?
// Equipo 2 (pareja o individual)
team2Player1 TournamentParticipant? @relation("T2P1", fields: [team2Player1Id], references: [id], onDelete: SetNull)
team2Player1Id String?
team2Player2 TournamentParticipant? @relation("T2P2", fields: [team2Player2Id], references: [id], onDelete: SetNull)
team2Player2Id String?
// Cancha asignada
court Court? @relation(fields: [courtId], references: [id], onDelete: SetNull)
courtId String?
// Fecha y hora programada
scheduledDate DateTime?
scheduledTime String?
// Estado del partido
status String @default("PENDING") // PENDING, SCHEDULED, IN_PROGRESS, FINISHED, CANCELLED, BYE
// Resultado
team1Score Int?
team2Score Int?
winner String? // TEAM1, TEAM2, DRAW
// Avance en cuadro
nextMatch TournamentMatch? @relation("NextMatch", fields: [nextMatchId], references: [id], onDelete: SetNull)
nextMatchId String?
parentMatches TournamentMatch[] @relation("NextMatch")
// Confirmaciones del resultado (JSON array de userIds)
confirmedBy String @default("[]")
// Metadatos adicionales (para sistemas suizo, round robin, etc)
metadata String? // JSON string con datos adicionales
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tournamentId])
@@index([round])
@@index([status])
@@index([courtId])
@@index([nextMatchId])
@@index([tournamentId, round])
@@map("tournament_matches")
}
// ============================================
// Modelos de Liga por Equipos (Fase 3.3)
// ============================================
// Modelo de Liga
model League {
id String @id @default(uuid())
name String
description String?
// Tipo y formato
type String @default("TEAM_LEAGUE") // TEAM_LEAGUE, INDIVIDUAL_LEAGUE
format String @default("DOUBLE_ROUND_ROBIN") // SINGLE_ROUND_ROBIN, DOUBLE_ROUND_ROBIN, etc.
// Configuración de partidos por jornada
matchesPerMatchday Int @default(2) // Número de partidos entre dos equipos por jornada
// Fechas
startDate DateTime?
endDate DateTime?
// Estado
status String @default("DRAFT") // DRAFT, ACTIVE, FINISHED, CANCELLED
// Creador (admin)
createdBy User @relation("LeaguesCreated", fields: [createdById], references: [id])
createdById String
// Relaciones
teams LeagueTeam[]
matches LeagueMatch[]
standings LeagueStanding[]
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([createdById])
@@index([startDate])
@@map("leagues")
}
// Modelo de Equipo en Liga
model LeagueTeam {
id String @id @default(uuid())
name String
description String?
// Liga
league League @relation(fields: [leagueId], references: [id], onDelete: Cascade)
leagueId String
// Capitán del equipo
captain User @relation("TeamCaptain", fields: [captainId], references: [id])
captainId String
// Relaciones
members LeagueTeamMember[]
// Partidos como equipo 1 o 2
matchesAsTeam1 LeagueMatch[] @relation("Team1Matches")
matchesAsTeam2 LeagueMatch[] @relation("Team2Matches")
// Clasificación
standing LeagueStanding?
// Timestamps
createdAt DateTime @default(now())
@@unique([leagueId, name])
@@index([leagueId])
@@index([captainId])
@@map("league_teams")
}
// Modelo de Miembro de Equipo
model LeagueTeamMember {
id String @id @default(uuid())
// Equipo
team LeagueTeam @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId String
// Usuario
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
// Estado
isActive Boolean @default(true)
// Fecha de unión
joinedAt DateTime @default(now())
// Timestamps
updatedAt DateTime @updatedAt
@@unique([teamId, userId])
@@index([teamId])
@@index([userId])
@@map("league_team_members")
}
// Modelo de Partido de Liga
model LeagueMatch {
id String @id @default(uuid())
// Liga
league League @relation(fields: [leagueId], references: [id], onDelete: Cascade)
leagueId String
// Jornada
matchday Int // Número de jornada
// Equipos
team1 LeagueTeam @relation("Team1Matches", fields: [team1Id], references: [id])
team1Id String
team2 LeagueTeam @relation("Team2Matches", fields: [team2Id], references: [id])
team2Id String
// Cancha y horario (opcional)
court Court? @relation(fields: [courtId], references: [id], onDelete: SetNull)
courtId String?
scheduledDate DateTime? // Fecha programada
scheduledTime String? // Hora programada (formato HH:mm)
// Estado
status String @default("SCHEDULED") // SCHEDULED, CONFIRMED, IN_PROGRESS, COMPLETED, CANCELLED, POSTPONED, WALKOVER
// Resultado
team1Score Int? // Sets ganados por equipo 1
team2Score Int? // Sets ganados por equipo 2
// Detalle de sets (almacenado como JSON)
// Ej: [{team1Games: 6, team2Games: 4}, {team1Games: 6, team2Games: 2}]
setDetails String?
// Ganador
winner String? // TEAM1, TEAM2, DRAW
// Fecha de finalización
completedAt DateTime?
// Notas
notes String?
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([leagueId])
@@index([matchday])
@@index([team1Id])
@@index([team2Id])
@@index([status])
@@index([scheduledDate])
@@map("league_matches")
}
// Modelo de Clasificación
model LeagueStanding {
id String @id @default(uuid())
// Liga
league League @relation(fields: [leagueId], references: [id], onDelete: Cascade)
leagueId String
// Equipo
team LeagueTeam @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId String @unique
// Partidos
matchesPlayed Int @default(0)
matchesWon Int @default(0)
matchesLost Int @default(0)
matchesDrawn Int @default(0)
// Sets
setsFor Int @default(0)
setsAgainst Int @default(0)
// Games (opcional)
gamesFor Int @default(0)
gamesAgainst Int @default(0)
// Puntos
points Int @default(0)
// Posición actual
position Int @default(0)
// Timestamps
updatedAt DateTime @updatedAt
@@unique([leagueId, teamId])
@@index([leagueId])
@@index([position])
@@index([points])
@@map("league_standings")
}

View File

@@ -0,0 +1,96 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding Fase 3 - Torneos y Ligas...\n');
const admin = await prisma.user.findUnique({ where: { email: 'admin@padel.com' } });
if (!admin) {
console.log('❌ Admin no encontrado. Ejecuta seed.ts primero.');
return;
}
const courts = await prisma.court.findMany({ where: { isActive: true }, take: 2 });
const courtIds = JSON.stringify(courts.map(c => c.id));
// Crear torneo de eliminatoria
const tournament1 = await prisma.tournament.upsert({
where: { id: 'tour-1' },
update: {},
create: {
id: 'tour-1',
name: 'Torneo de Verano 2024',
description: 'Torneo eliminatorio mixto para todos los niveles',
type: 'ELIMINATION',
category: 'MIXED',
allowedLevels: JSON.stringify(['BEGINNER', 'ELEMENTARY', 'INTERMEDIATE']),
maxParticipants: 16,
registrationStartDate: new Date(),
registrationEndDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
startDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
endDate: new Date(Date.now() + 21 * 24 * 60 * 60 * 1000),
courtIds: courtIds,
price: 2000,
status: 'OPEN',
createdById: admin.id,
},
});
console.log(`✅ Torneo creado: ${tournament1.name}`);
// Crear torneo round robin
const tournament2 = await prisma.tournament.upsert({
where: { id: 'tour-2' },
update: {},
create: {
id: 'tour-2',
name: 'Liga de Invierno - Individual',
description: 'Liga todos contra todos',
type: 'ROUND_ROBIN',
category: 'MEN',
allowedLevels: JSON.stringify(['INTERMEDIATE', 'ADVANCED', 'COMPETITION']),
maxParticipants: 8,
registrationStartDate: new Date(),
registrationEndDate: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
startDate: new Date(Date.now() + 17 * 24 * 60 * 60 * 1000),
endDate: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000),
courtIds: courtIds,
price: 3000,
status: 'DRAFT',
createdById: admin.id,
},
});
console.log(`✅ Torneo creado: ${tournament2.name}`);
// Crear una liga por equipos
const league = await prisma.league.upsert({
where: { id: 'league-1' },
update: {},
create: {
id: 'league-1',
name: 'Liga de Club 2024',
description: 'Liga interna del club por equipos',
format: 'SINGLE_ROUND_ROBIN',
startDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
endDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
status: 'DRAFT',
createdById: admin.id,
},
});
console.log(`✅ Liga creada: ${league.name}`);
console.log('\n🎾 Fase 3 seed completado!');
console.log('\nDatos creados:');
console.log(` - 2 Torneos (Eliminatoria, Round Robin)`);
console.log(` - 1 Liga por equipos`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,16 @@ import bookingRoutes from './booking.routes';
import matchRoutes from './match.routes'; import matchRoutes from './match.routes';
import rankingRoutes from './ranking.routes'; import rankingRoutes from './ranking.routes';
import statsRoutes from './stats.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(); const router = Router();
@@ -35,4 +45,32 @@ router.use('/ranking', rankingRoutes);
// Rutas de estadísticas // Rutas de estadísticas
router.use('/stats', statsRoutes); 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; export default router;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,239 @@
# Fase 3: Torneos # Fase 3: Torneos y Ligas
## Estado: ⏳ Pendiente ## Estado: ✅ COMPLETADA
*Esta fase comenzará al finalizar la Fase 2* ### ✅ Tareas completadas:
#### 3.1.1: Creación de Torneos
- [x] Formulario de creación de torneo
- [x] Configurar formato (eliminación, liga, suizo, consolación)
- [x] Definir categorías (masculina, femenina, mixta)
- [x] Configurar niveles permitidos
- [x] Establecer fechas y canchas asignadas
#### 3.1.2: Inscripciones
- [x] Inscripción online individual/parejas
- [x] Pago de inscripción integrado
- [x] Lista de inscritos visible
- [x] Cierre de inscripciones automático/manual
#### 3.1.3: Sorteos y Cuadros
- [x] Sorteo automático de emparejamientos
- [x] Generación de cuadro visual (bracket)
- [x] Cuadro de consolación automático
- [x] Publicación de cuadros en la app
#### 3.1.4: Gestión de Partidos de Torneo
- [x] Asignación de horarios y canchas
- [x] Registro de resultados
- [x] Avance automático en el cuadro
- [x] Notificaciones a jugadores
#### 3.2.1: Ligas por Equipos
- [x] Creación de equipos
- [x] Inscripción de equipos en liga
- [x] Configurar formato de jornadas
- [x] Generación automática de calendario
#### 3.2.2: Clasificaciones
- [x] Tabla de clasificación automática
- [x] Criterios de desempate configurables
- [x] Estadísticas de equipos (partidos, sets, puntos)
- [x] Historial de resultados por jornada
---
## 📊 Resumen de Implementación
### Modelos de Base de Datos
| Modelo | Descripción |
|--------|-------------|
| Tournament | Torneos con tipo, categoría, niveles, fechas |
| TournamentParticipant | Inscripciones con estado de pago |
| TournamentMatch | Partidos de torneo con relaciones de avance |
| League | Ligas por equipos |
| LeagueTeam | Equipos participantes |
| LeagueTeamMember | Miembros de cada equipo |
| LeagueMatch | Partidos de la liga |
| LeagueStanding | Clasificación actualizada automáticamente |
### Tipos de Torneo Soportados
| Tipo | Descripción |
|------|-------------|
| ELIMINATION | Eliminación simple con cuadro |
| ROUND_ROBIN | Todos contra todos |
| SWISS | Sistema suizo sin eliminación |
| CONSOLATION | Cuadro de consolación para perdedores 1ra ronda |
### Sistema de Puntos - Ligas
| Resultado | Puntos |
|-----------|--------|
| Victoria | 3 |
| Empate | 1 |
| Derrota | 0 |
### Criterios de Desempate
1. Puntos totales
2. Diferencia de sets
3. Diferencia de games
4. Enfrentamiento directo
---
## 🔌 Endpoints de Torneos
```
# Gestión de Torneos
GET /api/v1/tournaments - Listar torneos
POST /api/v1/tournaments - Crear torneo (admin)
GET /api/v1/tournaments/:id - Ver torneo
PUT /api/v1/tournaments/:id - Actualizar (admin)
DELETE /api/v1/tournaments/:id - Cancelar (admin)
POST /api/v1/tournaments/:id/open - Abrir inscripciones (admin)
POST /api/v1/tournaments/:id/close - Cerrar inscripciones (admin)
# Inscripciones
POST /api/v1/tournaments/:id/register - Inscribirme
DELETE /api/v1/tournaments/:id/register - Desinscribirme
GET /api/v1/tournaments/:id/participants - Listar participantes
PUT /api/v1/tournaments/participants/:id/pay - Confirmar pago (admin)
# Cuadros y Partidos
POST /api/v1/tournaments/:id/draw/generate - Generar cuadro (admin)
GET /api/v1/tournaments/:id/draw - Ver cuadro
GET /api/v1/tournaments/:id/matches - Listar partidos
GET /api/v1/tournaments/:id/matches/:matchId - Ver partido
PUT /api/v1/tournaments/:id/matches/:matchId/schedule - Programar (admin)
PUT /api/v1/tournaments/:id/matches/:matchId/result - Registrar resultado
PUT /api/v1/tournaments/:id/matches/:matchId/confirm - Confirmar resultado
```
## 🔌 Endpoints de Ligas
```
# Ligas
GET /api/v1/leagues - Listar ligas
POST /api/v1/leagues - Crear liga (admin)
GET /api/v1/leagues/:id - Ver liga
PUT /api/v1/leagues/:id - Actualizar (admin)
DELETE /api/v1/leagues/:id - Eliminar (admin)
POST /api/v1/leagues/:id/start - Iniciar liga (admin)
POST /api/v1/leagues/:id/finish - Finalizar liga (admin)
# Equipos
GET /api/v1/league-teams - Listar equipos
POST /api/v1/league-teams - Crear equipo
GET /api/v1/league-teams/:id - Ver equipo
PUT /api/v1/league-teams/:id - Actualizar equipo (capitán)
DELETE /api/v1/league-teams/:id - Eliminar equipo
POST /api/v1/league-teams/:id/members - Agregar miembro
DELETE /api/v1/league-teams/:id/members/:userId - Quitar miembro
# Calendario
GET /api/v1/league-schedule/:leagueId - Calendario completo
GET /api/v1/league-schedule/:leagueId/matchday/:n - Jornada específica
POST /api/v1/league-schedule/:leagueId/generate - Generar calendario (admin)
PUT /api/v1/league-matches/:id/schedule - Programar partido
# Clasificación
GET /api/v1/league-standings/:leagueId - Tabla de clasificación
GET /api/v1/league-standings/:leagueId/top - Top equipos
# Partidos de Liga
PUT /api/v1/league-matches/:id/result - Registrar resultado
PUT /api/v1/league-matches/:id/confirm - Confirmar resultado
```
---
## 🚀 Cómo probar
```bash
cd backend
npm run dev
```
### Crear un torneo (admin)
```bash
curl -X POST http://localhost:3000/api/v1/tournaments \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Torneo Test",
"type": "ELIMINATION",
"category": "MIXED",
"maxParticipants": 8,
"price": 2000
}'
```
### Inscribirse en torneo
```bash
curl -X POST http://localhost:3000/api/v1/tournaments/ID/register \
-H "Authorization: Bearer TOKEN"
```
### Generar cuadro (admin)
```bash
curl -X POST http://localhost:3000/api/v1/tournaments/ID/draw/generate \
-H "Authorization: Bearer TOKEN"
```
---
## 📁 Archivos creados en esta fase
```
backend/src/
├── services/
│ ├── tournament.service.ts
│ ├── tournamentDraw.service.ts
│ ├── tournamentMatch.service.ts
│ ├── league.service.ts
│ ├── leagueTeam.service.ts
│ ├── leagueSchedule.service.ts
│ ├── leagueStanding.service.ts
│ └── leagueMatch.service.ts
├── controllers/
│ ├── tournament.controller.ts
│ ├── tournamentDraw.controller.ts
│ ├── tournamentMatch.controller.ts
│ ├── league.controller.ts
│ ├── leagueTeam.controller.ts
│ ├── leagueSchedule.controller.ts
│ ├── leagueStanding.controller.ts
│ └── leagueMatch.controller.ts
├── routes/
│ ├── tournament.routes.ts
│ ├── tournamentDraw.routes.ts
│ ├── tournamentMatch.routes.ts
│ ├── league.routes.ts
│ ├── leagueTeam.routes.ts
│ ├── leagueSchedule.routes.ts
│ ├── leagueStanding.routes.ts
│ └── leagueMatch.routes.ts
├── validators/
│ ├── tournament.validator.ts
│ └── league.validator.ts
├── utils/
│ └── tournamentDraw.ts
└── ...
```
---
## 🎯 Datos de prueba creados
| Entidad | Nombre | Descripción |
|---------|--------|-------------|
| Torneo | Torneo de Verano 2024 | Eliminación, Mixto, Abierto |
| Torneo | Liga de Invierno | Round Robin, Hombres |
| Liga | Liga de Club 2024 | Liga por equipos |
---
*Completada el: 2026-01-31*