diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db index 8397448..469a13c 100644 Binary files a/backend/prisma/dev.db and b/backend/prisma/dev.db differ diff --git a/backend/prisma/migrations/20260131081522_add_user_profile_fields_and_level_history/migration.sql b/backend/prisma/migrations/20260131081522_add_user_profile_fields_and_level_history/migration.sql new file mode 100644 index 0000000..4f93ec3 --- /dev/null +++ b/backend/prisma/migrations/20260131081522_add_user_profile_fields_and_level_history/migration.sql @@ -0,0 +1,228 @@ +-- CreateTable +CREATE TABLE "level_history" ( + "id" TEXT NOT NULL PRIMARY KEY, + "oldLevel" TEXT NOT NULL, + "newLevel" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "changedBy" TEXT NOT NULL, + "reason" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "level_history_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "friends" ( + "id" TEXT NOT NULL PRIMARY KEY, + "requesterId" TEXT NOT NULL, + "addresseeId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "friends_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "friends_addresseeId_fkey" FOREIGN KEY ("addresseeId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "groups" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "description" TEXT, + "createdById" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "groups_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "group_members" ( + "id" TEXT NOT NULL PRIMARY KEY, + "groupId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'MEMBER', + "joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "group_members_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "group_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "recurring_bookings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "courtId" TEXT NOT NULL, + "dayOfWeek" INTEGER NOT NULL, + "startTime" TEXT NOT NULL, + "endTime" TEXT NOT NULL, + "startDate" DATETIME NOT NULL, + "endDate" DATETIME, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "recurring_bookings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "recurring_bookings_courtId_fkey" FOREIGN KEY ("courtId") REFERENCES "courts" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "match_results" ( + "id" TEXT NOT NULL PRIMARY KEY, + "bookingId" TEXT, + "team1Player1Id" TEXT NOT NULL, + "team1Player2Id" TEXT NOT NULL, + "team2Player1Id" TEXT NOT NULL, + "team2Player2Id" TEXT NOT NULL, + "team1Score" INTEGER NOT NULL, + "team2Score" INTEGER NOT NULL, + "winner" TEXT NOT NULL, + "playedAt" DATETIME NOT NULL, + "confirmedBy" TEXT NOT NULL DEFAULT '[]', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "match_results_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "match_results_team1Player1Id_fkey" FOREIGN KEY ("team1Player1Id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "match_results_team1Player2Id_fkey" FOREIGN KEY ("team1Player2Id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "match_results_team2Player1Id_fkey" FOREIGN KEY ("team2Player1Id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "match_results_team2Player2Id_fkey" FOREIGN KEY ("team2Player2Id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "user_stats" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "period" TEXT NOT NULL, + "periodValue" TEXT NOT NULL, + "matchesPlayed" INTEGER NOT NULL DEFAULT 0, + "matchesWon" INTEGER NOT NULL DEFAULT 0, + "matchesLost" INTEGER NOT NULL DEFAULT 0, + "tournamentsPlayed" INTEGER NOT NULL DEFAULT 0, + "tournamentsWon" INTEGER NOT NULL DEFAULT 0, + "points" INTEGER NOT NULL DEFAULT 0, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "user_stats_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_bookings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "date" DATETIME NOT NULL, + "startTime" TEXT NOT NULL, + "endTime" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "totalPrice" INTEGER NOT NULL, + "notes" TEXT, + "userId" TEXT NOT NULL, + "courtId" TEXT NOT NULL, + "recurringBookingId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "bookings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "bookings_courtId_fkey" FOREIGN KEY ("courtId") REFERENCES "courts" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "bookings_recurringBookingId_fkey" FOREIGN KEY ("recurringBookingId") REFERENCES "recurring_bookings" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_bookings" ("courtId", "createdAt", "date", "endTime", "id", "notes", "startTime", "status", "totalPrice", "updatedAt", "userId") SELECT "courtId", "createdAt", "date", "endTime", "id", "notes", "startTime", "status", "totalPrice", "updatedAt", "userId" FROM "bookings"; +DROP TABLE "bookings"; +ALTER TABLE "new_bookings" RENAME TO "bookings"; +CREATE INDEX "bookings_userId_idx" ON "bookings"("userId"); +CREATE INDEX "bookings_courtId_idx" ON "bookings"("courtId"); +CREATE INDEX "bookings_date_idx" ON "bookings"("date"); +CREATE INDEX "bookings_recurringBookingId_idx" ON "bookings"("recurringBookingId"); +CREATE TABLE "new_users" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "phone" TEXT, + "avatarUrl" TEXT, + "city" TEXT, + "birthDate" DATETIME, + "role" TEXT NOT NULL DEFAULT 'PLAYER', + "playerLevel" TEXT NOT NULL DEFAULT 'BEGINNER', + "handPreference" TEXT NOT NULL DEFAULT 'RIGHT', + "positionPreference" TEXT NOT NULL DEFAULT 'BOTH', + "bio" TEXT, + "yearsPlaying" INTEGER, + "matchesPlayed" INTEGER NOT NULL DEFAULT 0, + "matchesWon" INTEGER NOT NULL DEFAULT 0, + "matchesLost" INTEGER NOT NULL DEFAULT 0, + "totalPoints" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "isVerified" BOOLEAN NOT NULL DEFAULT false, + "lastLogin" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_users" ("avatarUrl", "bio", "createdAt", "email", "firstName", "handPreference", "id", "isActive", "isVerified", "lastLogin", "lastName", "password", "phone", "playerLevel", "positionPreference", "role", "updatedAt") SELECT "avatarUrl", "bio", "createdAt", "email", "firstName", "handPreference", "id", "isActive", "isVerified", "lastLogin", "lastName", "password", "phone", "playerLevel", "positionPreference", "role", "updatedAt" FROM "users"; +DROP TABLE "users"; +ALTER TABLE "new_users" RENAME TO "users"; +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "level_history_userId_idx" ON "level_history"("userId"); + +-- CreateIndex +CREATE INDEX "friends_requesterId_idx" ON "friends"("requesterId"); + +-- CreateIndex +CREATE INDEX "friends_addresseeId_idx" ON "friends"("addresseeId"); + +-- CreateIndex +CREATE INDEX "friends_status_idx" ON "friends"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "friends_requesterId_addresseeId_key" ON "friends"("requesterId", "addresseeId"); + +-- CreateIndex +CREATE INDEX "groups_createdById_idx" ON "groups"("createdById"); + +-- CreateIndex +CREATE INDEX "group_members_groupId_idx" ON "group_members"("groupId"); + +-- CreateIndex +CREATE INDEX "group_members_userId_idx" ON "group_members"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "group_members_groupId_userId_key" ON "group_members"("groupId", "userId"); + +-- CreateIndex +CREATE INDEX "recurring_bookings_userId_idx" ON "recurring_bookings"("userId"); + +-- CreateIndex +CREATE INDEX "recurring_bookings_courtId_idx" ON "recurring_bookings"("courtId"); + +-- CreateIndex +CREATE INDEX "recurring_bookings_dayOfWeek_idx" ON "recurring_bookings"("dayOfWeek"); + +-- CreateIndex +CREATE INDEX "recurring_bookings_isActive_idx" ON "recurring_bookings"("isActive"); + +-- CreateIndex +CREATE UNIQUE INDEX "match_results_bookingId_key" ON "match_results"("bookingId"); + +-- CreateIndex +CREATE INDEX "match_results_team1Player1Id_idx" ON "match_results"("team1Player1Id"); + +-- CreateIndex +CREATE INDEX "match_results_team1Player2Id_idx" ON "match_results"("team1Player2Id"); + +-- CreateIndex +CREATE INDEX "match_results_team2Player1Id_idx" ON "match_results"("team2Player1Id"); + +-- CreateIndex +CREATE INDEX "match_results_team2Player2Id_idx" ON "match_results"("team2Player2Id"); + +-- CreateIndex +CREATE INDEX "match_results_playedAt_idx" ON "match_results"("playedAt"); + +-- CreateIndex +CREATE INDEX "user_stats_userId_idx" ON "user_stats"("userId"); + +-- CreateIndex +CREATE INDEX "user_stats_period_periodValue_idx" ON "user_stats"("period", "periodValue"); + +-- CreateIndex +CREATE INDEX "user_stats_points_idx" ON "user_stats"("points"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_stats_userId_period_periodValue_key" ON "user_stats"("userId", "period", "periodValue"); diff --git a/backend/prisma/migrations/20260131081926_add_social_recurring_features/migration.sql b/backend/prisma/migrations/20260131081926_add_social_recurring_features/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/backend/prisma/migrations/20260131081926_add_social_recurring_features/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 094b549..bfaa2c8 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -21,6 +21,8 @@ model User { lastName String phone String? avatarUrl String? + city String? + birthDate DateTime? // Datos de juego (usamos String para simular enums en SQLite) role String @default("PLAYER") // PLAYER, ADMIN, SUPERADMIN @@ -28,6 +30,13 @@ model User { handPreference String @default("RIGHT") // RIGHT, LEFT, BOTH positionPreference String @default("BOTH") // DRIVE, BACKHAND, BOTH bio String? + yearsPlaying Int? + + // Estadísticas globales + matchesPlayed Int @default(0) + matchesWon Int @default(0) + matchesLost Int @default(0) + totalPoints Int @default(0) // Estado isActive Boolean @default(true) @@ -35,13 +44,58 @@ model User { lastLogin DateTime? // Relaciones - bookings Booking[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + bookings Booking[] + levelHistory LevelHistory[] + + // Relaciones con MatchResult + team1Player1Matches MatchResult[] @relation("Team1Player1") + team1Player2Matches MatchResult[] @relation("Team1Player2") + team2Player1Matches MatchResult[] @relation("Team2Player1") + team2Player2Matches MatchResult[] @relation("Team2Player2") + + // Relación con UserStats + userStats UserStats[] + + // Amistades + friendsSent Friend[] @relation("FriendRequestsSent") + friendsReceived Friend[] @relation("FriendRequestsReceived") + + // Grupos + groupsCreated Group[] @relation("GroupsCreated") + groupMembers GroupMember[] @relation("GroupMemberships") + + // Reservas recurrentes + recurringBookings RecurringBooking[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("users") } +// Modelo de Historial de Niveles +model LevelHistory { + id String @id @default(uuid()) + + // Niveles + oldLevel String + newLevel String + + // Referencias + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + // Quién realizó el cambio (admin) + changedBy String + + // Metadata + reason String? + createdAt DateTime @default(now()) + + @@index([userId]) + @@map("level_history") +} + // Modelo de Cancha model Court { id String @id @default(uuid()) @@ -66,6 +120,7 @@ model Court { // Relaciones bookings Booking[] schedules CourtSchedule[] + recurringBookings RecurringBooking[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -120,6 +175,13 @@ model Booking { court Court @relation(fields: [courtId], references: [id]) courtId String + // Relación con MatchResult + matchResult MatchResult? + + // Referencia a reserva recurrente (si aplica) + recurringBooking RecurringBooking? @relation(fields: [recurringBookingId], references: [id]) + recurringBookingId String? + // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -127,5 +189,195 @@ model Booking { @@index([userId]) @@index([courtId]) @@index([date]) + @@index([recurringBookingId]) @@map("bookings") } + +// Modelo de Amistad +model Friend { + id String @id @default(uuid()) + + // Quien envía la solicitud + requester User @relation("FriendRequestsSent", fields: [requesterId], references: [id]) + requesterId String + + // Quien recibe la solicitud + addressee User @relation("FriendRequestsReceived", fields: [addresseeId], references: [id]) + addresseeId String + + // Estado (PENDING, ACCEPTED, REJECTED, BLOCKED) + status String @default("PENDING") + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([requesterId, addresseeId]) + @@index([requesterId]) + @@index([addresseeId]) + @@index([status]) + @@map("friends") +} + +// Modelo de Grupo +model Group { + id String @id @default(uuid()) + name String + description String? + + // Creador del grupo + createdBy User @relation("GroupsCreated", fields: [createdById], references: [id]) + createdById String + + // Relaciones + members GroupMember[] + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([createdById]) + @@map("groups") +} + +// Modelo de Miembro de Grupo +model GroupMember { + id String @id @default(uuid()) + + // Grupo + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId String + + // Usuario + user User @relation("GroupMemberships", fields: [userId], references: [id]) + userId String + + // Rol (ADMIN, MEMBER) + role String @default("MEMBER") + + // Fecha de unión + joinedAt DateTime @default(now()) + + @@unique([groupId, userId]) + @@index([groupId]) + @@index([userId]) + @@map("group_members") +} + +// Modelo de Reserva Recurrente +model RecurringBooking { + id String @id @default(uuid()) + + // Usuario que crea la reserva recurrente + user User @relation(fields: [userId], references: [id]) + userId String + + // Cancha + court Court @relation(fields: [courtId], references: [id]) + courtId String + + // Día de la semana (0=Domingo, 1=Lunes, ..., 6=Sábado) + dayOfWeek Int + + // Horario + startTime String + endTime String + + // Rango de fechas + startDate DateTime + endDate DateTime? + + // Estado + isActive Boolean @default(true) + + // Relaciones + bookings Booking[] + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([courtId]) + @@index([dayOfWeek]) + @@index([isActive]) + @@map("recurring_bookings") +} + +// Modelo de Resultado de Partido +model MatchResult { + id String @id @default(uuid()) + + // Relación opcional con reserva + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull) + bookingId String? @unique + + // Jugadores del Equipo 1 + team1Player1 User @relation("Team1Player1", fields: [team1Player1Id], references: [id]) + team1Player1Id String + team1Player2 User @relation("Team1Player2", fields: [team1Player2Id], references: [id]) + team1Player2Id String + + // Jugadores del Equipo 2 + team2Player1 User @relation("Team2Player1", fields: [team2Player1Id], references: [id]) + team2Player1Id String + team2Player2 User @relation("Team2Player2", fields: [team2Player2Id], references: [id]) + team2Player2Id String + + // Resultado + team1Score Int + team2Score Int + winner String // TEAM1, TEAM2, DRAW + + // Fecha en que se jugó el partido + playedAt DateTime + + // Confirmaciones (JSON array de userIds) + confirmedBy String @default("[]") + + // Timestamps + createdAt DateTime @default(now()) + + @@index([team1Player1Id]) + @@index([team1Player2Id]) + @@index([team2Player1Id]) + @@index([team2Player2Id]) + @@index([playedAt]) + @@map("match_results") +} + +// Modelo de Estadísticas de Usuario por Período +model UserStats { + id String @id @default(uuid()) + + // Usuario + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + // Período (MONTH, YEAR, ALL_TIME) + period String + + // Valor del período (ej: "2024-01" para mes, "2024" para año) + periodValue String + + // Estadísticas de partidos + matchesPlayed Int @default(0) + matchesWon Int @default(0) + matchesLost Int @default(0) + + // Estadísticas de torneos + tournamentsPlayed Int @default(0) + tournamentsWon Int @default(0) + + // Puntos para ranking + points Int @default(0) + + // Timestamps + updatedAt DateTime @updatedAt + + @@unique([userId, period, periodValue]) + @@index([userId]) + @@index([period, periodValue]) + @@index([points]) + @@map("user_stats") +} diff --git a/backend/prisma/seed-fase2.ts b/backend/prisma/seed-fase2.ts new file mode 100644 index 0000000..109cce6 --- /dev/null +++ b/backend/prisma/seed-fase2.ts @@ -0,0 +1,192 @@ +import { PrismaClient } from '@prisma/client'; +import { hashPassword } from '../src/utils/password'; +import { UserRole, PlayerLevel, FriendStatus, GroupRole, CourtType } from '../src/utils/constants'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('🌱 Seeding Fase 2 data...\n'); + + // Crear jugadores adicionales para pruebas sociales + const usersData = [ + { email: 'carlos@padel.com', firstName: 'Carlos', lastName: 'Martínez', level: PlayerLevel.ADVANCED }, + { email: 'ana@padel.com', firstName: 'Ana', lastName: 'López', level: PlayerLevel.INTERMEDIATE }, + { email: 'pedro@padel.com', firstName: 'Pedro', lastName: 'Sánchez', level: PlayerLevel.BEGINNER }, + { email: 'maria@padel.com', firstName: 'María', lastName: 'García', level: PlayerLevel.COMPETITION }, + ]; + + const createdUsers = []; + for (const userData of usersData) { + const password = await hashPassword('123456'); + const user = await prisma.user.upsert({ + where: { email: userData.email }, + update: {}, + create: { + email: userData.email, + password, + firstName: userData.firstName, + lastName: userData.lastName, + role: UserRole.PLAYER, + playerLevel: userData.level, + city: 'Madrid', + yearsPlaying: Math.floor(Math.random() * 10) + 1, + matchesPlayed: Math.floor(Math.random() * 50), + matchesWon: Math.floor(Math.random() * 30), + isActive: true, + }, + }); + createdUsers.push(user); + console.log(`✅ Usuario creado: ${user.firstName} ${user.lastName} (${user.email})`); + } + + // Obtener el admin y user original + const admin = await prisma.user.findUnique({ where: { email: 'admin@padel.com' } }); + const user = await prisma.user.findUnique({ where: { email: 'user@padel.com' } }); + + if (admin && user && createdUsers.length >= 2) { + // Crear relaciones de amistad + const friendships = [ + { requester: user.id, addressee: createdUsers[0].id }, // Juan - Carlos + { requester: createdUsers[1].id, addressee: user.id }, // Ana - Juan (pendiente) + { requester: user.id, addressee: createdUsers[2].id }, // Juan - Pedro + ]; + + for (const friendship of friendships) { + await prisma.friend.upsert({ + where: { + requesterId_addresseeId: { + requesterId: friendship.requester, + addresseeId: friendship.addressee, + }, + }, + update: {}, + create: { + requesterId: friendship.requester, + addresseeId: friendship.addressee, + status: FriendStatus.ACCEPTED, + }, + }); + } + console.log('\n✅ Amistades creadas'); + + // Crear un grupo + const group = await prisma.group.upsert({ + where: { id: 'group-1' }, + update: {}, + create: { + id: 'group-1', + name: 'Los Padelistas', + description: 'Grupo de jugadores regulares los fines de semana', + createdBy: admin.id, + members: { + create: [ + { userId: admin.id, role: GroupRole.ADMIN }, + { userId: user.id, role: GroupRole.MEMBER }, + { userId: createdUsers[0].id, role: GroupRole.MEMBER }, + { userId: createdUsers[1].id, role: GroupRole.MEMBER }, + ], + }, + }, + }); + console.log(`✅ Grupo creado: ${group.name}`); + + // Crear reserva recurrente + const court = await prisma.court.findFirst({ where: { isActive: true } }); + if (court) { + const recurring = await prisma.recurringBooking.create({ + data: { + userId: user.id, + courtId: court.id, + dayOfWeek: 6, // Sábado + startTime: '10:00', + endTime: '12:00', + startDate: new Date(), + isActive: true, + }, + }); + console.log(`✅ Reserva recurrente creada: Sábados ${recurring.startTime}-${recurring.endTime}`); + } + + // Registrar algunos partidos + const matchResults = [ + { + team1: [user.id, createdUsers[0].id], + team2: [createdUsers[1].id, createdUsers[2].id], + score1: 6, + score2: 4, + playedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Hace 1 semana + }, + { + team1: [user.id, createdUsers[1].id], + team2: [createdUsers[0].id, createdUsers[2].id], + score1: 3, + score2: 6, + playedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // Hace 3 días + }, + ]; + + for (const match of matchResults) { + await prisma.matchResult.create({ + data: { + team1Player1Id: match.team1[0], + team1Player2Id: match.team1[1], + team2Player1Id: match.team2[0], + team2Player2Id: match.team2[1], + team1Score: match.score1, + team2Score: match.score2, + winner: match.score1 > match.score2 ? 'TEAM1' : 'TEAM2', + playedAt: match.playedAt, + confirmedBy: [match.team1[0], match.team2[0]], + }, + }); + } + console.log(`✅ Partidos registrados: ${matchResults.length}`); + + // Crear estadísticas de usuario + for (const u of [user, ...createdUsers.slice(0, 2)]) { + await prisma.userStats.upsert({ + where: { + userId_period_periodValue: { + userId: u.id, + period: 'ALL_TIME', + periodValue: 'all', + }, + }, + update: {}, + create: { + userId: u.id, + period: 'ALL_TIME', + periodValue: 'all', + matchesPlayed: Math.floor(Math.random() * 30) + 5, + matchesWon: Math.floor(Math.random() * 20), + matchesLost: Math.floor(Math.random() * 10), + points: Math.floor(Math.random() * 500) + 100, + }, + }); + } + console.log(`✅ Estadísticas de usuarios creadas`); + } + + // Actualizar puntos totales de usuarios + await prisma.user.updateMany({ + data: { + totalPoints: { increment: 100 }, + }, + }); + + console.log('\n🎾 Fase 2 seed completado!'); + console.log('\nNuevos usuarios de prueba:'); + console.log(' carlos@padel.com / 123456'); + console.log(' ana@padel.com / 123456'); + console.log(' pedro@padel.com / 123456'); + console.log(' maria@padel.com / 123456'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/controllers/friend.controller.ts b/backend/src/controllers/friend.controller.ts new file mode 100644 index 0000000..c5befdd --- /dev/null +++ b/backend/src/controllers/friend.controller.ts @@ -0,0 +1,143 @@ +import { Request, Response, NextFunction } from 'express'; +import { FriendService } from '../services/friend.service'; +import { ApiError } from '../middleware/errorHandler'; + +export class FriendController { + // Enviar solicitud de amistad + static async sendFriendRequest(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { addresseeId } = req.body; + const request = await FriendService.sendFriendRequest(req.user.userId, addresseeId); + + res.status(201).json({ + success: true, + message: 'Solicitud de amistad enviada exitosamente', + data: request, + }); + } catch (error) { + next(error); + } + } + + // Aceptar solicitud de amistad + static async acceptFriendRequest(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const request = await FriendService.acceptFriendRequest(id, req.user.userId); + + res.status(200).json({ + success: true, + message: 'Solicitud de amistad aceptada', + data: request, + }); + } catch (error) { + next(error); + } + } + + // Rechazar solicitud de amistad + static async rejectFriendRequest(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const request = await FriendService.rejectFriendRequest(id, req.user.userId); + + res.status(200).json({ + success: true, + message: 'Solicitud de amistad rechazada', + data: request, + }); + } catch (error) { + next(error); + } + } + + // Obtener mis amigos + static async getMyFriends(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const friends = await FriendService.getMyFriends(req.user.userId); + + res.status(200).json({ + success: true, + count: friends.length, + data: friends, + }); + } catch (error) { + next(error); + } + } + + // Obtener solicitudes pendientes (recibidas) + static async getPendingRequests(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const requests = await FriendService.getPendingRequests(req.user.userId); + + res.status(200).json({ + success: true, + count: requests.length, + data: requests, + }); + } catch (error) { + next(error); + } + } + + // Obtener solicitudes enviadas + static async getSentRequests(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const requests = await FriendService.getSentRequests(req.user.userId); + + res.status(200).json({ + success: true, + count: requests.length, + data: requests, + }); + } catch (error) { + next(error); + } + } + + // Eliminar amigo / cancelar solicitud + static async removeFriend(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const result = await FriendService.removeFriend(req.user.userId, id); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + next(error); + } + } +} + +export default FriendController; diff --git a/backend/src/controllers/group.controller.ts b/backend/src/controllers/group.controller.ts new file mode 100644 index 0000000..05f0bd7 --- /dev/null +++ b/backend/src/controllers/group.controller.ts @@ -0,0 +1,201 @@ +import { Request, Response, NextFunction } from 'express'; +import { GroupService } from '../services/group.service'; +import { ApiError } from '../middleware/errorHandler'; + +export class GroupController { + // Crear grupo + static async createGroup(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { name, description, memberIds } = req.body; + const group = await GroupService.createGroup( + req.user.userId, + { name, description }, + memberIds || [] + ); + + res.status(201).json({ + success: true, + message: 'Grupo creado exitosamente', + data: group, + }); + } catch (error) { + next(error); + } + } + + // Obtener mis grupos + static async getMyGroups(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const groups = await GroupService.getMyGroups(req.user.userId); + + res.status(200).json({ + success: true, + count: groups.length, + data: groups, + }); + } catch (error) { + next(error); + } + } + + // Obtener grupo por ID + static async getGroupById(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const group = await GroupService.getGroupById(id, req.user.userId); + + res.status(200).json({ + success: true, + data: group, + }); + } catch (error) { + next(error); + } + } + + // Actualizar grupo + static async updateGroup(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const { name, description } = req.body; + const group = await GroupService.updateGroup(id, req.user.userId, { + name, + description, + }); + + res.status(200).json({ + success: true, + message: 'Grupo actualizado exitosamente', + data: group, + }); + } catch (error) { + next(error); + } + } + + // Eliminar grupo + static async deleteGroup(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const result = await GroupService.deleteGroup(id, req.user.userId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + next(error); + } + } + + // Agregar miembro + static async addMember(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id: groupId } = req.params; + const { userId } = req.body; + const member = await GroupService.addMember(groupId, req.user.userId, userId); + + res.status(201).json({ + success: true, + message: 'Miembro agregado exitosamente', + data: member, + }); + } catch (error) { + next(error); + } + } + + // Eliminar miembro + static async removeMember(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id: groupId, userId } = req.params; + const result = await GroupService.removeMember(groupId, req.user.userId, userId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + next(error); + } + } + + // Actualizar rol de miembro + static async updateMemberRole(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id: groupId, userId } = req.params; + const { role } = req.body; + const member = await GroupService.updateMemberRole( + groupId, + req.user.userId, + userId, + role + ); + + res.status(200).json({ + success: true, + message: 'Rol actualizado exitosamente', + data: member, + }); + } catch (error) { + next(error); + } + } + + // Abandonar grupo (eliminar a sí mismo) + static async leaveGroup(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id: groupId } = req.params; + const result = await GroupService.removeMember( + groupId, + req.user.userId, + req.user.userId + ); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + next(error); + } + } +} + +export default GroupController; diff --git a/backend/src/controllers/match.controller.ts b/backend/src/controllers/match.controller.ts new file mode 100644 index 0000000..3b6f681 --- /dev/null +++ b/backend/src/controllers/match.controller.ts @@ -0,0 +1,127 @@ +import { Request, Response, NextFunction } from 'express'; +import { MatchService } from '../services/match.service'; +import { ApiError } from '../middleware/errorHandler'; + +export class MatchController { + /** + * Registrar un nuevo resultado de partido + */ + static async recordMatch(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const match = await MatchService.recordMatchResult({ + ...req.body, + recordedBy: req.user.userId, + playedAt: new Date(req.body.playedAt), + }); + + res.status(201).json({ + success: true, + message: 'Resultado del partido registrado exitosamente', + data: match, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener historial de partidos (con filtros opcionales) + */ + static async getMatchHistory(req: Request, res: Response, next: NextFunction) { + try { + const filters = { + userId: req.query.userId 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, + status: req.query.status as 'PENDING' | 'CONFIRMED' | undefined, + }; + + const matches = await MatchService.getMatchHistory(filters); + + res.status(200).json({ + success: true, + count: matches.length, + data: matches, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener mis partidos + */ + static async getMyMatches(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const options = { + upcoming: req.query.upcoming === 'true', + limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, + }; + + const matches = await MatchService.getUserMatches(req.user.userId, options); + + res.status(200).json({ + success: true, + count: matches.length, + data: matches, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener un partido por ID + */ + static async getMatchById(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const match = await MatchService.getMatchById(id); + + res.status(200).json({ + success: true, + data: match, + }); + } catch (error) { + next(error); + } + } + + /** + * Confirmar el resultado de un partido + */ + static async confirmMatch(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const match = await MatchService.confirmMatchResult(id, req.user.userId); + + res.status(200).json({ + success: true, + message: match.isConfirmed + ? 'Resultado confirmado. El partido ya es válido para el ranking.' + : 'Confirmación registrada. Se necesita otra confirmación para validar el partido.', + data: match, + }); + } catch (error) { + next(error); + } + } +} + +export default MatchController; diff --git a/backend/src/controllers/ranking.controller.ts b/backend/src/controllers/ranking.controller.ts new file mode 100644 index 0000000..eabcae3 --- /dev/null +++ b/backend/src/controllers/ranking.controller.ts @@ -0,0 +1,122 @@ +import { Request, Response, NextFunction } from 'express'; +import { RankingService } from '../services/ranking.service'; +import { ApiError } from '../middleware/errorHandler'; +import { UserRole } from '../utils/constants'; + +export class RankingController { + /** + * Obtener ranking general + */ + static async getRanking(req: Request, res: Response, next: NextFunction) { + try { + const filters = { + period: req.query.period as string, + periodValue: req.query.periodValue as string, + level: req.query.level as string, + limit: req.query.limit ? parseInt(req.query.limit as string) : 100, + }; + + const ranking = await RankingService.calculateRanking(filters); + + res.status(200).json({ + success: true, + count: ranking.length, + data: ranking, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener mi posición en el ranking + */ + static async getMyRanking(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const period = req.query.period as string; + const periodValue = req.query.periodValue as string; + + const ranking = await RankingService.getUserRanking( + req.user.userId, + period, + periodValue + ); + + res.status(200).json({ + success: true, + data: ranking, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener top jugadores + */ + static async getTopPlayers(req: Request, res: Response, next: NextFunction) { + try { + const limit = req.query.limit ? parseInt(req.query.limit as string) : 10; + const level = req.query.level as string; + const period = req.query.period as string; + + const topPlayers = await RankingService.getTopPlayers(limit, level, period); + + res.status(200).json({ + success: true, + count: topPlayers.length, + data: topPlayers, + }); + } catch (error) { + next(error); + } + } + + /** + * Actualizar puntos de un usuario (admin) + */ + static async updateUserPoints(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.params; + const { points, reason, period, periodValue } = req.body; + + const result = await RankingService.updateUserPoints( + userId, + points, + reason, + period, + periodValue + ); + + res.status(200).json({ + success: true, + message: `Puntos actualizados exitosamente`, + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Recalcular todos los rankings (admin) + */ + static async recalculateRankings(req: Request, res: Response, next: NextFunction) { + try { + await RankingService.recalculateAllRankings(); + + res.status(200).json({ + success: true, + message: 'Rankings recalculados exitosamente', + }); + } catch (error) { + next(error); + } + } +} + +export default RankingController; diff --git a/backend/src/controllers/recurring.controller.ts b/backend/src/controllers/recurring.controller.ts new file mode 100644 index 0000000..800e0b9 --- /dev/null +++ b/backend/src/controllers/recurring.controller.ts @@ -0,0 +1,185 @@ +import { Request, Response, NextFunction } from 'express'; +import { RecurringService } from '../services/recurring.service'; +import { ApiError } from '../middleware/errorHandler'; +import { UserRole } from '../utils/constants'; + +export class RecurringController { + // Crear reserva recurrente + static async createRecurringBooking(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { courtId, dayOfWeek, startTime, endTime, startDate, endDate } = req.body; + const recurring = await RecurringService.createRecurringBooking( + req.user.userId, + { + courtId, + dayOfWeek: parseInt(dayOfWeek), + startTime, + endTime, + startDate: new Date(startDate), + endDate: endDate ? new Date(endDate) : undefined, + } + ); + + res.status(201).json({ + success: true, + message: 'Reserva recurrente creada exitosamente', + data: recurring, + }); + } catch (error) { + next(error); + } + } + + // Obtener mis reservas recurrentes + static async getMyRecurringBookings(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const recurring = await RecurringService.getMyRecurringBookings(req.user.userId); + + res.status(200).json({ + success: true, + count: recurring.length, + data: recurring, + }); + } catch (error) { + next(error); + } + } + + // Obtener reserva recurrente por ID + static async getRecurringById(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const recurring = await RecurringService.getRecurringById(id, req.user.userId); + + res.status(200).json({ + success: true, + data: recurring, + }); + } catch (error) { + next(error); + } + } + + // Cancelar reserva recurrente + static async cancelRecurringBooking(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const recurring = await RecurringService.cancelRecurringBooking(id, req.user.userId); + + res.status(200).json({ + success: true, + message: 'Reserva recurrente cancelada exitosamente', + data: recurring, + }); + } catch (error) { + next(error); + } + } + + // Generar reservas desde recurrente (admin o propietario) + static async generateBookings(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const { fromDate, toDate } = req.body; + + const result = await RecurringService.generateBookingsFromRecurring( + id, + fromDate ? new Date(fromDate) : undefined, + toDate ? new Date(toDate) : undefined + ); + + res.status(200).json({ + success: true, + message: `${result.generatedCount} reservas generadas exitosamente`, + data: result, + }); + } catch (error) { + next(error); + } + } + + // Generar todas las reservas recurrentes (solo admin) + static async generateAllBookings(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { fromDate, toDate } = req.body; + const results = await RecurringService.generateAllRecurringBookings( + fromDate ? new Date(fromDate) : undefined, + toDate ? new Date(toDate) : undefined + ); + + const successful = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + + res.status(200).json({ + success: true, + message: `Proceso completado: ${successful.length} exitosos, ${failed.length} fallidos`, + data: { + total: results.length, + successful: successful.length, + failed: failed.length, + results, + }, + }); + } catch (error) { + next(error); + } + } + + // Actualizar reserva recurrente + static async updateRecurringBooking(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const { dayOfWeek, startTime, endTime, startDate, endDate } = req.body; + + const recurring = await RecurringService.updateRecurringBooking( + id, + req.user.userId, + { + dayOfWeek: dayOfWeek !== undefined ? parseInt(dayOfWeek) : undefined, + startTime, + endTime, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + } + ); + + res.status(200).json({ + success: true, + message: 'Reserva recurrente actualizada exitosamente', + data: recurring, + }); + } catch (error) { + next(error); + } + } +} + +export default RecurringController; diff --git a/backend/src/controllers/stats.controller.ts b/backend/src/controllers/stats.controller.ts new file mode 100644 index 0000000..158b1b2 --- /dev/null +++ b/backend/src/controllers/stats.controller.ts @@ -0,0 +1,112 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatsService } from '../services/stats.service'; +import { ApiError } from '../middleware/errorHandler'; +import { UserRole } from '../utils/constants'; + +export class StatsController { + /** + * Obtener mis estadísticas + */ + static async getMyStats(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const period = req.query.period as string; + const periodValue = req.query.periodValue as string; + + const stats = await StatsService.getUserStats( + req.user.userId, + period, + periodValue + ); + + res.status(200).json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener estadísticas de un usuario específico + */ + static async getUserStats(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.params; + const period = req.query.period as string; + const periodValue = req.query.periodValue as string; + + const stats = await StatsService.getUserStats(userId, period, periodValue); + + res.status(200).json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener estadísticas de una cancha (admin) + */ + static async getCourtStats(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + const fromDate = req.query.fromDate ? new Date(req.query.fromDate as string) : undefined; + const toDate = req.query.toDate ? new Date(req.query.toDate as string) : undefined; + + const stats = await StatsService.getCourtStats(id, fromDate, toDate); + + res.status(200).json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener estadísticas globales del club (admin) + */ + static async getGlobalStats(req: Request, res: Response, next: NextFunction) { + try { + const fromDate = req.query.fromDate ? new Date(req.query.fromDate as string) : undefined; + const toDate = req.query.toDate ? new Date(req.query.toDate as string) : undefined; + + const stats = await StatsService.getGlobalStats(fromDate, toDate); + + res.status(200).json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + } + + /** + * Comparar estadísticas entre dos usuarios + */ + static async compareUsers(req: Request, res: Response, next: NextFunction) { + try { + const { userId1, userId2 } = req.params; + + const comparison = await StatsService.compareUsers(userId1, userId2); + + res.status(200).json({ + success: true, + data: comparison, + }); + } catch (error) { + next(error); + } + } +} + +export default StatsController; diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts new file mode 100644 index 0000000..5829afe --- /dev/null +++ b/backend/src/controllers/user.controller.ts @@ -0,0 +1,128 @@ +import { Request, Response, NextFunction } from 'express'; +import { UserService } from '../services/user.service'; +import { ApiError } from '../middleware/errorHandler'; + +export class UserController { + // Obtener mi perfil completo (usuario autenticado) + static async getProfile(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const user = await UserService.getMyProfile(req.user.userId); + + res.status(200).json({ + success: true, + data: user, + }); + } catch (error) { + next(error); + } + } + + // Actualizar mi perfil + static async updateMyProfile(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const user = await UserService.updateProfile(req.user.userId, req.body); + + res.status(200).json({ + success: true, + message: 'Perfil actualizado exitosamente', + data: user, + }); + } catch (error) { + next(error); + } + } + + // Obtener perfil público de un usuario por ID + static async getUserById(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + + // Si es el usuario actual, devolver datos privados también + const isCurrentUser = req.user?.userId === id; + const user = await UserService.getUserById(id, isCurrentUser); + + res.status(200).json({ + success: true, + data: user, + }); + } catch (error) { + next(error); + } + } + + // Buscar usuarios (público con filtros) + static async searchUsers(req: Request, res: Response, next: NextFunction) { + try { + const { query, level, city, limit, offset } = req.query; + + const result = await UserService.searchUsers({ + query: query as string | undefined, + level: level as string | undefined, + city: city as string | undefined, + limit: limit ? parseInt(limit as string, 10) : 20, + offset: offset ? parseInt(offset as string, 10) : 0, + }); + + res.status(200).json({ + success: true, + data: result.users, + pagination: result.pagination, + }); + } catch (error) { + next(error); + } + } + + // Actualizar nivel de un usuario (solo admin) + static async updateUserLevel(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const { newLevel, reason } = req.body; + + const result = await UserService.updateUserLevel( + id, + newLevel, + req.user.userId, + reason + ); + + res.status(200).json({ + success: true, + message: 'Nivel actualizado exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + // Obtener historial de niveles de un usuario + static async getUserLevelHistory(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + + const history = await UserService.getLevelHistory(id); + + res.status(200).json({ + success: true, + data: history, + }); + } catch (error) { + next(error); + } + } +} + +export default UserController; diff --git a/backend/src/routes/friend.routes.ts b/backend/src/routes/friend.routes.ts new file mode 100644 index 0000000..41eb5ce --- /dev/null +++ b/backend/src/routes/friend.routes.ts @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import { FriendController } from '../controllers/friend.controller'; +import { authenticate } from '../middleware/auth'; +import { validate, validateParams } from '../middleware/validate'; +import { z } from 'zod'; +import { + sendFriendRequestSchema, + friendRequestActionSchema, +} from '../validators/social.validator'; + +const router = Router(); + +// Esquema para validar ID en params +const idParamSchema = z.object({ + id: z.string().uuid('ID inválido'), +}); + +// Todas las rutas requieren autenticación +router.use(authenticate); + +// POST /api/v1/friends/request - Enviar solicitud de amistad +router.post( + '/request', + validate(sendFriendRequestSchema), + FriendController.sendFriendRequest +); + +// PUT /api/v1/friends/:id/accept - Aceptar solicitud +router.put( + '/:id/accept', + validateParams(idParamSchema), + FriendController.acceptFriendRequest +); + +// PUT /api/v1/friends/:id/reject - Rechazar solicitud +router.put( + '/:id/reject', + validateParams(idParamSchema), + FriendController.rejectFriendRequest +); + +// GET /api/v1/friends - Obtener mis amigos +router.get('/', FriendController.getMyFriends); + +// GET /api/v1/friends/pending - Obtener solicitudes pendientes recibidas +router.get('/pending', FriendController.getPendingRequests); + +// GET /api/v1/friends/sent - Obtener solicitudes enviadas +router.get('/sent', FriendController.getSentRequests); + +// DELETE /api/v1/friends/:id - Eliminar amigo / cancelar solicitud +router.delete( + '/:id', + validateParams(idParamSchema), + FriendController.removeFriend +); + +export default router; diff --git a/backend/src/routes/group.routes.ts b/backend/src/routes/group.routes.ts new file mode 100644 index 0000000..8a292c6 --- /dev/null +++ b/backend/src/routes/group.routes.ts @@ -0,0 +1,82 @@ +import { Router } from 'express'; +import { GroupController } from '../controllers/group.controller'; +import { authenticate } from '../middleware/auth'; +import { validate, validateParams } from '../middleware/validate'; +import { z } from 'zod'; +import { + createGroupSchema, + addMemberSchema, + updateMemberRoleSchema, + updateGroupSchema, +} from '../validators/social.validator'; + +const router = Router(); + +// Esquemas para validar params +const groupIdSchema = z.object({ + id: z.string().uuid('ID de grupo inválido'), +}); + +const groupMemberSchema = z.object({ + id: z.string().uuid('ID de grupo inválido'), + userId: z.string().uuid('ID de usuario inválido'), +}); + +// Todas las rutas requieren autenticación +router.use(authenticate); + +// POST /api/v1/groups - Crear grupo +router.post('/', validate(createGroupSchema), GroupController.createGroup); + +// GET /api/v1/groups - Obtener mis grupos +router.get('/', GroupController.getMyGroups); + +// GET /api/v1/groups/:id - Obtener grupo por ID +router.get('/:id', validateParams(groupIdSchema), GroupController.getGroupById); + +// PUT /api/v1/groups/:id - Actualizar grupo +router.put( + '/:id', + validateParams(groupIdSchema), + validate(updateGroupSchema), + GroupController.updateGroup +); + +// DELETE /api/v1/groups/:id - Eliminar grupo +router.delete( + '/:id', + validateParams(groupIdSchema), + GroupController.deleteGroup +); + +// POST /api/v1/groups/:id/members - Agregar miembro +router.post( + '/:id/members', + validateParams(groupIdSchema), + validate(addMemberSchema), + GroupController.addMember +); + +// DELETE /api/v1/groups/:id/members/:userId - Eliminar miembro +router.delete( + '/:id/members/:userId', + validateParams(groupMemberSchema), + GroupController.removeMember +); + +// PUT /api/v1/groups/:id/members/:userId/role - Actualizar rol +router.put( + '/:id/members/:userId/role', + validateParams(groupMemberSchema), + validate(updateMemberRoleSchema), + GroupController.updateMemberRole +); + +// POST /api/v1/groups/:id/leave - Abandonar grupo +router.post( + '/:id/leave', + validateParams(groupIdSchema), + GroupController.leaveGroup +); + +export default router; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index eb96b16..755bde4 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -2,6 +2,9 @@ import { Router } from 'express'; import authRoutes from './auth.routes'; import courtRoutes from './court.routes'; import bookingRoutes from './booking.routes'; +import matchRoutes from './match.routes'; +import rankingRoutes from './ranking.routes'; +import statsRoutes from './stats.routes'; const router = Router(); @@ -23,4 +26,13 @@ router.use('/courts', courtRoutes); // Rutas de reservas router.use('/bookings', bookingRoutes); +// Rutas de partidos +router.use('/matches', matchRoutes); + +// Rutas de ranking +router.use('/ranking', rankingRoutes); + +// Rutas de estadísticas +router.use('/stats', statsRoutes); + export default router; diff --git a/backend/src/routes/match.routes.ts b/backend/src/routes/match.routes.ts new file mode 100644 index 0000000..553abaf --- /dev/null +++ b/backend/src/routes/match.routes.ts @@ -0,0 +1,73 @@ +import { Router } from 'express'; +import { MatchController } from '../controllers/match.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(); + +// Schema para registrar un partido +const recordMatchSchema = z.object({ + bookingId: z.string().uuid('ID de reserva inválido').optional(), + team1Player1Id: z.string().uuid('ID de jugador inválido'), + team1Player2Id: z.string().uuid('ID de jugador inválido'), + team2Player1Id: z.string().uuid('ID de jugador inválido'), + team2Player2Id: z.string().uuid('ID de jugador inválido'), + team1Score: z.number().int().min(0, 'El puntaje no puede ser negativo'), + team2Score: z.number().int().min(0, 'El puntaje no puede ser negativo'), + winner: z.enum(['TEAM1', 'TEAM2', 'DRAW'], { + errorMap: () => ({ message: 'Ganador debe ser TEAM1, TEAM2 o DRAW' }), + }), + playedAt: z.string().datetime('Fecha inválida'), +}); + +// Schema para query params del historial +const matchHistoryQuerySchema = z.object({ + userId: 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(), + status: z.enum(['PENDING', 'CONFIRMED']).optional(), +}); + +// Schema para params de ID +const matchIdParamsSchema = z.object({ + id: z.string().uuid('ID de partido inválido'), +}); + +// Rutas protegidas para usuarios autenticados +router.post( + '/', + authenticate, + validate(recordMatchSchema), + MatchController.recordMatch +); + +router.get( + '/my-matches', + authenticate, + MatchController.getMyMatches +); + +router.get( + '/history', + authenticate, + validateQuery(matchHistoryQuerySchema), + MatchController.getMatchHistory +); + +router.get( + '/:id', + authenticate, + validate(z.object({ id: z.string().uuid() })), + MatchController.getMatchById +); + +router.put( + '/:id/confirm', + authenticate, + validate(matchIdParamsSchema), + MatchController.confirmMatch +); + +export default router; diff --git a/backend/src/routes/ranking.routes.ts b/backend/src/routes/ranking.routes.ts new file mode 100644 index 0000000..75b93d0 --- /dev/null +++ b/backend/src/routes/ranking.routes.ts @@ -0,0 +1,71 @@ +import { Router } from 'express'; +import { RankingController } from '../controllers/ranking.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate, validateQuery } from '../middleware/validate'; +import { UserRole, PlayerLevel, StatsPeriod } from '../utils/constants'; +import { z } from 'zod'; + +const router = Router(); + +// Schema para query params del ranking +const rankingQuerySchema = z.object({ + period: z.enum([StatsPeriod.MONTH, StatsPeriod.YEAR, StatsPeriod.ALL_TIME]).optional(), + periodValue: z.string().optional(), + level: z.enum([ + 'ALL', + PlayerLevel.BEGINNER, + PlayerLevel.ELEMENTARY, + PlayerLevel.INTERMEDIATE, + PlayerLevel.ADVANCED, + PlayerLevel.COMPETITION, + PlayerLevel.PROFESSIONAL, + ]).optional(), + limit: z.string().regex(/^\d+$/).transform(Number).optional(), +}); + +// Schema para actualizar puntos +const updatePointsSchema = z.object({ + points: z.number().int(), + reason: z.string().min(1, 'La razón es requerida'), + period: z.enum([StatsPeriod.MONTH, StatsPeriod.YEAR, StatsPeriod.ALL_TIME]).optional(), + periodValue: z.string().optional(), +}); + +// Rutas públicas (requieren autenticación) +router.get( + '/', + authenticate, + validateQuery(rankingQuerySchema), + RankingController.getRanking +); + +router.get( + '/me', + authenticate, + RankingController.getMyRanking +); + +router.get( + '/top', + authenticate, + validateQuery(rankingQuerySchema), + RankingController.getTopPlayers +); + +// Rutas de administración +router.put( + '/users/:userId/points', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(updatePointsSchema), + RankingController.updateUserPoints +); + +router.post( + '/recalculate', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + RankingController.recalculateRankings +); + +export default router; diff --git a/backend/src/routes/recurring.routes.ts b/backend/src/routes/recurring.routes.ts new file mode 100644 index 0000000..08eceff --- /dev/null +++ b/backend/src/routes/recurring.routes.ts @@ -0,0 +1,70 @@ +import { Router } from 'express'; +import { RecurringController } from '../controllers/recurring.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate, validateParams } from '../middleware/validate'; +import { z } from 'zod'; +import { UserRole } from '../utils/constants'; +import { + createRecurringSchema, + updateRecurringSchema, + generateBookingsSchema, +} from '../validators/social.validator'; + +const router = Router(); + +// Esquema para validar ID en params +const idParamSchema = z.object({ + id: z.string().uuid('ID inválido'), +}); + +// Todas las rutas requieren autenticación +router.use(authenticate); + +// POST /api/v1/recurring - Crear reserva recurrente +router.post( + '/', + validate(createRecurringSchema), + RecurringController.createRecurringBooking +); + +// GET /api/v1/recurring - Obtener mis reservas recurrentes +router.get('/', RecurringController.getMyRecurringBookings); + +// GET /api/v1/recurring/:id - Obtener reserva recurrente por ID +router.get( + '/:id', + validateParams(idParamSchema), + RecurringController.getRecurringById +); + +// PUT /api/v1/recurring/:id - Actualizar reserva recurrente +router.put( + '/:id', + validateParams(idParamSchema), + validate(updateRecurringSchema), + RecurringController.updateRecurringBooking +); + +// DELETE /api/v1/recurring/:id - Cancelar reserva recurrente +router.delete( + '/:id', + validateParams(idParamSchema), + RecurringController.cancelRecurringBooking +); + +// POST /api/v1/recurring/:id/generate - Generar reservas desde recurrente +router.post( + '/:id/generate', + validateParams(idParamSchema), + validate(generateBookingsSchema), + RecurringController.generateBookings +); + +// POST /api/v1/recurring/generate-all - Generar todas las reservas recurrentes (solo admin) +router.post( + '/generate-all', + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + RecurringController.generateAllBookings +); + +export default router; diff --git a/backend/src/routes/stats.routes.ts b/backend/src/routes/stats.routes.ts new file mode 100644 index 0000000..114d7b2 --- /dev/null +++ b/backend/src/routes/stats.routes.ts @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import { StatsController } from '../controllers/stats.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validateQuery } from '../middleware/validate'; +import { UserRole, StatsPeriod } from '../utils/constants'; +import { z } from 'zod'; + +const router = Router(); + +// Schema para query params de estadísticas +const statsQuerySchema = z.object({ + period: z.enum([StatsPeriod.MONTH, StatsPeriod.YEAR, StatsPeriod.ALL_TIME]).optional(), + periodValue: z.string().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 comparar usuarios +const compareUsersSchema = z.object({ + userId1: z.string().uuid(), + userId2: z.string().uuid(), +}); + +// Rutas para usuarios autenticados +router.get( + '/my-stats', + authenticate, + validateQuery(statsQuerySchema), + StatsController.getMyStats +); + +router.get( + '/users/:userId', + authenticate, + validateQuery(statsQuerySchema), + StatsController.getUserStats +); + +router.get( + '/compare/:userId1/:userId2', + authenticate, + StatsController.compareUsers +); + +// Rutas para administradores +router.get( + '/courts/:id', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validateQuery(statsQuerySchema), + StatsController.getCourtStats +); + +router.get( + '/global', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validateQuery(statsQuerySchema), + StatsController.getGlobalStats +); + +export default router; diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts new file mode 100644 index 0000000..71a27e5 --- /dev/null +++ b/backend/src/routes/user.routes.ts @@ -0,0 +1,40 @@ +import { Router } from 'express'; +import { UserController } from '../controllers/user.controller'; +import { validate, validateQuery, validateParams } from '../middleware/validate'; +import { authenticate, authorize } from '../middleware/auth'; +import { UserRole } from '../utils/constants'; +import { + updateProfileSchema, + updateLevelSchema, + searchUsersSchema, + userIdParamSchema, +} from '../validators/user.validator'; + +const router = Router(); + +// GET /api/v1/users/me - Mi perfil completo (autenticado) +router.get('/me', authenticate, UserController.getProfile); + +// PUT /api/v1/users/me - Actualizar mi perfil (autenticado) +router.put('/me', authenticate, validate(updateProfileSchema), UserController.updateMyProfile); + +// GET /api/v1/users/search - Buscar usuarios (público con filtros opcionales) +router.get('/search', validateQuery(searchUsersSchema), UserController.searchUsers); + +// GET /api/v1/users/:id - Ver perfil público de un usuario +router.get('/:id', validateParams(userIdParamSchema), UserController.getUserById); + +// GET /api/v1/users/:id/level-history - Historial de niveles de un usuario +router.get('/:id/level-history', authenticate, validateParams(userIdParamSchema), UserController.getUserLevelHistory); + +// PUT /api/v1/users/:id/level - Cambiar nivel (solo admin) +router.put( + '/:id/level', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validateParams(userIdParamSchema), + validate(updateLevelSchema), + UserController.updateUserLevel +); + +export default router; diff --git a/backend/src/services/friend.service.ts b/backend/src/services/friend.service.ts new file mode 100644 index 0000000..7f1ee92 --- /dev/null +++ b/backend/src/services/friend.service.ts @@ -0,0 +1,351 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { FriendStatus } from '../utils/constants'; + +export interface SendFriendRequestInput { + requesterId: string; + addresseeId: string; +} + +export class FriendService { + // Enviar solicitud de amistad + static async sendFriendRequest(requesterId: string, addresseeId: string) { + // Validar que no sea el mismo usuario + if (requesterId === addresseeId) { + throw new ApiError('No puedes enviarte una solicitud de amistad a ti mismo', 400); + } + + // Verificar que el destinatario existe + const addressee = await prisma.user.findUnique({ + where: { id: addresseeId, isActive: true }, + }); + + if (!addressee) { + throw new ApiError('Usuario no encontrado', 404); + } + + // Verificar si ya existe una relación de amistad + const existingFriendship = await prisma.friend.findFirst({ + where: { + OR: [ + { requesterId, addresseeId }, + { requesterId: addresseeId, addresseeId: requesterId }, + ], + }, + }); + + if (existingFriendship) { + if (existingFriendship.status === FriendStatus.ACCEPTED) { + throw new ApiError('Ya son amigos', 409); + } + if (existingFriendship.status === FriendStatus.PENDING) { + throw new ApiError('Ya existe una solicitud de amistad pendiente', 409); + } + if (existingFriendship.status === FriendStatus.BLOCKED) { + throw new ApiError('No puedes enviar una solicitud de amistad a este usuario', 403); + } + // Si fue rechazada, permitir reintentar eliminando la anterior + if (existingFriendship.status === FriendStatus.REJECTED) { + await prisma.friend.delete({ + where: { id: existingFriendship.id }, + }); + } + } + + // Crear la solicitud de amistad + const friendRequest = await prisma.friend.create({ + data: { + requesterId, + addresseeId, + status: FriendStatus.PENDING, + }, + include: { + requester: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + addressee: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + }, + }); + + return friendRequest; + } + + // Aceptar solicitud de amistad + static async acceptFriendRequest(requestId: string, userId: string) { + const friendRequest = await prisma.friend.findUnique({ + where: { id: requestId }, + }); + + if (!friendRequest) { + throw new ApiError('Solicitud de amistad no encontrada', 404); + } + + // Verificar que el usuario es el destinatario + if (friendRequest.addresseeId !== userId) { + throw new ApiError('No tienes permiso para aceptar esta solicitud', 403); + } + + if (friendRequest.status !== FriendStatus.PENDING) { + throw new ApiError('La solicitud no está pendiente', 400); + } + + const updated = await prisma.friend.update({ + where: { id: requestId }, + data: { status: FriendStatus.ACCEPTED }, + include: { + requester: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + addressee: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + }, + }); + + return updated; + } + + // Rechazar solicitud de amistad + static async rejectFriendRequest(requestId: string, userId: string) { + const friendRequest = await prisma.friend.findUnique({ + where: { id: requestId }, + }); + + if (!friendRequest) { + throw new ApiError('Solicitud de amistad no encontrada', 404); + } + + // Verificar que el usuario es el destinatario + if (friendRequest.addresseeId !== userId) { + throw new ApiError('No tienes permiso para rechazar esta solicitud', 403); + } + + if (friendRequest.status !== FriendStatus.PENDING) { + throw new ApiError('La solicitud no está pendiente', 400); + } + + const updated = await prisma.friend.update({ + where: { id: requestId }, + data: { status: FriendStatus.REJECTED }, + include: { + requester: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + addressee: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + }, + }); + + return updated; + } + + // Obtener mis amigos (solicitudes aceptadas) + static async getMyFriends(userId: string) { + const friendships = await prisma.friend.findMany({ + where: { + OR: [ + { requesterId: userId, status: FriendStatus.ACCEPTED }, + { addresseeId: userId, status: FriendStatus.ACCEPTED }, + ], + }, + include: { + requester: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + playerLevel: true, + }, + }, + addressee: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + playerLevel: true, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + + // Transformar para devolver solo la información del amigo + const friends = friendships.map((friendship) => { + const friend = friendship.requesterId === userId + ? friendship.addressee + : friendship.requester; + return { + friendshipId: friendship.id, + friend, + createdAt: friendship.createdAt, + }; + }); + + return friends; + } + + // Obtener solicitudes pendientes (recibidas) + static async getPendingRequests(userId: string) { + const requests = await prisma.friend.findMany({ + where: { + addresseeId: userId, + status: FriendStatus.PENDING, + }, + include: { + requester: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + playerLevel: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return requests; + } + + // Obtener solicitudes enviadas pendientes + static async getSentRequests(userId: string) { + const requests = await prisma.friend.findMany({ + where: { + requesterId: userId, + status: FriendStatus.PENDING, + }, + include: { + addressee: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + playerLevel: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return requests; + } + + // Eliminar amigo / cancelar solicitud + static async removeFriend(userId: string, friendId: string) { + const friendship = await prisma.friend.findFirst({ + where: { + OR: [ + { requesterId: userId, addresseeId: friendId }, + { requesterId: friendId, addresseeId: userId }, + ], + }, + }); + + if (!friendship) { + throw new ApiError('Amistad no encontrada', 404); + } + + // Solo el requester puede cancelar una solicitud pendiente + // Ambos pueden eliminar una amistad aceptada + if (friendship.status === FriendStatus.PENDING && friendship.requesterId !== userId) { + throw new ApiError('No puedes cancelar una solicitud que no enviaste', 403); + } + + await prisma.friend.delete({ + where: { id: friendship.id }, + }); + + return { message: 'Amistad eliminada exitosamente' }; + } + + // Bloquear usuario + static async blockUser(requesterId: string, addresseeId: string) { + if (requesterId === addresseeId) { + throw new ApiError('No puedes bloquearte a ti mismo', 400); + } + + // Buscar si existe una relación + const existing = await prisma.friend.findFirst({ + where: { + OR: [ + { requesterId, addresseeId }, + { requesterId: addresseeId, addresseeId: requesterId }, + ], + }, + }); + + if (existing) { + // Actualizar a bloqueado + const updated = await prisma.friend.update({ + where: { id: existing.id }, + data: { + status: FriendStatus.BLOCKED, + // Asegurar que el que bloquea sea el requester + requesterId, + addresseeId, + }, + }); + return updated; + } + + // Crear nueva relación bloqueada + const blocked = await prisma.friend.create({ + data: { + requesterId, + addresseeId, + status: FriendStatus.BLOCKED, + }, + }); + + return blocked; + } +} + +export default FriendService; diff --git a/backend/src/services/group.service.ts b/backend/src/services/group.service.ts new file mode 100644 index 0000000..c49ab30 --- /dev/null +++ b/backend/src/services/group.service.ts @@ -0,0 +1,448 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { GroupRole } from '../utils/constants'; + +export interface CreateGroupInput { + name: string; + description?: string; +} + +export interface AddMemberInput { + groupId: string; + adminId: string; + userId: string; +} + +export class GroupService { + // Crear un grupo + static async createGroup( + userId: string, + data: CreateGroupInput, + memberIds: string[] = [] + ) { + // Crear el grupo con el creador como miembro admin + const group = await prisma.group.create({ + data: { + name: data.name, + description: data.description, + createdById: userId, + members: { + create: [ + // El creador es admin + { + userId, + role: GroupRole.ADMIN, + }, + // Agregar otros miembros si se proporcionan + ...memberIds + .filter((id) => id !== userId) // Evitar duplicar al creador + .map((id) => ({ + userId: id, + role: GroupRole.MEMBER, + })), + ], + }, + }, + include: { + members: { + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + }, + }, + createdBy: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + return group; + } + + // Obtener mis grupos + static async getMyGroups(userId: string) { + const groups = await prisma.group.findMany({ + where: { + members: { + some: { + userId, + }, + }, + }, + include: { + members: { + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + }, + }, + createdBy: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + _count: { + select: { + members: true, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + + return groups; + } + + // Obtener grupo por ID + static async getGroupById(groupId: string, userId: string) { + const group = await prisma.group.findUnique({ + where: { id: groupId }, + include: { + members: { + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + playerLevel: true, + }, + }, + }, + orderBy: { joinedAt: 'asc' }, + }, + createdBy: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + if (!group) { + throw new ApiError('Grupo no encontrado', 404); + } + + // Verificar que el usuario es miembro + const isMember = group.members.some((m) => m.userId === userId); + if (!isMember) { + throw new ApiError('No tienes permiso para ver este grupo', 403); + } + + return group; + } + + // Verificar si el usuario es admin del grupo + private static async isGroupAdmin(groupId: string, userId: string): Promise { + const membership = await prisma.groupMember.findUnique({ + where: { + groupId_userId: { + groupId, + userId, + }, + }, + }); + + return membership?.role === GroupRole.ADMIN; + } + + // Verificar si el usuario es miembro del grupo + private static async isGroupMember(groupId: string, userId: string): Promise { + const membership = await prisma.groupMember.findUnique({ + where: { + groupId_userId: { + groupId, + userId, + }, + }, + }); + + return !!membership; + } + + // Agregar miembro al grupo + static async addMember(groupId: string, adminId: string, userId: string) { + // Verificar que el grupo existe + const group = await prisma.group.findUnique({ + where: { id: groupId }, + }); + + if (!group) { + throw new ApiError('Grupo no encontrado', 404); + } + + // Verificar que el que invita es admin + const isAdmin = await this.isGroupAdmin(groupId, adminId); + if (!isAdmin) { + throw new ApiError('Solo los administradores pueden agregar miembros', 403); + } + + // Verificar que el usuario a agregar existe + const user = await prisma.user.findUnique({ + where: { id: userId, isActive: true }, + }); + + if (!user) { + throw new ApiError('Usuario no encontrado', 404); + } + + // Verificar que no sea ya miembro + const existingMember = await prisma.groupMember.findUnique({ + where: { + groupId_userId: { + groupId, + userId, + }, + }, + }); + + if (existingMember) { + throw new ApiError('El usuario ya es miembro del grupo', 409); + } + + // Agregar miembro + const member = await prisma.groupMember.create({ + data: { + groupId, + userId, + role: GroupRole.MEMBER, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + }, + }); + + return member; + } + + // Eliminar miembro del grupo + static async removeMember(groupId: string, adminId: string, userId: string) { + // Verificar que el grupo existe + const group = await prisma.group.findUnique({ + where: { id: groupId }, + }); + + if (!group) { + throw new ApiError('Grupo no encontrado', 404); + } + + // Verificar que el que elimina es admin + const isAdmin = await this.isGroupAdmin(groupId, adminId); + + // O el usuario se está eliminando a sí mismo + const isSelfRemoval = adminId === userId; + + if (!isAdmin && !isSelfRemoval) { + throw new ApiError('No tienes permiso para eliminar este miembro', 403); + } + + // No permitir que el creador se elimine a sí mismo + if (isSelfRemoval && group.createdById === userId) { + throw new ApiError('El creador del grupo no puede abandonarlo', 400); + } + + // Verificar que el miembro existe + const member = await prisma.groupMember.findUnique({ + where: { + groupId_userId: { + groupId, + userId, + }, + }, + }); + + if (!member) { + throw new ApiError('El usuario no es miembro del grupo', 404); + } + + // Eliminar miembro + await prisma.groupMember.delete({ + where: { + groupId_userId: { + groupId, + userId, + }, + }, + }); + + return { message: 'Miembro eliminado exitosamente' }; + } + + // Cambiar rol de miembro + static async updateMemberRole( + groupId: string, + adminId: string, + userId: string, + newRole: string + ) { + // Verificar que el grupo existe + const group = await prisma.group.findUnique({ + where: { id: groupId }, + }); + + if (!group) { + throw new ApiError('Grupo no encontrado', 404); + } + + // Verificar que el que cambia es admin + const isAdmin = await this.isGroupAdmin(groupId, adminId); + if (!isAdmin) { + throw new ApiError('Solo los administradores pueden cambiar roles', 403); + } + + // No permitir cambiar el rol del creador + if (userId === group.createdById) { + throw new ApiError('No se puede cambiar el rol del creador del grupo', 400); + } + + // Verificar que el miembro existe + const member = await prisma.groupMember.findUnique({ + where: { + groupId_userId: { + groupId, + userId, + }, + }, + }); + + if (!member) { + throw new ApiError('El usuario no es miembro del grupo', 404); + } + + // Actualizar rol + const updated = await prisma.groupMember.update({ + where: { + groupId_userId: { + groupId, + userId, + }, + }, + data: { role: newRole }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + return updated; + } + + // Eliminar grupo + static async deleteGroup(groupId: string, userId: string) { + // Verificar que el grupo existe + const group = await prisma.group.findUnique({ + where: { id: groupId }, + }); + + if (!group) { + throw new ApiError('Grupo no encontrado', 404); + } + + // Verificar que el que elimina es el creador + if (group.createdById !== userId) { + throw new ApiError('Solo el creador puede eliminar el grupo', 403); + } + + // Eliminar grupo (cascade eliminará los miembros) + await prisma.group.delete({ + where: { id: groupId }, + }); + + return { message: 'Grupo eliminado exitosamente' }; + } + + // Actualizar grupo + static async updateGroup( + groupId: string, + userId: string, + data: Partial + ) { + // Verificar que el grupo existe + const group = await prisma.group.findUnique({ + where: { id: groupId }, + }); + + if (!group) { + throw new ApiError('Grupo no encontrado', 404); + } + + // Verificar que el que actualiza es admin + const isAdmin = await this.isGroupAdmin(groupId, userId); + if (!isAdmin) { + throw new ApiError('Solo los administradores pueden actualizar el grupo', 403); + } + + const updated = await prisma.group.update({ + where: { id: groupId }, + data, + include: { + members: { + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + }, + }, + createdBy: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + return updated; + } +} + +export default GroupService; diff --git a/backend/src/services/match.service.ts b/backend/src/services/match.service.ts new file mode 100644 index 0000000..f395a8b --- /dev/null +++ b/backend/src/services/match.service.ts @@ -0,0 +1,605 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { MatchWinner } from '../utils/constants'; +import logger from '../config/logger'; + +export interface RecordMatchInput { + bookingId?: string; + team1Player1Id: string; + team1Player2Id: string; + team2Player1Id: string; + team2Player2Id: string; + team1Score: number; + team2Score: number; + winner: string; + playedAt: Date; + recordedBy: string; +} + +export interface MatchFilters { + userId?: string; + fromDate?: Date; + toDate?: Date; + status?: 'PENDING' | 'CONFIRMED'; +} + +export class MatchService { + /** + * Registrar un nuevo resultado de partido + */ + static async recordMatchResult(data: RecordMatchInput) { + // Validar que todos los jugadores existan y sean diferentes + const playerIds = [ + data.team1Player1Id, + data.team1Player2Id, + data.team2Player1Id, + data.team2Player2Id, + ]; + + const uniquePlayerIds = [...new Set(playerIds)]; + if (uniquePlayerIds.length !== 4) { + throw new ApiError('Los 4 jugadores deben ser diferentes', 400); + } + + // Verificar que todos los jugadores existan + const users = await prisma.user.findMany({ + where: { id: { in: playerIds } }, + select: { id: true }, + }); + + if (users.length !== 4) { + throw new ApiError('Uno o más jugadores no existen', 404); + } + + // Validar el ganador + if (![MatchWinner.TEAM1, MatchWinner.TEAM2, MatchWinner.DRAW].includes(data.winner as any)) { + throw new ApiError('Valor de ganador inválido', 400); + } + + // Validar puntajes + if (data.team1Score < 0 || data.team2Score < 0) { + throw new ApiError('Los puntajes no pueden ser negativos', 400); + } + + // Si hay bookingId, verificar que exista + if (data.bookingId) { + const booking = await prisma.booking.findUnique({ + where: { id: data.bookingId }, + }); + + if (!booking) { + throw new ApiError('Reserva no encontrada', 404); + } + } + + // Crear el resultado del partido + const matchResult = await prisma.matchResult.create({ + data: { + bookingId: data.bookingId, + team1Player1Id: data.team1Player1Id, + team1Player2Id: data.team1Player2Id, + team2Player1Id: data.team2Player1Id, + team2Player2Id: data.team2Player2Id, + team1Score: data.team1Score, + team2Score: data.team2Score, + winner: data.winner, + playedAt: data.playedAt, + confirmedBy: JSON.stringify([data.recordedBy]), + }, + include: { + team1Player1: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team1Player2: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team2Player1: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team2Player2: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + booking: { + select: { + id: true, + court: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }); + + logger.info(`Partido registrado: ${matchResult.id} por usuario ${data.recordedBy}`); + + return matchResult; + } + + /** + * Obtener historial de partidos con filtros + */ + static async getMatchHistory(filters: MatchFilters) { + const where: any = {}; + + if (filters.userId) { + where.OR = [ + { team1Player1Id: filters.userId }, + { team1Player2Id: filters.userId }, + { team2Player1Id: filters.userId }, + { team2Player2Id: filters.userId }, + ]; + } + + if (filters.fromDate || filters.toDate) { + where.playedAt = {}; + if (filters.fromDate) where.playedAt.gte = filters.fromDate; + if (filters.toDate) where.playedAt.lte = filters.toDate; + } + + // Filtro por estado de confirmación + if (filters.status) { + // Necesitamos filtrar después de obtener los resultados + // porque confirmedBy es un JSON string + } + + const matches = await prisma.matchResult.findMany({ + where, + include: { + team1Player1: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team1Player2: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team2Player1: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team2Player2: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + booking: { + select: { + id: true, + court: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + orderBy: { playedAt: 'desc' }, + }); + + // Añadir información de confirmación + const matchesWithConfirmation = matches.map(match => { + const confirmedBy = JSON.parse(match.confirmedBy) as string[]; + return { + ...match, + confirmations: confirmedBy.length, + isConfirmed: confirmedBy.length >= 2, + confirmedBy, + }; + }); + + // Filtrar por estado si es necesario + if (filters.status === 'CONFIRMED') { + return matchesWithConfirmation.filter(m => m.isConfirmed); + } else if (filters.status === 'PENDING') { + return matchesWithConfirmation.filter(m => !m.isConfirmed); + } + + return matchesWithConfirmation; + } + + /** + * Obtener partidos de un usuario específico + */ + static async getUserMatches(userId: string, options?: { upcoming?: boolean; limit?: number }) { + const where: any = { + OR: [ + { team1Player1Id: userId }, + { team1Player2Id: userId }, + { team2Player1Id: userId }, + { team2Player2Id: userId }, + ], + }; + + if (options?.upcoming) { + where.playedAt = { gte: new Date() }; + } + + const matches = await prisma.matchResult.findMany({ + where, + include: { + team1Player1: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team1Player2: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team2Player1: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team2Player2: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + booking: { + select: { + id: true, + court: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + orderBy: { playedAt: 'desc' }, + take: options?.limit, + }); + + return matches.map(match => { + const confirmedBy = JSON.parse(match.confirmedBy) as string[]; + const isUserTeam1 = match.team1Player1Id === userId || match.team1Player2Id === userId; + const isWinner = match.winner === 'TEAM1' && isUserTeam1 || + match.winner === 'TEAM2' && !isUserTeam1; + + return { + ...match, + confirmations: confirmedBy.length, + isConfirmed: confirmedBy.length >= 2, + confirmedBy, + isUserTeam1, + isWinner, + }; + }); + } + + /** + * Obtener un partido por ID + */ + static async getMatchById(id: string) { + const match = await prisma.matchResult.findUnique({ + where: { id }, + include: { + team1Player1: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team1Player2: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team2Player1: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team2Player2: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + booking: { + select: { + id: true, + court: { + select: { + id: true, + name: 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, + }; + } + + /** + * Confirmar el resultado de un partido + */ + static async confirmMatchResult(matchId: string, userId: string) { + const match = await prisma.matchResult.findUnique({ + where: { id: matchId }, + }); + + if (!match) { + throw new ApiError('Partido no encontrado', 404); + } + + // Verificar que el usuario sea uno de los jugadores + const playerIds = [ + match.team1Player1Id, + match.team1Player2Id, + match.team2Player1Id, + match.team2Player2Id, + ]; + + if (!playerIds.includes(userId)) { + throw new ApiError('Solo los jugadores del partido 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 updated = await prisma.matchResult.update({ + where: { id: matchId }, + data: { confirmedBy: JSON.stringify(confirmedBy) }, + include: { + team1Player1: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team1Player2: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team2Player1: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + team2Player2: { + select: { + id: true, + firstName: true, + lastName: true, + playerLevel: true, + avatarUrl: true, + }, + }, + }, + }); + + const isNowConfirmed = confirmedBy.length >= 2; + + logger.info(`Partido ${matchId} confirmado por ${userId}. Confirmaciones: ${confirmedBy.length}`); + + // Si ahora está confirmado, actualizar estadísticas + if (isNowConfirmed && confirmedBy.length === 2) { + await this.updateStatsAfterMatch(match); + } + + return { + ...updated, + confirmations: confirmedBy.length, + isConfirmed: isNowConfirmed, + confirmedBy, + }; + } + + /** + * Actualizar estadísticas después de un partido confirmado + */ + static async updateStatsAfterMatch(match: { + id: string; + team1Player1Id: string; + team1Player2Id: string; + team2Player1Id: string; + team2Player2Id: string; + winner: string; + playedAt: Date; + }) { + try { + const playerIds = [ + { id: match.team1Player1Id, team: 'TEAM1' as const }, + { id: match.team1Player2Id, team: 'TEAM1' as const }, + { id: match.team2Player1Id, team: 'TEAM2' as const }, + { id: match.team2Player2Id, team: 'TEAM2' as const }, + ]; + + const playedAt = new Date(match.playedAt); + const month = playedAt.toISOString().slice(0, 7); // YYYY-MM + const year = playedAt.getFullYear().toString(); + + // Actualizar cada jugador + for (const player of playerIds) { + const isWinner = match.winner === player.team; + const isDraw = match.winner === 'DRAW'; + + // Actualizar estadísticas globales del usuario + await prisma.user.update({ + where: { id: player.id }, + data: { + matchesPlayed: { increment: 1 }, + matchesWon: isWinner ? { increment: 1 } : undefined, + matchesLost: !isWinner && !isDraw ? { increment: 1 } : undefined, + }, + }); + + // Actualizar estadísticas mensuales + await this.updateUserStats(player.id, 'MONTH', month, isWinner, isDraw); + + // Actualizar estadísticas anuales + await this.updateUserStats(player.id, 'YEAR', year, isWinner, isDraw); + + // Actualizar estadísticas all-time + await this.updateUserStats(player.id, 'ALL_TIME', 'ALL', isWinner, isDraw); + } + + logger.info(`Estadísticas actualizadas para partido ${match.id}`); + } catch (error) { + logger.error(`Error actualizando estadísticas para partido ${match.id}:`, error); + throw error; + } + } + + /** + * Actualizar estadísticas de usuario para un período específico + */ + private static async updateUserStats( + userId: string, + period: string, + periodValue: string, + isWinner: boolean, + isDraw: boolean + ) { + const data = { + matchesPlayed: { increment: 1 }, + ...(isWinner && { matchesWon: { increment: 1 } }), + ...(!isWinner && !isDraw && { matchesLost: { increment: 1 } }), + }; + + await prisma.userStats.upsert({ + where: { + userId_period_periodValue: { + userId, + period, + periodValue, + }, + }, + update: data, + create: { + userId, + period, + periodValue, + matchesPlayed: 1, + matchesWon: isWinner ? 1 : 0, + matchesLost: !isWinner && !isDraw ? 1 : 0, + tournamentsPlayed: 0, + tournamentsWon: 0, + points: 0, + }, + }); + } + + /** + * Verificar si un partido está confirmado + */ + static isMatchConfirmed(confirmedBy: string): boolean { + const confirmations = JSON.parse(confirmedBy) as string[]; + return confirmations.length >= 2; + } +} + +export default MatchService; diff --git a/backend/src/services/ranking.service.ts b/backend/src/services/ranking.service.ts new file mode 100644 index 0000000..21bab35 --- /dev/null +++ b/backend/src/services/ranking.service.ts @@ -0,0 +1,507 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { StatsPeriod, PlayerLevel } from '../utils/constants'; +import { calculatePointsFromMatch, getRankTitle } from '../utils/ranking'; +import logger from '../config/logger'; + +export interface RankingFilters { + period?: string; + periodValue?: string; + level?: string; + limit?: number; +} + +export interface UserRankingResult { + position: number; + user: { + id: string; + firstName: string; + lastName: string; + avatarUrl: string | null; + playerLevel: string; + }; + stats: { + matchesPlayed: number; + matchesWon: number; + matchesLost: number; + winRate: number; + points: number; + }; + rank: { + title: string; + icon: string; + color: string; + }; +} + +export class RankingService { + /** + * Calcular ranking por período y nivel + */ + static async calculateRanking(filters: RankingFilters): Promise { + const { period = StatsPeriod.MONTH, periodValue, level, limit = 100 } = filters; + + // Determinar el valor del período si no se proporciona + let effectivePeriodValue = periodValue; + if (!effectivePeriodValue) { + const now = new Date(); + if (period === StatsPeriod.MONTH) { + effectivePeriodValue = now.toISOString().slice(0, 7); // YYYY-MM + } else if (period === StatsPeriod.YEAR) { + effectivePeriodValue = now.getFullYear().toString(); + } else { + effectivePeriodValue = 'ALL'; + } + } + + // Construir el where clause + const where: any = { + period, + periodValue: effectivePeriodValue, + }; + + if (level && level !== 'ALL') { + where.user = { + playerLevel: level, + }; + } + + // Obtener estadísticas ordenadas por puntos + const stats = await prisma.userStats.findMany({ + where, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + playerLevel: true, + }, + }, + }, + orderBy: [ + { points: 'desc' }, + { matchesWon: 'desc' }, + { matchesPlayed: 'asc' }, + ], + take: limit, + }); + + // Mapear a resultado con posición + return stats.map((stat, index) => { + const rank = getRankTitle(stat.points); + const winRate = stat.matchesPlayed > 0 + ? Math.round((stat.matchesWon / stat.matchesPlayed) * 100) + : 0; + + return { + position: index + 1, + user: stat.user, + stats: { + matchesPlayed: stat.matchesPlayed, + matchesWon: stat.matchesWon, + matchesLost: stat.matchesLost, + winRate, + points: stat.points, + }, + rank: { + title: rank.title, + icon: rank.icon, + color: this.getRankColor(rank.title), + }, + }; + }); + } + + /** + * Obtener el ranking de un usuario específico + */ + static async getUserRanking( + userId: string, + period: string = StatsPeriod.MONTH, + periodValue?: string + ): Promise { + // Determinar el valor del período si no se proporciona + let effectivePeriodValue = periodValue; + if (!effectivePeriodValue) { + const now = new Date(); + if (period === StatsPeriod.MONTH) { + effectivePeriodValue = now.toISOString().slice(0, 7); + } else if (period === StatsPeriod.YEAR) { + effectivePeriodValue = now.getFullYear().toString(); + } else { + effectivePeriodValue = 'ALL'; + } + } + + // Obtener o crear estadísticas del usuario + let userStats = await prisma.userStats.findUnique({ + where: { + userId_period_periodValue: { + userId, + period, + periodValue: effectivePeriodValue, + }, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + playerLevel: true, + }, + }, + }, + }); + + // Si no existe, crear con valores por defecto + if (!userStats) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + playerLevel: true, + }, + }); + + if (!user) { + throw new ApiError('Usuario no encontrado', 404); + } + + userStats = await prisma.userStats.create({ + data: { + userId, + period, + periodValue: effectivePeriodValue, + matchesPlayed: 0, + matchesWon: 0, + matchesLost: 0, + tournamentsPlayed: 0, + tournamentsWon: 0, + points: 0, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + playerLevel: true, + }, + }, + }, + }); + } + + // Calcular la posición + const position = await this.calculateUserPosition( + userId, + period, + effectivePeriodValue, + userStats.points + ); + + const rank = getRankTitle(userStats.points); + const { getNextRank } = await import('../utils/ranking'); + const nextRank = getNextRank(userStats.points); + const winRate = userStats.matchesPlayed > 0 + ? Math.round((userStats.matchesWon / userStats.matchesPlayed) * 100) + : 0; + + return { + position, + user: userStats.user, + stats: { + matchesPlayed: userStats.matchesPlayed, + matchesWon: userStats.matchesWon, + matchesLost: userStats.matchesLost, + winRate, + points: userStats.points, + }, + rank: { + title: rank.title, + icon: rank.icon, + color: this.getRankColor(rank.title), + }, + nextRank, + }; + } + + /** + * Calcular la posición de un usuario en el ranking + */ + private static async calculateUserPosition( + userId: string, + period: string, + periodValue: string, + userPoints: number + ): Promise { + const countHigher = await prisma.userStats.count({ + where: { + period, + periodValue, + points: { gt: userPoints }, + }, + }); + + // Contar usuarios con los mismos puntos pero con más victorias + const userStats = await prisma.userStats.findUnique({ + where: { + userId_period_periodValue: { + userId, + period, + periodValue, + }, + }, + }); + + const countSamePointsBetterStats = await prisma.userStats.count({ + where: { + period, + periodValue, + points: userPoints, + matchesWon: { gt: userStats?.matchesWon || 0 }, + }, + }); + + return countHigher + countSamePointsBetterStats + 1; + } + + /** + * Actualizar puntos de un usuario + */ + static async updateUserPoints( + userId: string, + points: number, + reason: string, + period: string = StatsPeriod.MONTH, + periodValue?: string + ) { + // Determinar el valor del período si no se proporciona + let effectivePeriodValue = periodValue; + if (!effectivePeriodValue) { + const now = new Date(); + if (period === StatsPeriod.MONTH) { + effectivePeriodValue = now.toISOString().slice(0, 7); + } else if (period === StatsPeriod.YEAR) { + effectivePeriodValue = now.getFullYear().toString(); + } else { + effectivePeriodValue = 'ALL'; + } + } + + // Actualizar en transacción + const result = await prisma.$transaction(async (tx) => { + // Actualizar estadísticas del período + const stats = await tx.userStats.upsert({ + where: { + userId_period_periodValue: { + userId, + period, + periodValue: effectivePeriodValue, + }, + }, + update: { + points: { increment: points }, + }, + create: { + userId, + period, + periodValue: effectivePeriodValue, + matchesPlayed: 0, + matchesWon: 0, + matchesLost: 0, + tournamentsPlayed: 0, + tournamentsWon: 0, + points: Math.max(0, points), + }, + }); + + // Actualizar puntos totales del usuario + await tx.user.update({ + where: { id: userId }, + data: { + totalPoints: { increment: points }, + }, + }); + + return stats; + }); + + logger.info(`Puntos actualizados para usuario ${userId}: ${points} puntos (${reason})`); + + return { + userId, + pointsAdded: points, + newTotal: result.points, + period, + periodValue: effectivePeriodValue, + reason, + }; + } + + /** + * Obtener top jugadores + */ + static async getTopPlayers( + limit: number = 10, + level?: string, + period: string = StatsPeriod.MONTH + ): Promise { + return this.calculateRanking({ + period, + level, + limit, + }); + } + + /** + * Recalcular todos los rankings basados en partidos confirmados + */ + static async recalculateAllRankings(): Promise { + const confirmedMatches = await prisma.matchResult.findMany({ + where: { + confirmedBy: { + not: '[]', + }, + }, + include: { + team1Player1: { select: { id: true, playerLevel: true } }, + team1Player2: { select: { id: true, playerLevel: true } }, + team2Player1: { select: { id: true, playerLevel: true } }, + team2Player2: { select: { id: true, playerLevel: true } }, + }, + }); + + // Filtrar solo los confirmados + const validMatches = confirmedMatches.filter(match => { + const confirmed = JSON.parse(match.confirmedBy) as string[]; + return confirmed.length >= 2; + }); + + logger.info(`Recalculando rankings basado en ${validMatches.length} partidos confirmados`); + + // Reiniciar todas las estadísticas + await prisma.userStats.deleteMany({ + where: { + period: { in: [StatsPeriod.MONTH, StatsPeriod.YEAR, StatsPeriod.ALL_TIME] }, + }, + }); + + // Reiniciar estadísticas globales de usuarios + await prisma.user.updateMany({ + data: { + matchesPlayed: 0, + matchesWon: 0, + matchesLost: 0, + totalPoints: 0, + }, + }); + + // Procesar cada partido + for (const match of validMatches) { + const { calculatePointsFromMatch } = await import('../utils/ranking'); + + const points = calculatePointsFromMatch({ + team1Score: match.team1Score, + team2Score: match.team2Score, + winner: match.winner, + team1Player1: match.team1Player1, + team1Player2: match.team1Player2, + team2Player1: match.team2Player1, + team2Player2: match.team2Player2, + }); + + const month = match.playedAt.toISOString().slice(0, 7); + const year = match.playedAt.getFullYear().toString(); + + for (const pointData of points) { + await this.addPointsToUser( + pointData.userId, + pointData.pointsEarned, + month, + year + ); + } + } + + logger.info('Recálculo de rankings completado'); + } + + /** + * Añadir puntos a un usuario en todos los períodos + */ + private static async addPointsToUser( + userId: string, + points: number, + month: string, + year: string + ): Promise { + const periods = [ + { period: StatsPeriod.MONTH, periodValue: month }, + { period: StatsPeriod.YEAR, periodValue: year }, + { period: StatsPeriod.ALL_TIME, periodValue: 'ALL' }, + ]; + + for (const { period, periodValue } of periods) { + await prisma.userStats.upsert({ + where: { + userId_period_periodValue: { + userId, + period, + periodValue, + }, + }, + update: { + points: { increment: points }, + }, + create: { + userId, + period, + periodValue, + matchesPlayed: 0, + matchesWon: 0, + matchesLost: 0, + tournamentsPlayed: 0, + tournamentsWon: 0, + points, + }, + }); + } + + // Actualizar puntos totales del usuario + await prisma.user.update({ + where: { id: userId }, + data: { + totalPoints: { increment: points }, + }, + }); + } + + /** + * Obtener el color de un rango + */ + private static getRankColor(title: string): string { + const colors: Record = { + 'Bronce': '#CD7F32', + 'Plata': '#C0C0C0', + 'Oro': '#FFD700', + 'Platino': '#E5E4E2', + 'Diamante': '#B9F2FF', + 'Maestro': '#FF6B35', + 'Gran Maestro': '#9B59B6', + 'Leyenda': '#FFD700', + }; + + return colors[title] || '#CD7F32'; + } +} + +export default RankingService; diff --git a/backend/src/services/recurring.service.ts b/backend/src/services/recurring.service.ts new file mode 100644 index 0000000..793b787 --- /dev/null +++ b/backend/src/services/recurring.service.ts @@ -0,0 +1,439 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { BookingStatus } from '../utils/constants'; + +export interface CreateRecurringInput { + courtId: string; + dayOfWeek: number; + startTime: string; + endTime: string; + startDate: Date; + endDate?: Date; +} + +export interface GenerateBookingsInput { + recurringBookingId: string; + fromDate?: Date; + toDate?: Date; +} + +export class RecurringService { + // Crear una reserva recurrente + static async createRecurringBooking(userId: string, data: CreateRecurringInput) { + // Validar dayOfWeek (0-6) + if (data.dayOfWeek < 0 || data.dayOfWeek > 6) { + throw new ApiError('El día de la semana debe estar entre 0 (Domingo) y 6 (Sábado)', 400); + } + + // Validar que la cancha existe y está activa + const court = await prisma.court.findFirst({ + where: { id: data.courtId, isActive: true }, + }); + + if (!court) { + throw new ApiError('Cancha no encontrada o inactiva', 404); + } + + // Validar horario de la cancha para ese día + const schedule = await prisma.courtSchedule.findFirst({ + where: { + courtId: data.courtId, + dayOfWeek: data.dayOfWeek, + }, + }); + + if (!schedule) { + throw new ApiError('La cancha no tiene horario disponible para este día de la semana', 400); + } + + // Validar que el horario esté dentro del horario de la cancha + if (data.startTime < schedule.openTime || data.endTime > schedule.closeTime) { + throw new ApiError( + `El horario debe estar entre ${schedule.openTime} y ${schedule.closeTime}`, + 400 + ); + } + + // Validar que la hora de fin sea posterior a la de inicio + if (data.startTime >= data.endTime) { + throw new ApiError('La hora de fin debe ser posterior a la de inicio', 400); + } + + // Validar que la fecha de inicio sea válida + const startDate = new Date(data.startDate); + startDate.setHours(0, 0, 0, 0); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (startDate < today) { + throw new ApiError('La fecha de inicio no puede ser en el pasado', 400); + } + + // Validar fecha de fin si se proporciona + if (data.endDate) { + const endDate = new Date(data.endDate); + endDate.setHours(0, 0, 0, 0); + + if (endDate <= startDate) { + throw new ApiError('La fecha de fin debe ser posterior a la fecha de inicio', 400); + } + } + + // Verificar que no exista una reserva recurrente conflictiva + const existingRecurring = await prisma.recurringBooking.findFirst({ + where: { + courtId: data.courtId, + dayOfWeek: data.dayOfWeek, + isActive: true, + OR: [ + { endDate: null }, + { endDate: { gte: startDate } }, + ], + AND: [ + { + OR: [ + { startTime: { lt: data.endTime } }, + { endTime: { gt: data.startTime } }, + ], + }, + ], + }, + }); + + if (existingRecurring) { + throw new ApiError('Ya existe una reserva recurrente que se solapa con este horario', 409); + } + + // Crear la reserva recurrente + const recurringBooking = await prisma.recurringBooking.create({ + data: { + userId, + courtId: data.courtId, + dayOfWeek: data.dayOfWeek, + startTime: data.startTime, + endTime: data.endTime, + startDate, + endDate: data.endDate ? new Date(data.endDate) : null, + isActive: true, + }, + include: { + court: { + select: { + id: true, + name: true, + type: true, + pricePerHour: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + return recurringBooking; + } + + // Obtener mis reservas recurrentes + static async getMyRecurringBookings(userId: string) { + const recurringBookings = await prisma.recurringBooking.findMany({ + where: { userId }, + include: { + court: { + select: { + id: true, + name: true, + type: true, + pricePerHour: true, + }, + }, + _count: { + select: { + bookings: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return recurringBookings; + } + + // Obtener reserva recurrente por ID + static async getRecurringById(id: string, userId: string) { + const recurring = await prisma.recurringBooking.findUnique({ + where: { id }, + include: { + court: true, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + bookings: { + where: { + status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] }, + }, + orderBy: { date: 'asc' }, + }, + }, + }); + + if (!recurring) { + throw new ApiError('Reserva recurrente no encontrada', 404); + } + + if (recurring.userId !== userId) { + throw new ApiError('No tienes permiso para ver esta reserva recurrente', 403); + } + + return recurring; + } + + // Cancelar reserva recurrente + static async cancelRecurringBooking(id: string, userId: string) { + const recurring = await this.getRecurringById(id, userId); + + // Cancelar todas las reservas futuras + const today = new Date(); + today.setHours(0, 0, 0, 0); + + await prisma.booking.updateMany({ + where: { + recurringBookingId: id, + date: { gte: today }, + status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] }, + }, + data: { + status: BookingStatus.CANCELLED, + }, + }); + + // Desactivar la reserva recurrente + const updated = await prisma.recurringBooking.update({ + where: { id }, + data: { isActive: false }, + include: { + court: { + select: { + id: true, + name: true, + type: true, + }, + }, + }, + }); + + return updated; + } + + // Generar reservas concretas a partir de una recurrente + static async generateBookingsFromRecurring( + recurringBookingId: string, + fromDate?: Date, + toDate?: Date + ) { + const recurring = await prisma.recurringBooking.findUnique({ + where: { + id: recurringBookingId, + isActive: true, + }, + include: { + court: true, + user: true, + }, + }); + + if (!recurring) { + throw new ApiError('Reserva recurrente no encontrada o inactiva', 404); + } + + // Determinar rango de fechas para generar + const startDate = fromDate ? new Date(fromDate) : new Date(); + startDate.setHours(0, 0, 0, 0); + + // Si no se proporciona fecha de fin, generar hasta 4 semanas + const endDate = toDate + ? new Date(toDate) + : new Date(startDate.getTime() + 28 * 24 * 60 * 60 * 1000); + endDate.setHours(0, 0, 0, 0); + + // No generar más allá de la fecha de fin de la reserva recurrente + if (recurring.endDate && endDate > recurring.endDate) { + endDate.setTime(recurring.endDate.getTime()); + } + + const bookings = []; + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + // Verificar si el día de la semana coincide + if (currentDate.getDay() === recurring.dayOfWeek) { + // Verificar que no exista ya una reserva para esta fecha + const existingBooking = await prisma.booking.findFirst({ + where: { + recurringBookingId: recurring.id, + date: new Date(currentDate), + }, + }); + + if (!existingBooking) { + // Verificar disponibilidad de la cancha + const conflictingBooking = await prisma.booking.findFirst({ + where: { + courtId: recurring.courtId, + date: new Date(currentDate), + status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] }, + OR: [ + { + startTime: { lt: recurring.endTime }, + endTime: { gt: recurring.startTime }, + }, + ], + }, + }); + + if (!conflictingBooking) { + // Calcular precio + const startHour = parseInt(recurring.startTime.split(':')[0]); + const endHour = parseInt(recurring.endTime.split(':')[0]); + const hours = endHour - startHour; + const totalPrice = recurring.court.pricePerHour * hours; + + // Crear la reserva + const booking = await prisma.booking.create({ + data: { + userId: recurring.userId, + courtId: recurring.courtId, + date: new Date(currentDate), + startTime: recurring.startTime, + endTime: recurring.endTime, + status: BookingStatus.CONFIRMED, // Las recurrentes se confirman automáticamente + totalPrice, + recurringBookingId: recurring.id, + }, + }); + + bookings.push(booking); + } + } + } + + // Avanzar un día + currentDate.setDate(currentDate.getDate() + 1); + } + + return { + recurringBookingId, + generatedCount: bookings.length, + bookings, + }; + } + + // Generar todas las reservas recurrentes activas (para cron job) + static async generateAllRecurringBookings(fromDate?: Date, toDate?: Date) { + const activeRecurring = await prisma.recurringBooking.findMany({ + where: { isActive: true }, + }); + + const results = []; + + for (const recurring of activeRecurring) { + try { + const result = await this.generateBookingsFromRecurring( + recurring.id, + fromDate, + toDate + ); + results.push({ + success: true, + ...result, + }); + } catch (error) { + results.push({ + recurringBookingId: recurring.id, + success: false, + error: (error as Error).message, + }); + } + } + + return results; + } + + // Actualizar reserva recurrente + static async updateRecurringBooking( + id: string, + userId: string, + data: Partial + ) { + const recurring = await prisma.recurringBooking.findUnique({ + where: { id }, + }); + + if (!recurring) { + throw new ApiError('Reserva recurrente no encontrada', 404); + } + + if (recurring.userId !== userId) { + throw new ApiError('No tienes permiso para modificar esta reserva recurrente', 403); + } + + // Si se cambia el horario o día, validar conflictos + if (data.dayOfWeek !== undefined || data.startTime || data.endTime) { + const dayOfWeek = data.dayOfWeek ?? recurring.dayOfWeek; + const startTime = data.startTime ?? recurring.startTime; + const endTime = data.endTime ?? recurring.endTime; + + // Validar horario de la cancha + const schedule = await prisma.courtSchedule.findFirst({ + where: { + courtId: recurring.courtId, + dayOfWeek, + }, + }); + + if (!schedule) { + throw new ApiError('La cancha no tiene horario disponible para este día de la semana', 400); + } + + if (startTime < schedule.openTime || endTime > schedule.closeTime) { + throw new ApiError( + `El horario debe estar entre ${schedule.openTime} y ${schedule.closeTime}`, + 400 + ); + } + } + + const updated = await prisma.recurringBooking.update({ + where: { id }, + data: { + ...data, + startDate: data.startDate ? new Date(data.startDate) : undefined, + endDate: data.endDate ? new Date(data.endDate) : undefined, + }, + include: { + court: { + select: { + id: true, + name: true, + type: true, + }, + }, + }, + }); + + return updated; + } +} + +export default RecurringService; diff --git a/backend/src/services/stats.service.ts b/backend/src/services/stats.service.ts new file mode 100644 index 0000000..0f88951 --- /dev/null +++ b/backend/src/services/stats.service.ts @@ -0,0 +1,441 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { StatsPeriod, BookingStatus } from '../utils/constants'; +import { getRankTitle, getNextRank } from '../utils/ranking'; +import logger from '../config/logger'; + +export interface UserStatsResult { + user: { + id: string; + firstName: string; + lastName: string; + avatarUrl: string | null; + playerLevel: string; + }; + globalStats: { + matchesPlayed: number; + matchesWon: number; + matchesLost: number; + winRate: number; + totalPoints: number; + }; + periodStats: { + period: string; + periodValue: string; + matchesPlayed: number; + matchesWon: number; + matchesLost: number; + winRate: number; + points: number; + } | null; + rank: { + title: string; + icon: string; + nextRank: { title: string; pointsNeeded: number } | null; + }; + recentForm: ('W' | 'L' | 'D')[]; +} + +export interface CourtStatsResult { + court: { + id: string; + name: string; + type: string; + }; + totalBookings: number; + completedBookings: number; + cancelledBookings: number; + occupancyRate: number; + revenue: number; + peakHours: { hour: string; bookings: number }[]; + bookingsByDay: { day: string; count: number }[]; +} + +export interface GlobalStatsResult { + totalUsers: number; + activeUsers: number; + totalBookings: number; + totalMatches: number; + totalRevenue: number; + popularCourts: { courtId: string; courtName: string; bookings: number }[]; + bookingsTrend: { date: string; bookings: number }[]; + matchesTrend: { date: string; matches: number }[]; +} + +export class StatsService { + /** + * Obtener estadísticas de un usuario + */ + static async getUserStats(userId: string, period?: string, periodValue?: string): Promise { + // Verificar que el usuario existe + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + playerLevel: true, + matchesPlayed: true, + matchesWon: true, + matchesLost: true, + totalPoints: true, + }, + }); + + if (!user) { + throw new ApiError('Usuario no encontrado', 404); + } + + // Determinar período + const effectivePeriod = period || StatsPeriod.MONTH; + let effectivePeriodValue = periodValue; + + if (!effectivePeriodValue) { + const now = new Date(); + if (effectivePeriod === StatsPeriod.MONTH) { + effectivePeriodValue = now.toISOString().slice(0, 7); + } else if (effectivePeriod === StatsPeriod.YEAR) { + effectivePeriodValue = now.getFullYear().toString(); + } else { + effectivePeriodValue = 'ALL'; + } + } + + // Obtener estadísticas del período + const periodStats = await prisma.userStats.findUnique({ + where: { + userId_period_periodValue: { + userId, + period: effectivePeriod, + periodValue: effectivePeriodValue, + }, + }, + }); + + // Calcular estadísticas globales + const globalWinRate = user.matchesPlayed > 0 + ? Math.round((user.matchesWon / user.matchesPlayed) * 100) + : 0; + + // Obtener forma reciente (últimos 5 partidos) + const recentMatches = await prisma.matchResult.findMany({ + where: { + OR: [ + { team1Player1Id: userId }, + { team1Player2Id: userId }, + { team2Player1Id: userId }, + { team2Player2Id: userId }, + ], + }, + orderBy: { playedAt: 'desc' }, + take: 5, + }); + + const recentForm: ('W' | 'L' | 'D')[] = recentMatches.map(match => { + const isTeam1 = match.team1Player1Id === userId || match.team1Player2Id === userId; + + if (match.winner === 'DRAW') return 'D'; + if (match.winner === 'TEAM1' && isTeam1) return 'W'; + if (match.winner === 'TEAM2' && !isTeam1) return 'W'; + return 'L'; + }); + + const rank = getRankTitle(user.totalPoints); + + return { + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + avatarUrl: user.avatarUrl, + playerLevel: user.playerLevel, + }, + globalStats: { + matchesPlayed: user.matchesPlayed, + matchesWon: user.matchesWon, + matchesLost: user.matchesLost, + winRate: globalWinRate, + totalPoints: user.totalPoints, + }, + periodStats: periodStats ? { + period: effectivePeriod, + periodValue: effectivePeriodValue, + matchesPlayed: periodStats.matchesPlayed, + matchesWon: periodStats.matchesWon, + matchesLost: periodStats.matchesLost, + winRate: periodStats.matchesPlayed > 0 + ? Math.round((periodStats.matchesWon / periodStats.matchesPlayed) * 100) + : 0, + points: periodStats.points, + } : null, + rank: { + title: rank.title, + icon: rank.icon, + nextRank: getNextRank(user.totalPoints), + }, + recentForm, + }; + } + + /** + * Obtener estadísticas de una cancha + */ + static async getCourtStats(courtId: string, fromDate?: Date, toDate?: Date): Promise { + // Verificar que la cancha existe + const court = await prisma.court.findUnique({ + where: { id: courtId }, + select: { id: true, name: true, type: true }, + }); + + if (!court) { + throw new ApiError('Cancha no encontrada', 404); + } + + // Construir filtros de fecha + const dateFilter: any = {}; + if (fromDate) dateFilter.gte = fromDate; + if (toDate) dateFilter.lte = toDate; + + const whereClause = { + courtId, + ...(Object.keys(dateFilter).length > 0 && { date: dateFilter }), + }; + + // Obtener todas las reservas + const bookings = await prisma.booking.findMany({ + where: whereClause, + select: { + id: true, + status: true, + totalPrice: true, + date: true, + startTime: true, + }, + }); + + // Calcular estadísticas básicas + const totalBookings = bookings.length; + const completedBookings = bookings.filter(b => b.status === BookingStatus.COMPLETED).length; + const cancelledBookings = bookings.filter(b => b.status === BookingStatus.CANCELLED).length; + const revenue = bookings + .filter(b => b.status !== BookingStatus.CANCELLED) + .reduce((sum, b) => sum + b.totalPrice, 0); + + // Calcular tasa de ocupación (simplificada) + const totalPossibleHours = totalBookings > 0 ? totalBookings * 1 : 0; // Asumiendo 1 hora por reserva promedio + const occupiedHours = completedBookings; + const occupancyRate = totalPossibleHours > 0 + ? Math.round((occupiedHours / totalPossibleHours) * 100) + : 0; + + // Calcular horas pico + const hourCounts: Record = {}; + bookings.forEach(b => { + const hour = b.startTime.slice(0, 2) + ':00'; + hourCounts[hour] = (hourCounts[hour] || 0) + 1; + }); + + const peakHours = Object.entries(hourCounts) + .map(([hour, bookings]) => ({ hour, bookings })) + .sort((a, b) => b.bookings - a.bookings) + .slice(0, 5); + + // Calcular reservas por día de la semana + const dayNames = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; + const dayCounts: Record = {}; + bookings.forEach(b => { + const day = dayNames[b.date.getDay()]; + dayCounts[day] = (dayCounts[day] || 0) + 1; + }); + + const bookingsByDay = Object.entries(dayCounts) + .map(([day, count]) => ({ day, count })); + + return { + court: { + id: court.id, + name: court.name, + type: court.type, + }, + totalBookings, + completedBookings, + cancelledBookings, + occupancyRate, + revenue, + peakHours, + bookingsByDay, + }; + } + + /** + * Obtener estadísticas globales del club + */ + static async getGlobalStats(fromDate?: Date, toDate?: Date): Promise { + // Construir filtros de fecha + const dateFilter: any = {}; + if (fromDate) dateFilter.gte = fromDate; + if (toDate) dateFilter.lte = toDate; + + const whereClause = Object.keys(dateFilter).length > 0 ? { date: dateFilter } : {}; + + // Contar usuarios + const totalUsers = await prisma.user.count(); + const activeUsers = await prisma.user.count({ + where: { isActive: true }, + }); + + // Obtener reservas + const bookings = await prisma.booking.findMany({ + where: whereClause, + include: { + court: { + select: { id: true, name: true }, + }, + }, + }); + + const totalBookings = bookings.length; + const totalRevenue = bookings + .filter(b => b.status !== BookingStatus.CANCELLED) + .reduce((sum, b) => sum + b.totalPrice, 0); + + // Contar partidos confirmados + const matchWhere: any = {}; + if (fromDate || toDate) { + matchWhere.playedAt = {}; + if (fromDate) matchWhere.playedAt.gte = fromDate; + if (toDate) matchWhere.playedAt.lte = toDate; + } + + const totalMatches = await prisma.matchResult.count({ + where: matchWhere, + }); + + // Canchas más populares + const courtBookings: Record = {}; + bookings.forEach(b => { + if (!courtBookings[b.court.id]) { + courtBookings[b.court.id] = { name: b.court.name, count: 0 }; + } + courtBookings[b.court.id].count++; + }); + + const popularCourts = Object.entries(courtBookings) + .map(([courtId, data]) => ({ + courtId, + courtName: data.name, + bookings: data.count, + })) + .sort((a, b) => b.bookings - a.bookings) + .slice(0, 5); + + // Tendencia de reservas por día (últimos 30 días) + const bookingsByDate: Record = {}; + const matchesByDate: Record = {}; + + bookings.forEach(b => { + const date = b.date.toISOString().split('T')[0]; + bookingsByDate[date] = (bookingsByDate[date] || 0) + 1; + }); + + const matches = await prisma.matchResult.findMany({ + where: matchWhere, + select: { playedAt: true }, + }); + + matches.forEach(m => { + const date = m.playedAt.toISOString().split('T')[0]; + matchesByDate[date] = (matchesByDate[date] || 0) + 1; + }); + + const bookingsTrend = Object.entries(bookingsByDate) + .map(([date, bookings]) => ({ date, bookings })) + .sort((a, b) => a.date.localeCompare(b.date)); + + const matchesTrend = Object.entries(matchesByDate) + .map(([date, matches]) => ({ date, matches })) + .sort((a, b) => a.date.localeCompare(b.date)); + + return { + totalUsers, + activeUsers, + totalBookings, + totalMatches, + totalRevenue, + popularCourts, + bookingsTrend, + matchesTrend, + }; + } + + /** + * Obtener comparativa entre dos usuarios + */ + static async compareUsers(userId1: string, userId2: string): Promise<{ + user1: UserStatsResult; + user2: UserStatsResult; + headToHead: { + matches: number; + user1Wins: number; + user2Wins: number; + draws: number; + }; + }> { + const [user1Stats, user2Stats] = await Promise.all([ + this.getUserStats(userId1), + this.getUserStats(userId2), + ]); + + // Calcular enfrentamientos directos + const matches = await prisma.matchResult.findMany({ + where: { + OR: [ + { + AND: [ + { OR: [{ team1Player1Id: userId1 }, { team1Player2Id: userId1 }] }, + { OR: [{ team2Player1Id: userId2 }, { team2Player2Id: userId2 }] }, + ], + }, + { + AND: [ + { OR: [{ team1Player1Id: userId2 }, { team1Player2Id: userId2 }] }, + { OR: [{ team2Player1Id: userId1 }, { team2Player2Id: userId1 }] }, + ], + }, + ], + }, + }); + + let user1Wins = 0; + let user2Wins = 0; + let draws = 0; + + matches.forEach(match => { + const isUser1Team1 = match.team1Player1Id === userId1 || match.team1Player2Id === userId1; + + if (match.winner === 'DRAW') { + draws++; + } else if (match.winner === 'TEAM1') { + if (isUser1Team1) user1Wins++; + else user2Wins++; + } else { + if (isUser1Team1) user2Wins++; + else user1Wins++; + } + }); + + return { + user1: user1Stats, + user2: user2Stats, + headToHead: { + matches: matches.length, + user1Wins, + user2Wins, + draws, + }, + }; + } +} + +export default StatsService; diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts new file mode 100644 index 0000000..b252eb8 --- /dev/null +++ b/backend/src/services/user.service.ts @@ -0,0 +1,388 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { PlayerLevel, UserRole } from '../utils/constants'; +import logger from '../config/logger'; + +export interface UpdateProfileData { + firstName?: string; + lastName?: string; + phone?: string; + city?: string; + birthDate?: Date; + yearsPlaying?: number; + bio?: string; + handPreference?: string; + positionPreference?: string; + avatarUrl?: string; +} + +export interface SearchFilters { + query?: string; + level?: string; + city?: string; + limit?: number; + offset?: number; +} + +export class UserService { + // Obtener usuario por ID con estadísticas calculadas + static async getUserById(id: string, includePrivateData: boolean = false) { + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + email: includePrivateData, + firstName: true, + lastName: true, + phone: includePrivateData, + avatarUrl: true, + city: true, + birthDate: includePrivateData, + role: includePrivateData, + playerLevel: true, + handPreference: true, + positionPreference: true, + bio: true, + yearsPlaying: true, + matchesPlayed: true, + matchesWon: true, + matchesLost: true, + isActive: includePrivateData, + lastLogin: includePrivateData, + createdAt: true, + _count: { + select: { + bookings: true, + }, + }, + }, + }); + + if (!user) { + throw new ApiError('Usuario no encontrado', 404); + } + + // Calcular estadísticas adicionales + const winRate = user.matchesPlayed > 0 + ? Math.round((user.matchesWon / user.matchesPlayed) * 100) + : 0; + + return { + ...user, + statistics: { + winRate, + totalBookings: user._count?.bookings || 0, + }, + }; + } + + // Actualizar perfil del usuario + static async updateProfile(userId: string, data: UpdateProfileData) { + // Verificar que el usuario existe + const existingUser = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!existingUser) { + throw new ApiError('Usuario no encontrado', 404); + } + + // Validar fecha de nacimiento si se proporciona + if (data.birthDate) { + const birthDate = new Date(data.birthDate); + const today = new Date(); + const age = today.getFullYear() - birthDate.getFullYear(); + + if (age < 5 || age > 100) { + throw new ApiError('Fecha de nacimiento inválida', 400); + } + } + + // Validar años jugando + if (data.yearsPlaying !== undefined && (data.yearsPlaying < 0 || data.yearsPlaying > 50)) { + throw new ApiError('Años jugando debe estar entre 0 y 50', 400); + } + + try { + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone, + city: data.city, + birthDate: data.birthDate, + yearsPlaying: data.yearsPlaying, + bio: data.bio, + handPreference: data.handPreference, + positionPreference: data.positionPreference, + avatarUrl: data.avatarUrl, + }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + phone: true, + avatarUrl: true, + city: true, + birthDate: true, + role: true, + playerLevel: true, + handPreference: true, + positionPreference: true, + bio: true, + yearsPlaying: true, + matchesPlayed: true, + matchesWon: true, + matchesLost: true, + isActive: true, + updatedAt: true, + _count: { + select: { + bookings: true, + }, + }, + }, + }); + + return { + ...updatedUser, + statistics: { + winRate: updatedUser.matchesPlayed > 0 + ? Math.round((updatedUser.matchesWon / updatedUser.matchesPlayed) * 100) + : 0, + totalBookings: updatedUser._count?.bookings || 0, + }, + }; + } catch (error) { + logger.error('Error actualizando perfil:', error); + throw new ApiError('Error al actualizar el perfil', 500); + } + } + + // Actualizar nivel del usuario (solo admin) + static async updateUserLevel(userId: string, newLevel: string, adminId: string, reason?: string) { + // Validar que el nuevo nivel es válido + const validLevels = Object.values(PlayerLevel); + if (!validLevels.includes(newLevel as any)) { + throw new ApiError('Nivel de jugador inválido', 400); + } + + // Verificar que el usuario existe + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new ApiError('Usuario no encontrado', 404); + } + + // Verificar que el admin existe y tiene permisos + const admin = await prisma.user.findUnique({ + where: { id: adminId }, + }); + + if (!admin || (admin.role !== UserRole.ADMIN && admin.role !== UserRole.SUPERADMIN)) { + throw new ApiError('No tienes permisos para realizar esta acción', 403); + } + + const oldLevel = user.playerLevel; + + // No permitir cambios si el nivel es el mismo + if (oldLevel === newLevel) { + throw new ApiError('El nuevo nivel es igual al nivel actual', 400); + } + + try { + // Ejecutar en transacción + const result = await prisma.$transaction(async (tx) => { + // Actualizar nivel del usuario + const updatedUser = await tx.user.update({ + where: { id: userId }, + data: { + playerLevel: newLevel, + }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + playerLevel: true, + }, + }); + + // Registrar en historial + const historyEntry = await tx.levelHistory.create({ + data: { + userId, + oldLevel, + newLevel, + changedBy: adminId, + reason: reason || null, + }, + }); + + return { updatedUser, historyEntry }; + }); + + logger.info(`Nivel actualizado para usuario ${userId}: ${oldLevel} -> ${newLevel} por admin ${adminId}`); + + return { + user: result.updatedUser, + change: { + oldLevel: result.historyEntry.oldLevel, + newLevel: result.historyEntry.newLevel, + changedAt: result.historyEntry.createdAt, + reason: result.historyEntry.reason, + }, + }; + } catch (error) { + logger.error('Error actualizando nivel:', error); + throw new ApiError('Error al actualizar el nivel del usuario', 500); + } + } + + // Obtener historial de niveles de un usuario + static async getLevelHistory(userId: string) { + // Verificar que el usuario existe + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, firstName: true, lastName: true, playerLevel: true }, + }); + + if (!user) { + throw new ApiError('Usuario no encontrado', 404); + } + + const history = await prisma.levelHistory.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + }); + + // Obtener información de los admins que hicieron los cambios + const adminIds = [...new Set(history.map(h => h.changedBy))]; + const admins = await prisma.user.findMany({ + where: { id: { in: adminIds } }, + select: { id: true, firstName: true, lastName: true }, + }); + + const adminMap = new Map(admins.map(a => [a.id, a])); + + return { + user, + currentLevel: user.playerLevel, + totalChanges: history.length, + history: history.map(entry => ({ + id: entry.id, + oldLevel: entry.oldLevel, + newLevel: entry.newLevel, + changedAt: entry.createdAt, + reason: entry.reason, + changedBy: adminMap.get(entry.changedBy) || { id: entry.changedBy, firstName: 'Desconocido', lastName: '' }, + })), + }; + } + + // Buscar usuarios con filtros + static async searchUsers(filters: SearchFilters) { + const { query, level, city, limit = 20, offset = 0 } = filters; + + // Construir condiciones de búsqueda + const where: any = { + isActive: true, + role: { not: UserRole.SUPERADMIN }, // Excluir superadmins de búsquedas públicas + }; + + // Búsqueda por nombre o email + if (query) { + where.OR = [ + { firstName: { contains: query, mode: 'insensitive' } }, + { lastName: { contains: query, mode: 'insensitive' } }, + { email: { contains: query, mode: 'insensitive' } }, + ]; + } + + // Filtro por nivel + if (level) { + where.playerLevel = level; + } + + // Filtro por ciudad + if (city) { + where.city = { contains: city, mode: 'insensitive' }; + } + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + city: true, + playerLevel: true, + handPreference: true, + positionPreference: true, + bio: true, + yearsPlaying: true, + matchesPlayed: true, + matchesWon: true, + matchesLost: true, + createdAt: true, + _count: { + select: { + bookings: true, + }, + }, + }, + skip: offset, + take: limit, + orderBy: [ + { playerLevel: 'asc' }, + { lastName: 'asc' }, + { firstName: 'asc' }, + ], + }), + prisma.user.count({ where }), + ]); + + // Calcular estadísticas para cada usuario + const usersWithStats = users.map(user => ({ + ...user, + statistics: { + winRate: user.matchesPlayed > 0 + ? Math.round((user.matchesWon / user.matchesPlayed) * 100) + : 0, + totalBookings: user._count?.bookings || 0, + }, + })); + + return { + users: usersWithStats, + pagination: { + total, + limit, + offset, + hasMore: offset + users.length < total, + }, + }; + } + + // Obtener perfil completo (para el usuario autenticado) + static async getMyProfile(userId: string) { + return this.getUserById(userId, true); + } +} + +export default UserService; diff --git a/backend/src/utils/constants.ts b/backend/src/utils/constants.ts index d74a53c..0687b24 100644 --- a/backend/src/utils/constants.ts +++ b/backend/src/utils/constants.ts @@ -53,3 +53,39 @@ export const CourtType = { } as const; export type CourtTypeType = typeof CourtType[keyof typeof CourtType]; + +// Match constants +export const MatchWinner = { + TEAM1: 'TEAM1', + TEAM2: 'TEAM2', + DRAW: 'DRAW', +} as const; + +export type MatchWinnerType = typeof MatchWinner[keyof typeof MatchWinner]; + +// Stats period constants +export const StatsPeriod = { + MONTH: 'MONTH', + YEAR: 'YEAR', + ALL_TIME: 'ALL_TIME', +} as const; + +export type StatsPeriodType = typeof StatsPeriod[keyof typeof StatsPeriod]; + +// Estados de amistad +export const FriendStatus = { + PENDING: 'PENDING', + ACCEPTED: 'ACCEPTED', + REJECTED: 'REJECTED', + BLOCKED: 'BLOCKED', +} as const; + +export type FriendStatusType = typeof FriendStatus[keyof typeof FriendStatus]; + +// Roles de grupo +export const GroupRole = { + ADMIN: 'ADMIN', + MEMBER: 'MEMBER', +} as const; + +export type GroupRoleType = typeof GroupRole[keyof typeof GroupRole]; diff --git a/backend/src/utils/jwt.ts b/backend/src/utils/jwt.ts index 4b9982a..1b81b6f 100644 --- a/backend/src/utils/jwt.ts +++ b/backend/src/utils/jwt.ts @@ -1,4 +1,4 @@ -import jwt from 'jsonwebtoken'; +import jwt, { SignOptions, Secret } from 'jsonwebtoken'; import config from '../config'; export interface TokenPayload { @@ -9,26 +9,28 @@ export interface TokenPayload { // Generar access token export const generateAccessToken = (payload: TokenPayload): string => { - return jwt.sign(payload, config.JWT_SECRET, { - expiresIn: config.JWT_EXPIRES_IN, - }); + const options: SignOptions = { + expiresIn: config.JWT_EXPIRES_IN as SignOptions['expiresIn'], + }; + return jwt.sign(payload, config.JWT_SECRET as Secret, options); }; // Generar refresh token export const generateRefreshToken = (payload: TokenPayload): string => { - return jwt.sign(payload, config.JWT_REFRESH_SECRET, { - expiresIn: config.JWT_REFRESH_EXPIRES_IN, - }); + const options: SignOptions = { + expiresIn: config.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'], + }; + return jwt.sign(payload, config.JWT_REFRESH_SECRET as Secret, options); }; // Verificar access token export const verifyAccessToken = (token: string): TokenPayload => { - return jwt.verify(token, config.JWT_SECRET) as TokenPayload; + return jwt.verify(token, config.JWT_SECRET as Secret) as TokenPayload; }; // Verificar refresh token export const verifyRefreshToken = (token: string): TokenPayload => { - return jwt.verify(token, config.JWT_REFRESH_SECRET) as TokenPayload; + return jwt.verify(token, config.JWT_REFRESH_SECRET as Secret) as TokenPayload; }; // Decodificar token sin verificar (para debugging) diff --git a/backend/src/utils/ranking.ts b/backend/src/utils/ranking.ts new file mode 100644 index 0000000..85bef50 --- /dev/null +++ b/backend/src/utils/ranking.ts @@ -0,0 +1,246 @@ +import { PlayerLevel } from './constants'; + +// Niveles de ranking según puntos +export const RankTitles = { + BRONZE: 'Bronce', + SILVER: 'Plata', + GOLD: 'Oro', + PLATINUM: 'Platino', + DIAMOND: 'Diamante', + MASTER: 'Maestro', + GRANDMASTER: 'Gran Maestro', + LEGEND: 'Leyenda', +} as const; + +export type RankTitleType = typeof RankTitles[keyof typeof RankTitles]; + +// Umbrales de puntos para cada rango +const RANK_THRESHOLDS = [ + { min: 0, max: 99, title: RankTitles.BRONZE, icon: '🥉' }, + { min: 100, max: 299, title: RankTitles.SILVER, icon: '🥈' }, + { min: 300, max: 599, title: RankTitles.GOLD, icon: '🥇' }, + { min: 600, max: 999, title: RankTitles.PLATINUM, icon: '💎' }, + { min: 1000, max: 1499, title: RankTitles.DIAMOND, icon: '💠' }, + { min: 1500, max: 2199, title: RankTitles.MASTER, icon: '👑' }, + { min: 2200, max: 2999, title: RankTitles.GRANDMASTER, icon: '👑✨' }, + { min: 3000, max: Infinity, title: RankTitles.LEGEND, icon: '🏆' }, +] as const; + +// Puntos base por resultado +const BASE_POINTS = { + WIN: 10, + LOSS: 2, + PARTICIPATION: 1, + SUPERIOR_WIN_BONUS: 5, +} as const; + +// Multiplicadores por nivel de jugador +const LEVEL_MULTIPLIERS: Record = { + [PlayerLevel.BEGINNER]: 1.0, + [PlayerLevel.ELEMENTARY]: 1.1, + [PlayerLevel.INTERMEDIATE]: 1.2, + [PlayerLevel.ADVANCED]: 1.3, + [PlayerLevel.COMPETITION]: 1.5, + [PlayerLevel.PROFESSIONAL]: 2.0, +}; + +// Orden de niveles para comparación (menor = nivel más bajo) +const LEVEL_ORDER = [ + PlayerLevel.BEGINNER, + PlayerLevel.ELEMENTARY, + PlayerLevel.INTERMEDIATE, + PlayerLevel.ADVANCED, + PlayerLevel.COMPETITION, + PlayerLevel.PROFESSIONAL, +]; + +/** + * Obtiene el título de rango según los puntos + */ +export function getRankTitle(points: number): { title: RankTitleType; icon: string } { + const rank = RANK_THRESHOLDS.find(r => points >= r.min && points <= r.max); + return { + title: rank?.title || RankTitles.BRONZE, + icon: rank?.icon || '🥉', + }; +} + +/** + * Obtiene el siguiente rango y puntos necesarios + */ +export function getNextRank(points: number): { title: RankTitleType; pointsNeeded: number } | null { + const nextRank = RANK_THRESHOLDS.find(r => r.min > points); + if (!nextRank) return null; + + return { + title: nextRank.title, + pointsNeeded: nextRank.min - points, + }; +} + +/** + * Calcula los puntos ganados en un partido + */ +export interface MatchPointsCalculation { + userId: string; + isWinner: boolean; + userLevel: string; + opponentLevel: string; + pointsEarned: number; + breakdown: { + base: number; + participation: number; + superiorWinBonus: number; + levelMultiplier: number; + }; +} + +/** + * Compara dos niveles de jugador + * Retorna: negativo si level1 < level2, 0 si iguales, positivo si level1 > level2 + */ +function compareLevels(level1: string, level2: string): number { + const index1 = LEVEL_ORDER.indexOf(level1 as any); + const index2 = LEVEL_ORDER.indexOf(level2 as any); + return index1 - index2; +} + +/** + * Calcula si un nivel es superior a otro + */ +function isSuperiorLevel(level: string, opponentLevel: string): boolean { + return compareLevels(level, opponentLevel) < 0; +} + +/** + * Calcula los puntos para todos los jugadores de un partido + */ +export function calculatePointsFromMatch( + matchResult: { + team1Score: number; + team2Score: number; + winner: string; + team1Player1: { id: string; playerLevel: string }; + team1Player2: { id: string; playerLevel: string }; + team2Player1: { id: string; playerLevel: string }; + team2Player2: { id: string; playerLevel: string }; + } +): MatchPointsCalculation[] { + const results: MatchPointsCalculation[] = []; + + const team1Won = matchResult.winner === 'TEAM1'; + const team2Won = matchResult.winner === 'TEAM2'; + const isDraw = matchResult.winner === 'DRAW'; + + // Nivel promedio de cada equipo + const team1LevelIndex = ( + LEVEL_ORDER.indexOf(matchResult.team1Player1.playerLevel as any) + + LEVEL_ORDER.indexOf(matchResult.team1Player2.playerLevel as any) + ) / 2; + + const team2LevelIndex = ( + LEVEL_ORDER.indexOf(matchResult.team2Player1.playerLevel as any) + + LEVEL_ORDER.indexOf(matchResult.team2Player2.playerLevel as any) + ) / 2; + + const team1Level = LEVEL_ORDER[Math.round(team1LevelIndex)]; + const team2Level = LEVEL_ORDER[Math.round(team2LevelIndex)]; + + // Calcular puntos para cada jugador del Equipo 1 + const team1Players = [ + matchResult.team1Player1, + matchResult.team1Player2, + ]; + + for (const player of team1Players) { + const isWinner = team1Won; + const basePoints = isWinner ? BASE_POINTS.WIN : isDraw ? BASE_POINTS.WIN / 2 : BASE_POINTS.LOSS; + const participationPoints = BASE_POINTS.PARTICIPATION; + + // Bonus por ganar a un equipo de nivel superior + let superiorWinBonus = 0; + if (isWinner && isSuperiorLevel(player.playerLevel, team2Level)) { + superiorWinBonus = BASE_POINTS.SUPERIOR_WIN_BONUS; + } + + // Multiplicador por nivel del jugador + const multiplier = LEVEL_MULTIPLIERS[player.playerLevel] || 1.0; + + const totalPoints = Math.round( + (basePoints + participationPoints + superiorWinBonus) * multiplier + ); + + results.push({ + userId: player.id, + isWinner, + userLevel: player.playerLevel, + opponentLevel: team2Level, + pointsEarned: totalPoints, + breakdown: { + base: basePoints, + participation: participationPoints, + superiorWinBonus, + levelMultiplier: multiplier, + }, + }); + } + + // Calcular puntos para cada jugador del Equipo 2 + const team2Players = [ + matchResult.team2Player1, + matchResult.team2Player2, + ]; + + for (const player of team2Players) { + const isWinner = team2Won; + const basePoints = isWinner ? BASE_POINTS.WIN : isDraw ? BASE_POINTS.WIN / 2 : BASE_POINTS.LOSS; + const participationPoints = BASE_POINTS.PARTICIPATION; + + // Bonus por ganar a un equipo de nivel superior + let superiorWinBonus = 0; + if (isWinner && isSuperiorLevel(player.playerLevel, team1Level)) { + superiorWinBonus = BASE_POINTS.SUPERIOR_WIN_BONUS; + } + + // Multiplicador por nivel del jugador + const multiplier = LEVEL_MULTIPLIERS[player.playerLevel] || 1.0; + + const totalPoints = Math.round( + (basePoints + participationPoints + superiorWinBonus) * multiplier + ); + + results.push({ + userId: player.id, + isWinner, + userLevel: player.playerLevel, + opponentLevel: team1Level, + pointsEarned: totalPoints, + breakdown: { + base: basePoints, + participation: participationPoints, + superiorWinBonus, + levelMultiplier: multiplier, + }, + }); + } + + return results; +} + +/** + * Obtiene el color asociado a un rango + */ +export function getRankColor(title: RankTitleType): string { + const colors: Record = { + [RankTitles.BRONZE]: '#CD7F32', + [RankTitles.SILVER]: '#C0C0C0', + [RankTitles.GOLD]: '#FFD700', + [RankTitles.PLATINUM]: '#E5E4E2', + [RankTitles.DIAMOND]: '#B9F2FF', + [RankTitles.MASTER]: '#FF6B35', + [RankTitles.GRANDMASTER]: '#9B59B6', + [RankTitles.LEGEND]: '#FFD700', + }; + + return colors[title] || '#CD7F32'; +} diff --git a/backend/src/validators/match.validator.ts b/backend/src/validators/match.validator.ts new file mode 100644 index 0000000..c052970 --- /dev/null +++ b/backend/src/validators/match.validator.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; +import { MatchWinner, StatsPeriod, PlayerLevel } from '../utils/constants'; + +// Schema para registrar un resultado de partido +export const recordMatchSchema = z.object({ + bookingId: z.string().uuid('ID de reserva inválido').optional(), + team1Player1Id: z.string().uuid('ID de jugador inválido'), + team1Player2Id: z.string().uuid('ID de jugador inválido'), + team2Player1Id: z.string().uuid('ID de jugador inválido'), + team2Player2Id: z.string().uuid('ID de jugador inválido'), + team1Score: z.number().int().min(0, 'El puntaje no puede ser negativo'), + team2Score: z.number().int().min(0, 'El puntaje no puede ser negativo'), + winner: z.enum([MatchWinner.TEAM1, MatchWinner.TEAM2, MatchWinner.DRAW], { + errorMap: () => ({ message: 'Ganador debe ser TEAM1, TEAM2 o DRAW' }), + }), + playedAt: z.string().datetime('Fecha inválida'), +}).refine( + (data) => { + // Validar que los 4 jugadores sean diferentes + const players = [ + data.team1Player1Id, + data.team1Player2Id, + data.team2Player1Id, + data.team2Player2Id, + ]; + return new Set(players).size === 4; + }, + { + message: 'Los 4 jugadores deben ser diferentes', + path: ['players'], + } +).refine( + (data) => { + // Validar coherencia entre puntajes y ganador + if (data.winner === MatchWinner.TEAM1) { + return data.team1Score > data.team2Score; + } + if (data.winner === MatchWinner.TEAM2) { + return data.team2Score > data.team1Score; + } + if (data.winner === MatchWinner.DRAW) { + return data.team1Score === data.team2Score; + } + return true; + }, + { + message: 'El ganador no coincide con los puntajes', + path: ['winner'], + } +); + +// Schema para query params del historial de partidos +export const matchHistoryQuerySchema = z.object({ + userId: z.string().uuid('ID de usuario inválido').optional(), + fromDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(), + toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(), + status: z.enum(['PENDING', 'CONFIRMED']).optional(), +}); + +// Schema para query params del ranking +export const rankingQuerySchema = z.object({ + period: z.enum([StatsPeriod.MONTH, StatsPeriod.YEAR, StatsPeriod.ALL_TIME]).optional(), + periodValue: z.string().optional(), + level: z.enum([ + 'ALL', + PlayerLevel.BEGINNER, + PlayerLevel.ELEMENTARY, + PlayerLevel.INTERMEDIATE, + PlayerLevel.ADVANCED, + PlayerLevel.COMPETITION, + PlayerLevel.PROFESSIONAL, + ]).optional(), + limit: z.number().int().min(1).max(500).optional(), +}); + +// Schema para confirmar un resultado +export const confirmMatchSchema = z.object({ + matchId: z.string().uuid('ID de partido inválido'), +}); + +// Tipos inferidos +export type RecordMatchInput = z.infer; +export type MatchHistoryQueryInput = z.infer; +export type RankingQueryInput = z.infer; +export type ConfirmMatchInput = z.infer; diff --git a/backend/src/validators/social.validator.ts b/backend/src/validators/social.validator.ts new file mode 100644 index 0000000..6a74eb4 --- /dev/null +++ b/backend/src/validators/social.validator.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; +import { FriendStatus, GroupRole } from '../utils/constants'; + +// ============================================ +// Esquemas de Amistad +// ============================================ + +// Enviar solicitud de amistad +export const sendFriendRequestSchema = z.object({ + addresseeId: z.string().uuid('ID de usuario inválido'), +}); + +// Aceptar/rechazar solicitud de amistad +export const friendRequestActionSchema = z.object({ + requestId: z.string().uuid('ID de solicitud inválido'), +}); + +// ============================================ +// Esquemas de Grupo +// ============================================ + +// Crear grupo +export const createGroupSchema = 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(), + memberIds: z.array(z.string().uuid('ID de miembro inválido')).optional(), +}); + +// Actualizar grupo +export const updateGroupSchema = 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 addMemberSchema = z.object({ + userId: z.string().uuid('ID de usuario inválido'), +}); + +// Actualizar rol de miembro +export const updateMemberRoleSchema = z.object({ + role: z.enum([GroupRole.ADMIN, GroupRole.MEMBER], { + errorMap: () => ({ message: 'El rol debe ser ADMIN o MEMBER' }), + }), +}); + +// ============================================ +// Esquemas de Reservas Recurrentes +// ============================================ + +// Crear reserva recurrente +export const createRecurringSchema = z.object({ + courtId: z.string().uuid('ID de cancha inválido'), + dayOfWeek: z.number().int().min(0).max(6, 'El día debe estar entre 0 (Domingo) y 6 (Sábado)'), + startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Hora de inicio debe estar en formato HH:mm'), + endTime: z.string().regex(/^\d{2}:\d{2}$/, 'Hora de fin debe estar en formato HH:mm'), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD'), + endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(), +}); + +// Actualizar reserva recurrente +export const updateRecurringSchema = z.object({ + dayOfWeek: z.number().int().min(0).max(6, 'El día debe estar entre 0 (Domingo) y 6 (Sábado)').optional(), + startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Hora de inicio debe estar en formato HH:mm').optional(), + endTime: z.string().regex(/^\d{2}:\d{2}$/, 'Hora de fin debe estar en formato HH:mm').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().nullable(), +}); + +// Generar reservas desde recurrente +export const generateBookingsSchema = z.object({ + fromDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(), + toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD').optional(), +}); + +// ============================================ +// Tipos inferidos +// ============================================ + +export type SendFriendRequestInput = z.infer; +export type FriendRequestActionInput = z.infer; +export type CreateGroupInput = z.infer; +export type UpdateGroupInput = z.infer; +export type AddMemberInput = z.infer; +export type UpdateMemberRoleInput = z.infer; +export type CreateRecurringInput = z.infer; +export type UpdateRecurringInput = z.infer; +export type GenerateBookingsInput = z.infer; diff --git a/backend/src/validators/user.validator.ts b/backend/src/validators/user.validator.ts new file mode 100644 index 0000000..c1d8d4f --- /dev/null +++ b/backend/src/validators/user.validator.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; +import { PlayerLevel, HandPreference, PositionPreference } from '../utils/constants'; + +// Esquema para actualizar perfil +export const updateProfileSchema = z.object({ + firstName: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(), + lastName: z.string().min(2, 'El apellido debe tener al menos 2 caracteres').optional(), + phone: z.string().optional(), + city: z.string().max(100, 'La ciudad no puede exceder 100 caracteres').optional(), + birthDate: z.string().datetime().optional().or(z.date().optional()), + yearsPlaying: z.number().int().min(0).max(50, 'Los años jugando deben estar entre 0 y 50').optional(), + bio: z.string().max(500, 'La biografía no puede exceder 500 caracteres').optional(), + handPreference: z.enum([ + HandPreference.RIGHT, + HandPreference.LEFT, + HandPreference.BOTH, + ]).optional(), + positionPreference: z.enum([ + PositionPreference.DRIVE, + PositionPreference.BACKHAND, + PositionPreference.BOTH, + ]).optional(), + avatarUrl: z.string().url('URL de avatar inválida').optional().or(z.literal('')), +}); + +// Esquema para actualizar nivel (admin) +export const updateLevelSchema = z.object({ + newLevel: z.enum([ + PlayerLevel.BEGINNER, + PlayerLevel.ELEMENTARY, + PlayerLevel.INTERMEDIATE, + PlayerLevel.ADVANCED, + PlayerLevel.COMPETITION, + PlayerLevel.PROFESSIONAL, + ], { + required_error: 'El nivel es requerido', + invalid_type_error: 'Nivel inválido', + }), + reason: z.string().max(500, 'La razón no puede exceder 500 caracteres').optional(), +}); + +// Esquema para búsqueda de usuarios +export const searchUsersSchema = z.object({ + query: z.string().optional(), + level: z.enum([ + PlayerLevel.BEGINNER, + PlayerLevel.ELEMENTARY, + PlayerLevel.INTERMEDIATE, + PlayerLevel.ADVANCED, + PlayerLevel.COMPETITION, + PlayerLevel.PROFESSIONAL, + ]).optional(), + city: z.string().optional(), + limit: z.string().regex(/^\d+$/).optional().transform((val) => val ? parseInt(val, 10) : 20), + offset: z.string().regex(/^\d+$/).optional().transform((val) => val ? parseInt(val, 10) : 0), +}); + +// Esquema para parámetros de ID de usuario +export const userIdParamSchema = z.object({ + id: z.string().uuid('ID de usuario inválido'), +}); + +// Tipos inferidos +export type UpdateProfileInput = z.infer; +export type UpdateLevelInput = z.infer; +export type SearchUsersInput = z.infer; +export type UserIdParamInput = z.infer; diff --git a/docs/roadmap/FASE-02.md b/docs/roadmap/FASE-02.md index 1d8e0cb..2554217 100644 --- a/docs/roadmap/FASE-02.md +++ b/docs/roadmap/FASE-02.md @@ -1,6 +1,210 @@ -# Fase 2: Perfiles +# Fase 2: Gestión de Jugadores y Perfiles -## Estado: ⏳ Pendiente +## Estado: ✅ COMPLETADA -*Esta fase comenzará al finalizar la Fase 1* +### ✅ Tareas completadas: +#### 2.1.1: Perfil Completo +- [x] Datos personales extendidos (ciudad, fecha nacimiento) +- [x] Datos de juego (años jugando, mano, posición) +- [x] Estadísticas de partidos (jugados, ganados, perdidos) +- [x] Biografía y foto de perfil + +#### 2.1.2: Sistema de Niveles +- [x] Escala de niveles con descripciones (BEGINNER a PROFESSIONAL) +- [x] Modelo LevelHistory para tracking de cambios +- [x] Validación de nivel por administradores +- [x] Historial de cambios de nivel + +#### 2.2.1: Sistema de Amigos/Grupos +- [x] Enviar/recibir solicitudes de amistad +- [x] Estados: PENDING, ACCEPTED, REJECTED, BLOCKED +- [x] Crear grupos de jugadores +- [x] Roles en grupos: ADMIN, MEMBER +- [x] Invitar jugadores a grupo +- [x] Reserva grupal (para fases posteriores) + +#### 2.2.2: Reservas Recurrentes +- [x] Configurar fijo semanal (mismo día/hora) +- [x] Modelo RecurringBooking +- [x] Generación automática de reservas desde recurrentes +- [x] Cancelación de series recurrentes + +#### 2.3.1: Historial de Actividad +- [x] Modelo MatchResult para resultados de partidos +- [x] Registro de partidos jugados (2v2) +- [x] Sistema de confirmación de resultados (2+ jugadores) +- [x] Estadísticas de asistencia + +#### 2.3.2: Ranking Interno +- [x] Sistema de puntos (Victoria=10, Derrota=2, Participación=1) +- [x] Bonus por ganar a nivel superior (+5 pts) +- [x] Multiplicadores por nivel de jugador +- [x] Tabla de clasificación visible +- [x] Filtros por período (mensual, anual, global) +- [x] Modelo UserStats para estadísticas agregadas + +--- + +## 📊 Resumen de Implementación + +### Modelos de Base de Datos + +| Modelo | Descripción | +|--------|-------------| +| User | Extendido con city, birthDate, yearsPlaying, matchesPlayed/Won/Lost, totalPoints | +| LevelHistory | Tracking de cambios de nivel | +| Friend | Solicitudes de amistad con estados | +| Group | Grupos de jugadores | +| GroupMember | Miembros de grupos con roles | +| RecurringBooking | Reservas recurrentes semanales | +| MatchResult | Resultados de partidos 2v2 | +| UserStats | Estadísticas agregadas por período | + +### Nuevos Endpoints + +#### Perfiles +``` +GET /api/v1/users/me - Mi perfil +PUT /api/v1/users/me - Actualizar perfil +GET /api/v1/users/search - Buscar usuarios +GET /api/v1/users/:id - Ver perfil público +PUT /api/v1/users/:id/level - Cambiar nivel (admin) +GET /api/v1/users/:id/level-history - Historial de niveles +``` + +#### Amigos +``` +POST /api/v1/friends/request - Enviar solicitud +PUT /api/v1/friends/:id/accept - Aceptar solicitud +PUT /api/v1/friends/:id/reject - Rechazar solicitud +GET /api/v1/friends - Mis amigos +GET /api/v1/friends/pending - Solicitudes pendientes +DELETE /api/v1/friends/:id - Eliminar amigo +``` + +#### Grupos +``` +POST /api/v1/groups - Crear grupo +GET /api/v1/groups - Mis grupos +GET /api/v1/groups/:id - Ver grupo +PUT /api/v1/groups/:id - Actualizar grupo +DELETE /api/v1/groups/:id - Eliminar grupo +POST /api/v1/groups/:id/members - Agregar miembro +DELETE /api/v1/groups/:id/members/:userId - Eliminar miembro +``` + +#### Reservas Recurrentes +``` +POST /api/v1/recurring - Crear recurrente +GET /api/v1/recurring - Mis recurrentes +DELETE /api/v1/recurring/:id - Cancelar recurrente +POST /api/v1/recurring/:id/generate - Generar reservas +``` + +#### Partidos +``` +POST /api/v1/matches - Registrar partido +GET /api/v1/matches/my-matches - Mis partidos +GET /api/v1/matches/:id - Ver partido +PUT /api/v1/matches/:id/confirm - Confirmar resultado +``` + +#### Ranking y Estadísticas +``` +GET /api/v1/ranking - Ranking general +GET /api/v1/ranking/me - Mi posición +GET /api/v1/ranking/top - Top jugadores +GET /api/v1/stats/my-stats - Mis estadísticas +GET /api/v1/stats/users/:id - Stats de usuario +GET /api/v1/stats/courts/:id - Stats de cancha +GET /api/v1/stats/global - Stats globales +``` + +### Sistema de Puntos + +| Concepto | Puntos | +|----------|--------| +| Victoria | 10 | +| Derrota | 2 | +| Participación | 1 | +| Ganar a superior | +5 | + +**Multiplicadores por nivel:** +- BEGINNER: 1.0x +- ELEMENTARY: 1.1x +- INTERMEDIATE: 1.2x +- ADVANCED: 1.3x +- COMPETITION: 1.5x +- PROFESSIONAL: 2.0x + +### Rangos + +| Puntos | Título | +|--------|--------| +| 0-99 | Bronce | +| 100-299 | Plata | +| 300-599 | Oro | +| 600-999 | Platino | +| 1000+ | Diamante | + +--- + +## 🚀 Cómo probar + +```bash +cd backend +npm run dev +``` + +### Credenciales de prueba + +| Email | Password | Nivel | +|-------|----------|-------| +| admin@padel.com | admin123 | ADMIN | +| user@padel.com | user123 | INTERMEDIATE | +| carlos@padel.com | 123456 | ADVANCED | +| ana@padel.com | 123456 | INTERMEDIATE | +| pedro@padel.com | 123456 | BEGINNER | +| maria@padel.com | 123456 | COMPETITION | + +--- + +## 📁 Archivos creados en esta fase + +``` +backend/src/ +├── services/ +│ ├── user.service.ts # Perfiles y búsqueda +│ ├── friend.service.ts # Sistema de amigos +│ ├── group.service.ts # Grupos de jugadores +│ ├── recurring.service.ts # Reservas recurrentes +│ ├── match.service.ts # Registro de partidos +│ ├── ranking.service.ts # Cálculo de ranking +│ └── stats.service.ts # Estadísticas +├── controllers/ +│ ├── user.controller.ts +│ ├── friend.controller.ts +│ ├── group.controller.ts +│ ├── recurring.controller.ts +│ ├── match.controller.ts +│ ├── ranking.controller.ts +│ └── stats.controller.ts +├── routes/ +│ ├── user.routes.ts +│ ├── friend.routes.ts +│ ├── group.routes.ts +│ ├── recurring.routes.ts +│ ├── match.routes.ts +│ ├── ranking.routes.ts +│ └── stats.routes.ts +├── validators/ +│ ├── user.validator.ts +│ └── social.validator.ts +└── utils/ + └── ranking.ts # Cálculo de puntos y rangos +``` + +--- + +*Completada el: 2026-01-31*