✅ 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:
Binary file not shown.
@@ -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");
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
96
backend/prisma/seed-fase3.ts
Normal file
96
backend/prisma/seed-fase3.ts
Normal 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();
|
||||||
|
});
|
||||||
291
backend/src/controllers/league.controller.ts
Normal file
291
backend/src/controllers/league.controller.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { LeagueService } from '../services/league.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class LeagueController {
|
||||||
|
/**
|
||||||
|
* Crear nueva liga
|
||||||
|
*/
|
||||||
|
static async createLeague(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, description, format, matchesPerMatchday, startDate, endDate } = req.body;
|
||||||
|
|
||||||
|
const league = await LeagueService.createLeague(req.user.userId, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
format,
|
||||||
|
matchesPerMatchday,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Liga creada exitosamente',
|
||||||
|
data: league,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener lista de ligas
|
||||||
|
*/
|
||||||
|
static async getLeagues(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { status, type, createdById } = req.query;
|
||||||
|
|
||||||
|
const leagues = await LeagueService.getLeagues({
|
||||||
|
status: status as string,
|
||||||
|
type: type as string,
|
||||||
|
createdById: createdById as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: leagues.length,
|
||||||
|
data: leagues,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener mis ligas (ligas donde el usuario ha creado equipos o es creador)
|
||||||
|
*/
|
||||||
|
static async getMyLeagues(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener ligas creadas por el usuario
|
||||||
|
const createdLeagues = await LeagueService.getLeagues({
|
||||||
|
createdById: req.user.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener ligas donde el usuario es capitán de un equipo
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const captainLeagues = await prisma.leagueTeam.findMany({
|
||||||
|
where: { captainId: req.user.userId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
teams: true,
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener ligas donde el usuario es miembro de un equipo
|
||||||
|
const memberLeagues = await prisma.leagueTeamMember.findMany({
|
||||||
|
where: {
|
||||||
|
userId: req.user.userId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team: {
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
teams: true,
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combinar y eliminar duplicados
|
||||||
|
const allLeagues = [
|
||||||
|
...createdLeagues,
|
||||||
|
...captainLeagues.map((cl: any) => cl.league),
|
||||||
|
...memberLeagues.map((ml: any) => ml.team.league),
|
||||||
|
];
|
||||||
|
|
||||||
|
const uniqueLeagues = allLeagues.filter(
|
||||||
|
(league, index, self) =>
|
||||||
|
index === self.findIndex((l) => l.id === league.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: uniqueLeagues.length,
|
||||||
|
data: uniqueLeagues,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener liga por ID
|
||||||
|
*/
|
||||||
|
static async getLeagueById(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const league = await LeagueService.getLeagueById(id);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: league,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar liga
|
||||||
|
*/
|
||||||
|
static async updateLeague(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, description, format, matchesPerMatchday, startDate, endDate } = req.body;
|
||||||
|
|
||||||
|
const league = await LeagueService.updateLeague(id, req.user.userId, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
format,
|
||||||
|
matchesPerMatchday,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Liga actualizada exitosamente',
|
||||||
|
data: league,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar liga
|
||||||
|
*/
|
||||||
|
static async deleteLeague(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await LeagueService.deleteLeague(id, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iniciar liga
|
||||||
|
*/
|
||||||
|
static async startLeague(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const league = await LeagueService.startLeague(id, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Liga iniciada exitosamente',
|
||||||
|
data: league,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalizar liga
|
||||||
|
*/
|
||||||
|
static async finishLeague(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const league = await LeagueService.finishLeague(id, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Liga finalizada exitosamente',
|
||||||
|
data: league,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelar liga
|
||||||
|
*/
|
||||||
|
static async cancelLeague(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const league = await LeagueService.cancelLeague(id, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Liga cancelada exitosamente',
|
||||||
|
data: league,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeagueController;
|
||||||
156
backend/src/controllers/leagueMatch.controller.ts
Normal file
156
backend/src/controllers/leagueMatch.controller.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { LeagueMatchService } from '../services/leagueMatch.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class LeagueMatchController {
|
||||||
|
/**
|
||||||
|
* Obtener todos los partidos de una liga
|
||||||
|
*/
|
||||||
|
static async getMatches(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const { status, matchday } = req.query;
|
||||||
|
|
||||||
|
const matches = await LeagueMatchService.getMatches(leagueId, {
|
||||||
|
status: status as string,
|
||||||
|
matchday: matchday ? parseInt(matchday as string, 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: matches.length,
|
||||||
|
data: matches,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener partido por ID
|
||||||
|
*/
|
||||||
|
static async getMatchById(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const match = await LeagueMatchService.getMatchById(matchId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar resultado de un partido
|
||||||
|
*/
|
||||||
|
static async updateMatchResult(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const { team1Score, team2Score, setDetails, winner, notes } = req.body;
|
||||||
|
|
||||||
|
const match = await LeagueMatchService.updateMatchResult(
|
||||||
|
matchId,
|
||||||
|
req.user.userId,
|
||||||
|
{
|
||||||
|
team1Score,
|
||||||
|
team2Score,
|
||||||
|
setDetails,
|
||||||
|
winner,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Resultado registrado exitosamente',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar estado de un partido
|
||||||
|
*/
|
||||||
|
static async updateMatchStatus(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const { status, scheduledDate, scheduledTime, courtId } = req.body;
|
||||||
|
|
||||||
|
const match = await LeagueMatchService.updateMatchStatus(
|
||||||
|
matchId,
|
||||||
|
req.user.userId,
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
scheduledDate,
|
||||||
|
scheduledTime,
|
||||||
|
courtId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Estado actualizado exitosamente',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anular resultado de un partido
|
||||||
|
*/
|
||||||
|
static async voidMatchResult(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const match = await LeagueMatchService.voidMatchResult(
|
||||||
|
matchId,
|
||||||
|
req.user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Resultado anulado exitosamente',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener resumen de partidos de una liga
|
||||||
|
*/
|
||||||
|
static async getMatchSummary(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const summary = await LeagueMatchService.getMatchSummary(leagueId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: summary,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeagueMatchController;
|
||||||
155
backend/src/controllers/leagueSchedule.controller.ts
Normal file
155
backend/src/controllers/leagueSchedule.controller.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { LeagueScheduleService } from '../services/leagueSchedule.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class LeagueScheduleController {
|
||||||
|
/**
|
||||||
|
* Generar calendario de la liga
|
||||||
|
*/
|
||||||
|
static async generateSchedule(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const schedule = await LeagueScheduleService.generateSchedule(leagueId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Calendario generado exitosamente',
|
||||||
|
data: schedule,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener calendario completo
|
||||||
|
*/
|
||||||
|
static async getSchedule(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const schedule = await LeagueScheduleService.getSchedule(leagueId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: schedule,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener jornada específica
|
||||||
|
*/
|
||||||
|
static async getMatchday(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { leagueId, matchday } = req.params;
|
||||||
|
const matchdayData = await LeagueScheduleService.getMatchday(
|
||||||
|
leagueId,
|
||||||
|
parseInt(matchday, 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: matchdayData,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar fecha/hora/cancha de un partido
|
||||||
|
*/
|
||||||
|
static async updateMatchDate(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const { scheduledDate, scheduledTime, courtId } = req.body;
|
||||||
|
|
||||||
|
const match = await LeagueScheduleService.updateMatchDate(
|
||||||
|
matchId,
|
||||||
|
req.user.userId,
|
||||||
|
{
|
||||||
|
scheduledDate,
|
||||||
|
scheduledTime,
|
||||||
|
courtId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Partido actualizado exitosamente',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener partidos de un equipo
|
||||||
|
*/
|
||||||
|
static async getTeamMatches(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { teamId } = req.params;
|
||||||
|
const matches = await LeagueScheduleService.getTeamMatches(teamId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: matches.length,
|
||||||
|
data: matches,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener partidos pendientes de programar
|
||||||
|
*/
|
||||||
|
static async getUnscheduledMatches(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const matches = await LeagueScheduleService.getUnscheduledMatches(leagueId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: matches.length,
|
||||||
|
data: matches,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar calendario
|
||||||
|
*/
|
||||||
|
static async deleteSchedule(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const result = await LeagueScheduleService.deleteSchedule(leagueId, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeagueScheduleController;
|
||||||
139
backend/src/controllers/leagueStanding.controller.ts
Normal file
139
backend/src/controllers/leagueStanding.controller.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { LeagueStandingService } from '../services/leagueStanding.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class LeagueStandingController {
|
||||||
|
/**
|
||||||
|
* Calcular y obtener clasificación
|
||||||
|
*/
|
||||||
|
static async calculateStandings(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const standings = await LeagueStandingService.calculateStandings(leagueId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Clasificación recalculada exitosamente',
|
||||||
|
data: standings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener clasificación
|
||||||
|
*/
|
||||||
|
static async getStandings(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const standings = await LeagueStandingService.getStandings(leagueId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: standings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar clasificación tras un partido
|
||||||
|
*/
|
||||||
|
static async updateStandingsAfterMatch(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const standings = await LeagueStandingService.updateStandingsAfterMatch(matchId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Clasificación actualizada exitosamente',
|
||||||
|
data: standings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener goleadores / mejores jugadores
|
||||||
|
*/
|
||||||
|
static async getTopScorers(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const { limit } = req.query;
|
||||||
|
|
||||||
|
const topScorers = await LeagueStandingService.getTopScorers(
|
||||||
|
leagueId,
|
||||||
|
limit ? parseInt(limit as string, 10) : 10
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: topScorers.length,
|
||||||
|
data: topScorers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiniciar clasificación
|
||||||
|
*/
|
||||||
|
static async resetStandings(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const result = await LeagueStandingService.resetStandings(leagueId, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparar dos equipos
|
||||||
|
*/
|
||||||
|
static async getTeamComparison(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const { team1Id, team2Id } = req.query;
|
||||||
|
|
||||||
|
if (!team1Id || !team2Id) {
|
||||||
|
throw new ApiError('Se requieren los IDs de ambos equipos', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparison = await LeagueStandingService.getTeamComparison(
|
||||||
|
leagueId,
|
||||||
|
team1Id as string,
|
||||||
|
team2Id as string
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: comparison,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeagueStandingController;
|
||||||
269
backend/src/controllers/leagueTeam.controller.ts
Normal file
269
backend/src/controllers/leagueTeam.controller.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { LeagueTeamService } from '../services/leagueTeam.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class LeagueTeamController {
|
||||||
|
/**
|
||||||
|
* Crear equipo en una liga
|
||||||
|
*/
|
||||||
|
static async createTeam(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const { name, description } = req.body;
|
||||||
|
|
||||||
|
const team = await LeagueTeamService.createTeam(leagueId, req.user.userId, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Equipo creado exitosamente',
|
||||||
|
data: team,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener equipos de una liga
|
||||||
|
*/
|
||||||
|
static async getTeams(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { leagueId } = req.params;
|
||||||
|
const teams = await LeagueTeamService.getTeams(leagueId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: teams.length,
|
||||||
|
data: teams,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener equipo por ID
|
||||||
|
*/
|
||||||
|
static async getTeamById(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { teamId } = req.params;
|
||||||
|
const team = await LeagueTeamService.getTeamById(teamId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: team,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar equipo
|
||||||
|
*/
|
||||||
|
static async updateTeam(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = req.params;
|
||||||
|
const { name, description } = req.body;
|
||||||
|
|
||||||
|
const team = await LeagueTeamService.updateTeam(teamId, req.user.userId, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Equipo actualizado exitosamente',
|
||||||
|
data: team,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar equipo
|
||||||
|
*/
|
||||||
|
static async deleteTeam(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = req.params;
|
||||||
|
const result = await LeagueTeamService.deleteTeam(teamId, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agregar miembro al equipo
|
||||||
|
*/
|
||||||
|
static async addMember(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = req.params;
|
||||||
|
const { userId } = req.body;
|
||||||
|
|
||||||
|
const member = await LeagueTeamService.addMember(teamId, req.user.userId, userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Miembro agregado exitosamente',
|
||||||
|
data: member,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quitar miembro del equipo
|
||||||
|
*/
|
||||||
|
static async removeMember(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId, userId } = req.params;
|
||||||
|
const result = await LeagueTeamService.removeMember(teamId, req.user.userId, userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abandonar equipo
|
||||||
|
*/
|
||||||
|
static async leaveTeam(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = req.params;
|
||||||
|
const result = await LeagueTeamService.leaveTeam(teamId, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener mis equipos (equipos donde el usuario es capitán o miembro)
|
||||||
|
*/
|
||||||
|
static async getMyTeams(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Obtener equipos donde es capitán
|
||||||
|
const captainTeams = await prisma.leagueTeam.findMany({
|
||||||
|
where: { captainId: req.user.userId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener equipos donde es miembro (pero no capitán)
|
||||||
|
const memberTeams = await prisma.leagueTeam.findMany({
|
||||||
|
where: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId: req.user.userId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
captainId: {
|
||||||
|
not: req.user.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
asCaptain: captainTeams,
|
||||||
|
asMember: memberTeams,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeagueTeamController;
|
||||||
298
backend/src/controllers/tournament.controller.ts
Normal file
298
backend/src/controllers/tournament.controller.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { TournamentService } from '../services/tournament.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import { UserRole } from '../utils/constants';
|
||||||
|
|
||||||
|
export class TournamentController {
|
||||||
|
// Crear un torneo
|
||||||
|
static async create(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
allowedLevels,
|
||||||
|
maxParticipants,
|
||||||
|
registrationStartDate,
|
||||||
|
registrationEndDate,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
courtIds,
|
||||||
|
price,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const tournament = await TournamentService.createTournament(req.user.userId, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
allowedLevels,
|
||||||
|
maxParticipants,
|
||||||
|
registrationStartDate: new Date(registrationStartDate),
|
||||||
|
registrationEndDate: new Date(registrationEndDate),
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
courtIds,
|
||||||
|
price,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Torneo creado exitosamente',
|
||||||
|
data: tournament,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener todos los torneos
|
||||||
|
static async getAll(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const filters = {
|
||||||
|
status: req.query.status as string,
|
||||||
|
type: req.query.type as string,
|
||||||
|
category: req.query.category as string,
|
||||||
|
upcoming: req.query.upcoming === 'true',
|
||||||
|
open: req.query.open === 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tournaments = await TournamentService.getTournaments(filters);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: tournaments.length,
|
||||||
|
data: tournaments,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener un torneo por ID
|
||||||
|
static async getById(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tournament = await TournamentService.getTournamentById(id);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: tournament,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar un torneo
|
||||||
|
static async update(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
allowedLevels,
|
||||||
|
maxParticipants,
|
||||||
|
registrationStartDate,
|
||||||
|
registrationEndDate,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
courtIds,
|
||||||
|
price,
|
||||||
|
status,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
allowedLevels,
|
||||||
|
maxParticipants,
|
||||||
|
courtIds,
|
||||||
|
price,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convertir fechas si se proporcionan
|
||||||
|
if (registrationStartDate) {
|
||||||
|
updateData.registrationStartDate = new Date(registrationStartDate);
|
||||||
|
}
|
||||||
|
if (registrationEndDate) {
|
||||||
|
updateData.registrationEndDate = new Date(registrationEndDate);
|
||||||
|
}
|
||||||
|
if (startDate) {
|
||||||
|
updateData.startDate = new Date(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
updateData.endDate = new Date(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tournament = await TournamentService.updateTournament(
|
||||||
|
id,
|
||||||
|
req.user.userId,
|
||||||
|
updateData
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Torneo actualizado exitosamente',
|
||||||
|
data: tournament,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar (cancelar) un torneo
|
||||||
|
static async delete(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const tournament = await TournamentService.deleteTournament(id, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Torneo cancelado exitosamente',
|
||||||
|
data: tournament,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abrir inscripciones
|
||||||
|
static async openRegistration(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const tournament = await TournamentService.openRegistration(id, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Inscripciones abiertas exitosamente',
|
||||||
|
data: tournament,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cerrar inscripciones
|
||||||
|
static async closeRegistration(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const tournament = await TournamentService.closeRegistration(id, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Inscripciones cerradas exitosamente',
|
||||||
|
data: tournament,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inscribirse a un torneo
|
||||||
|
static async register(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const participant = await TournamentService.registerParticipant(id, req.user.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Inscripción realizada exitosamente',
|
||||||
|
data: participant,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desinscribirse de un torneo
|
||||||
|
static async unregister(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const participant = await TournamentService.unregisterParticipant(id, req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Inscripción cancelada exitosamente',
|
||||||
|
data: participant,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmar pago de inscripción
|
||||||
|
static async confirmPayment(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { participantId } = req.params;
|
||||||
|
const participant = await TournamentService.confirmPayment(
|
||||||
|
participantId,
|
||||||
|
req.user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Pago confirmado exitosamente',
|
||||||
|
data: participant,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener participantes de un torneo
|
||||||
|
static async getParticipants(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const participants = await TournamentService.getParticipants(id);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: participants.length,
|
||||||
|
data: participants,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TournamentController;
|
||||||
149
backend/src/controllers/tournamentDraw.controller.ts
Normal file
149
backend/src/controllers/tournamentDraw.controller.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { TournamentDrawService } from '../services/tournamentDraw.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class TournamentDrawController {
|
||||||
|
/**
|
||||||
|
* Generar cuadro de torneo
|
||||||
|
* POST /tournaments/:id/draw/generate
|
||||||
|
*/
|
||||||
|
static async generateDraw(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { shuffle = false, respectSeeds = true } = req.body;
|
||||||
|
|
||||||
|
const result = await TournamentDrawService.generateDraw(id, {
|
||||||
|
shuffle,
|
||||||
|
respectSeeds,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Cuadro generado exitosamente',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener cuadro completo de un torneo
|
||||||
|
* GET /tournaments/:id/draw
|
||||||
|
*/
|
||||||
|
static async getDraw(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const draw = await TournamentDrawService.getDraw(id);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: draw,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programar un partido
|
||||||
|
* PUT /tournaments/:id/matches/:matchId/schedule
|
||||||
|
*/
|
||||||
|
static async scheduleMatch(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const { courtId, date, time } = req.body;
|
||||||
|
|
||||||
|
if (!courtId || !date || !time) {
|
||||||
|
throw new ApiError('Cancha, fecha y hora son requeridos', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = await TournamentDrawService.scheduleMatch(matchId, {
|
||||||
|
courtId,
|
||||||
|
date: new Date(date),
|
||||||
|
time,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Partido programado exitosamente',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar siguiente ronda de sistema suizo
|
||||||
|
* POST /tournaments/:id/draw/swiss-next-round
|
||||||
|
*/
|
||||||
|
static async generateNextRoundSwiss(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await TournamentDrawService.generateNextRoundSwiss(id);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: `Ronda ${result.round} generada exitosamente`,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrar resultado de un partido
|
||||||
|
* PUT /tournaments/:id/matches/:matchId/result
|
||||||
|
*/
|
||||||
|
static async recordResult(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const { team1Score, team2Score } = req.body;
|
||||||
|
|
||||||
|
if (team1Score === undefined || team2Score === undefined) {
|
||||||
|
throw new ApiError('Los puntajes de ambos equipos son requeridos', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = await TournamentDrawService.recordMatchResult(matchId, {
|
||||||
|
team1Score: parseInt(team1Score),
|
||||||
|
team2Score: parseInt(team2Score),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Resultado registrado exitosamente',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TournamentDrawController;
|
||||||
317
backend/src/controllers/tournamentMatch.controller.ts
Normal file
317
backend/src/controllers/tournamentMatch.controller.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { TournamentMatchService } from '../services/tournamentMatch.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class TournamentMatchController {
|
||||||
|
/**
|
||||||
|
* Listar partidos de un torneo
|
||||||
|
* GET /tournaments/:id/matches
|
||||||
|
*/
|
||||||
|
static async getMatches(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const filters = {
|
||||||
|
round: req.query.round ? parseInt(req.query.round as string) : undefined,
|
||||||
|
status: req.query.status as string,
|
||||||
|
courtId: req.query.courtId as string,
|
||||||
|
playerId: req.query.playerId as string,
|
||||||
|
fromDate: req.query.fromDate
|
||||||
|
? new Date(req.query.fromDate as string)
|
||||||
|
: undefined,
|
||||||
|
toDate: req.query.toDate
|
||||||
|
? new Date(req.query.toDate as string)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const matches = await TournamentMatchService.getMatches(id, filters);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: matches.length,
|
||||||
|
data: matches,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener un partido específico
|
||||||
|
* GET /tournaments/:id/matches/:matchId
|
||||||
|
*/
|
||||||
|
static async getMatch(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const match = await TournamentMatchService.getMatchById(matchId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar un partido
|
||||||
|
* PUT /tournaments/:id/matches/:matchId
|
||||||
|
*/
|
||||||
|
static async updateMatch(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const { courtId, scheduledDate, scheduledTime, status, notes } = req.body;
|
||||||
|
|
||||||
|
const match = await TournamentMatchService.updateMatch(matchId, {
|
||||||
|
courtId,
|
||||||
|
scheduledDate: scheduledDate ? new Date(scheduledDate) : undefined,
|
||||||
|
scheduledTime,
|
||||||
|
status,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Partido actualizado exitosamente',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asignar cancha a un partido
|
||||||
|
* PUT /tournaments/:id/matches/:matchId/assign-court
|
||||||
|
*/
|
||||||
|
static async assignCourt(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const { courtId, date, time } = req.body;
|
||||||
|
|
||||||
|
if (!courtId || !date || !time) {
|
||||||
|
throw new ApiError('Cancha, fecha y hora son requeridos', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = await TournamentMatchService.assignCourt(
|
||||||
|
matchId,
|
||||||
|
courtId,
|
||||||
|
new Date(date),
|
||||||
|
time
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Cancha asignada exitosamente',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrar resultado de un partido
|
||||||
|
* PUT /tournaments/:id/matches/:matchId/result
|
||||||
|
*/
|
||||||
|
static async recordResult(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const { team1Score, team2Score } = req.body;
|
||||||
|
|
||||||
|
if (team1Score === undefined || team2Score === undefined) {
|
||||||
|
throw new ApiError('Los puntajes de ambos equipos son requeridos', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = await TournamentMatchService.recordResult(
|
||||||
|
matchId,
|
||||||
|
{
|
||||||
|
team1Score: parseInt(team1Score),
|
||||||
|
team2Score: parseInt(team2Score),
|
||||||
|
},
|
||||||
|
req.user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: match.isConfirmed
|
||||||
|
? 'Resultado registrado y confirmado'
|
||||||
|
: 'Resultado registrado. Esperando confirmación del oponente.',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmar resultado de un partido
|
||||||
|
* PUT /tournaments/:id/matches/:matchId/confirm
|
||||||
|
*/
|
||||||
|
static async confirmResult(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const match = await TournamentMatchService.confirmResult(
|
||||||
|
matchId,
|
||||||
|
req.user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: match.isConfirmed
|
||||||
|
? 'Resultado confirmado. El partido es válido.'
|
||||||
|
: 'Confirmación registrada. Se necesita otra confirmación para validar.',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iniciar partido (cambiar estado a IN_PROGRESS)
|
||||||
|
* PUT /tournaments/:id/matches/:matchId/start
|
||||||
|
*/
|
||||||
|
static async startMatch(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const match = await TournamentMatchService.startMatch(matchId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Partido iniciado',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelar partido
|
||||||
|
* PUT /tournaments/:id/matches/:matchId/cancel
|
||||||
|
*/
|
||||||
|
static async cancelMatch(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId } = req.params;
|
||||||
|
const { reason } = req.body;
|
||||||
|
|
||||||
|
const match = await TournamentMatchService.cancelMatch(matchId, reason);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Partido cancelado',
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener partidos de un participante específico
|
||||||
|
* GET /tournaments/:id/participants/:participantId/matches
|
||||||
|
*/
|
||||||
|
static async getParticipantMatches(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, participantId } = req.params;
|
||||||
|
const matches = await TournamentMatchService.getParticipantMatches(
|
||||||
|
id,
|
||||||
|
participantId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: matches.length,
|
||||||
|
data: matches,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener mis partidos en un torneo
|
||||||
|
* GET /tournaments/:id/my-matches
|
||||||
|
*/
|
||||||
|
static async getMyMatches(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Buscar el participante asociado al usuario
|
||||||
|
const participant = await prisma.tournamentParticipant.findFirst({
|
||||||
|
where: {
|
||||||
|
tournamentId: id,
|
||||||
|
userId: req.user.userId,
|
||||||
|
status: { in: ['REGISTERED', 'CONFIRMED'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
throw new ApiError('No estás registrado en este torneo', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await TournamentMatchService.getParticipantMatches(
|
||||||
|
id,
|
||||||
|
participant.id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: matches.length,
|
||||||
|
data: matches,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Importación necesaria para getMyMatches
|
||||||
|
import prisma from '../config/database';
|
||||||
|
|
||||||
|
export default TournamentMatchController;
|
||||||
@@ -5,6 +5,16 @@ import bookingRoutes from './booking.routes';
|
|||||||
import matchRoutes from './match.routes';
|
import 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;
|
||||||
|
|||||||
78
backend/src/routes/league.routes.ts
Normal file
78
backend/src/routes/league.routes.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { LeagueController } from '../controllers/league.controller';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { validate, validateParams } from '../middleware/validate';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { LeagueStatus, LeagueFormat, LeagueType } from '../utils/constants';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Esquemas de validación
|
||||||
|
const leagueIdSchema = z.object({
|
||||||
|
id: z.string().uuid('ID de liga inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createLeagueSchema = z.object({
|
||||||
|
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres'),
|
||||||
|
description: z.string().max(1000, 'La descripción no puede exceder 1000 caracteres').optional(),
|
||||||
|
format: z.enum([LeagueFormat.SINGLE_ROUND_ROBIN, LeagueFormat.DOUBLE_ROUND_ROBIN], {
|
||||||
|
errorMap: () => ({ message: 'Formato inválido' }),
|
||||||
|
}).optional(),
|
||||||
|
matchesPerMatchday: z.number().int().min(1).max(10).optional(),
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(),
|
||||||
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLeagueSchema = z.object({
|
||||||
|
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres').optional(),
|
||||||
|
description: z.string().max(1000, 'La descripción no puede exceder 1000 caracteres').optional(),
|
||||||
|
format: z.enum([LeagueFormat.SINGLE_ROUND_ROBIN, LeagueFormat.DOUBLE_ROUND_ROBIN], {
|
||||||
|
errorMap: () => ({ message: 'Formato inválido' }),
|
||||||
|
}).optional(),
|
||||||
|
matchesPerMatchday: z.number().int().min(1).max(10).optional(),
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
|
||||||
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLeaguesQuerySchema = z.object({
|
||||||
|
status: z.enum([LeagueStatus.DRAFT, LeagueStatus.ACTIVE, LeagueStatus.FINISHED, LeagueStatus.CANCELLED]).optional(),
|
||||||
|
type: z.enum([LeagueType.TEAM_LEAGUE, LeagueType.INDIVIDUAL_LEAGUE]).optional(),
|
||||||
|
createdById: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Todas las rutas requieren autenticación
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// POST /api/v1/leagues - Crear liga
|
||||||
|
router.post('/', validate(createLeagueSchema), LeagueController.createLeague);
|
||||||
|
|
||||||
|
// GET /api/v1/leagues - Listar ligas
|
||||||
|
router.get('/', validate(getLeaguesQuerySchema), LeagueController.getLeagues);
|
||||||
|
|
||||||
|
// GET /api/v1/leagues/my-leagues - Mis ligas
|
||||||
|
router.get('/my-leagues', LeagueController.getMyLeagues);
|
||||||
|
|
||||||
|
// GET /api/v1/leagues/:id - Obtener liga por ID
|
||||||
|
router.get('/:id', validateParams(leagueIdSchema), LeagueController.getLeagueById);
|
||||||
|
|
||||||
|
// PUT /api/v1/leagues/:id - Actualizar liga
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
validate(updateLeagueSchema),
|
||||||
|
LeagueController.updateLeague
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/v1/leagues/:id - Eliminar liga
|
||||||
|
router.delete('/:id', validateParams(leagueIdSchema), LeagueController.deleteLeague);
|
||||||
|
|
||||||
|
// POST /api/v1/leagues/:id/start - Iniciar liga
|
||||||
|
router.post('/:id/start', validateParams(leagueIdSchema), LeagueController.startLeague);
|
||||||
|
|
||||||
|
// POST /api/v1/leagues/:id/finish - Finalizar liga
|
||||||
|
router.post('/:id/finish', validateParams(leagueIdSchema), LeagueController.finishLeague);
|
||||||
|
|
||||||
|
// POST /api/v1/leagues/:id/cancel - Cancelar liga
|
||||||
|
router.post('/:id/cancel', validateParams(leagueIdSchema), LeagueController.cancelLeague);
|
||||||
|
|
||||||
|
export default router;
|
||||||
88
backend/src/routes/leagueMatch.routes.ts
Normal file
88
backend/src/routes/leagueMatch.routes.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { LeagueMatchController } from '../controllers/leagueMatch.controller';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { validate, validateParams } from '../middleware/validate';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { LeagueMatchStatus, MatchWinner } from '../utils/constants';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Esquemas de validación
|
||||||
|
const leagueIdSchema = z.object({
|
||||||
|
leagueId: z.string().uuid('ID de liga inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchIdSchema = z.object({
|
||||||
|
matchId: z.string().uuid('ID de partido inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMatchResultSchema = z.object({
|
||||||
|
team1Score: z.number().int().min(0).max(9, 'Máximo 9 sets'),
|
||||||
|
team2Score: z.number().int().min(0).max(9, 'Máximo 9 sets'),
|
||||||
|
setDetails: z.array(z.object({
|
||||||
|
team1Games: z.number().int().min(0).max(7, 'Máximo 7 games'),
|
||||||
|
team2Games: z.number().int().min(0).max(7, 'Máximo 7 games'),
|
||||||
|
})).optional(),
|
||||||
|
winner: z.enum([MatchWinner.TEAM1, MatchWinner.TEAM2, MatchWinner.DRAW], {
|
||||||
|
errorMap: () => ({ message: 'Ganador inválido' }),
|
||||||
|
}),
|
||||||
|
notes: z.string().max(500, 'Las notas no pueden exceder 500 caracteres').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMatchStatusSchema = z.object({
|
||||||
|
status: z.enum([LeagueMatchStatus.SCHEDULED, LeagueMatchStatus.CONFIRMED, LeagueMatchStatus.IN_PROGRESS, LeagueMatchStatus.CANCELLED, LeagueMatchStatus.POSTPONED, LeagueMatchStatus.WALKOVER], {
|
||||||
|
errorMap: () => ({ message: 'Estado inválido' }),
|
||||||
|
}),
|
||||||
|
scheduledDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
|
||||||
|
scheduledTime: z.string().regex(/^\d{2}:\d{2}$/, 'Hora debe estar en formato HH:mm').optional().nullable(),
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido').optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Todas las rutas requieren autenticación
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// GET /api/v1/league-matches/league/:leagueId - Listar partidos
|
||||||
|
router.get(
|
||||||
|
'/league/:leagueId',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueMatchController.getMatches
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/v1/league-matches/league/:leagueId/summary - Resumen de partidos
|
||||||
|
router.get(
|
||||||
|
'/league/:leagueId/summary',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueMatchController.getMatchSummary
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/v1/league-matches/:matchId - Obtener partido por ID
|
||||||
|
router.get(
|
||||||
|
'/:matchId',
|
||||||
|
validateParams(matchIdSchema),
|
||||||
|
LeagueMatchController.getMatchById
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /api/v1/league-matches/:matchId/result - Actualizar resultado
|
||||||
|
router.put(
|
||||||
|
'/:matchId/result',
|
||||||
|
validateParams(matchIdSchema),
|
||||||
|
validate(updateMatchResultSchema),
|
||||||
|
LeagueMatchController.updateMatchResult
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /api/v1/league-matches/:matchId/status - Actualizar estado
|
||||||
|
router.put(
|
||||||
|
'/:matchId/status',
|
||||||
|
validateParams(matchIdSchema),
|
||||||
|
validate(updateMatchStatusSchema),
|
||||||
|
LeagueMatchController.updateMatchStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/v1/league-matches/:matchId/void - Anular resultado
|
||||||
|
router.post(
|
||||||
|
'/:matchId/void',
|
||||||
|
validateParams(matchIdSchema),
|
||||||
|
LeagueMatchController.voidMatchResult
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
86
backend/src/routes/leagueSchedule.routes.ts
Normal file
86
backend/src/routes/leagueSchedule.routes.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { LeagueScheduleController } from '../controllers/leagueSchedule.controller';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { validate, validateParams } from '../middleware/validate';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Esquemas de validación
|
||||||
|
const leagueIdSchema = z.object({
|
||||||
|
leagueId: z.string().uuid('ID de liga inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchdaySchema = z.object({
|
||||||
|
leagueId: z.string().uuid('ID de liga inválido'),
|
||||||
|
matchday: z.string().regex(/^\d+$/, 'La jornada debe ser un número').transform(Number),
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchIdSchema = z.object({
|
||||||
|
matchId: z.string().uuid('ID de partido inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamIdSchema = z.object({
|
||||||
|
teamId: z.string().uuid('ID de equipo inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMatchSchema = z.object({
|
||||||
|
scheduledDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
|
||||||
|
scheduledTime: z.string().regex(/^\d{2}:\d{2}$/, 'Hora debe estar en formato HH:mm').optional().nullable(),
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido').optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Todas las rutas requieren autenticación
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// POST /api/v1/league-schedule/league/:leagueId/generate - Generar calendario
|
||||||
|
router.post(
|
||||||
|
'/league/:leagueId/generate',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueScheduleController.generateSchedule
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/v1/league-schedule/league/:leagueId - Obtener calendario
|
||||||
|
router.get(
|
||||||
|
'/league/:leagueId',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueScheduleController.getSchedule
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/v1/league-schedule/league/:leagueId/matchday/:matchday - Obtener jornada
|
||||||
|
router.get(
|
||||||
|
'/league/:leagueId/matchday/:matchday',
|
||||||
|
validateParams(matchdaySchema),
|
||||||
|
LeagueScheduleController.getMatchday
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/v1/league-schedule/league/:leagueId/unscheduled - Partidos pendientes
|
||||||
|
router.get(
|
||||||
|
'/league/:leagueId/unscheduled',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueScheduleController.getUnscheduledMatches
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/v1/league-schedule/team/:teamId - Partidos de un equipo
|
||||||
|
router.get(
|
||||||
|
'/team/:teamId',
|
||||||
|
validateParams(teamIdSchema),
|
||||||
|
LeagueScheduleController.getTeamMatches
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /api/v1/league-schedule/match/:matchId - Actualizar partido
|
||||||
|
router.put(
|
||||||
|
'/match/:matchId',
|
||||||
|
validateParams(matchIdSchema),
|
||||||
|
validate(updateMatchSchema),
|
||||||
|
LeagueScheduleController.updateMatchDate
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/v1/league-schedule/league/:leagueId - Eliminar calendario
|
||||||
|
router.delete(
|
||||||
|
'/league/:leagueId',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueScheduleController.deleteSchedule
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
69
backend/src/routes/leagueStanding.routes.ts
Normal file
69
backend/src/routes/leagueStanding.routes.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { LeagueStandingController } from '../controllers/leagueStanding.controller';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { validate, validateParams } from '../middleware/validate';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Esquemas de validación
|
||||||
|
const leagueIdSchema = z.object({
|
||||||
|
leagueId: z.string().uuid('ID de liga inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchIdSchema = z.object({
|
||||||
|
matchId: z.string().uuid('ID de partido inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamComparisonSchema = z.object({
|
||||||
|
leagueId: z.string().uuid('ID de liga inválido'),
|
||||||
|
team1Id: z.string().uuid('ID de equipo 1 inválido'),
|
||||||
|
team2Id: z.string().uuid('ID de equipo 2 inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Todas las rutas requieren autenticación
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// GET /api/v1/league-standings/league/:leagueId - Obtener clasificación
|
||||||
|
router.get(
|
||||||
|
'/league/:leagueId',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueStandingController.getStandings
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/v1/league-standings/league/:leagueId/calculate - Recalcular clasificación
|
||||||
|
router.post(
|
||||||
|
'/league/:leagueId/calculate',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueStandingController.calculateStandings
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/v1/league-standings/match/:matchId/update - Actualizar tras partido
|
||||||
|
router.post(
|
||||||
|
'/match/:matchId/update',
|
||||||
|
validateParams(matchIdSchema),
|
||||||
|
LeagueStandingController.updateStandingsAfterMatch
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/v1/league-standings/league/:leagueId/top-scorers - Goleadores
|
||||||
|
router.get(
|
||||||
|
'/league/:leagueId/top-scorers',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueStandingController.getTopScorers
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/v1/league-standings/league/:leagueId/reset - Reiniciar clasificación
|
||||||
|
router.post(
|
||||||
|
'/league/:leagueId/reset',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueStandingController.resetStandings
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/v1/league-standings/league/:leagueId/compare - Comparar equipos
|
||||||
|
router.get(
|
||||||
|
'/league/:leagueId/compare',
|
||||||
|
validateParams(teamComparisonSchema),
|
||||||
|
LeagueStandingController.getTeamComparison
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
102
backend/src/routes/leagueTeam.routes.ts
Normal file
102
backend/src/routes/leagueTeam.routes.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { LeagueTeamController } from '../controllers/leagueTeam.controller';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { validate, validateParams } from '../middleware/validate';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Esquemas de validación
|
||||||
|
const leagueIdSchema = z.object({
|
||||||
|
leagueId: z.string().uuid('ID de liga inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamIdSchema = z.object({
|
||||||
|
teamId: z.string().uuid('ID de equipo inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMemberSchema = z.object({
|
||||||
|
teamId: z.string().uuid('ID de equipo inválido'),
|
||||||
|
userId: z.string().uuid('ID de usuario inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTeamSchema = z.object({
|
||||||
|
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
|
||||||
|
description: z.string().max(500, 'La descripción no puede exceder 500 caracteres').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTeamSchema = z.object({
|
||||||
|
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(),
|
||||||
|
description: z.string().max(500, 'La descripción no puede exceder 500 caracteres').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMemberSchema = z.object({
|
||||||
|
userId: z.string().uuid('ID de usuario inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Todas las rutas requieren autenticación
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// GET /api/v1/league-teams/my-teams - Mis equipos
|
||||||
|
router.get('/my-teams', LeagueTeamController.getMyTeams);
|
||||||
|
|
||||||
|
// GET /api/v1/league-teams/league/:leagueId - Listar equipos de una liga
|
||||||
|
router.get(
|
||||||
|
'/league/:leagueId',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
LeagueTeamController.getTeams
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/v1/league-teams/league/:leagueId - Crear equipo
|
||||||
|
router.post(
|
||||||
|
'/league/:leagueId',
|
||||||
|
validateParams(leagueIdSchema),
|
||||||
|
validate(createTeamSchema),
|
||||||
|
LeagueTeamController.createTeam
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/v1/league-teams/:teamId - Obtener equipo por ID
|
||||||
|
router.get(
|
||||||
|
'/:teamId',
|
||||||
|
validateParams(teamIdSchema),
|
||||||
|
LeagueTeamController.getTeamById
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /api/v1/league-teams/:teamId - Actualizar equipo
|
||||||
|
router.put(
|
||||||
|
'/:teamId',
|
||||||
|
validateParams(teamIdSchema),
|
||||||
|
validate(updateTeamSchema),
|
||||||
|
LeagueTeamController.updateTeam
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/v1/league-teams/:teamId - Eliminar equipo
|
||||||
|
router.delete(
|
||||||
|
'/:teamId',
|
||||||
|
validateParams(teamIdSchema),
|
||||||
|
LeagueTeamController.deleteTeam
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/v1/league-teams/:teamId/members - Agregar miembro
|
||||||
|
router.post(
|
||||||
|
'/:teamId/members',
|
||||||
|
validateParams(teamIdSchema),
|
||||||
|
validate(addMemberSchema),
|
||||||
|
LeagueTeamController.addMember
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/v1/league-teams/:teamId/members/:userId - Quitar miembro
|
||||||
|
router.delete(
|
||||||
|
'/:teamId/members/:userId',
|
||||||
|
validateParams(teamMemberSchema),
|
||||||
|
LeagueTeamController.removeMember
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/v1/league-teams/:teamId/leave - Abandonar equipo
|
||||||
|
router.post(
|
||||||
|
'/:teamId/leave',
|
||||||
|
validateParams(teamIdSchema),
|
||||||
|
LeagueTeamController.leaveTeam
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
67
backend/src/routes/tournament.routes.ts
Normal file
67
backend/src/routes/tournament.routes.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { TournamentController } from '../controllers/tournament.controller';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth';
|
||||||
|
import { validate } from '../middleware/validate';
|
||||||
|
import { UserRole } from '../utils/constants';
|
||||||
|
import {
|
||||||
|
createTournamentSchema,
|
||||||
|
updateTournamentSchema,
|
||||||
|
} from '../validators/tournament.validator';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Rutas públicas (lectura)
|
||||||
|
router.get('/', TournamentController.getAll);
|
||||||
|
router.get('/:id', TournamentController.getById);
|
||||||
|
router.get('/:id/participants', TournamentController.getParticipants);
|
||||||
|
|
||||||
|
// Rutas protegidas para usuarios autenticados (inscripciones)
|
||||||
|
router.post('/:id/register', authenticate, TournamentController.register);
|
||||||
|
router.delete('/:id/register', authenticate, TournamentController.unregister);
|
||||||
|
|
||||||
|
// Rutas de admin (creación y gestión)
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
validate(createTournamentSchema),
|
||||||
|
TournamentController.create
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
validate(updateTournamentSchema),
|
||||||
|
TournamentController.update
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
TournamentController.delete
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/open',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
TournamentController.openRegistration
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/close',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
TournamentController.closeRegistration
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/participants/:participantId/pay',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
TournamentController.confirmPayment
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
68
backend/src/routes/tournamentDraw.routes.ts
Normal file
68
backend/src/routes/tournamentDraw.routes.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { TournamentDrawController } from '../controllers/tournamentDraw.controller';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth';
|
||||||
|
import { validate, validateQuery } from '../middleware/validate';
|
||||||
|
import { UserRole } from '../utils/constants';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = Router({ mergeParams: true });
|
||||||
|
|
||||||
|
// Schema para generar cuadro
|
||||||
|
const generateDrawSchema = z.object({
|
||||||
|
shuffle: z.boolean().optional().default(false),
|
||||||
|
respectSeeds: z.boolean().optional().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema para programar partido
|
||||||
|
const scheduleMatchSchema = z.object({
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido'),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe ser YYYY-MM-DD'),
|
||||||
|
time: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Hora debe ser HH:MM'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema para registrar resultado
|
||||||
|
const recordResultSchema = z.object({
|
||||||
|
team1Score: z.number().int().min(0, 'El puntaje no puede ser negativo'),
|
||||||
|
team2Score: z.number().int().min(0, 'El puntaje no puede ser negativo'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rutas de cuadro (solo admins)
|
||||||
|
router.post(
|
||||||
|
'/draw/generate',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
validate(generateDrawSchema),
|
||||||
|
TournamentDrawController.generateDraw
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/draw',
|
||||||
|
authenticate,
|
||||||
|
TournamentDrawController.getDraw
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/draw/swiss-next-round',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
TournamentDrawController.generateNextRoundSwiss
|
||||||
|
);
|
||||||
|
|
||||||
|
// Programar partido (solo admins)
|
||||||
|
router.put(
|
||||||
|
'/matches/:matchId/schedule',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
validate(scheduleMatchSchema),
|
||||||
|
TournamentDrawController.scheduleMatch
|
||||||
|
);
|
||||||
|
|
||||||
|
// Registrar resultado (jugadores o admins)
|
||||||
|
router.put(
|
||||||
|
'/matches/:matchId/result',
|
||||||
|
authenticate,
|
||||||
|
validate(recordResultSchema),
|
||||||
|
TournamentDrawController.recordResult
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
146
backend/src/routes/tournamentMatch.routes.ts
Normal file
146
backend/src/routes/tournamentMatch.routes.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { TournamentMatchController } from '../controllers/tournamentMatch.controller';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth';
|
||||||
|
import { validate, validateQuery } from '../middleware/validate';
|
||||||
|
import { UserRole, TournamentMatchStatus } from '../utils/constants';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = Router({ mergeParams: true });
|
||||||
|
|
||||||
|
// Schema para query params de filtros
|
||||||
|
const matchFiltersSchema = z.object({
|
||||||
|
round: z.string().regex(/^\d+$/).optional().transform(Number),
|
||||||
|
status: z.enum([
|
||||||
|
TournamentMatchStatus.PENDING,
|
||||||
|
TournamentMatchStatus.SCHEDULED,
|
||||||
|
TournamentMatchStatus.IN_PROGRESS,
|
||||||
|
TournamentMatchStatus.FINISHED,
|
||||||
|
TournamentMatchStatus.CANCELLED,
|
||||||
|
TournamentMatchStatus.BYE,
|
||||||
|
]).optional(),
|
||||||
|
courtId: z.string().uuid().optional(),
|
||||||
|
playerId: z.string().uuid().optional(),
|
||||||
|
fromDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema para actualizar partido
|
||||||
|
const updateMatchSchema = z.object({
|
||||||
|
courtId: z.string().uuid().optional(),
|
||||||
|
scheduledDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
scheduledTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/).optional(),
|
||||||
|
status: z.enum([
|
||||||
|
TournamentMatchStatus.PENDING,
|
||||||
|
TournamentMatchStatus.SCHEDULED,
|
||||||
|
TournamentMatchStatus.IN_PROGRESS,
|
||||||
|
TournamentMatchStatus.CANCELLED,
|
||||||
|
]).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema para asignar cancha
|
||||||
|
const assignCourtSchema = z.object({
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido'),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe ser YYYY-MM-DD'),
|
||||||
|
time: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Hora debe ser HH:MM'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema para registrar resultado
|
||||||
|
const recordResultSchema = z.object({
|
||||||
|
team1Score: z.number().int().min(0, 'El puntaje no puede ser negativo'),
|
||||||
|
team2Score: z.number().int().min(0, 'El puntaje no puede ser negativo'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema para cancelar partido
|
||||||
|
const cancelMatchSchema = z.object({
|
||||||
|
reason: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema para params de IDs
|
||||||
|
const matchIdSchema = z.object({
|
||||||
|
matchId: z.string().uuid('ID de partido inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listar partidos del torneo
|
||||||
|
router.get(
|
||||||
|
'/matches',
|
||||||
|
authenticate,
|
||||||
|
validateQuery(matchFiltersSchema),
|
||||||
|
TournamentMatchController.getMatches
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener mis partidos en el torneo
|
||||||
|
router.get(
|
||||||
|
'/my-matches',
|
||||||
|
authenticate,
|
||||||
|
TournamentMatchController.getMyMatches
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener partidos de un participante específico
|
||||||
|
router.get(
|
||||||
|
'/participants/:participantId/matches',
|
||||||
|
authenticate,
|
||||||
|
TournamentMatchController.getParticipantMatches
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener un partido específico
|
||||||
|
router.get(
|
||||||
|
'/matches/:matchId',
|
||||||
|
authenticate,
|
||||||
|
validate(z.object({ matchId: z.string().uuid() })),
|
||||||
|
TournamentMatchController.getMatch
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actualizar partido (solo admins)
|
||||||
|
router.put(
|
||||||
|
'/matches/:matchId',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
validate(updateMatchSchema),
|
||||||
|
TournamentMatchController.updateMatch
|
||||||
|
);
|
||||||
|
|
||||||
|
// Asignar cancha (solo admins)
|
||||||
|
router.put(
|
||||||
|
'/matches/:matchId/assign-court',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
validate(assignCourtSchema),
|
||||||
|
TournamentMatchController.assignCourt
|
||||||
|
);
|
||||||
|
|
||||||
|
// Iniciar partido (solo admins)
|
||||||
|
router.put(
|
||||||
|
'/matches/:matchId/start',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
validate(matchIdSchema),
|
||||||
|
TournamentMatchController.startMatch
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancelar partido (solo admins)
|
||||||
|
router.put(
|
||||||
|
'/matches/:matchId/cancel',
|
||||||
|
authenticate,
|
||||||
|
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||||
|
validate(cancelMatchSchema),
|
||||||
|
TournamentMatchController.cancelMatch
|
||||||
|
);
|
||||||
|
|
||||||
|
// Registrar resultado (jugadores o admins)
|
||||||
|
router.put(
|
||||||
|
'/matches/:matchId/result',
|
||||||
|
authenticate,
|
||||||
|
validate(recordResultSchema),
|
||||||
|
TournamentMatchController.recordResult
|
||||||
|
);
|
||||||
|
|
||||||
|
// Confirmar resultado (jugadores)
|
||||||
|
router.put(
|
||||||
|
'/matches/:matchId/confirm',
|
||||||
|
authenticate,
|
||||||
|
validate(matchIdSchema),
|
||||||
|
TournamentMatchController.confirmResult
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
502
backend/src/services/league.service.ts
Normal file
502
backend/src/services/league.service.ts
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import { LeagueStatus, LeagueType, LeagueFormat } from '../utils/constants';
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
export interface CreateLeagueInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
format?: string;
|
||||||
|
matchesPerMatchday?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeagueInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
format?: string;
|
||||||
|
matchesPerMatchday?: number;
|
||||||
|
startDate?: string | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeagueFilters {
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
createdById?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeagueService {
|
||||||
|
/**
|
||||||
|
* Crear una nueva liga
|
||||||
|
*/
|
||||||
|
static async createLeague(adminId: string, data: CreateLeagueInput) {
|
||||||
|
// Validar fechas si se proporcionan
|
||||||
|
let startDate: Date | undefined;
|
||||||
|
let endDate: Date | undefined;
|
||||||
|
|
||||||
|
if (data.startDate) {
|
||||||
|
startDate = new Date(data.startDate);
|
||||||
|
if (isNaN(startDate.getTime())) {
|
||||||
|
throw new ApiError('Fecha de inicio inválida', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.endDate) {
|
||||||
|
endDate = new Date(data.endDate);
|
||||||
|
if (isNaN(endDate.getTime())) {
|
||||||
|
throw new ApiError('Fecha de fin inválida', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate && endDate && endDate <= startDate) {
|
||||||
|
throw new ApiError('La fecha de fin debe ser posterior a la fecha de inicio', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const league = await prisma.league.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
type: LeagueType.TEAM_LEAGUE,
|
||||||
|
format: data.format || LeagueFormat.DOUBLE_ROUND_ROBIN,
|
||||||
|
matchesPerMatchday: data.matchesPerMatchday || 2,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
status: LeagueStatus.DRAFT,
|
||||||
|
createdById: adminId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
teams: true,
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return league;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener lista de ligas con filtros
|
||||||
|
*/
|
||||||
|
static async getLeagues(filters: LeagueFilters = {}) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
where.status = filters.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.type) {
|
||||||
|
where.type = filters.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.createdById) {
|
||||||
|
where.createdById = filters.createdById;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leagues = await prisma.league.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
teams: true,
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return leagues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener liga por ID con detalles completos
|
||||||
|
*/
|
||||||
|
static async getLeagueById(id: string) {
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
include: {
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: { isActive: true },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
},
|
||||||
|
standings: {
|
||||||
|
include: {
|
||||||
|
team: true,
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ position: 'asc' },
|
||||||
|
{ points: 'desc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
teams: true,
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return league;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar liga (solo si está en estado DRAFT o por el creador/admin)
|
||||||
|
*/
|
||||||
|
static async updateLeague(id: string, adminId: string, data: UpdateLeagueInput) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo el creador puede actualizar
|
||||||
|
if (league.createdById !== adminId) {
|
||||||
|
throw new ApiError('No tienes permisos para actualizar esta liga', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No se puede modificar si ya está finalizada o cancelada
|
||||||
|
if (league.status === LeagueStatus.FINISHED || league.status === LeagueStatus.CANCELLED) {
|
||||||
|
throw new ApiError('No se puede modificar una liga finalizada o cancelada', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar fechas
|
||||||
|
let startDate: Date | undefined | null = data.startDate === null ? null : undefined;
|
||||||
|
let endDate: Date | undefined | null = data.endDate === null ? null : undefined;
|
||||||
|
|
||||||
|
if (data.startDate && data.startDate !== null) {
|
||||||
|
startDate = new Date(data.startDate);
|
||||||
|
if (isNaN(startDate.getTime())) {
|
||||||
|
throw new ApiError('Fecha de inicio inválida', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.endDate && data.endDate !== null) {
|
||||||
|
endDate = new Date(data.endDate);
|
||||||
|
if (isNaN(endDate.getTime())) {
|
||||||
|
throw new ApiError('Fecha de fin inválida', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalStartDate = startDate !== undefined ? startDate : league.startDate;
|
||||||
|
const finalEndDate = endDate !== undefined ? endDate : league.endDate;
|
||||||
|
|
||||||
|
if (finalStartDate && finalEndDate && finalEndDate <= finalStartDate) {
|
||||||
|
throw new ApiError('La fecha de fin debe ser posterior a la fecha de inicio', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.league.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
format: data.format,
|
||||||
|
matchesPerMatchday: data.matchesPerMatchday,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
teams: true,
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar liga (solo si está en estado DRAFT)
|
||||||
|
*/
|
||||||
|
static async deleteLeague(id: string, adminId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo el creador puede eliminar
|
||||||
|
if (league.createdById !== adminId) {
|
||||||
|
throw new ApiError('No tienes permisos para eliminar esta liga', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo se puede eliminar si está en DRAFT
|
||||||
|
if (league.status !== LeagueStatus.DRAFT) {
|
||||||
|
throw new ApiError('Solo se pueden eliminar ligas en estado borrador', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.league.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Liga eliminada exitosamente' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iniciar liga (cambiar estado de DRAFT a ACTIVE)
|
||||||
|
* Requiere mínimo 3 equipos
|
||||||
|
*/
|
||||||
|
static async startLeague(id: string, adminId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
teams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo el creador puede iniciar
|
||||||
|
if (league.createdById !== adminId) {
|
||||||
|
throw new ApiError('No tienes permisos para iniciar esta liga', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo se puede iniciar si está en DRAFT
|
||||||
|
if (league.status !== LeagueStatus.DRAFT) {
|
||||||
|
throw new ApiError('Solo se pueden iniciar ligas en estado borrador', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mínimo 3 equipos
|
||||||
|
if (league._count.teams < 3) {
|
||||||
|
throw new ApiError('Se requieren al menos 3 equipos para iniciar la liga', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.league.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: LeagueStatus.ACTIVE,
|
||||||
|
startDate: league.startDate || new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
teams: true,
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalizar liga (cambiar estado a FINISHED)
|
||||||
|
*/
|
||||||
|
static async finishLeague(id: string, adminId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo el creador puede finalizar
|
||||||
|
if (league.createdById !== adminId) {
|
||||||
|
throw new ApiError('No tienes permisos para finalizar esta liga', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo se puede finalizar si está en ACTIVE
|
||||||
|
if (league.status !== LeagueStatus.ACTIVE) {
|
||||||
|
throw new ApiError('Solo se pueden finalizar ligas activas', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.league.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: LeagueStatus.FINISHED,
|
||||||
|
endDate: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
teams: true,
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelar liga
|
||||||
|
*/
|
||||||
|
static async cancelLeague(id: string, adminId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo el creador puede cancelar
|
||||||
|
if (league.createdById !== adminId) {
|
||||||
|
throw new ApiError('No tienes permisos para cancelar esta liga', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No se puede cancelar si ya está finalizada
|
||||||
|
if (league.status === LeagueStatus.FINISHED) {
|
||||||
|
throw new ApiError('No se puede cancelar una liga finalizada', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.league.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: LeagueStatus.CANCELLED,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
teams: true,
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si el usuario es el creador de la liga
|
||||||
|
*/
|
||||||
|
static async isLeagueCreator(leagueId: string, userId: string): Promise<boolean> {
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
select: { createdById: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return league?.createdById === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si la liga está en estado editable (DRAFT)
|
||||||
|
*/
|
||||||
|
static async isLeagueEditable(leagueId: string): Promise<boolean> {
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return league?.status === LeagueStatus.DRAFT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeagueService;
|
||||||
442
backend/src/services/leagueMatch.service.ts
Normal file
442
backend/src/services/leagueMatch.service.ts
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import { LeagueMatchStatus, LeagueStatus } from '../utils/constants';
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
export interface UpdateMatchResultInput {
|
||||||
|
team1Score: number;
|
||||||
|
team2Score: number;
|
||||||
|
setDetails?: { team1Games: number; team2Games: number }[];
|
||||||
|
winner: 'TEAM1' | 'TEAM2' | 'DRAW';
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMatchStatusInput {
|
||||||
|
status: string;
|
||||||
|
scheduledDate?: string;
|
||||||
|
scheduledTime?: string;
|
||||||
|
courtId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeagueMatchService {
|
||||||
|
/**
|
||||||
|
* Obtener todos los partidos de una liga
|
||||||
|
*/
|
||||||
|
static async getMatches(leagueId: string, filters?: { status?: string; matchday?: number }) {
|
||||||
|
const where: any = { leagueId };
|
||||||
|
|
||||||
|
if (filters?.status) {
|
||||||
|
where.status = filters.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.matchday !== undefined) {
|
||||||
|
where.matchday = filters.matchday;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await prisma.leagueMatch.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ matchday: 'asc' },
|
||||||
|
{ scheduledDate: 'asc' },
|
||||||
|
{ scheduledTime: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener partido por ID
|
||||||
|
*/
|
||||||
|
static async getMatchById(matchId: string) {
|
||||||
|
const match = await prisma.leagueMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
createdById: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team1: {
|
||||||
|
include: {
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
where: { isActive: true },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
include: {
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
where: { isActive: true },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
court: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar resultado de un partido
|
||||||
|
*/
|
||||||
|
static async updateMatchResult(
|
||||||
|
matchId: string,
|
||||||
|
userId: string,
|
||||||
|
data: UpdateMatchResultInput
|
||||||
|
) {
|
||||||
|
// Verificar que el partido existe
|
||||||
|
const match = await prisma.leagueMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
createdById: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
captainId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
captainId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permisos (creador de liga, capitán de equipo 1 o capitán de equipo 2)
|
||||||
|
const isLeagueCreator = match.league.createdById === userId;
|
||||||
|
const isTeam1Captain = match.team1.captainId === userId;
|
||||||
|
const isTeam2Captain = match.team2.captainId === userId;
|
||||||
|
|
||||||
|
if (!isLeagueCreator && !isTeam1Captain && !isTeam2Captain) {
|
||||||
|
throw new ApiError('No tienes permisos para actualizar este partido', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que la liga esté activa
|
||||||
|
if (match.league.status !== LeagueStatus.ACTIVE) {
|
||||||
|
throw new ApiError('No se pueden actualizar resultados en una liga que no está activa', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar el resultado
|
||||||
|
if (data.team1Score < 0 || data.team2Score < 0) {
|
||||||
|
throw new ApiError('El resultado no puede ser negativo', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar consistencia del ganador
|
||||||
|
if (data.winner === 'TEAM1' && data.team1Score <= data.team2Score) {
|
||||||
|
throw new ApiError('El ganador TEAM1 debe tener más sets que TEAM2', 400);
|
||||||
|
}
|
||||||
|
if (data.winner === 'TEAM2' && data.team2Score <= data.team1Score) {
|
||||||
|
throw new ApiError('El ganador TEAM2 debe tener más sets que TEAM1', 400);
|
||||||
|
}
|
||||||
|
if (data.winner === 'DRAW' && data.team1Score !== data.team2Score) {
|
||||||
|
throw new ApiError('En empate ambos equipos deben tener el mismo número de sets', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar detalle de sets si se proporciona
|
||||||
|
if (data.setDetails && data.setDetails.length > 0) {
|
||||||
|
const setsTeam1 = data.setDetails.filter(s => (s.team1Games || 0) > (s.team2Games || 0)).length;
|
||||||
|
const setsTeam2 = data.setDetails.filter(s => (s.team2Games || 0) > (s.team1Games || 0)).length;
|
||||||
|
|
||||||
|
if (setsTeam1 !== data.team1Score || setsTeam2 !== data.team2Score) {
|
||||||
|
throw new ApiError('El detalle de sets no coincide con el resultado', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leagueMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: {
|
||||||
|
team1Score: data.team1Score,
|
||||||
|
team2Score: data.team2Score,
|
||||||
|
setDetails: data.setDetails ? JSON.stringify(data.setDetails) : undefined,
|
||||||
|
winner: data.winner,
|
||||||
|
status: LeagueMatchStatus.COMPLETED,
|
||||||
|
completedAt: new Date(),
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar estado de un partido
|
||||||
|
*/
|
||||||
|
static async updateMatchStatus(
|
||||||
|
matchId: string,
|
||||||
|
userId: string,
|
||||||
|
data: UpdateMatchStatusInput
|
||||||
|
) {
|
||||||
|
// Verificar que el partido existe
|
||||||
|
const match = await prisma.leagueMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
createdById: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
captainId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
captainId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permisos
|
||||||
|
const isLeagueCreator = match.league.createdById === userId;
|
||||||
|
const isTeam1Captain = match.team1.captainId === userId;
|
||||||
|
const isTeam2Captain = match.team2.captainId === userId;
|
||||||
|
|
||||||
|
if (!isLeagueCreator && !isTeam1Captain && !isTeam2Captain) {
|
||||||
|
throw new ApiError('No tienes permisos para actualizar este partido', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No se puede modificar si ya está completado (excepto el propio estado)
|
||||||
|
if (match.status === LeagueMatchStatus.COMPLETED && data.status !== LeagueMatchStatus.COMPLETED) {
|
||||||
|
throw new ApiError('No se puede cambiar el estado de un partido completado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = { status: data.status };
|
||||||
|
|
||||||
|
if (data.scheduledDate !== undefined) {
|
||||||
|
updateData.scheduledDate = data.scheduledDate ? new Date(data.scheduledDate) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.scheduledTime !== undefined) {
|
||||||
|
updateData.scheduledTime = data.scheduledTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.courtId !== undefined) {
|
||||||
|
updateData.courtId = data.courtId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leagueMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
court: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anular resultado de un partido (volver a programado)
|
||||||
|
*/
|
||||||
|
static async voidMatchResult(matchId: string, userId: string) {
|
||||||
|
// Verificar que el partido existe
|
||||||
|
const match = await prisma.leagueMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
createdById: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo el creador de la liga puede anular resultados
|
||||||
|
if (match.league.createdById !== userId) {
|
||||||
|
throw new ApiError('Solo el creador de la liga puede anular resultados', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo se puede anular si está completado
|
||||||
|
if (match.status !== LeagueMatchStatus.COMPLETED) {
|
||||||
|
throw new ApiError('Solo se pueden anular partidos completados', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leagueMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: {
|
||||||
|
status: LeagueMatchStatus.SCHEDULED,
|
||||||
|
team1Score: null,
|
||||||
|
team2Score: null,
|
||||||
|
setDetails: null,
|
||||||
|
winner: null,
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener resumen de partidos de una liga
|
||||||
|
*/
|
||||||
|
static async getMatchSummary(leagueId: string) {
|
||||||
|
const matches = await prisma.leagueMatch.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
where: { leagueId },
|
||||||
|
_count: {
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalMatches = await prisma.leagueMatch.count({
|
||||||
|
where: { leagueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedMatches = await prisma.leagueMatch.count({
|
||||||
|
where: {
|
||||||
|
leagueId,
|
||||||
|
status: LeagueMatchStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: totalMatches,
|
||||||
|
completed: completedMatches,
|
||||||
|
pending: totalMatches - completedMatches,
|
||||||
|
byStatus: matches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeagueMatchService;
|
||||||
553
backend/src/services/leagueSchedule.service.ts
Normal file
553
backend/src/services/leagueSchedule.service.ts
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import { LeagueStatus, LeagueFormat, LeagueMatchStatus } from '../utils/constants';
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
export interface MatchScheduleInput {
|
||||||
|
matchId: string;
|
||||||
|
scheduledDate?: string;
|
||||||
|
scheduledTime?: string;
|
||||||
|
courtId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundRobinMatch {
|
||||||
|
team1Id: string;
|
||||||
|
team2Id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Matchday {
|
||||||
|
matchday: number;
|
||||||
|
matches: RoundRobinMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeagueScheduleService {
|
||||||
|
/**
|
||||||
|
* Generar calendario completo de la liga (todos vs todos)
|
||||||
|
*/
|
||||||
|
static async generateSchedule(leagueId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
include: {
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
matches: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que la liga está en estado DRAFT
|
||||||
|
if (league.status !== LeagueStatus.DRAFT) {
|
||||||
|
throw new ApiError('Solo se puede generar el calendario en ligas en estado borrador', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que hay al menos 3 equipos
|
||||||
|
if (league.teams.length < 3) {
|
||||||
|
throw new ApiError('Se requieren al menos 3 equipos para generar el calendario', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que no hay partidos existentes
|
||||||
|
if (league.matches.length > 0) {
|
||||||
|
throw new ApiError('Ya existe un calendario generado para esta liga', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamIds = league.teams.map((t) => t.id);
|
||||||
|
const isDoubleRoundRobin = league.format === LeagueFormat.DOUBLE_ROUND_ROBIN ||
|
||||||
|
league.format === LeagueFormat.DOUBLE_MATCHDAY;
|
||||||
|
|
||||||
|
// Generar jornadas (ida)
|
||||||
|
const firstRoundMatchdays = this.generateRoundRobin(teamIds);
|
||||||
|
|
||||||
|
// Generar jornadas de vuelta si es doble round robin
|
||||||
|
let allMatchdays: Matchday[];
|
||||||
|
|
||||||
|
if (isDoubleRoundRobin) {
|
||||||
|
const secondRoundMatchdays = firstRoundMatchdays.map((matchday) => ({
|
||||||
|
matchday: matchday.matchday + firstRoundMatchdays.length,
|
||||||
|
matches: matchday.matches.map((match) => ({
|
||||||
|
team1Id: match.team2Id,
|
||||||
|
team2Id: match.team1Id,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
allMatchdays = [...firstRoundMatchdays, ...secondRoundMatchdays];
|
||||||
|
} else {
|
||||||
|
allMatchdays = firstRoundMatchdays;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear los partidos en la base de datos
|
||||||
|
const createdMatches = [];
|
||||||
|
for (const matchday of allMatchdays) {
|
||||||
|
for (const match of matchday.matches) {
|
||||||
|
const createdMatch = await prisma.leagueMatch.create({
|
||||||
|
data: {
|
||||||
|
leagueId,
|
||||||
|
matchday: matchday.matchday,
|
||||||
|
team1Id: match.team1Id,
|
||||||
|
team2Id: match.team2Id,
|
||||||
|
status: LeagueMatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdMatches.push(createdMatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
leagueId,
|
||||||
|
totalMatchdays: allMatchdays.length,
|
||||||
|
totalMatches: createdMatches.length,
|
||||||
|
matches: createdMatches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Algoritmo de round-robin (todos vs todos)
|
||||||
|
* Usa el algoritmo de "circle method"
|
||||||
|
*/
|
||||||
|
static generateRoundRobin(teamIds: string[]): Matchday[] {
|
||||||
|
const numTeams = teamIds.length;
|
||||||
|
|
||||||
|
// Si es número impar, agregar un "bye"
|
||||||
|
const teams = [...teamIds];
|
||||||
|
if (numTeams % 2 === 1) {
|
||||||
|
teams.push('BYE');
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = teams.length;
|
||||||
|
const numRounds = n - 1;
|
||||||
|
const matchesPerRound = n / 2;
|
||||||
|
|
||||||
|
const matchdays: Matchday[] = [];
|
||||||
|
|
||||||
|
// Crear array mutable para rotar (el primer equipo se queda fijo)
|
||||||
|
let rotatingTeams = teams.slice(1);
|
||||||
|
|
||||||
|
for (let round = 0; round < numRounds; round++) {
|
||||||
|
const matches: RoundRobinMatch[] = [];
|
||||||
|
|
||||||
|
// El primer equipo juega contra el último de los rotantes
|
||||||
|
if (teams[0] !== 'BYE' && rotatingTeams[rotatingTeams.length - 1] !== 'BYE') {
|
||||||
|
matches.push({
|
||||||
|
team1Id: teams[0],
|
||||||
|
team2Id: rotatingTeams[rotatingTeams.length - 1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Los demás equipos se emparejan simétricamente
|
||||||
|
for (let i = 0; i < matchesPerRound - 1; i++) {
|
||||||
|
const team1 = rotatingTeams[i];
|
||||||
|
const team2 = rotatingTeams[rotatingTeams.length - 2 - i];
|
||||||
|
|
||||||
|
if (team1 !== 'BYE' && team2 !== 'BYE') {
|
||||||
|
matches.push({
|
||||||
|
team1Id: team1,
|
||||||
|
team2Id: team2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matchdays.push({
|
||||||
|
matchday: round + 1,
|
||||||
|
matches,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rotar los equipos (excepto el primero)
|
||||||
|
rotatingTeams = [
|
||||||
|
rotatingTeams[rotatingTeams.length - 1],
|
||||||
|
...rotatingTeams.slice(0, rotatingTeams.length - 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchdays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener calendario completo de la liga
|
||||||
|
*/
|
||||||
|
static async getSchedule(leagueId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await prisma.leagueMatch.findMany({
|
||||||
|
where: { leagueId },
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ matchday: 'asc' },
|
||||||
|
{ scheduledDate: 'asc' },
|
||||||
|
{ scheduledTime: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por jornada
|
||||||
|
const matchdays = new Map<number, typeof matches>();
|
||||||
|
for (const match of matches) {
|
||||||
|
if (!matchdays.has(match.matchday)) {
|
||||||
|
matchdays.set(match.matchday, []);
|
||||||
|
}
|
||||||
|
matchdays.get(match.matchday)!.push(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
leagueId,
|
||||||
|
totalMatchdays: matchdays.size,
|
||||||
|
matchdays: Array.from(matchdays.entries()).map(([matchday, matches]) => ({
|
||||||
|
matchday,
|
||||||
|
matches,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener jornada específica
|
||||||
|
*/
|
||||||
|
static async getMatchday(leagueId: string, matchday: number) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await prisma.leagueMatch.findMany({
|
||||||
|
where: {
|
||||||
|
leagueId,
|
||||||
|
matchday,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ scheduledDate: 'asc' },
|
||||||
|
{ scheduledTime: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
throw new ApiError('Jornada no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
leagueId,
|
||||||
|
matchday,
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar fecha/hora/cancha de un partido
|
||||||
|
*/
|
||||||
|
static async updateMatchDate(
|
||||||
|
matchId: string,
|
||||||
|
userId: string,
|
||||||
|
data: {
|
||||||
|
scheduledDate?: string;
|
||||||
|
scheduledTime?: string;
|
||||||
|
courtId?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
// Verificar que el partido existe
|
||||||
|
const match = await prisma.leagueMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
createdById: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
captainId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
captainId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permisos (creador de liga, capitán de equipo 1 o capitán de equipo 2)
|
||||||
|
const isLeagueCreator = match.league.createdById === userId;
|
||||||
|
const isTeam1Captain = match.team1.captainId === userId;
|
||||||
|
const isTeam2Captain = match.team2.captainId === userId;
|
||||||
|
|
||||||
|
if (!isLeagueCreator && !isTeam1Captain && !isTeam2Captain) {
|
||||||
|
throw new ApiError('No tienes permisos para modificar este partido', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No se puede modificar si ya está completado
|
||||||
|
if (match.status === 'COMPLETED' || match.status === 'CANCELLED') {
|
||||||
|
throw new ApiError('No se puede modificar un partido finalizado o cancelado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar cancha si se proporciona
|
||||||
|
if (data.courtId) {
|
||||||
|
const court = await prisma.court.findUnique({
|
||||||
|
where: { id: data.courtId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!court) {
|
||||||
|
throw new ApiError('Cancha no encontrada', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar fecha
|
||||||
|
let scheduledDate: Date | null = null;
|
||||||
|
if (data.scheduledDate !== undefined) {
|
||||||
|
if (data.scheduledDate === null) {
|
||||||
|
scheduledDate = null;
|
||||||
|
} else {
|
||||||
|
scheduledDate = new Date(data.scheduledDate);
|
||||||
|
if (isNaN(scheduledDate.getTime())) {
|
||||||
|
throw new ApiError('Fecha inválida', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar hora
|
||||||
|
if (data.scheduledTime !== undefined && data.scheduledTime !== null) {
|
||||||
|
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||||
|
if (!timeRegex.test(data.scheduledTime)) {
|
||||||
|
throw new ApiError('Hora inválida. Use formato HH:mm', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leagueMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: {
|
||||||
|
scheduledDate: scheduledDate !== undefined ? scheduledDate : match.scheduledDate,
|
||||||
|
scheduledTime: data.scheduledTime !== undefined ? data.scheduledTime : match.scheduledTime,
|
||||||
|
courtId: data.courtId !== undefined ? data.courtId : match.courtId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener partidos de un equipo específico
|
||||||
|
*/
|
||||||
|
static async getTeamMatches(teamId: string) {
|
||||||
|
const matches = await prisma.leagueMatch.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ team1Id: teamId },
|
||||||
|
{ team2Id: teamId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ matchday: 'asc' },
|
||||||
|
{ scheduledDate: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener partidos pendientes de programar
|
||||||
|
*/
|
||||||
|
static async getUnscheduledMatches(leagueId: string) {
|
||||||
|
const matches = await prisma.leagueMatch.findMany({
|
||||||
|
where: {
|
||||||
|
leagueId,
|
||||||
|
scheduledDate: null,
|
||||||
|
status: {
|
||||||
|
notIn: ['CANCELLED', 'COMPLETED'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ matchday: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar calendario (solo si la liga está en DRAFT)
|
||||||
|
*/
|
||||||
|
static async deleteSchedule(leagueId: string, userId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo el creador puede eliminar
|
||||||
|
if (league.createdById !== userId) {
|
||||||
|
throw new ApiError('No tienes permisos para eliminar el calendario', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo se puede eliminar si está en DRAFT
|
||||||
|
if (league.status !== LeagueStatus.DRAFT) {
|
||||||
|
throw new ApiError('No se puede eliminar el calendario una vez iniciada la liga', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar todos los partidos
|
||||||
|
await prisma.leagueMatch.deleteMany({
|
||||||
|
where: { leagueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Calendario eliminado exitosamente' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeagueScheduleService;
|
||||||
533
backend/src/services/leagueStanding.service.ts
Normal file
533
backend/src/services/leagueStanding.service.ts
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import { LeaguePoints, DEFAULT_TIEBREAKER_ORDER, TiebreakerCriteria } from '../utils/constants';
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
export interface StandingTeam {
|
||||||
|
teamId: string;
|
||||||
|
matchesPlayed: number;
|
||||||
|
matchesWon: number;
|
||||||
|
matchesLost: number;
|
||||||
|
matchesDrawn: number;
|
||||||
|
setsFor: number;
|
||||||
|
setsAgainst: number;
|
||||||
|
gamesFor: number;
|
||||||
|
gamesAgainst: number;
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopScorer {
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
matchesPlayed: number;
|
||||||
|
setsWon: number;
|
||||||
|
gamesWon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeagueStandingService {
|
||||||
|
/**
|
||||||
|
* Calcular y actualizar clasificación completa de una liga
|
||||||
|
*/
|
||||||
|
static async calculateStandings(leagueId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
include: {
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
matches: {
|
||||||
|
where: {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
winner: { not: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inicializar estadísticas para todos los equipos
|
||||||
|
const standingsMap = new Map<string, StandingTeam>();
|
||||||
|
|
||||||
|
for (const team of league.teams) {
|
||||||
|
standingsMap.set(team.id, {
|
||||||
|
teamId: team.id,
|
||||||
|
matchesPlayed: 0,
|
||||||
|
matchesWon: 0,
|
||||||
|
matchesLost: 0,
|
||||||
|
matchesDrawn: 0,
|
||||||
|
setsFor: 0,
|
||||||
|
setsAgainst: 0,
|
||||||
|
gamesFor: 0,
|
||||||
|
gamesAgainst: 0,
|
||||||
|
points: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar todos los partidos completados
|
||||||
|
for (const match of league.matches) {
|
||||||
|
const team1 = standingsMap.get(match.team1Id);
|
||||||
|
const team2 = standingsMap.get(match.team2Id);
|
||||||
|
|
||||||
|
if (!team1 || !team2) continue;
|
||||||
|
|
||||||
|
// Parsear detalle de sets si existe
|
||||||
|
let setDetails: { team1Games: number; team2Games: number }[] = [];
|
||||||
|
if (match.setDetails) {
|
||||||
|
try {
|
||||||
|
setDetails = JSON.parse(match.setDetails);
|
||||||
|
} catch {
|
||||||
|
setDetails = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular games totales
|
||||||
|
let team1Games = 0;
|
||||||
|
let team2Games = 0;
|
||||||
|
|
||||||
|
for (const set of setDetails) {
|
||||||
|
team1Games += set.team1Games || 0;
|
||||||
|
team2Games += set.team2Games || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar estadísticas
|
||||||
|
team1.matchesPlayed++;
|
||||||
|
team2.matchesPlayed++;
|
||||||
|
|
||||||
|
team1.setsFor += match.team1Score || 0;
|
||||||
|
team1.setsAgainst += match.team2Score || 0;
|
||||||
|
team2.setsFor += match.team2Score || 0;
|
||||||
|
team2.setsAgainst += match.team1Score || 0;
|
||||||
|
|
||||||
|
team1.gamesFor += team1Games;
|
||||||
|
team1.gamesAgainst += team2Games;
|
||||||
|
team2.gamesFor += team2Games;
|
||||||
|
team2.gamesAgainst += team1Games;
|
||||||
|
|
||||||
|
if (match.winner === 'TEAM1') {
|
||||||
|
team1.matchesWon++;
|
||||||
|
team1.points += LeaguePoints.WIN;
|
||||||
|
team2.matchesLost++;
|
||||||
|
team2.points += LeaguePoints.LOSS;
|
||||||
|
} else if (match.winner === 'TEAM2') {
|
||||||
|
team2.matchesWon++;
|
||||||
|
team2.points += LeaguePoints.WIN;
|
||||||
|
team1.matchesLost++;
|
||||||
|
team1.points += LeaguePoints.LOSS;
|
||||||
|
} else if (match.winner === 'DRAW') {
|
||||||
|
team1.matchesDrawn++;
|
||||||
|
team1.points += LeaguePoints.DRAW;
|
||||||
|
team2.matchesDrawn++;
|
||||||
|
team2.points += LeaguePoints.DRAW;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir a array y ordenar según criterios de desempate
|
||||||
|
let standings = Array.from(standingsMap.values());
|
||||||
|
standings = this.applyTiebreakers(standings);
|
||||||
|
|
||||||
|
// Guardar en la base de datos
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (let i = 0; i < standings.length; i++) {
|
||||||
|
const standing = standings[i];
|
||||||
|
|
||||||
|
await tx.leagueStanding.updateMany({
|
||||||
|
where: {
|
||||||
|
leagueId,
|
||||||
|
teamId: standing.teamId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
matchesPlayed: standing.matchesPlayed,
|
||||||
|
matchesWon: standing.matchesWon,
|
||||||
|
matchesLost: standing.matchesLost,
|
||||||
|
matchesDrawn: standing.matchesDrawn,
|
||||||
|
setsFor: standing.setsFor,
|
||||||
|
setsAgainst: standing.setsAgainst,
|
||||||
|
gamesFor: standing.gamesFor,
|
||||||
|
gamesAgainst: standing.gamesAgainst,
|
||||||
|
points: standing.points,
|
||||||
|
position: i + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getStandings(leagueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar clasificación después de un partido específico
|
||||||
|
*/
|
||||||
|
static async updateStandingsAfterMatch(matchId: string) {
|
||||||
|
const match = await prisma.leagueMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
select: {
|
||||||
|
leagueId: true,
|
||||||
|
team1Id: true,
|
||||||
|
team2Id: true,
|
||||||
|
team1Score: true,
|
||||||
|
team2Score: true,
|
||||||
|
winner: true,
|
||||||
|
setDetails: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match || match.status !== 'COMPLETED' || !match.winner) {
|
||||||
|
throw new ApiError('El partido no está completado o no tiene resultado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalcular toda la clasificación
|
||||||
|
return this.calculateStandings(match.leagueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener clasificación de una liga ordenada por posición
|
||||||
|
*/
|
||||||
|
static async getStandings(leagueId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const standings = await prisma.leagueStanding.findMany({
|
||||||
|
where: { leagueId },
|
||||||
|
include: {
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
where: { isActive: true },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ position: 'asc' },
|
||||||
|
{ points: 'desc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agregar estadísticas adicionales
|
||||||
|
return standings.map((standing) => ({
|
||||||
|
...standing,
|
||||||
|
setsDifference: standing.setsFor - standing.setsAgainst,
|
||||||
|
gamesDifference: standing.gamesFor - standing.gamesAgainst,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplicar criterios de desempate
|
||||||
|
* Orden por defecto: Puntos -> Diferencia de sets -> Diferencia de games -> Enfrentamiento directo
|
||||||
|
*/
|
||||||
|
static applyTiebreakers(standings: StandingTeam[]): StandingTeam[] {
|
||||||
|
return standings.sort((a, b) => {
|
||||||
|
for (const criteria of DEFAULT_TIEBREAKER_ORDER) {
|
||||||
|
const comparison = this.compareByCriteria(a, b, criteria);
|
||||||
|
if (comparison !== 0) {
|
||||||
|
return comparison;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparar dos equipos por un criterio específico
|
||||||
|
*/
|
||||||
|
private static compareByCriteria(
|
||||||
|
a: StandingTeam,
|
||||||
|
b: StandingTeam,
|
||||||
|
criteria: string
|
||||||
|
): number {
|
||||||
|
switch (criteria) {
|
||||||
|
case TiebreakerCriteria.POINTS:
|
||||||
|
return b.points - a.points;
|
||||||
|
|
||||||
|
case TiebreakerCriteria.SETS_DIFFERENCE:
|
||||||
|
const setDiffA = a.setsFor - a.setsAgainst;
|
||||||
|
const setDiffB = b.setsFor - b.setsAgainst;
|
||||||
|
return setDiffB - setDiffA;
|
||||||
|
|
||||||
|
case TiebreakerCriteria.GAMES_DIFFERENCE:
|
||||||
|
const gameDiffA = a.gamesFor - a.gamesAgainst;
|
||||||
|
const gameDiffB = b.gamesFor - b.gamesAgainst;
|
||||||
|
return gameDiffB - gameDiffA;
|
||||||
|
|
||||||
|
case TiebreakerCriteria.WINS:
|
||||||
|
return b.matchesWon - a.matchesWon;
|
||||||
|
|
||||||
|
case TiebreakerCriteria.DIRECT_ENCOUNTER:
|
||||||
|
// Para implementar completamente necesitaría consultar los resultados directos
|
||||||
|
// Por ahora, no afecta el ordenamiento (retorna 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener mejores jugadores (goleadores) de la liga
|
||||||
|
* Basado en sets y games ganados
|
||||||
|
*/
|
||||||
|
static async getTopScorers(leagueId: string, limit: number = 10) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener todos los partidos completados con detalles
|
||||||
|
const matches = await prisma.leagueMatch.findMany({
|
||||||
|
where: {
|
||||||
|
leagueId,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
where: { isActive: true },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
where: { isActive: true },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mapa para acumular estadísticas de jugadores
|
||||||
|
const playerStats = new Map<string, TopScorer>();
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
// Parsear detalle de sets
|
||||||
|
let setDetails: { team1Games: number; team2Games: number }[] = [];
|
||||||
|
if (match.setDetails) {
|
||||||
|
try {
|
||||||
|
setDetails = JSON.parse(match.setDetails);
|
||||||
|
} catch {
|
||||||
|
setDetails = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular games totales
|
||||||
|
const team1Games = setDetails.reduce((sum, set) => sum + (set.team1Games || 0), 0);
|
||||||
|
const team2Games = setDetails.reduce((sum, set) => sum + (set.team2Games || 0), 0);
|
||||||
|
|
||||||
|
// Procesar jugadores del equipo 1
|
||||||
|
for (const member of match.team1.members) {
|
||||||
|
const userId = member.userId;
|
||||||
|
const existing = playerStats.get(userId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.matchesPlayed++;
|
||||||
|
existing.setsWon += match.team1Score || 0;
|
||||||
|
existing.gamesWon += team1Games;
|
||||||
|
} else {
|
||||||
|
playerStats.set(userId, {
|
||||||
|
userId,
|
||||||
|
firstName: member.user.firstName,
|
||||||
|
lastName: member.user.lastName,
|
||||||
|
teamId: match.team1.id,
|
||||||
|
teamName: match.team1.name,
|
||||||
|
matchesPlayed: 1,
|
||||||
|
setsWon: match.team1Score || 0,
|
||||||
|
gamesWon: team1Games,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar jugadores del equipo 2
|
||||||
|
for (const member of match.team2.members) {
|
||||||
|
const userId = member.userId;
|
||||||
|
const existing = playerStats.get(userId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.matchesPlayed++;
|
||||||
|
existing.setsWon += match.team2Score || 0;
|
||||||
|
existing.gamesWon += team2Games;
|
||||||
|
} else {
|
||||||
|
playerStats.set(userId, {
|
||||||
|
userId,
|
||||||
|
firstName: member.user.firstName,
|
||||||
|
lastName: member.user.lastName,
|
||||||
|
teamId: match.team2.id,
|
||||||
|
teamName: match.team2.name,
|
||||||
|
matchesPlayed: 1,
|
||||||
|
setsWon: match.team2Score || 0,
|
||||||
|
gamesWon: team2Games,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir a array y ordenar por sets ganados, luego por games
|
||||||
|
const topScorers = Array.from(playerStats.values())
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.setsWon !== a.setsWon) {
|
||||||
|
return b.setsWon - a.setsWon;
|
||||||
|
}
|
||||||
|
return b.gamesWon - a.gamesWon;
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return topScorers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiniciar clasificación de una liga
|
||||||
|
*/
|
||||||
|
static async resetStandings(leagueId: string, userId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo el creador puede reiniciar
|
||||||
|
if (league.createdById !== userId) {
|
||||||
|
throw new ApiError('No tienes permisos para reiniciar la clasificación', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reiniciar todas las estadísticas
|
||||||
|
await prisma.leagueStanding.updateMany({
|
||||||
|
where: { leagueId },
|
||||||
|
data: {
|
||||||
|
matchesPlayed: 0,
|
||||||
|
matchesWon: 0,
|
||||||
|
matchesLost: 0,
|
||||||
|
matchesDrawn: 0,
|
||||||
|
setsFor: 0,
|
||||||
|
setsAgainst: 0,
|
||||||
|
gamesFor: 0,
|
||||||
|
gamesAgainst: 0,
|
||||||
|
points: 0,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Clasificación reiniciada exitosamente' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener estadísticas comparativas entre equipos
|
||||||
|
*/
|
||||||
|
static async getTeamComparison(leagueId: string, team1Id: string, team2Id: string) {
|
||||||
|
// Verificar que ambos equipos existen y pertenecen a la liga
|
||||||
|
const teams = await prisma.leagueTeam.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: [team1Id, team2Id] },
|
||||||
|
leagueId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
standing: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (teams.length !== 2) {
|
||||||
|
throw new ApiError('Uno o ambos equipos no encontrados en esta liga', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener enfrentamientos directos
|
||||||
|
const directMatches = await prisma.leagueMatch.findMany({
|
||||||
|
where: {
|
||||||
|
leagueId,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
team1Id,
|
||||||
|
team2Id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
team1Id: team2Id,
|
||||||
|
team2Id: team1Id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
completedAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular estadísticas de enfrentamientos directos
|
||||||
|
let team1Wins = 0;
|
||||||
|
let team2Wins = 0;
|
||||||
|
let draws = 0;
|
||||||
|
|
||||||
|
for (const match of directMatches) {
|
||||||
|
if (match.winner === 'DRAW') {
|
||||||
|
draws++;
|
||||||
|
} else if (
|
||||||
|
(match.team1Id === team1Id && match.winner === 'TEAM1') ||
|
||||||
|
(match.team2Id === team1Id && match.winner === 'TEAM2')
|
||||||
|
) {
|
||||||
|
team1Wins++;
|
||||||
|
} else {
|
||||||
|
team2Wins++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
team1: teams.find((t) => t.id === team1Id)?.standing,
|
||||||
|
team2: teams.find((t) => t.id === team2Id)?.standing,
|
||||||
|
directMatches: {
|
||||||
|
total: directMatches.length,
|
||||||
|
team1Wins,
|
||||||
|
team2Wins,
|
||||||
|
draws,
|
||||||
|
matches: directMatches,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeagueStandingService;
|
||||||
641
backend/src/services/leagueTeam.service.ts
Normal file
641
backend/src/services/leagueTeam.service.ts
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import { LeagueStatus } from '../utils/constants';
|
||||||
|
import LeagueService from './league.service';
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
export interface CreateTeamInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTeamInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeagueTeamService {
|
||||||
|
/**
|
||||||
|
* Crear un nuevo equipo en una liga
|
||||||
|
*/
|
||||||
|
static async createTeam(
|
||||||
|
leagueId: string,
|
||||||
|
captainId: string,
|
||||||
|
data: CreateTeamInput
|
||||||
|
) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
include: {
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo se pueden agregar equipos si la liga está en DRAFT
|
||||||
|
if (league.status !== LeagueStatus.DRAFT) {
|
||||||
|
throw new ApiError('No se pueden agregar equipos una vez iniciada la liga', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el nombre no exista ya en la liga
|
||||||
|
const existingTeam = await prisma.leagueTeam.findFirst({
|
||||||
|
where: {
|
||||||
|
leagueId,
|
||||||
|
name: data.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTeam) {
|
||||||
|
throw new ApiError('Ya existe un equipo con este nombre en la liga', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el usuario no sea capitán de otro equipo en esta liga
|
||||||
|
const existingCaptain = await prisma.leagueTeam.findFirst({
|
||||||
|
where: {
|
||||||
|
leagueId,
|
||||||
|
captainId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCaptain) {
|
||||||
|
throw new ApiError('Ya eres capitán de otro equipo en esta liga', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el usuario no sea miembro de otro equipo en esta liga
|
||||||
|
const existingMembership = await prisma.leagueTeamMember.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: captainId,
|
||||||
|
team: {
|
||||||
|
leagueId,
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMembership) {
|
||||||
|
throw new ApiError('Ya eres miembro de otro equipo en esta liga', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear el equipo con el capitán como primer miembro
|
||||||
|
const team = await prisma.leagueTeam.create({
|
||||||
|
data: {
|
||||||
|
leagueId,
|
||||||
|
captainId,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
members: {
|
||||||
|
create: {
|
||||||
|
userId: captainId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Inicializar standing vacío
|
||||||
|
standing: {
|
||||||
|
create: {
|
||||||
|
leagueId,
|
||||||
|
matchesPlayed: 0,
|
||||||
|
matchesWon: 0,
|
||||||
|
matchesLost: 0,
|
||||||
|
matchesDrawn: 0,
|
||||||
|
setsFor: 0,
|
||||||
|
setsAgainst: 0,
|
||||||
|
gamesFor: 0,
|
||||||
|
gamesAgainst: 0,
|
||||||
|
points: 0,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
standing: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener todos los equipos de una liga
|
||||||
|
*/
|
||||||
|
static async getTeams(leagueId: string) {
|
||||||
|
// Verificar que la liga existe
|
||||||
|
const league = await prisma.league.findUnique({
|
||||||
|
where: { id: leagueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new ApiError('Liga no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = await prisma.leagueTeam.findMany({
|
||||||
|
where: { leagueId },
|
||||||
|
include: {
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: { isActive: true },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
standing: true,
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return teams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener equipo por ID con detalles completos
|
||||||
|
*/
|
||||||
|
static async getTeamById(teamId: string) {
|
||||||
|
const team = await prisma.leagueTeam.findUnique({
|
||||||
|
where: { id: teamId },
|
||||||
|
include: {
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { joinedAt: 'asc' },
|
||||||
|
},
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
matchesAsTeam1: {
|
||||||
|
include: {
|
||||||
|
team2: true,
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ matchday: 'asc' },
|
||||||
|
{ scheduledDate: 'asc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
matchesAsTeam2: {
|
||||||
|
include: {
|
||||||
|
team1: true,
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ matchday: 'asc' },
|
||||||
|
{ scheduledDate: 'asc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
standing: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new ApiError('Equipo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar equipo (solo capitán o admin de liga)
|
||||||
|
*/
|
||||||
|
static async updateTeam(
|
||||||
|
teamId: string,
|
||||||
|
userId: string,
|
||||||
|
data: UpdateTeamInput
|
||||||
|
) {
|
||||||
|
// Verificar que el equipo existe
|
||||||
|
const team = await prisma.leagueTeam.findUnique({
|
||||||
|
where: { id: teamId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
createdById: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new ApiError('Equipo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo capitán o creador de liga pueden actualizar
|
||||||
|
const isCaptain = team.captainId === userId;
|
||||||
|
const isLeagueCreator = team.league.createdById === userId;
|
||||||
|
|
||||||
|
if (!isCaptain && !isLeagueCreator) {
|
||||||
|
throw new ApiError('No tienes permisos para actualizar este equipo', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar nombre único si se está cambiando
|
||||||
|
if (data.name && data.name !== team.name) {
|
||||||
|
const existingTeam = await prisma.leagueTeam.findFirst({
|
||||||
|
where: {
|
||||||
|
leagueId: team.leagueId,
|
||||||
|
name: data.name,
|
||||||
|
id: { not: teamId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTeam) {
|
||||||
|
throw new ApiError('Ya existe un equipo con este nombre en la liga', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leagueTeam.update({
|
||||||
|
where: { id: teamId },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
captain: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
standing: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar equipo (solo capitán o admin de liga, y solo si la liga está en DRAFT)
|
||||||
|
*/
|
||||||
|
static async deleteTeam(teamId: string, userId: string) {
|
||||||
|
// Verificar que el equipo existe
|
||||||
|
const team = await prisma.leagueTeam.findUnique({
|
||||||
|
where: { id: teamId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
createdById: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new ApiError('Equipo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo se puede eliminar si la liga está en DRAFT
|
||||||
|
if (team.league.status !== LeagueStatus.DRAFT) {
|
||||||
|
throw new ApiError('No se pueden eliminar equipos una vez iniciada la liga', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo capitán o creador de liga pueden eliminar
|
||||||
|
const isCaptain = team.captainId === userId;
|
||||||
|
const isLeagueCreator = team.league.createdById === userId;
|
||||||
|
|
||||||
|
if (!isCaptain && !isLeagueCreator) {
|
||||||
|
throw new ApiError('No tienes permisos para eliminar este equipo', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.leagueTeam.delete({
|
||||||
|
where: { id: teamId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Equipo eliminado exitosamente' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agregar miembro al equipo (solo capitán)
|
||||||
|
*/
|
||||||
|
static async addMember(teamId: string, captainId: string, userId: string) {
|
||||||
|
// Verificar que el equipo existe
|
||||||
|
const team = await prisma.leagueTeam.findUnique({
|
||||||
|
where: { id: teamId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { userId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new ApiError('Equipo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que es el capitán
|
||||||
|
if (team.captainId !== captainId) {
|
||||||
|
throw new ApiError('Solo el capitán puede agregar miembros', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo se pueden agregar miembros si la liga está en DRAFT
|
||||||
|
if (team.league.status !== LeagueStatus.DRAFT) {
|
||||||
|
throw new ApiError('No se pueden agregar miembros una vez iniciada la liga', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el usuario existe
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApiError('Usuario no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el usuario no es ya miembro del equipo
|
||||||
|
const existingMember = team.members.find((m) => m.userId === userId);
|
||||||
|
if (existingMember) {
|
||||||
|
throw new ApiError('El usuario ya es miembro del equipo', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el usuario no es miembro de otro equipo en esta liga
|
||||||
|
const existingMembership = await prisma.leagueTeamMember.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
team: {
|
||||||
|
leagueId: team.league.id,
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMembership) {
|
||||||
|
throw new ApiError('El usuario ya es miembro de otro equipo en esta liga', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear membresía
|
||||||
|
const member = await prisma.leagueTeamMember.create({
|
||||||
|
data: {
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quitar miembro del equipo (solo capitán)
|
||||||
|
*/
|
||||||
|
static async removeMember(teamId: string, captainId: string, userId: string) {
|
||||||
|
// Verificar que el equipo existe
|
||||||
|
const team = await prisma.leagueTeam.findUnique({
|
||||||
|
where: { id: teamId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
createdById: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new ApiError('Equipo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que es el capitán o el propio usuario
|
||||||
|
const isCaptain = team.captainId === captainId;
|
||||||
|
const isLeagueCreator = team.league.createdById === captainId;
|
||||||
|
const isSelf = captainId === userId;
|
||||||
|
|
||||||
|
if (!isCaptain && !isSelf && !isLeagueCreator) {
|
||||||
|
throw new ApiError('No tienes permisos para quitar este miembro', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No se puede quitar al capitán
|
||||||
|
if (userId === team.captainId && !isSelf) {
|
||||||
|
throw new ApiError('No se puede quitar al capitán del equipo', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el miembro existe
|
||||||
|
const member = await prisma.leagueTeamMember.findUnique({
|
||||||
|
where: {
|
||||||
|
teamId_userId: {
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member || !member.isActive) {
|
||||||
|
throw new ApiError('El usuario no es miembro activo del equipo', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar membresía (física, no soft delete)
|
||||||
|
await prisma.leagueTeamMember.delete({
|
||||||
|
where: {
|
||||||
|
teamId_userId: {
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Miembro eliminado exitosamente' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abandonar equipo (el propio usuario)
|
||||||
|
*/
|
||||||
|
static async leaveTeam(teamId: string, userId: string) {
|
||||||
|
// Verificar que el equipo existe
|
||||||
|
const team = await prisma.leagueTeam.findUnique({
|
||||||
|
where: { id: teamId },
|
||||||
|
include: {
|
||||||
|
league: {
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new ApiError('Equipo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El capitán no puede abandonar
|
||||||
|
if (userId === team.captainId) {
|
||||||
|
throw new ApiError('El capitán no puede abandonar el equipo. Transfiere el liderazgo primero o elimina el equipo.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el miembro existe
|
||||||
|
const member = await prisma.leagueTeamMember.findUnique({
|
||||||
|
where: {
|
||||||
|
teamId_userId: {
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member || !member.isActive) {
|
||||||
|
throw new ApiError('No eres miembro de este equipo', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar membresía
|
||||||
|
await prisma.leagueTeamMember.delete({
|
||||||
|
where: {
|
||||||
|
teamId_userId: {
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Has abandonado el equipo exitosamente' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si el usuario es capitán del equipo
|
||||||
|
*/
|
||||||
|
static async isTeamCaptain(teamId: string, userId: string): Promise<boolean> {
|
||||||
|
const team = await prisma.leagueTeam.findUnique({
|
||||||
|
where: { id: teamId },
|
||||||
|
select: { captainId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return team?.captainId === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si el usuario es miembro del equipo
|
||||||
|
*/
|
||||||
|
static async isTeamMember(teamId: string, userId: string): Promise<boolean> {
|
||||||
|
const member = await prisma.leagueTeamMember.findUnique({
|
||||||
|
where: {
|
||||||
|
teamId_userId: {
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!member && member.isActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeagueTeamService;
|
||||||
799
backend/src/services/tournament.service.ts
Normal file
799
backend/src/services/tournament.service.ts
Normal file
@@ -0,0 +1,799 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import {
|
||||||
|
TournamentType,
|
||||||
|
TournamentCategory,
|
||||||
|
TournamentStatus,
|
||||||
|
ParticipantStatus,
|
||||||
|
PaymentStatus,
|
||||||
|
PlayerLevel,
|
||||||
|
UserRole,
|
||||||
|
} from '../utils/constants';
|
||||||
|
import logger from '../config/logger';
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
export interface CreateTournamentInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: string;
|
||||||
|
category: string;
|
||||||
|
allowedLevels: string[];
|
||||||
|
maxParticipants: number;
|
||||||
|
registrationStartDate: Date;
|
||||||
|
registrationEndDate: Date;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
courtIds: string[];
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTournamentInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
type?: string;
|
||||||
|
category?: string;
|
||||||
|
allowedLevels?: string[];
|
||||||
|
maxParticipants?: number;
|
||||||
|
registrationStartDate?: Date;
|
||||||
|
registrationEndDate?: Date;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
courtIds?: string[];
|
||||||
|
price?: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TournamentService {
|
||||||
|
// Crear un torneo
|
||||||
|
static async createTournament(adminId: string, data: CreateTournamentInput) {
|
||||||
|
// Verificar que el usuario sea admin
|
||||||
|
const admin = await prisma.user.findUnique({
|
||||||
|
where: { id: adminId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin || (admin.role !== UserRole.ADMIN && admin.role !== UserRole.SUPERADMIN)) {
|
||||||
|
throw new ApiError('No tienes permisos para crear torneos', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar tipo de torneo
|
||||||
|
if (!Object.values(TournamentType).includes(data.type as any)) {
|
||||||
|
throw new ApiError('Tipo de torneo inválido', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar categoría
|
||||||
|
if (!Object.values(TournamentCategory).includes(data.category as any)) {
|
||||||
|
throw new ApiError('Categoría de torneo inválida', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar niveles permitidos
|
||||||
|
for (const level of data.allowedLevels) {
|
||||||
|
if (!Object.values(PlayerLevel).includes(level as any)) {
|
||||||
|
throw new ApiError(`Nivel inválido: ${level}`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar fechas
|
||||||
|
const now = new Date();
|
||||||
|
if (data.registrationStartDate < now) {
|
||||||
|
throw new ApiError('La fecha de inicio de inscripción no puede ser en el pasado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.registrationEndDate <= data.registrationStartDate) {
|
||||||
|
throw new ApiError('La fecha de fin de inscripción debe ser posterior a la de inicio', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.startDate <= data.registrationEndDate) {
|
||||||
|
throw new ApiError('La fecha de inicio del torneo debe ser posterior al cierre de inscripciones', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.endDate <= data.startDate) {
|
||||||
|
throw new ApiError('La fecha de fin del torneo debe ser posterior a la de inicio', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que las canchas existan
|
||||||
|
if (data.courtIds.length > 0) {
|
||||||
|
const courts = await prisma.court.findMany({
|
||||||
|
where: { id: { in: data.courtIds } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (courts.length !== data.courtIds.length) {
|
||||||
|
throw new ApiError('Una o más canchas no existen', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar cupo máximo
|
||||||
|
if (data.maxParticipants < 2) {
|
||||||
|
throw new ApiError('El torneo debe permitir al menos 2 participantes', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear torneo
|
||||||
|
const tournament = await prisma.tournament.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
type: data.type,
|
||||||
|
category: data.category,
|
||||||
|
allowedLevels: JSON.stringify(data.allowedLevels),
|
||||||
|
maxParticipants: data.maxParticipants,
|
||||||
|
registrationStartDate: data.registrationStartDate,
|
||||||
|
registrationEndDate: data.registrationEndDate,
|
||||||
|
startDate: data.startDate,
|
||||||
|
endDate: data.endDate,
|
||||||
|
courtIds: JSON.stringify(data.courtIds),
|
||||||
|
price: data.price,
|
||||||
|
status: TournamentStatus.DRAFT,
|
||||||
|
createdById: adminId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...tournament,
|
||||||
|
allowedLevels: data.allowedLevels,
|
||||||
|
courtIds: data.courtIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener todos los torneos (con filtros)
|
||||||
|
static async getTournaments(filters: {
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
category?: string;
|
||||||
|
upcoming?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
}) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (filters.status) where.status = filters.status;
|
||||||
|
if (filters.type) where.type = filters.type;
|
||||||
|
if (filters.category) where.category = filters.category;
|
||||||
|
|
||||||
|
if (filters.upcoming) {
|
||||||
|
where.startDate = { gte: new Date() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.open) {
|
||||||
|
where.status = TournamentStatus.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tournaments = await prisma.tournament.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
participants: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ startDate: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return tournaments.map((t) => ({
|
||||||
|
...t,
|
||||||
|
allowedLevels: JSON.parse(t.allowedLevels),
|
||||||
|
courtIds: JSON.parse(t.courtIds),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener torneo por ID
|
||||||
|
static async getTournamentById(id: string) {
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
playerLevel: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ seed: 'asc' }, { registrationDate: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...tournament,
|
||||||
|
allowedLevels: JSON.parse(tournament.allowedLevels),
|
||||||
|
courtIds: JSON.parse(tournament.courtIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar torneo
|
||||||
|
static async updateTournament(
|
||||||
|
id: string,
|
||||||
|
adminId: string,
|
||||||
|
data: UpdateTournamentInput
|
||||||
|
) {
|
||||||
|
// Verificar que el torneo existe
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permisos (creador o admin)
|
||||||
|
if (tournament.createdById !== adminId) {
|
||||||
|
const admin = await prisma.user.findUnique({
|
||||||
|
where: { id: adminId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin || admin.role !== UserRole.SUPERADMIN) {
|
||||||
|
throw new ApiError('No tienes permisos para modificar este torneo', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No permitir modificar si ya está en progreso o finalizado
|
||||||
|
if (
|
||||||
|
tournament.status === TournamentStatus.IN_PROGRESS ||
|
||||||
|
tournament.status === TournamentStatus.FINISHED
|
||||||
|
) {
|
||||||
|
throw new ApiError('No se puede modificar un torneo en progreso o finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar tipo si se proporciona
|
||||||
|
if (data.type && !Object.values(TournamentType).includes(data.type as any)) {
|
||||||
|
throw new ApiError('Tipo de torneo inválido', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar categoría si se proporciona
|
||||||
|
if (data.category && !Object.values(TournamentCategory).includes(data.category as any)) {
|
||||||
|
throw new ApiError('Categoría de torneo inválida', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar niveles si se proporcionan
|
||||||
|
if (data.allowedLevels) {
|
||||||
|
for (const level of data.allowedLevels) {
|
||||||
|
if (!Object.values(PlayerLevel).includes(level as any)) {
|
||||||
|
throw new ApiError(`Nivel inválido: ${level}`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar fechas si se proporcionan
|
||||||
|
if (data.registrationStartDate && data.registrationEndDate) {
|
||||||
|
if (data.registrationEndDate <= data.registrationStartDate) {
|
||||||
|
throw new ApiError('La fecha de fin de inscripción debe ser posterior a la de inicio', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar torneo
|
||||||
|
const updated = await prisma.tournament.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
type: data.type,
|
||||||
|
category: data.category,
|
||||||
|
allowedLevels: data.allowedLevels ? JSON.stringify(data.allowedLevels) : undefined,
|
||||||
|
maxParticipants: data.maxParticipants,
|
||||||
|
registrationStartDate: data.registrationStartDate,
|
||||||
|
registrationEndDate: data.registrationEndDate,
|
||||||
|
startDate: data.startDate,
|
||||||
|
endDate: data.endDate,
|
||||||
|
courtIds: data.courtIds ? JSON.stringify(data.courtIds) : undefined,
|
||||||
|
price: data.price,
|
||||||
|
status: data.status,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
participants: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...updated,
|
||||||
|
allowedLevels: data.allowedLevels
|
||||||
|
? data.allowedLevels
|
||||||
|
: JSON.parse(updated.allowedLevels),
|
||||||
|
courtIds: data.courtIds ? data.courtIds : JSON.parse(updated.courtIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar (cancelar) torneo
|
||||||
|
static async deleteTournament(id: string, adminId: string) {
|
||||||
|
// Verificar que el torneo existe
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permisos
|
||||||
|
if (tournament.createdById !== adminId) {
|
||||||
|
const admin = await prisma.user.findUnique({
|
||||||
|
where: { id: adminId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin || admin.role !== UserRole.SUPERADMIN) {
|
||||||
|
throw new ApiError('No tienes permisos para cancelar este torneo', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No permitir cancelar si ya está finalizado
|
||||||
|
if (tournament.status === TournamentStatus.FINISHED) {
|
||||||
|
throw new ApiError('No se puede cancelar un torneo finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelar torneo (soft delete cambiando estado)
|
||||||
|
const cancelled = await prisma.tournament.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: TournamentStatus.CANCELLED },
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actualizar participantes como retirados
|
||||||
|
await prisma.tournamentParticipant.updateMany({
|
||||||
|
where: { tournamentId: id },
|
||||||
|
data: { status: ParticipantStatus.WITHDRAWN },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Torneo ${id} cancelado por admin ${adminId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cancelled,
|
||||||
|
allowedLevels: JSON.parse(cancelled.allowedLevels),
|
||||||
|
courtIds: JSON.parse(cancelled.courtIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abrir inscripciones
|
||||||
|
static async openRegistration(id: string, adminId: string) {
|
||||||
|
// Verificar que el torneo existe
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permisos
|
||||||
|
if (tournament.createdById !== adminId) {
|
||||||
|
const admin = await prisma.user.findUnique({
|
||||||
|
where: { id: adminId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin || admin.role !== UserRole.SUPERADMIN) {
|
||||||
|
throw new ApiError('No tienes permisos para modificar este torneo', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo se puede abrir desde DRAFT
|
||||||
|
if (tournament.status !== TournamentStatus.DRAFT) {
|
||||||
|
throw new ApiError('Solo se pueden abrir inscripciones de torneos en borrador', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.tournament.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: TournamentStatus.OPEN },
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
participants: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Inscripciones abiertas para torneo ${id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...updated,
|
||||||
|
allowedLevels: JSON.parse(updated.allowedLevels),
|
||||||
|
courtIds: JSON.parse(updated.courtIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cerrar inscripciones
|
||||||
|
static async closeRegistration(id: string, adminId: string) {
|
||||||
|
// Verificar que el torneo existe
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permisos
|
||||||
|
if (tournament.createdById !== adminId) {
|
||||||
|
const admin = await prisma.user.findUnique({
|
||||||
|
where: { id: adminId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin || admin.role !== UserRole.SUPERADMIN) {
|
||||||
|
throw new ApiError('No tienes permisos para modificar este torneo', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo se puede cerrar desde OPEN
|
||||||
|
if (tournament.status !== TournamentStatus.OPEN) {
|
||||||
|
throw new ApiError('Solo se pueden cerrar inscripciones de torneos abiertos', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.tournament.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: TournamentStatus.CLOSED },
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
participants: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Inscripciones cerradas para torneo ${id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...updated,
|
||||||
|
allowedLevels: JSON.parse(updated.allowedLevels),
|
||||||
|
courtIds: JSON.parse(updated.courtIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inscribir participante
|
||||||
|
static async registerParticipant(tournamentId: string, userId: string) {
|
||||||
|
// Verificar que el torneo existe y está abierto
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
participants: {
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
in: [ParticipantStatus.REGISTERED, ParticipantStatus.CONFIRMED],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tournament.status !== TournamentStatus.OPEN) {
|
||||||
|
throw new ApiError('Las inscripciones no están abiertas', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar fechas de inscripción
|
||||||
|
const now = new Date();
|
||||||
|
if (now < tournament.registrationStartDate) {
|
||||||
|
throw new ApiError('Las inscripciones aún no han comenzado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now > tournament.registrationEndDate) {
|
||||||
|
throw new ApiError('El período de inscripciones ha finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar cupo
|
||||||
|
if (tournament._count.participants >= tournament.maxParticipants) {
|
||||||
|
throw new ApiError('El torneo ha alcanzado el máximo de participantes', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el usuario existe
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
playerLevel: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApiError('Usuario no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isActive) {
|
||||||
|
throw new ApiError('Usuario no está activo', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el usuario tiene el nivel requerido
|
||||||
|
const allowedLevels = JSON.parse(tournament.allowedLevels) as string[];
|
||||||
|
if (!allowedLevels.includes(user.playerLevel)) {
|
||||||
|
throw new ApiError(
|
||||||
|
`Tu nivel (${user.playerLevel}) no está permitido en este torneo. Niveles permitidos: ${allowedLevels.join(', ')}`,
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que no esté ya inscrito
|
||||||
|
const existingRegistration = await prisma.tournamentParticipant.findFirst({
|
||||||
|
where: {
|
||||||
|
tournamentId,
|
||||||
|
userId,
|
||||||
|
status: {
|
||||||
|
in: [ParticipantStatus.REGISTERED, ParticipantStatus.CONFIRMED],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRegistration) {
|
||||||
|
throw new ApiError('Ya estás inscrito en este torneo', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear inscripción
|
||||||
|
const participant = await prisma.tournamentParticipant.create({
|
||||||
|
data: {
|
||||||
|
tournamentId,
|
||||||
|
userId,
|
||||||
|
paymentStatus: tournament.price > 0 ? PaymentStatus.PENDING : PaymentStatus.PAID,
|
||||||
|
status: ParticipantStatus.REGISTERED,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
playerLevel: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tournament: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Usuario ${userId} inscrito en torneo ${tournamentId}`);
|
||||||
|
|
||||||
|
return participant;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desinscribir participante
|
||||||
|
static async unregisterParticipant(tournamentId: string, userId: string) {
|
||||||
|
// Verificar que el torneo existe
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No permitir desinscribir si el torneo ya empezó
|
||||||
|
if (tournament.status === TournamentStatus.IN_PROGRESS ||
|
||||||
|
tournament.status === TournamentStatus.FINISHED) {
|
||||||
|
throw new ApiError('No puedes desinscribirte de un torneo en progreso o finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar la inscripción
|
||||||
|
const participant = await prisma.tournamentParticipant.findFirst({
|
||||||
|
where: {
|
||||||
|
tournamentId,
|
||||||
|
userId,
|
||||||
|
status: {
|
||||||
|
in: [ParticipantStatus.REGISTERED, ParticipantStatus.CONFIRMED],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
throw new ApiError('No estás inscrito en este torneo', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar estado a retirado
|
||||||
|
const updated = await prisma.tournamentParticipant.update({
|
||||||
|
where: { id: participant.id },
|
||||||
|
data: {
|
||||||
|
status: ParticipantStatus.WITHDRAWN,
|
||||||
|
paymentStatus:
|
||||||
|
participant.paymentStatus === PaymentStatus.PAID
|
||||||
|
? PaymentStatus.REFUNDED
|
||||||
|
: participant.paymentStatus,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tournament: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Usuario ${userId} desinscrito del torneo ${tournamentId}`);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmar pago de inscripción
|
||||||
|
static async confirmPayment(participantId: string, adminId: string) {
|
||||||
|
// Verificar que el admin tiene permisos
|
||||||
|
const admin = await prisma.user.findUnique({
|
||||||
|
where: { id: adminId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin || (admin.role !== UserRole.ADMIN && admin.role !== UserRole.SUPERADMIN)) {
|
||||||
|
throw new ApiError('No tienes permisos para confirmar pagos', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que la inscripción existe
|
||||||
|
const participant = await prisma.tournamentParticipant.findUnique({
|
||||||
|
where: { id: participantId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tournament: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
throw new ApiError('Inscripción no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (participant.paymentStatus !== PaymentStatus.PENDING) {
|
||||||
|
throw new ApiError('El pago ya fue procesado o no está pendiente', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar pago y estado
|
||||||
|
const updated = await prisma.tournamentParticipant.update({
|
||||||
|
where: { id: participantId },
|
||||||
|
data: {
|
||||||
|
paymentStatus: PaymentStatus.PAID,
|
||||||
|
status: ParticipantStatus.CONFIRMED,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
playerLevel: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tournament: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Pago confirmado para participante ${participantId} por admin ${adminId}`);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener participantes de un torneo
|
||||||
|
static async getParticipants(tournamentId: string) {
|
||||||
|
// Verificar que el torneo existe
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = await prisma.tournamentParticipant.findMany({
|
||||||
|
where: { tournamentId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
playerLevel: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ seed: 'asc' }, { registrationDate: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return participants;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TournamentService;
|
||||||
788
backend/src/services/tournamentDraw.service.ts
Normal file
788
backend/src/services/tournamentDraw.service.ts
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import logger from '../config/logger';
|
||||||
|
import {
|
||||||
|
TournamentType,
|
||||||
|
TournamentStatus,
|
||||||
|
TournamentMatchStatus,
|
||||||
|
} from '../utils/constants';
|
||||||
|
import {
|
||||||
|
shuffleArray,
|
||||||
|
calculateRounds,
|
||||||
|
seedParticipants,
|
||||||
|
generateBracketPositions,
|
||||||
|
nextPowerOfTwo,
|
||||||
|
calculateByes,
|
||||||
|
generateRoundRobinPairings,
|
||||||
|
generateSwissPairings,
|
||||||
|
validateDrawGeneration,
|
||||||
|
} from '../utils/tournamentDraw';
|
||||||
|
|
||||||
|
export interface GenerateDrawInput {
|
||||||
|
shuffle?: boolean; // Mezclar participantes aleatoriamente
|
||||||
|
respectSeeds?: boolean; // Respetar cabezas de serie
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleMatchInput {
|
||||||
|
courtId: string;
|
||||||
|
date: Date;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchResultInput {
|
||||||
|
team1Score: number;
|
||||||
|
team2Score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TournamentDrawService {
|
||||||
|
/**
|
||||||
|
* Generar cuadro de torneo según el tipo
|
||||||
|
*/
|
||||||
|
static async generateDraw(
|
||||||
|
tournamentId: string,
|
||||||
|
input: GenerateDrawInput = {}
|
||||||
|
) {
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
include: {
|
||||||
|
participants: {
|
||||||
|
where: { status: { in: ['REGISTERED', 'CONFIRMED'] } },
|
||||||
|
include: { user: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar estado del torneo
|
||||||
|
if (tournament.status === TournamentStatus.DRAFT) {
|
||||||
|
throw new ApiError(
|
||||||
|
'El torneo debe estar abierto o cerrado para generar el cuadro',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tournament.status === TournamentStatus.IN_PROGRESS) {
|
||||||
|
throw new ApiError(
|
||||||
|
'El torneo ya está en progreso, no se puede regenerar el cuadro',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tournament.status === TournamentStatus.FINISHED) {
|
||||||
|
throw new ApiError('El torneo ya ha finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar participantes
|
||||||
|
const participants = tournament.participants;
|
||||||
|
const validation = validateDrawGeneration(
|
||||||
|
participants.length,
|
||||||
|
tournament.type
|
||||||
|
);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new ApiError(validation.error || 'Error de validación', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar cuadro existente si hay
|
||||||
|
await prisma.tournamentMatch.deleteMany({
|
||||||
|
where: { tournamentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generar cuadro según tipo
|
||||||
|
let matches;
|
||||||
|
switch (tournament.type) {
|
||||||
|
case TournamentType.ELIMINATION:
|
||||||
|
matches = await this.generateEliminationDraw(
|
||||||
|
tournamentId,
|
||||||
|
participants,
|
||||||
|
input
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case TournamentType.CONSOLATION:
|
||||||
|
matches = await this.generateConsolationDraw(
|
||||||
|
tournamentId,
|
||||||
|
participants,
|
||||||
|
input
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case TournamentType.ROUND_ROBIN:
|
||||||
|
matches = await this.generateRoundRobin(
|
||||||
|
tournamentId,
|
||||||
|
participants,
|
||||||
|
input
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case TournamentType.SWISS:
|
||||||
|
matches = await this.generateSwiss(tournamentId, participants, input);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ApiError('Tipo de torneo no soportado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar estado del torneo
|
||||||
|
await prisma.tournament.update({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
data: { status: TournamentStatus.IN_PROGRESS },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Cuadro generado para torneo ${tournamentId}: ${matches.length} partidos`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournamentId,
|
||||||
|
type: tournament.type,
|
||||||
|
participantsCount: participants.length,
|
||||||
|
matchesCount: matches.length,
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar cuadro de eliminatoria simple
|
||||||
|
*/
|
||||||
|
private static async generateEliminationDraw(
|
||||||
|
tournamentId: string,
|
||||||
|
participants: any[],
|
||||||
|
input: GenerateDrawInput
|
||||||
|
) {
|
||||||
|
const { shuffle = false, respectSeeds = true } = input;
|
||||||
|
const participantCount = participants.length;
|
||||||
|
const bracketSize = nextPowerOfTwo(participantCount);
|
||||||
|
const rounds = calculateRounds(participantCount);
|
||||||
|
|
||||||
|
// Ordenar participantes
|
||||||
|
let orderedParticipants = respectSeeds
|
||||||
|
? seedParticipants(participants)
|
||||||
|
: shuffle
|
||||||
|
? shuffleArray(participants)
|
||||||
|
: participants;
|
||||||
|
|
||||||
|
// Generar posiciones del cuadro
|
||||||
|
const positions = generateBracketPositions(participantCount);
|
||||||
|
|
||||||
|
// Asignar participantes a posiciones
|
||||||
|
const positionedParticipants: (typeof participants[0] | null)[] = new Array(
|
||||||
|
bracketSize
|
||||||
|
).fill(null);
|
||||||
|
|
||||||
|
for (let i = 0; i < participantCount; i++) {
|
||||||
|
const pos = positions[i] % bracketSize;
|
||||||
|
positionedParticipants[pos] = orderedParticipants[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear partidos por ronda
|
||||||
|
const createdMatches: any[] = [];
|
||||||
|
const matchMap = new Map<string, Map<number, any>>(); // round -> position -> match
|
||||||
|
|
||||||
|
// Primera ronda (ronda más alta = primera ronda)
|
||||||
|
const firstRound = rounds;
|
||||||
|
const matchesInFirstRound = bracketSize / 2;
|
||||||
|
|
||||||
|
for (let i = 0; i < matchesInFirstRound; i++) {
|
||||||
|
const team1 = positionedParticipants[i * 2];
|
||||||
|
const team2 = positionedParticipants[i * 2 + 1];
|
||||||
|
|
||||||
|
// Si hay bye, el equipo avanza automáticamente
|
||||||
|
const isBye = !team1 || !team2;
|
||||||
|
const status = isBye
|
||||||
|
? TournamentMatchStatus.BYE
|
||||||
|
: TournamentMatchStatus.PENDING;
|
||||||
|
|
||||||
|
const match = await prisma.tournamentMatch.create({
|
||||||
|
data: {
|
||||||
|
tournamentId,
|
||||||
|
round: firstRound,
|
||||||
|
matchNumber: i + 1,
|
||||||
|
position: i,
|
||||||
|
team1Player1Id: team1?.id,
|
||||||
|
team1Player2Id: null, // Para individuales
|
||||||
|
team2Player1Id: team2?.id,
|
||||||
|
team2Player2Id: null,
|
||||||
|
status,
|
||||||
|
winner: isBye ? (team1 ? 'TEAM1' : 'TEAM2') : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdMatches.push(match);
|
||||||
|
|
||||||
|
if (!matchMap.has(firstRound.toString())) {
|
||||||
|
matchMap.set(firstRound.toString(), new Map());
|
||||||
|
}
|
||||||
|
matchMap.get(firstRound.toString())!.set(i, match);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear partidos de rondas siguientes
|
||||||
|
for (let round = firstRound - 1; round >= 1; round--) {
|
||||||
|
const matchesInRound = Math.pow(2, round - 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < matchesInRound; i++) {
|
||||||
|
// Buscar partidos padre
|
||||||
|
const parentRound = round + 1;
|
||||||
|
const parentPosition1 = i * 2;
|
||||||
|
const parentPosition2 = i * 2 + 1;
|
||||||
|
|
||||||
|
const parent1 = matchMap.get(parentRound.toString())?.get(parentPosition1);
|
||||||
|
const parent2 = matchMap.get(parentRound.toString())?.get(parentPosition2);
|
||||||
|
|
||||||
|
const match = await prisma.tournamentMatch.create({
|
||||||
|
data: {
|
||||||
|
tournamentId,
|
||||||
|
round,
|
||||||
|
matchNumber: i + 1,
|
||||||
|
position: i,
|
||||||
|
status: TournamentMatchStatus.PENDING,
|
||||||
|
parentMatches: {
|
||||||
|
connect: [
|
||||||
|
...(parent1 ? [{ id: parent1.id }] : []),
|
||||||
|
...(parent2 ? [{ id: parent2.id }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdMatches.push(match);
|
||||||
|
|
||||||
|
if (!matchMap.has(round.toString())) {
|
||||||
|
matchMap.set(round.toString(), new Map());
|
||||||
|
}
|
||||||
|
matchMap.get(round.toString())!.set(i, match);
|
||||||
|
|
||||||
|
// Actualizar nextMatchId de los padres
|
||||||
|
if (parent1) {
|
||||||
|
await prisma.tournamentMatch.update({
|
||||||
|
where: { id: parent1.id },
|
||||||
|
data: { nextMatchId: match.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parent2) {
|
||||||
|
await prisma.tournamentMatch.update({
|
||||||
|
where: { id: parent2.id },
|
||||||
|
data: { nextMatchId: match.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar cuadro de consolación (los perdedores de 1ra ronda juegan cuadro paralelo)
|
||||||
|
*/
|
||||||
|
private static async generateConsolationDraw(
|
||||||
|
tournamentId: string,
|
||||||
|
participants: any[],
|
||||||
|
input: GenerateDrawInput
|
||||||
|
) {
|
||||||
|
// Primero generar el cuadro principal
|
||||||
|
const mainMatches = await this.generateEliminationDraw(
|
||||||
|
tournamentId,
|
||||||
|
participants,
|
||||||
|
input
|
||||||
|
);
|
||||||
|
|
||||||
|
// Identificar partidos de primera ronda
|
||||||
|
const maxRound = Math.max(...mainMatches.map(m => m.round));
|
||||||
|
const firstRoundMatches = mainMatches.filter(m => m.round === maxRound);
|
||||||
|
|
||||||
|
// Crear cuadro de consolación con los perdedores
|
||||||
|
// Por simplicidad, hacemos un round robin entre los perdedores de 1ra ronda
|
||||||
|
const consolationMatches: any[] = [];
|
||||||
|
|
||||||
|
// Marcar partidos que alimentan consolación
|
||||||
|
for (const match of firstRoundMatches) {
|
||||||
|
await prisma.tournamentMatch.update({
|
||||||
|
where: { id: match.id },
|
||||||
|
data: {
|
||||||
|
metadata: JSON.stringify({ feedsConsolation: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Cuadro de consolación marcado para ${firstRoundMatches.length} partidos de primera ronda`
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...mainMatches, ...consolationMatches];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar round robin (todos contra todos)
|
||||||
|
*/
|
||||||
|
private static async generateRoundRobin(
|
||||||
|
tournamentId: string,
|
||||||
|
participants: any[],
|
||||||
|
input: GenerateDrawInput
|
||||||
|
) {
|
||||||
|
const { shuffle = true } = input;
|
||||||
|
|
||||||
|
// Mezclar o mantener orden
|
||||||
|
const orderedParticipants = shuffle
|
||||||
|
? shuffleArray(participants)
|
||||||
|
: participants;
|
||||||
|
|
||||||
|
// Generar emparejamientos
|
||||||
|
const pairings = generateRoundRobinPairings(orderedParticipants);
|
||||||
|
|
||||||
|
// Crear partidos (una ronda por cada conjunto de emparejamientos)
|
||||||
|
const createdMatches: any[] = [];
|
||||||
|
const matchesPerRound = Math.floor(participants.length / 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < pairings.length; i++) {
|
||||||
|
const [player1, player2] = pairings[i];
|
||||||
|
const round = Math.floor(i / matchesPerRound) + 1;
|
||||||
|
const matchNumber = (i % matchesPerRound) + 1;
|
||||||
|
|
||||||
|
const match = await prisma.tournamentMatch.create({
|
||||||
|
data: {
|
||||||
|
tournamentId,
|
||||||
|
round,
|
||||||
|
matchNumber,
|
||||||
|
position: i,
|
||||||
|
team1Player1Id: player1.id,
|
||||||
|
team1Player2Id: null,
|
||||||
|
team2Player1Id: player2.id,
|
||||||
|
team2Player2Id: null,
|
||||||
|
status: TournamentMatchStatus.PENDING,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
type: 'ROUND_ROBIN',
|
||||||
|
matchIndex: i,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdMatches.push(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar primera ronda de sistema suizo
|
||||||
|
*/
|
||||||
|
private static async generateSwiss(
|
||||||
|
tournamentId: string,
|
||||||
|
participants: any[],
|
||||||
|
input: GenerateDrawInput
|
||||||
|
) {
|
||||||
|
const { shuffle = true } = input;
|
||||||
|
|
||||||
|
// En la primera ronda, emparejar aleatoriamente o por seed
|
||||||
|
let orderedParticipants = shuffle
|
||||||
|
? shuffleArray(participants)
|
||||||
|
: seedParticipants(participants);
|
||||||
|
|
||||||
|
// Preparar jugadores para emparejamiento
|
||||||
|
const swissPlayers = orderedParticipants.map((p, index) => ({
|
||||||
|
id: p.id,
|
||||||
|
points: 0,
|
||||||
|
playedAgainst: [],
|
||||||
|
seed: p.seed || index + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Generar emparejamientos
|
||||||
|
const pairings = generateSwissPairings(swissPlayers);
|
||||||
|
|
||||||
|
// Crear partidos de primera ronda
|
||||||
|
const createdMatches: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < pairings.length; i++) {
|
||||||
|
const [player1Id, player2Id] = pairings[i];
|
||||||
|
|
||||||
|
const match = await prisma.tournamentMatch.create({
|
||||||
|
data: {
|
||||||
|
tournamentId,
|
||||||
|
round: 1,
|
||||||
|
matchNumber: i + 1,
|
||||||
|
position: i,
|
||||||
|
team1Player1Id: player1Id,
|
||||||
|
team1Player2Id: null,
|
||||||
|
team2Player1Id: player2Id,
|
||||||
|
team2Player2Id: null,
|
||||||
|
status: TournamentMatchStatus.PENDING,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
type: 'SWISS',
|
||||||
|
swissRound: 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdMatches.push(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar siguiente ronda de sistema suizo
|
||||||
|
*/
|
||||||
|
static async generateNextRoundSwiss(tournamentId: string) {
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
include: {
|
||||||
|
participants: {
|
||||||
|
where: { status: { in: ['REGISTERED', 'CONFIRMED'] } },
|
||||||
|
},
|
||||||
|
matches: {
|
||||||
|
where: { status: TournamentMatchStatus.FINISHED },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tournament.type !== TournamentType.SWISS) {
|
||||||
|
throw new ApiError('Esta función es solo para torneos suizo', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular puntos de cada jugador
|
||||||
|
const playerPoints = new Map<string, number>();
|
||||||
|
const playedAgainst = new Map<string, string[]>();
|
||||||
|
|
||||||
|
for (const participant of tournament.participants) {
|
||||||
|
playerPoints.set(participant.id, 0);
|
||||||
|
playedAgainst.set(participant.id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sumar puntos de partidos terminados
|
||||||
|
for (const match of tournament.matches) {
|
||||||
|
if (match.winner === 'TEAM1' && match.team1Player1Id) {
|
||||||
|
playerPoints.set(
|
||||||
|
match.team1Player1Id,
|
||||||
|
(playerPoints.get(match.team1Player1Id) || 0) + 1
|
||||||
|
);
|
||||||
|
} else if (match.winner === 'TEAM2' && match.team2Player1Id) {
|
||||||
|
playerPoints.set(
|
||||||
|
match.team2Player1Id,
|
||||||
|
(playerPoints.get(match.team2Player1Id) || 0) + 1
|
||||||
|
);
|
||||||
|
} else if (match.winner === 'DRAW') {
|
||||||
|
if (match.team1Player1Id) {
|
||||||
|
playerPoints.set(
|
||||||
|
match.team1Player1Id,
|
||||||
|
(playerPoints.get(match.team1Player1Id) || 0) + 0.5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (match.team2Player1Id) {
|
||||||
|
playerPoints.set(
|
||||||
|
match.team2Player1Id,
|
||||||
|
(playerPoints.get(match.team2Player1Id) || 0) + 0.5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar enfrentamientos
|
||||||
|
if (match.team1Player1Id && match.team2Player1Id) {
|
||||||
|
playedAgainst.get(match.team1Player1Id)?.push(match.team2Player1Id);
|
||||||
|
playedAgainst.get(match.team2Player1Id)?.push(match.team1Player1Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar número de siguiente ronda
|
||||||
|
const currentRound = Math.max(...tournament.matches.map(m => m.round), 0);
|
||||||
|
const nextRound = currentRound + 1;
|
||||||
|
const totalRounds = tournament.participants.length - 1;
|
||||||
|
|
||||||
|
if (nextRound > totalRounds) {
|
||||||
|
throw new ApiError('Todas las rondas del sistema suizo han sido jugadas', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preparar jugadores para emparejamiento
|
||||||
|
const swissPlayers = tournament.participants.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
points: playerPoints.get(p.id) || 0,
|
||||||
|
playedAgainst: playedAgainst.get(p.id) || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Generar emparejamientos
|
||||||
|
const pairings = generateSwissPairings(swissPlayers);
|
||||||
|
|
||||||
|
// Crear partidos
|
||||||
|
const createdMatches: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < pairings.length; i++) {
|
||||||
|
const [player1Id, player2Id] = pairings[i];
|
||||||
|
|
||||||
|
const match = await prisma.tournamentMatch.create({
|
||||||
|
data: {
|
||||||
|
tournamentId,
|
||||||
|
round: nextRound,
|
||||||
|
matchNumber: i + 1,
|
||||||
|
position: i,
|
||||||
|
team1Player1Id: player1Id,
|
||||||
|
team1Player2Id: null,
|
||||||
|
team2Player1Id: player2Id,
|
||||||
|
team2Player2Id: null,
|
||||||
|
status: TournamentMatchStatus.PENDING,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
type: 'SWISS',
|
||||||
|
swissRound: nextRound,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdMatches.push(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Ronda ${nextRound} de suizo generada para torneo ${tournamentId}: ${createdMatches.length} partidos`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
round: nextRound,
|
||||||
|
matches: createdMatches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener cuadro completo de un torneo
|
||||||
|
*/
|
||||||
|
static async getDraw(tournamentId: string) {
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
include: {
|
||||||
|
participants: {
|
||||||
|
where: { status: { in: ['REGISTERED', 'CONFIRMED'] } },
|
||||||
|
include: { user: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await prisma.tournamentMatch.findMany({
|
||||||
|
where: { tournamentId },
|
||||||
|
include: {
|
||||||
|
team1Player1: { include: { user: true } },
|
||||||
|
team1Player2: { include: { user: true } },
|
||||||
|
team2Player1: { include: { user: true } },
|
||||||
|
team2Player2: { include: { user: true } },
|
||||||
|
court: true,
|
||||||
|
nextMatch: true,
|
||||||
|
parentMatches: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ round: 'desc' }, { matchNumber: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por ronda
|
||||||
|
const rounds = matches.reduce((acc, match) => {
|
||||||
|
if (!acc[match.round]) {
|
||||||
|
acc[match.round] = [];
|
||||||
|
}
|
||||||
|
acc[match.round].push(match);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, typeof matches>);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournamentId,
|
||||||
|
type: tournament.type,
|
||||||
|
status: tournament.status,
|
||||||
|
participantsCount: tournament.participants.length,
|
||||||
|
matchesCount: matches.length,
|
||||||
|
rounds,
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programar un partido (asignar cancha y fecha)
|
||||||
|
*/
|
||||||
|
static async scheduleMatch(
|
||||||
|
matchId: string,
|
||||||
|
input: ScheduleMatchInput
|
||||||
|
) {
|
||||||
|
const { courtId, date, time } = input;
|
||||||
|
|
||||||
|
const match = await prisma.tournamentMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: { tournament: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.FINISHED) {
|
||||||
|
throw new ApiError('No se puede reprogramar un partido finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.CANCELLED) {
|
||||||
|
throw new ApiError('No se puede programar un partido cancelado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que la cancha exista
|
||||||
|
const court = await prisma.court.findUnique({
|
||||||
|
where: { id: courtId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!court) {
|
||||||
|
throw new ApiError('Cancha no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que la cancha esté asignada al torneo
|
||||||
|
const courtIds = JSON.parse(match.tournament.courtIds) as string[];
|
||||||
|
if (!courtIds.includes(courtId)) {
|
||||||
|
throw new ApiError('La cancha no está asignada a este torneo', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar disponibilidad de la cancha
|
||||||
|
const conflictingMatch = await prisma.tournamentMatch.findFirst({
|
||||||
|
where: {
|
||||||
|
courtId,
|
||||||
|
scheduledDate: date,
|
||||||
|
scheduledTime: time,
|
||||||
|
status: { not: TournamentMatchStatus.CANCELLED },
|
||||||
|
id: { not: matchId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflictingMatch) {
|
||||||
|
throw new ApiError('La cancha no está disponible en esa fecha y hora', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMatch = await prisma.tournamentMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: {
|
||||||
|
courtId,
|
||||||
|
scheduledDate: date,
|
||||||
|
scheduledTime: time,
|
||||||
|
status: TournamentMatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: true,
|
||||||
|
team1Player1: { include: { user: true } },
|
||||||
|
team2Player1: { include: { user: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Partido ${matchId} programado para ${date.toISOString()} en cancha ${courtId}`);
|
||||||
|
|
||||||
|
return updatedMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrar resultado de un partido de torneo
|
||||||
|
*/
|
||||||
|
static async recordMatchResult(
|
||||||
|
matchId: string,
|
||||||
|
input: MatchResultInput
|
||||||
|
) {
|
||||||
|
const { team1Score, team2Score } = input;
|
||||||
|
|
||||||
|
const match = await prisma.tournamentMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
tournament: true,
|
||||||
|
nextMatch: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.FINISHED) {
|
||||||
|
throw new ApiError('El partido ya ha finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.CANCELLED) {
|
||||||
|
throw new ApiError('El partido está cancelado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.BYE) {
|
||||||
|
throw new ApiError('No se puede registrar resultado en un bye', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar puntajes
|
||||||
|
if (team1Score < 0 || team2Score < 0) {
|
||||||
|
throw new ApiError('Los puntajes no pueden ser negativos', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar ganador
|
||||||
|
let winner: string;
|
||||||
|
if (team1Score > team2Score) {
|
||||||
|
winner = 'TEAM1';
|
||||||
|
} else if (team2Score > team1Score) {
|
||||||
|
winner = 'TEAM2';
|
||||||
|
} else {
|
||||||
|
winner = 'DRAW';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar partido
|
||||||
|
const updatedMatch = await prisma.tournamentMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: {
|
||||||
|
team1Score,
|
||||||
|
team2Score,
|
||||||
|
winner,
|
||||||
|
status: TournamentMatchStatus.FINISHED,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1Player1: { include: { user: true } },
|
||||||
|
team2Player1: { include: { user: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Resultado registrado para partido ${matchId}: ${team1Score}-${team2Score}`);
|
||||||
|
|
||||||
|
// Avanzar ganador si es eliminatoria
|
||||||
|
if (match.tournament.type === TournamentType.ELIMINATION && match.nextMatchId) {
|
||||||
|
await this.advanceWinner(match, winner);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avanzar ganador a siguiente ronda
|
||||||
|
*/
|
||||||
|
private static async advanceWinner(
|
||||||
|
match: any,
|
||||||
|
winner: string
|
||||||
|
) {
|
||||||
|
if (!match.nextMatchId) return;
|
||||||
|
|
||||||
|
const nextMatch = await prisma.tournamentMatch.findUnique({
|
||||||
|
where: { id: match.nextMatchId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!nextMatch) return;
|
||||||
|
|
||||||
|
// Determinar si va a team1 o team2 del siguiente partido
|
||||||
|
// basado en la posición del partido actual
|
||||||
|
const isTeam1Slot = match.position % 2 === 0;
|
||||||
|
|
||||||
|
const winnerId =
|
||||||
|
winner === 'TEAM1'
|
||||||
|
? match.team1Player1Id
|
||||||
|
: winner === 'TEAM2'
|
||||||
|
? match.team2Player1Id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!winnerId) return;
|
||||||
|
|
||||||
|
const updateData = isTeam1Slot
|
||||||
|
? { team1Player1Id: winnerId }
|
||||||
|
: { team2Player1Id: winnerId };
|
||||||
|
|
||||||
|
await prisma.tournamentMatch.update({
|
||||||
|
where: { id: match.nextMatchId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Ganador ${winnerId} avanzado a partido ${match.nextMatchId} (${
|
||||||
|
isTeam1Slot ? 'team1' : 'team2'
|
||||||
|
})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TournamentDrawService;
|
||||||
690
backend/src/services/tournamentMatch.service.ts
Normal file
690
backend/src/services/tournamentMatch.service.ts
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import logger from '../config/logger';
|
||||||
|
import { TournamentMatchStatus, TournamentType } from '../utils/constants';
|
||||||
|
|
||||||
|
export interface MatchFilters {
|
||||||
|
round?: number;
|
||||||
|
status?: string;
|
||||||
|
courtId?: string;
|
||||||
|
playerId?: string;
|
||||||
|
fromDate?: Date;
|
||||||
|
toDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMatchInput {
|
||||||
|
courtId?: string;
|
||||||
|
scheduledDate?: Date;
|
||||||
|
scheduledTime?: string;
|
||||||
|
status?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordResultInput {
|
||||||
|
team1Score: number;
|
||||||
|
team2Score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TournamentMatchService {
|
||||||
|
/**
|
||||||
|
* Listar partidos de un torneo con filtros
|
||||||
|
*/
|
||||||
|
static async getMatches(tournamentId: string, filters: MatchFilters = {}) {
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new ApiError('Torneo no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: any = { tournamentId };
|
||||||
|
|
||||||
|
if (filters.round !== undefined) {
|
||||||
|
where.round = filters.round;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
where.status = filters.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.courtId) {
|
||||||
|
where.courtId = filters.courtId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.playerId) {
|
||||||
|
where.OR = [
|
||||||
|
{ team1Player1Id: filters.playerId },
|
||||||
|
{ team1Player2Id: filters.playerId },
|
||||||
|
{ team2Player1Id: filters.playerId },
|
||||||
|
{ team2Player2Id: filters.playerId },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.fromDate || filters.toDate) {
|
||||||
|
where.scheduledDate = {};
|
||||||
|
if (filters.fromDate) where.scheduledDate.gte = filters.fromDate;
|
||||||
|
if (filters.toDate) where.scheduledDate.lte = filters.toDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await prisma.tournamentMatch.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
team1Player1: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team1Player2: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2Player1: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2Player2: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nextMatch: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
round: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ round: 'desc' },
|
||||||
|
{ matchNumber: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Añadir información de confirmaciones
|
||||||
|
return matches.map(match => {
|
||||||
|
const confirmedBy = JSON.parse(match.confirmedBy) as string[];
|
||||||
|
return {
|
||||||
|
...match,
|
||||||
|
confirmations: confirmedBy.length,
|
||||||
|
isConfirmed: confirmedBy.length >= 2,
|
||||||
|
confirmedBy,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener un partido por ID
|
||||||
|
*/
|
||||||
|
static async getMatchById(matchId: string) {
|
||||||
|
const match = await prisma.tournamentMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
tournament: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team1Player1: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team1Player2: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2Player1: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2Player2: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
playerLevel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
court: true,
|
||||||
|
nextMatch: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
round: true,
|
||||||
|
matchNumber: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parentMatches: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
round: true,
|
||||||
|
matchNumber: true,
|
||||||
|
winner: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmedBy = JSON.parse(match.confirmedBy) as string[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...match,
|
||||||
|
confirmations: confirmedBy.length,
|
||||||
|
isConfirmed: confirmedBy.length >= 2,
|
||||||
|
confirmedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar un partido
|
||||||
|
*/
|
||||||
|
static async updateMatch(matchId: string, data: UpdateMatchInput) {
|
||||||
|
const match = await prisma.tournamentMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.FINISHED) {
|
||||||
|
throw new ApiError('No se puede editar un partido finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar cancha si se proporciona
|
||||||
|
if (data.courtId) {
|
||||||
|
const court = await prisma.court.findUnique({
|
||||||
|
where: { id: data.courtId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!court) {
|
||||||
|
throw new ApiError('Cancha no encontrada', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMatch = await prisma.tournamentMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: {
|
||||||
|
...(data.courtId && { courtId: data.courtId }),
|
||||||
|
...(data.scheduledDate && { scheduledDate: data.scheduledDate }),
|
||||||
|
...(data.scheduledTime && { scheduledTime: data.scheduledTime }),
|
||||||
|
...(data.status && { status: data.status }),
|
||||||
|
...(data.notes && {
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
...JSON.parse(match.metadata || '{}'),
|
||||||
|
notes: data.notes,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: true,
|
||||||
|
team1Player1: { include: { user: true } },
|
||||||
|
team2Player1: { include: { user: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Partido ${matchId} actualizado`);
|
||||||
|
|
||||||
|
return updatedMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asignar cancha a un partido
|
||||||
|
*/
|
||||||
|
static async assignCourt(
|
||||||
|
matchId: string,
|
||||||
|
courtId: string,
|
||||||
|
date: Date,
|
||||||
|
time: string
|
||||||
|
) {
|
||||||
|
const match = await prisma.tournamentMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: { tournament: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.FINISHED) {
|
||||||
|
throw new ApiError('No se puede reasignar cancha a un partido finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar cancha
|
||||||
|
const court = await prisma.court.findUnique({
|
||||||
|
where: { id: courtId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!court) {
|
||||||
|
throw new ApiError('Cancha no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar disponibilidad
|
||||||
|
const conflictingMatch = await prisma.tournamentMatch.findFirst({
|
||||||
|
where: {
|
||||||
|
courtId,
|
||||||
|
scheduledDate: date,
|
||||||
|
scheduledTime: time,
|
||||||
|
status: { not: TournamentMatchStatus.CANCELLED },
|
||||||
|
id: { not: matchId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflictingMatch) {
|
||||||
|
throw new ApiError('La cancha no está disponible en esa fecha y hora', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMatch = await prisma.tournamentMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: {
|
||||||
|
courtId,
|
||||||
|
scheduledDate: date,
|
||||||
|
scheduledTime: time,
|
||||||
|
status: TournamentMatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: true,
|
||||||
|
team1Player1: { include: { user: true } },
|
||||||
|
team2Player1: { include: { user: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Cancha asignada a partido ${matchId}: ${courtId}`);
|
||||||
|
|
||||||
|
return updatedMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrar resultado de un partido con lógica de avance
|
||||||
|
*/
|
||||||
|
static async recordResult(
|
||||||
|
matchId: string,
|
||||||
|
input: RecordResultInput,
|
||||||
|
recordedBy: string
|
||||||
|
) {
|
||||||
|
const { team1Score, team2Score } = input;
|
||||||
|
|
||||||
|
const match = await prisma.tournamentMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
tournament: true,
|
||||||
|
nextMatch: true,
|
||||||
|
team1Player1: true,
|
||||||
|
team2Player1: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.FINISHED) {
|
||||||
|
throw new ApiError('El partido ya ha finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.CANCELLED) {
|
||||||
|
throw new ApiError('El partido está cancelado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.BYE) {
|
||||||
|
throw new ApiError('No se puede registrar resultado en un bye', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que ambos equipos estén asignados
|
||||||
|
if (!match.team1Player1Id || !match.team2Player1Id) {
|
||||||
|
throw new ApiError('Ambos equipos deben estar asignados', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar puntajes
|
||||||
|
if (team1Score < 0 || team2Score < 0) {
|
||||||
|
throw new ApiError('Los puntajes no pueden ser negativos', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar ganador
|
||||||
|
let winner: string;
|
||||||
|
if (team1Score > team2Score) {
|
||||||
|
winner = 'TEAM1';
|
||||||
|
} else if (team2Score > team1Score) {
|
||||||
|
winner = 'TEAM2';
|
||||||
|
} else {
|
||||||
|
winner = 'DRAW';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar partido
|
||||||
|
const updatedMatch = await prisma.tournamentMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: {
|
||||||
|
team1Score,
|
||||||
|
team2Score,
|
||||||
|
winner,
|
||||||
|
status: TournamentMatchStatus.FINISHED,
|
||||||
|
confirmedBy: JSON.stringify([recordedBy]),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1Player1: { include: { user: true } },
|
||||||
|
team2Player1: { include: { user: true } },
|
||||||
|
court: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Resultado registrado para partido ${matchId}: ${team1Score}-${team2Score}`);
|
||||||
|
|
||||||
|
// Avanzar ganador en eliminatoria
|
||||||
|
if (match.tournament.type === TournamentType.ELIMINATION && match.nextMatchId && winner !== 'DRAW') {
|
||||||
|
await this.advanceWinnerToNextRound(match, winner);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...updatedMatch,
|
||||||
|
confirmations: 1,
|
||||||
|
isConfirmed: false,
|
||||||
|
confirmedBy: [recordedBy],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avanzar ganador a siguiente ronda (para eliminatoria)
|
||||||
|
*/
|
||||||
|
private static async advanceWinnerToNextRound(
|
||||||
|
match: any,
|
||||||
|
winner: string
|
||||||
|
) {
|
||||||
|
if (!match.nextMatchId) return;
|
||||||
|
|
||||||
|
const winnerId = winner === 'TEAM1' ? match.team1Player1Id : match.team2Player1Id;
|
||||||
|
if (!winnerId) return;
|
||||||
|
|
||||||
|
// Determinar posición en el siguiente partido
|
||||||
|
const isTeam1Slot = match.position % 2 === 0;
|
||||||
|
|
||||||
|
const updateData = isTeam1Slot
|
||||||
|
? { team1Player1Id: winnerId }
|
||||||
|
: { team2Player1Id: winnerId };
|
||||||
|
|
||||||
|
await prisma.tournamentMatch.update({
|
||||||
|
where: { id: match.nextMatchId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Ganador ${winnerId} avanzado a partido ${match.nextMatchId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmar resultado de un partido (requiere 2 confirmaciones)
|
||||||
|
*/
|
||||||
|
static async confirmResult(matchId: string, userId: string) {
|
||||||
|
const match = await prisma.tournamentMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
team1Player1: true,
|
||||||
|
team1Player2: true,
|
||||||
|
team2Player1: true,
|
||||||
|
team2Player2: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status !== TournamentMatchStatus.FINISHED) {
|
||||||
|
throw new ApiError('El partido no ha finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el usuario sea participante del partido
|
||||||
|
const playerIds = [
|
||||||
|
match.team1Player1?.userId,
|
||||||
|
match.team1Player2?.userId,
|
||||||
|
match.team2Player1?.userId,
|
||||||
|
match.team2Player2?.userId,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (!playerIds.includes(userId)) {
|
||||||
|
throw new ApiError('Solo los participantes pueden confirmar el resultado', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmedBy = JSON.parse(match.confirmedBy) as string[];
|
||||||
|
|
||||||
|
// Verificar que no haya confirmado ya
|
||||||
|
if (confirmedBy.includes(userId)) {
|
||||||
|
throw new ApiError('Ya has confirmado este resultado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Añadir confirmación
|
||||||
|
confirmedBy.push(userId);
|
||||||
|
|
||||||
|
const updatedMatch = await prisma.tournamentMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: {
|
||||||
|
confirmedBy: JSON.stringify(confirmedBy),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1Player1: { include: { user: true } },
|
||||||
|
team2Player1: { include: { user: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isNowConfirmed = confirmedBy.length >= 2;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Partido ${matchId} confirmado por ${userId}. Confirmaciones: ${confirmedBy.length}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si está confirmado, actualizar estadísticas
|
||||||
|
if (isNowConfirmed) {
|
||||||
|
await this.updateStatsAfterMatch(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...updatedMatch,
|
||||||
|
confirmations: confirmedBy.length,
|
||||||
|
isConfirmed: isNowConfirmed,
|
||||||
|
confirmedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar estadísticas después de un partido confirmado
|
||||||
|
*/
|
||||||
|
private static async updateStatsAfterMatch(match: any) {
|
||||||
|
try {
|
||||||
|
// Actualizar estadísticas de participantes si es necesario
|
||||||
|
// Esto puede incluir estadísticas específicas del torneo
|
||||||
|
logger.info(`Estadísticas actualizadas para partido ${match.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error actualizando estadísticas: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iniciar partido (cambiar estado a IN_PROGRESS)
|
||||||
|
*/
|
||||||
|
static async startMatch(matchId: string) {
|
||||||
|
const match = await prisma.tournamentMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status !== TournamentMatchStatus.SCHEDULED) {
|
||||||
|
throw new ApiError('El partido debe estar programado para iniciar', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMatch = await prisma.tournamentMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: { status: TournamentMatchStatus.IN_PROGRESS },
|
||||||
|
include: {
|
||||||
|
team1Player1: { include: { user: true } },
|
||||||
|
team2Player1: { include: { user: true } },
|
||||||
|
court: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Partido ${matchId} iniciado`);
|
||||||
|
|
||||||
|
return updatedMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelar partido
|
||||||
|
*/
|
||||||
|
static async cancelMatch(matchId: string, reason?: string) {
|
||||||
|
const match = await prisma.tournamentMatch.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError('Partido no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status === TournamentMatchStatus.FINISHED) {
|
||||||
|
throw new ApiError('No se puede cancelar un partido finalizado', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMatch = await prisma.tournamentMatch.update({
|
||||||
|
where: { id: matchId },
|
||||||
|
data: {
|
||||||
|
status: TournamentMatchStatus.CANCELLED,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
...JSON.parse(match.metadata || '{}'),
|
||||||
|
cancellationReason: reason || 'Cancelado por administrador',
|
||||||
|
cancelledAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1Player1: { include: { user: true } },
|
||||||
|
team2Player1: { include: { user: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Partido ${matchId} cancelado`);
|
||||||
|
|
||||||
|
return updatedMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener partidos de un participante específico
|
||||||
|
*/
|
||||||
|
static async getParticipantMatches(tournamentId: string, participantId: string) {
|
||||||
|
const matches = await prisma.tournamentMatch.findMany({
|
||||||
|
where: {
|
||||||
|
tournamentId,
|
||||||
|
OR: [
|
||||||
|
{ team1Player1Id: participantId },
|
||||||
|
{ team1Player2Id: participantId },
|
||||||
|
{ team2Player1Id: participantId },
|
||||||
|
{ team2Player2Id: participantId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team1Player1: { include: { user: true } },
|
||||||
|
team2Player1: { include: { user: true } },
|
||||||
|
court: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ round: 'desc' }, { matchNumber: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return matches.map(match => {
|
||||||
|
const confirmedBy = JSON.parse(match.confirmedBy) as string[];
|
||||||
|
const isUserTeam1 =
|
||||||
|
match.team1Player1Id === participantId || match.team1Player2Id === participantId;
|
||||||
|
const isWinner =
|
||||||
|
(match.winner === 'TEAM1' && isUserTeam1) ||
|
||||||
|
(match.winner === 'TEAM2' && !isUserTeam1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...match,
|
||||||
|
confirmations: confirmedBy.length,
|
||||||
|
isConfirmed: confirmedBy.length >= 2,
|
||||||
|
confirmedBy,
|
||||||
|
isUserTeam1,
|
||||||
|
isWinner,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TournamentMatchService;
|
||||||
@@ -89,3 +89,135 @@ export const GroupRole = {
|
|||||||
} as const;
|
} 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;
|
||||||
|
|||||||
284
backend/src/utils/tournamentDraw.ts
Normal file
284
backend/src/utils/tournamentDraw.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Utilidades para generación de cuadros de torneo
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mezcla un array aleatoriamente (algoritmo Fisher-Yates)
|
||||||
|
*/
|
||||||
|
export function shuffleArray<T>(array: T[]): T[] {
|
||||||
|
const shuffled = [...array];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el número de rondas necesarias para una eliminatoria
|
||||||
|
*/
|
||||||
|
export function calculateRounds(participantCount: number): number {
|
||||||
|
if (participantCount <= 1) return 0;
|
||||||
|
return Math.ceil(Math.log2(participantCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordena participantes por seed (cabeza de serie)
|
||||||
|
* Los seeds más bajos (1, 2, 3...) se distribuyen estratégicamente
|
||||||
|
*/
|
||||||
|
export function seedParticipants<T extends { seed?: number | null; id: string }>(
|
||||||
|
participants: T[]
|
||||||
|
): T[] {
|
||||||
|
// Separar seeds y no seeds
|
||||||
|
const withSeed = participants.filter(p => p.seed !== null && p.seed !== undefined);
|
||||||
|
const withoutSeed = participants.filter(p => p.seed === null || p.seed === undefined);
|
||||||
|
|
||||||
|
// Ordenar seeds de menor a mayor
|
||||||
|
withSeed.sort((a, b) => (a.seed as number) - (b.seed as number));
|
||||||
|
|
||||||
|
// Mezclar no seeds
|
||||||
|
const shuffledNoSeed = shuffleArray(withoutSeed);
|
||||||
|
|
||||||
|
// Combinar: seeds primero, luego no seeds mezclados
|
||||||
|
return [...withSeed, ...shuffledNoSeed];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera las posiciones en el cuadro para una eliminatoria
|
||||||
|
* Distribuye los seeds estratégicamente
|
||||||
|
*/
|
||||||
|
export function generateBracketPositions(count: number): number[] {
|
||||||
|
const positions: number[] = [];
|
||||||
|
|
||||||
|
if (count <= 0) return positions;
|
||||||
|
|
||||||
|
// Encontrar la siguiente potencia de 2
|
||||||
|
const bracketSize = nextPowerOfTwo(count);
|
||||||
|
|
||||||
|
// Crear array de posiciones
|
||||||
|
for (let i = 0; i < bracketSize; i++) {
|
||||||
|
positions.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reordenar usando el algoritmo de distribución de seeds
|
||||||
|
return distributeSeeds(positions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distribuye los seeds en el cuadro para evitar enfrentamientos tempranos
|
||||||
|
* entre favoritos
|
||||||
|
*/
|
||||||
|
function distributeSeeds(positions: number[]): number[] {
|
||||||
|
if (positions.length <= 2) return positions;
|
||||||
|
|
||||||
|
const result: number[] = new Array(positions.length);
|
||||||
|
const seeds = positions.map((_, i) => i + 1); // 1, 2, 3, 4...
|
||||||
|
|
||||||
|
// Algoritmo de distribución de seeds
|
||||||
|
// Seed 1 -> posición 0
|
||||||
|
// Seed 2 -> última posición
|
||||||
|
// Seeds 3-4 -> cuartos opuestos
|
||||||
|
// Seeds 5-8 -> octavos opuestos, etc.
|
||||||
|
|
||||||
|
const distribute = (start: number, end: number, seedStart: number, seedEnd: number) => {
|
||||||
|
if (start > end || seedStart > seedEnd) return;
|
||||||
|
|
||||||
|
const mid = Math.floor((start + end) / 2);
|
||||||
|
const seedMid = Math.floor((seedStart + seedEnd) / 2);
|
||||||
|
|
||||||
|
result[start] = seeds[seedStart - 1]; // Mejor seed del grupo
|
||||||
|
result[end] = seeds[seedEnd - 1]; // Peor seed del grupo
|
||||||
|
|
||||||
|
if (start < end - 1) {
|
||||||
|
distribute(start + 1, mid, seedStart + 1, seedMid);
|
||||||
|
distribute(mid + 1, end - 1, seedMid + 1, seedEnd - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
distribute(0, positions.length - 1, 1, positions.length);
|
||||||
|
|
||||||
|
return result.map(pos => pos - 1); // Convertir a índices 0-based
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un número es potencia de 2
|
||||||
|
*/
|
||||||
|
export function isPowerOfTwo(n: number): boolean {
|
||||||
|
if (n <= 0) return false;
|
||||||
|
return (n & (n - 1)) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encuentra la siguiente potencia de 2 mayor o igual a n
|
||||||
|
*/
|
||||||
|
export function nextPowerOfTwo(n: number): number {
|
||||||
|
if (n <= 0) return 1;
|
||||||
|
if (isPowerOfTwo(n)) return n;
|
||||||
|
return Math.pow(2, Math.ceil(Math.log2(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el número de byes necesarios para completar una potencia de 2
|
||||||
|
*/
|
||||||
|
export function calculateByes(participantCount: number): number {
|
||||||
|
const bracketSize = nextPowerOfTwo(participantCount);
|
||||||
|
return bracketSize - participantCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera emparejamientos para round robin (todos vs todos)
|
||||||
|
* Usa el algoritmo de circle method
|
||||||
|
*/
|
||||||
|
export function generateRoundRobinPairings<T>(participants: T[]): Array<[T, T]> {
|
||||||
|
const n = participants.length;
|
||||||
|
const pairings: Array<[T, T]> = [];
|
||||||
|
|
||||||
|
if (n < 2) return pairings;
|
||||||
|
|
||||||
|
// Si es impar, añadir un "descanso"
|
||||||
|
const players = [...participants];
|
||||||
|
if (players.length % 2 !== 0) {
|
||||||
|
players.push(null as any); // bye
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = players.length;
|
||||||
|
const rounds = count - 1;
|
||||||
|
|
||||||
|
// Fijar el primer jugador, rotar el resto
|
||||||
|
for (let round = 0; round < rounds; round++) {
|
||||||
|
for (let i = 0; i < count / 2; i++) {
|
||||||
|
const player1 = players[i];
|
||||||
|
const player2 = players[count - 1 - i];
|
||||||
|
|
||||||
|
if (player1 !== null && player2 !== null) {
|
||||||
|
pairings.push([player1, player2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotar (excepto el primero)
|
||||||
|
const last = players.pop()!;
|
||||||
|
players.splice(1, 0, last);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera emparejamientos para sistema suizo
|
||||||
|
* Empareja jugadores con puntajes similares
|
||||||
|
*/
|
||||||
|
export interface SwissPlayer {
|
||||||
|
id: string;
|
||||||
|
points: number;
|
||||||
|
playedAgainst: string[]; // IDs de oponentes ya enfrentados
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSwissPairings(players: SwissPlayer[]): Array<[string, string]> {
|
||||||
|
const pairings: Array<[string, string]> = [];
|
||||||
|
const unpaired = [...players].sort((a, b) => b.points - a.points);
|
||||||
|
const paired = new Set<string>();
|
||||||
|
|
||||||
|
while (unpaired.length >= 2) {
|
||||||
|
const player1 = unpaired.shift()!;
|
||||||
|
|
||||||
|
if (paired.has(player1.id)) continue;
|
||||||
|
|
||||||
|
// Buscar oponente con puntaje similar que no haya jugado contra
|
||||||
|
let opponentIndex = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < unpaired.length; i++) {
|
||||||
|
const candidate = unpaired[i];
|
||||||
|
|
||||||
|
if (paired.has(candidate.id)) continue;
|
||||||
|
|
||||||
|
// Verificar que no hayan jugado antes
|
||||||
|
if (!player1.playedAgainst.includes(candidate.id)) {
|
||||||
|
opponentIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no hay oponente nuevo, tomar el primero disponible
|
||||||
|
if (opponentIndex === -1) {
|
||||||
|
for (let i = 0; i < unpaired.length; i++) {
|
||||||
|
if (!paired.has(unpaired[i].id)) {
|
||||||
|
opponentIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opponentIndex !== -1) {
|
||||||
|
const player2 = unpaired.splice(opponentIndex, 1)[0];
|
||||||
|
pairings.push([player1.id, player2.id]);
|
||||||
|
paired.add(player1.id);
|
||||||
|
paired.add(player2.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula puntos para sistema suizo
|
||||||
|
*/
|
||||||
|
export function calculateSwissPoints(wins: number, draws: number = 0): number {
|
||||||
|
return wins * 3 + draws * 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determina la siguiente ronda para un cuadro de eliminatoria
|
||||||
|
*/
|
||||||
|
export function getNextRoundMatch(currentRound: number, currentPosition: number): {
|
||||||
|
round: number;
|
||||||
|
position: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
round: currentRound - 1, // 1 es la final, 2 semifinal, etc.
|
||||||
|
position: Math.floor(currentPosition / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el número total de partidos en un cuadro de eliminatoria
|
||||||
|
*/
|
||||||
|
export function calculateTotalMatchesElimination(participantCount: number): number {
|
||||||
|
const bracketSize = nextPowerOfTwo(participantCount);
|
||||||
|
return bracketSize - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el número total de partidos en round robin
|
||||||
|
*/
|
||||||
|
export function calculateTotalMatchesRoundRobin(participantCount: number): number {
|
||||||
|
return (participantCount * (participantCount - 1)) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida si un cuadro puede generarse
|
||||||
|
*/
|
||||||
|
export function validateDrawGeneration(
|
||||||
|
participantCount: number,
|
||||||
|
type: string
|
||||||
|
): { valid: boolean; error?: string } {
|
||||||
|
if (participantCount < 2) {
|
||||||
|
return { valid: false, error: 'Se necesitan al menos 2 participantes' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'ELIMINATION' || type === 'CONSOLATION') {
|
||||||
|
// Eliminatoria puede generarse con cualquier número (se usan byes)
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'ROUND_ROBIN') {
|
||||||
|
// Round robin puede generarse con cualquier número >= 2
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'SWISS') {
|
||||||
|
// Suizo necesita al menos 2 jugadores
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, error: 'Tipo de torneo no soportado' };
|
||||||
|
}
|
||||||
86
backend/src/validators/league.validator.ts
Normal file
86
backend/src/validators/league.validator.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { LeagueStatus, LeagueFormat, LeagueType, LeagueMatchStatus } from '../utils/constants';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Esquemas de Liga
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Crear liga
|
||||||
|
export const createLeagueSchema = z.object({
|
||||||
|
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres'),
|
||||||
|
description: z.string().max(1000, 'La descripción no puede exceder 1000 caracteres').optional(),
|
||||||
|
format: z.enum([LeagueFormat.SINGLE_ROUND_ROBIN, LeagueFormat.DOUBLE_ROUND_ROBIN], {
|
||||||
|
errorMap: () => ({ message: 'Formato inválido' }),
|
||||||
|
}).optional(),
|
||||||
|
matchesPerMatchday: z.number().int().min(1).max(10).optional(),
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(),
|
||||||
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actualizar liga
|
||||||
|
export const updateLeagueSchema = z.object({
|
||||||
|
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres').optional(),
|
||||||
|
description: z.string().max(1000, 'La descripción no puede exceder 1000 caracteres').optional(),
|
||||||
|
format: z.enum([LeagueFormat.SINGLE_ROUND_ROBIN, LeagueFormat.DOUBLE_ROUND_ROBIN], {
|
||||||
|
errorMap: () => ({ message: 'Formato inválido' }),
|
||||||
|
}).optional(),
|
||||||
|
matchesPerMatchday: z.number().int().min(1).max(10).optional(),
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
|
||||||
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Esquemas de Equipos de Liga
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Crear equipo
|
||||||
|
export const createLeagueTeamSchema = z.object({
|
||||||
|
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
|
||||||
|
description: z.string().max(500, 'La descripción no puede exceder 500 caracteres').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actualizar equipo
|
||||||
|
export const updateLeagueTeamSchema = z.object({
|
||||||
|
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(),
|
||||||
|
description: z.string().max(500, 'La descripción no puede exceder 500 caracteres').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agregar miembro
|
||||||
|
export const addLeagueTeamMemberSchema = z.object({
|
||||||
|
userId: z.string().uuid('ID de usuario inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Esquemas de Calendario
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Actualizar partido
|
||||||
|
export const updateLeagueMatchSchema = z.object({
|
||||||
|
scheduledDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional().nullable(),
|
||||||
|
scheduledTime: z.string().regex(/^\d{2}:\d{2}$/, 'Hora debe estar en formato HH:mm').optional().nullable(),
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido').optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Registrar resultado de partido
|
||||||
|
export const updateLeagueMatchResultSchema = z.object({
|
||||||
|
team1Score: z.number().int().min(0).max(9),
|
||||||
|
team2Score: z.number().int().min(0).max(9),
|
||||||
|
setDetails: z.array(z.object({
|
||||||
|
team1Games: z.number().int().min(0).max(7),
|
||||||
|
team2Games: z.number().int().min(0).max(7),
|
||||||
|
})).optional(),
|
||||||
|
winner: z.enum(['TEAM1', 'TEAM2', 'DRAW']),
|
||||||
|
notes: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tipos inferidos
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type CreateLeagueInput = z.infer<typeof createLeagueSchema>;
|
||||||
|
export type UpdateLeagueInput = z.infer<typeof updateLeagueSchema>;
|
||||||
|
export type CreateLeagueTeamInput = z.infer<typeof createLeagueTeamSchema>;
|
||||||
|
export type UpdateLeagueTeamInput = z.infer<typeof updateLeagueTeamSchema>;
|
||||||
|
export type AddLeagueTeamMemberInput = z.infer<typeof addLeagueTeamMemberSchema>;
|
||||||
|
export type UpdateLeagueMatchInput = z.infer<typeof updateLeagueMatchSchema>;
|
||||||
|
export type UpdateLeagueMatchResultInput = z.infer<typeof updateLeagueMatchResultSchema>;
|
||||||
104
backend/src/validators/tournament.validator.ts
Normal file
104
backend/src/validators/tournament.validator.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
TournamentType,
|
||||||
|
TournamentCategory,
|
||||||
|
TournamentStatus,
|
||||||
|
PlayerLevel,
|
||||||
|
} from '../utils/constants';
|
||||||
|
|
||||||
|
// Esquema para crear torneo
|
||||||
|
export const createTournamentSchema = z.object({
|
||||||
|
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
type: z.enum([
|
||||||
|
TournamentType.ELIMINATION,
|
||||||
|
TournamentType.ROUND_ROBIN,
|
||||||
|
TournamentType.SWISS,
|
||||||
|
TournamentType.CONSOLATION,
|
||||||
|
], {
|
||||||
|
errorMap: () => ({ message: 'Tipo de torneo inválido' }),
|
||||||
|
}),
|
||||||
|
category: z.enum([
|
||||||
|
TournamentCategory.MEN,
|
||||||
|
TournamentCategory.WOMEN,
|
||||||
|
TournamentCategory.MIXED,
|
||||||
|
], {
|
||||||
|
errorMap: () => ({ message: 'Categoría inválida' }),
|
||||||
|
}),
|
||||||
|
allowedLevels: z
|
||||||
|
.array(
|
||||||
|
z.enum([
|
||||||
|
PlayerLevel.BEGINNER,
|
||||||
|
PlayerLevel.ELEMENTARY,
|
||||||
|
PlayerLevel.INTERMEDIATE,
|
||||||
|
PlayerLevel.ADVANCED,
|
||||||
|
PlayerLevel.COMPETITION,
|
||||||
|
PlayerLevel.PROFESSIONAL,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.min(1, 'Debe especificar al menos un nivel permitido'),
|
||||||
|
maxParticipants: z.number().int().min(2, 'Mínimo 2 participantes'),
|
||||||
|
registrationStartDate: z.string().datetime('Fecha inválida'),
|
||||||
|
registrationEndDate: z.string().datetime('Fecha inválida'),
|
||||||
|
startDate: z.string().datetime('Fecha inválida'),
|
||||||
|
endDate: z.string().datetime('Fecha inválida'),
|
||||||
|
courtIds: z.array(z.string().uuid('ID de cancha inválido')),
|
||||||
|
price: z.number().int().min(0, 'El precio no puede ser negativo').default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Esquema para actualizar torneo
|
||||||
|
export const updateTournamentSchema = z.object({
|
||||||
|
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres').optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
type: z
|
||||||
|
.enum([
|
||||||
|
TournamentType.ELIMINATION,
|
||||||
|
TournamentType.ROUND_ROBIN,
|
||||||
|
TournamentType.SWISS,
|
||||||
|
TournamentType.CONSOLATION,
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
category: z
|
||||||
|
.enum([
|
||||||
|
TournamentCategory.MEN,
|
||||||
|
TournamentCategory.WOMEN,
|
||||||
|
TournamentCategory.MIXED,
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
allowedLevels: z
|
||||||
|
.array(
|
||||||
|
z.enum([
|
||||||
|
PlayerLevel.BEGINNER,
|
||||||
|
PlayerLevel.ELEMENTARY,
|
||||||
|
PlayerLevel.INTERMEDIATE,
|
||||||
|
PlayerLevel.ADVANCED,
|
||||||
|
PlayerLevel.COMPETITION,
|
||||||
|
PlayerLevel.PROFESSIONAL,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
maxParticipants: z.number().int().min(2).optional(),
|
||||||
|
registrationStartDate: z.string().datetime('Fecha inválida').optional(),
|
||||||
|
registrationEndDate: z.string().datetime('Fecha inválida').optional(),
|
||||||
|
startDate: z.string().datetime('Fecha inválida').optional(),
|
||||||
|
endDate: z.string().datetime('Fecha inválida').optional(),
|
||||||
|
courtIds: z.array(z.string().uuid('ID de cancha inválido')).optional(),
|
||||||
|
price: z.number().int().min(0).optional(),
|
||||||
|
status: z
|
||||||
|
.enum([
|
||||||
|
TournamentStatus.DRAFT,
|
||||||
|
TournamentStatus.OPEN,
|
||||||
|
TournamentStatus.CLOSED,
|
||||||
|
TournamentStatus.IN_PROGRESS,
|
||||||
|
TournamentStatus.FINISHED,
|
||||||
|
TournamentStatus.CANCELLED,
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Esquema para registro de participante (solo valida que el cuerpo esté vacío o tenga datos opcionales)
|
||||||
|
export const registerSchema = z.object({}).optional();
|
||||||
|
|
||||||
|
// Tipos inferidos
|
||||||
|
export type CreateTournamentInput = z.infer<typeof createTournamentSchema>;
|
||||||
|
export type UpdateTournamentInput = z.infer<typeof updateTournamentSchema>;
|
||||||
@@ -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*
|
||||||
|
|||||||
Reference in New Issue
Block a user