From 6494e2b38b76015574741802ff0f83d1e1f7df33 Mon Sep 17 00:00:00 2001 From: Ivan Alcaraz Date: Sat, 31 Jan 2026 08:38:54 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20FASE=203=20COMPLETADA:=20Torneos=20?= =?UTF-8?q?y=20Ligas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/prisma/dev.db | Bin 241664 -> 462848 bytes .../migration.sql | 258 ++++++ backend/prisma/schema.prisma | 376 +++++++++ backend/prisma/seed-fase3.ts | 96 +++ backend/src/controllers/league.controller.ts | 291 +++++++ .../src/controllers/leagueMatch.controller.ts | 156 ++++ .../controllers/leagueSchedule.controller.ts | 155 ++++ .../controllers/leagueStanding.controller.ts | 139 +++ .../src/controllers/leagueTeam.controller.ts | 269 ++++++ .../src/controllers/tournament.controller.ts | 298 +++++++ .../controllers/tournamentDraw.controller.ts | 149 ++++ .../controllers/tournamentMatch.controller.ts | 317 +++++++ backend/src/routes/index.ts | 38 + backend/src/routes/league.routes.ts | 78 ++ backend/src/routes/leagueMatch.routes.ts | 88 ++ backend/src/routes/leagueSchedule.routes.ts | 86 ++ backend/src/routes/leagueStanding.routes.ts | 69 ++ backend/src/routes/leagueTeam.routes.ts | 102 +++ backend/src/routes/tournament.routes.ts | 67 ++ backend/src/routes/tournamentDraw.routes.ts | 68 ++ backend/src/routes/tournamentMatch.routes.ts | 146 ++++ backend/src/services/league.service.ts | 502 +++++++++++ backend/src/services/leagueMatch.service.ts | 442 ++++++++++ .../src/services/leagueSchedule.service.ts | 553 ++++++++++++ .../src/services/leagueStanding.service.ts | 533 ++++++++++++ backend/src/services/leagueTeam.service.ts | 641 ++++++++++++++ backend/src/services/tournament.service.ts | 799 ++++++++++++++++++ .../src/services/tournamentDraw.service.ts | 788 +++++++++++++++++ .../src/services/tournamentMatch.service.ts | 690 +++++++++++++++ backend/src/utils/constants.ts | 132 +++ backend/src/utils/tournamentDraw.ts | 284 +++++++ backend/src/validators/league.validator.ts | 86 ++ .../src/validators/tournament.validator.ts | 104 +++ docs/roadmap/FASE-03.md | 239 +++++- 34 files changed, 9036 insertions(+), 3 deletions(-) create mode 100644 backend/prisma/migrations/20260131083026_add_tournament_matches/migration.sql create mode 100644 backend/prisma/seed-fase3.ts create mode 100644 backend/src/controllers/league.controller.ts create mode 100644 backend/src/controllers/leagueMatch.controller.ts create mode 100644 backend/src/controllers/leagueSchedule.controller.ts create mode 100644 backend/src/controllers/leagueStanding.controller.ts create mode 100644 backend/src/controllers/leagueTeam.controller.ts create mode 100644 backend/src/controllers/tournament.controller.ts create mode 100644 backend/src/controllers/tournamentDraw.controller.ts create mode 100644 backend/src/controllers/tournamentMatch.controller.ts create mode 100644 backend/src/routes/league.routes.ts create mode 100644 backend/src/routes/leagueMatch.routes.ts create mode 100644 backend/src/routes/leagueSchedule.routes.ts create mode 100644 backend/src/routes/leagueStanding.routes.ts create mode 100644 backend/src/routes/leagueTeam.routes.ts create mode 100644 backend/src/routes/tournament.routes.ts create mode 100644 backend/src/routes/tournamentDraw.routes.ts create mode 100644 backend/src/routes/tournamentMatch.routes.ts create mode 100644 backend/src/services/league.service.ts create mode 100644 backend/src/services/leagueMatch.service.ts create mode 100644 backend/src/services/leagueSchedule.service.ts create mode 100644 backend/src/services/leagueStanding.service.ts create mode 100644 backend/src/services/leagueTeam.service.ts create mode 100644 backend/src/services/tournament.service.ts create mode 100644 backend/src/services/tournamentDraw.service.ts create mode 100644 backend/src/services/tournamentMatch.service.ts create mode 100644 backend/src/utils/tournamentDraw.ts create mode 100644 backend/src/validators/league.validator.ts create mode 100644 backend/src/validators/tournament.validator.ts diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db index 469a13c5bb285ca5fc671933db6ad42079687fe6..437ede571dc79ae1602caa71c982775a4fa7bfe1 100644 GIT binary patch literal 462848 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 delta 577 zcma)&J!lhQ7{}k|?!DYc?%wB;RuoNg2}CDpiXVd?=s8soEp(_$15ujNVigCeN;}j= zI`|bX_%6jBsnDrH?<_aCD2O1g9ZIK&lOQ!!aL}ajy<$O}eBl4*`5ONGX8p`;lRY_* zsu4m?3GLRg+To+w8tI#wQ^ZQft@v;ff8h_@GS(BHwds|aG33@6z^u6BOTzXz!g}{w z!ey2wc$2+i>9EjGS(wg!gNRn(K6*~B=)EWnn-l|r8~6nSd>5wt28O}eAv>hajuS$& z>jXZHC)HNs1XR3y#jv}e#LKqR@a;O#UTPMIS-{h5%32m*Ng*Rl*}BOKCVYUme8qyE zgr;jmQTUZXsmM1inATvvr?~hM=A&f`CXXmd@|+CtoC_Dcs+8f^x542PF1(RW?BlNo zAj5C>LpJiy!6@6ww+PMr-?Fqz#3mJjm)UprfU_3tPn%0-F8R=C8`ty=eMD<%BVNir z6jfTVsWL*8kEJZ!owzYty>pdMF39!hQC;qn>Al2pHG2OOEQy!4p&Z9(M{`}6aT65F z1j`X~r4IA*_AoCGN=rL1{-A2_GW(yV;~W&QP+cWhW$Wy`6)KrqUj8DdpzNU|B YYudQFDri1dql?ev5dMicCO2IBH!ZKQ3IG5A diff --git a/backend/prisma/migrations/20260131083026_add_tournament_matches/migration.sql b/backend/prisma/migrations/20260131083026_add_tournament_matches/migration.sql new file mode 100644 index 0000000..7f928f7 --- /dev/null +++ b/backend/prisma/migrations/20260131083026_add_tournament_matches/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bfaa2c8..0ce142b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -64,9 +64,18 @@ model User { groupsCreated Group[] @relation("GroupsCreated") 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 recurringBookings RecurringBooking[] + // Torneos + tournamentsCreated Tournament[] @relation("TournamentsCreated") + tournamentParticipations TournamentParticipant[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -121,6 +130,8 @@ model Court { bookings Booking[] schedules CourtSchedule[] recurringBookings RecurringBooking[] + leagueMatches LeagueMatch[] + tournamentMatches TournamentMatch[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -381,3 +392,368 @@ model UserStats { @@index([points]) @@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") +} diff --git a/backend/prisma/seed-fase3.ts b/backend/prisma/seed-fase3.ts new file mode 100644 index 0000000..765239c --- /dev/null +++ b/backend/prisma/seed-fase3.ts @@ -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(); + }); diff --git a/backend/src/controllers/league.controller.ts b/backend/src/controllers/league.controller.ts new file mode 100644 index 0000000..a0d0c7b --- /dev/null +++ b/backend/src/controllers/league.controller.ts @@ -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; diff --git a/backend/src/controllers/leagueMatch.controller.ts b/backend/src/controllers/leagueMatch.controller.ts new file mode 100644 index 0000000..818b239 --- /dev/null +++ b/backend/src/controllers/leagueMatch.controller.ts @@ -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; diff --git a/backend/src/controllers/leagueSchedule.controller.ts b/backend/src/controllers/leagueSchedule.controller.ts new file mode 100644 index 0000000..dc37621 --- /dev/null +++ b/backend/src/controllers/leagueSchedule.controller.ts @@ -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; diff --git a/backend/src/controllers/leagueStanding.controller.ts b/backend/src/controllers/leagueStanding.controller.ts new file mode 100644 index 0000000..c12478d --- /dev/null +++ b/backend/src/controllers/leagueStanding.controller.ts @@ -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; diff --git a/backend/src/controllers/leagueTeam.controller.ts b/backend/src/controllers/leagueTeam.controller.ts new file mode 100644 index 0000000..df1abc9 --- /dev/null +++ b/backend/src/controllers/leagueTeam.controller.ts @@ -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; diff --git a/backend/src/controllers/tournament.controller.ts b/backend/src/controllers/tournament.controller.ts new file mode 100644 index 0000000..7983f3d --- /dev/null +++ b/backend/src/controllers/tournament.controller.ts @@ -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; diff --git a/backend/src/controllers/tournamentDraw.controller.ts b/backend/src/controllers/tournamentDraw.controller.ts new file mode 100644 index 0000000..9210eed --- /dev/null +++ b/backend/src/controllers/tournamentDraw.controller.ts @@ -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; diff --git a/backend/src/controllers/tournamentMatch.controller.ts b/backend/src/controllers/tournamentMatch.controller.ts new file mode 100644 index 0000000..8f2744c --- /dev/null +++ b/backend/src/controllers/tournamentMatch.controller.ts @@ -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; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 755bde4..8d64e8b 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -5,6 +5,16 @@ import bookingRoutes from './booking.routes'; import matchRoutes from './match.routes'; import rankingRoutes from './ranking.routes'; import statsRoutes from './stats.routes'; +import tournamentRoutes from './tournament.routes'; +import tournamentDrawRoutes from './tournamentDraw.routes'; +import tournamentMatchRoutes from './tournamentMatch.routes'; + +// Rutas de Ligas por Equipos (Fase 3.3) +import leagueRoutes from './league.routes'; +import leagueTeamRoutes from './leagueTeam.routes'; +import leagueScheduleRoutes from './leagueSchedule.routes'; +import leagueStandingRoutes from './leagueStanding.routes'; +import leagueMatchRoutes from './leagueMatch.routes'; const router = Router(); @@ -35,4 +45,32 @@ router.use('/ranking', rankingRoutes); // Rutas de estadísticas router.use('/stats', statsRoutes); +// Rutas de torneos (base) +router.use('/tournaments', tournamentRoutes); + +// Rutas de cuadro de torneo (sub-rutas de /tournaments/:id) +router.use('/tournaments', tournamentDrawRoutes); + +// Rutas de partidos de torneo (sub-rutas de /tournaments/:id) +router.use('/tournaments', tournamentMatchRoutes); + +// ============================================ +// Rutas de Ligas por Equipos (Fase 3.3) +// ============================================ + +// Rutas de ligas +router.use('/leagues', leagueRoutes); + +// Rutas de equipos de liga +router.use('/league-teams', leagueTeamRoutes); + +// Rutas de calendario de liga +router.use('/league-schedule', leagueScheduleRoutes); + +// Rutas de clasificación de liga +router.use('/league-standings', leagueStandingRoutes); + +// Rutas de partidos de liga +router.use('/league-matches', leagueMatchRoutes); + export default router; diff --git a/backend/src/routes/league.routes.ts b/backend/src/routes/league.routes.ts new file mode 100644 index 0000000..1c3c361 --- /dev/null +++ b/backend/src/routes/league.routes.ts @@ -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; diff --git a/backend/src/routes/leagueMatch.routes.ts b/backend/src/routes/leagueMatch.routes.ts new file mode 100644 index 0000000..a9fe5d8 --- /dev/null +++ b/backend/src/routes/leagueMatch.routes.ts @@ -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; diff --git a/backend/src/routes/leagueSchedule.routes.ts b/backend/src/routes/leagueSchedule.routes.ts new file mode 100644 index 0000000..069f54c --- /dev/null +++ b/backend/src/routes/leagueSchedule.routes.ts @@ -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; diff --git a/backend/src/routes/leagueStanding.routes.ts b/backend/src/routes/leagueStanding.routes.ts new file mode 100644 index 0000000..69ececc --- /dev/null +++ b/backend/src/routes/leagueStanding.routes.ts @@ -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; diff --git a/backend/src/routes/leagueTeam.routes.ts b/backend/src/routes/leagueTeam.routes.ts new file mode 100644 index 0000000..002b6e3 --- /dev/null +++ b/backend/src/routes/leagueTeam.routes.ts @@ -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; diff --git a/backend/src/routes/tournament.routes.ts b/backend/src/routes/tournament.routes.ts new file mode 100644 index 0000000..15689e8 --- /dev/null +++ b/backend/src/routes/tournament.routes.ts @@ -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; diff --git a/backend/src/routes/tournamentDraw.routes.ts b/backend/src/routes/tournamentDraw.routes.ts new file mode 100644 index 0000000..183464a --- /dev/null +++ b/backend/src/routes/tournamentDraw.routes.ts @@ -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; diff --git a/backend/src/routes/tournamentMatch.routes.ts b/backend/src/routes/tournamentMatch.routes.ts new file mode 100644 index 0000000..4368281 --- /dev/null +++ b/backend/src/routes/tournamentMatch.routes.ts @@ -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; diff --git a/backend/src/services/league.service.ts b/backend/src/services/league.service.ts new file mode 100644 index 0000000..3206532 --- /dev/null +++ b/backend/src/services/league.service.ts @@ -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 { + 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 { + const league = await prisma.league.findUnique({ + where: { id: leagueId }, + select: { status: true }, + }); + + return league?.status === LeagueStatus.DRAFT; + } +} + +export default LeagueService; diff --git a/backend/src/services/leagueMatch.service.ts b/backend/src/services/leagueMatch.service.ts new file mode 100644 index 0000000..ba02dc4 --- /dev/null +++ b/backend/src/services/leagueMatch.service.ts @@ -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; diff --git a/backend/src/services/leagueSchedule.service.ts b/backend/src/services/leagueSchedule.service.ts new file mode 100644 index 0000000..a6ef8ee --- /dev/null +++ b/backend/src/services/leagueSchedule.service.ts @@ -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(); + 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; diff --git a/backend/src/services/leagueStanding.service.ts b/backend/src/services/leagueStanding.service.ts new file mode 100644 index 0000000..6609de0 --- /dev/null +++ b/backend/src/services/leagueStanding.service.ts @@ -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(); + + 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(); + + 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; diff --git a/backend/src/services/leagueTeam.service.ts b/backend/src/services/leagueTeam.service.ts new file mode 100644 index 0000000..2904b4b --- /dev/null +++ b/backend/src/services/leagueTeam.service.ts @@ -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 { + 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 { + const member = await prisma.leagueTeamMember.findUnique({ + where: { + teamId_userId: { + teamId, + userId, + }, + }, + }); + + return !!member && member.isActive; + } +} + +export default LeagueTeamService; diff --git a/backend/src/services/tournament.service.ts b/backend/src/services/tournament.service.ts new file mode 100644 index 0000000..a8d5db2 --- /dev/null +++ b/backend/src/services/tournament.service.ts @@ -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; diff --git a/backend/src/services/tournamentDraw.service.ts b/backend/src/services/tournamentDraw.service.ts new file mode 100644 index 0000000..edc0466 --- /dev/null +++ b/backend/src/services/tournamentDraw.service.ts @@ -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>(); // 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(); + const playedAgainst = new Map(); + + 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); + + 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; diff --git a/backend/src/services/tournamentMatch.service.ts b/backend/src/services/tournamentMatch.service.ts new file mode 100644 index 0000000..ae8a412 --- /dev/null +++ b/backend/src/services/tournamentMatch.service.ts @@ -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; diff --git a/backend/src/utils/constants.ts b/backend/src/utils/constants.ts index 0687b24..c805af3 100644 --- a/backend/src/utils/constants.ts +++ b/backend/src/utils/constants.ts @@ -89,3 +89,135 @@ export const GroupRole = { } as const; export type GroupRoleType = typeof GroupRole[keyof typeof GroupRole]; + +// Tipos de torneo +export const TournamentType = { + ELIMINATION: 'ELIMINATION', + ROUND_ROBIN: 'ROUND_ROBIN', + SWISS: 'SWISS', + CONSOLATION: 'CONSOLATION', +} as const; + +export type TournamentTypeType = typeof TournamentType[keyof typeof TournamentType]; + +// Estados de torneo +export const TournamentStatus = { + DRAFT: 'DRAFT', + OPEN: 'OPEN', + CLOSED: 'CLOSED', + IN_PROGRESS: 'IN_PROGRESS', + FINISHED: 'FINISHED', + CANCELLED: 'CANCELLED', +} as const; + +export type TournamentStatusType = typeof TournamentStatus[keyof typeof TournamentStatus]; + +// Categorías de torneo +export const TournamentCategory = { + MEN: 'MEN', + WOMEN: 'WOMEN', + MIXED: 'MIXED', +} as const; + +export type TournamentCategoryType = typeof TournamentCategory[keyof typeof TournamentCategory]; + +// Estados de partido de torneo +export const TournamentMatchStatus = { + PENDING: 'PENDING', + SCHEDULED: 'SCHEDULED', + IN_PROGRESS: 'IN_PROGRESS', + FINISHED: 'FINISHED', + CANCELLED: 'CANCELLED', + BYE: 'BYE', +} as const; + +export type TournamentMatchStatusType = typeof TournamentMatchStatus[keyof typeof TournamentMatchStatus]; + +// Estados de participante en torneo +export const ParticipantStatus = { + REGISTERED: 'REGISTERED', + CONFIRMED: 'CONFIRMED', + WITHDRAWN: 'WITHDRAWN', +} as const; + +export type ParticipantStatusType = typeof ParticipantStatus[keyof typeof ParticipantStatus]; + +// Estado de pago +export const PaymentStatus = { + PENDING: 'PENDING', + PAID: 'PAID', + REFUNDED: 'REFUNDED', +} as const; + +export type PaymentStatusType = typeof PaymentStatus[keyof typeof PaymentStatus]; + +// ============================================ +// Constantes de Liga (Fase 3.3) +// ============================================ + +// Tipos de liga +export const LeagueType = { + TEAM_LEAGUE: 'TEAM_LEAGUE', + INDIVIDUAL_LEAGUE: 'INDIVIDUAL_LEAGUE', +} as const; + +export type LeagueTypeType = typeof LeagueType[keyof typeof LeagueType]; + +// Formatos de liga +export const LeagueFormat = { + SINGLE_ROUND_ROBIN: 'SINGLE_ROUND_ROBIN', // Todos vs todos (ida) + DOUBLE_ROUND_ROBIN: 'DOUBLE_ROUND_ROBIN', // Todos vs todos (ida y vuelta) + SINGLE_MATCHDAY: 'SINGLE_MATCHDAY', // Una jornada por equipo + DOUBLE_MATCHDAY: 'DOUBLE_MATCHDAY', // Dos jornadas por equipo +} as const; + +export type LeagueFormatType = typeof LeagueFormat[keyof typeof LeagueFormat]; + +// Estados de liga +export const LeagueStatus = { + DRAFT: 'DRAFT', // En creación, se pueden agregar/quitar equipos + ACTIVE: 'ACTIVE', // En curso + FINISHED: 'FINISHED', // Finalizada + CANCELLED: 'CANCELLED', // Cancelada +} as const; + +export type LeagueStatusType = typeof LeagueStatus[keyof typeof LeagueStatus]; + +// Estados de partido de liga +export const LeagueMatchStatus = { + SCHEDULED: 'SCHEDULED', // Programado + CONFIRMED: 'CONFIRMED', // Confirmado + IN_PROGRESS: 'IN_PROGRESS', // En juego + COMPLETED: 'COMPLETED', // Completado + CANCELLED: 'CANCELLED', // Cancelado + POSTPONED: 'POSTPONED', // Aplazado + WALKOVER: 'WALKOVER', // Walkover +} as const; + +export type LeagueMatchStatusType = typeof LeagueMatchStatus[keyof typeof LeagueMatchStatus]; + +// Criterios de desempate +export const TiebreakerCriteria = { + POINTS: 'POINTS', // Puntos + SETS_DIFFERENCE: 'SETS_DIFFERENCE', // Diferencia de sets + GAMES_DIFFERENCE: 'GAMES_DIFFERENCE', // Diferencia de games + DIRECT_ENCOUNTER: 'DIRECT_ENCOUNTER', // Enfrentamiento directo + WINS: 'WINS', // Victorias +} as const; + +export type TiebreakerCriteriaType = typeof TiebreakerCriteria[keyof typeof TiebreakerCriteria]; + +// Orden de aplicación de desempates por defecto +export const DEFAULT_TIEBREAKER_ORDER = [ + TiebreakerCriteria.POINTS, + TiebreakerCriteria.SETS_DIFFERENCE, + TiebreakerCriteria.GAMES_DIFFERENCE, + TiebreakerCriteria.DIRECT_ENCOUNTER, +]; + +// Puntos por resultado +export const LeaguePoints = { + WIN: 3, // Victoria + DRAW: 1, // Empate + LOSS: 0, // Derrota +} as const; diff --git a/backend/src/utils/tournamentDraw.ts b/backend/src/utils/tournamentDraw.ts new file mode 100644 index 0000000..b08740d --- /dev/null +++ b/backend/src/utils/tournamentDraw.ts @@ -0,0 +1,284 @@ +/** + * Utilidades para generación de cuadros de torneo + */ + +/** + * Mezcla un array aleatoriamente (algoritmo Fisher-Yates) + */ +export function shuffleArray(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( + 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(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(); + + 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' }; +} diff --git a/backend/src/validators/league.validator.ts b/backend/src/validators/league.validator.ts new file mode 100644 index 0000000..91005eb --- /dev/null +++ b/backend/src/validators/league.validator.ts @@ -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; +export type UpdateLeagueInput = z.infer; +export type CreateLeagueTeamInput = z.infer; +export type UpdateLeagueTeamInput = z.infer; +export type AddLeagueTeamMemberInput = z.infer; +export type UpdateLeagueMatchInput = z.infer; +export type UpdateLeagueMatchResultInput = z.infer; diff --git a/backend/src/validators/tournament.validator.ts b/backend/src/validators/tournament.validator.ts new file mode 100644 index 0000000..c60c556 --- /dev/null +++ b/backend/src/validators/tournament.validator.ts @@ -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; +export type UpdateTournamentInput = z.infer; diff --git a/docs/roadmap/FASE-03.md b/docs/roadmap/FASE-03.md index 70760f7..35964fc 100644 --- a/docs/roadmap/FASE-03.md +++ b/docs/roadmap/FASE-03.md @@ -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*