From e20c5b956b528f135d2aa58586003f11b60676a6 Mon Sep 17 00:00:00 2001 From: Ivan Alcaraz Date: Sat, 31 Jan 2026 08:22:41 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20FASE=202=20COMPLETADA:=20Perfiles,?= =?UTF-8?q?=20Social=20y=20Ranking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementados 3 módulos principales: 1. PERFILES EXTENDIDOS - Campos adicionales: ciudad, fecha nacimiento, años jugando - Estadísticas: partidos jugados/ganados/perdidos - Historial de cambios de nivel - Búsqueda de usuarios con filtros 2. SISTEMA SOCIAL - Amigos: solicitudes, aceptar, rechazar, bloquear - Grupos: crear, gestionar miembros, roles - Reservas recurrentes: fijos semanales 3. RANKING Y ESTADÍSTICAS - Registro de partidos 2v2 con confirmación - Sistema de puntos con bonus y multiplicadores - Ranking mensual, anual y global - Estadísticas personales y globales Nuevos endpoints: - /users/* - Perfiles y búsqueda - /friends/* - Gestión de amistades - /groups/* - Grupos de jugadores - /recurring/* - Reservas recurrentes - /matches/* - Registro de partidos - /ranking/* - Clasificaciones - /stats/* - Estadísticas Nuevos usuarios de prueba: - carlos@padel.com / 123456 - ana@padel.com / 123456 - pedro@padel.com / 123456 - maria@padel.com / 123456 --- backend/prisma/dev.db | Bin 69632 -> 241664 bytes .../migration.sql | 228 +++++++ .../migration.sql | 1 + backend/prisma/schema.prisma | 258 +++++++- backend/prisma/seed-fase2.ts | 192 ++++++ backend/src/controllers/friend.controller.ts | 143 +++++ backend/src/controllers/group.controller.ts | 201 ++++++ backend/src/controllers/match.controller.ts | 127 ++++ backend/src/controllers/ranking.controller.ts | 122 ++++ .../src/controllers/recurring.controller.ts | 185 ++++++ backend/src/controllers/stats.controller.ts | 112 ++++ backend/src/controllers/user.controller.ts | 128 ++++ backend/src/routes/friend.routes.ts | 58 ++ backend/src/routes/group.routes.ts | 82 +++ backend/src/routes/index.ts | 12 + backend/src/routes/match.routes.ts | 73 +++ backend/src/routes/ranking.routes.ts | 71 ++ backend/src/routes/recurring.routes.ts | 70 ++ backend/src/routes/stats.routes.ts | 62 ++ backend/src/routes/user.routes.ts | 40 ++ backend/src/services/friend.service.ts | 351 ++++++++++ backend/src/services/group.service.ts | 448 +++++++++++++ backend/src/services/match.service.ts | 605 ++++++++++++++++++ backend/src/services/ranking.service.ts | 507 +++++++++++++++ backend/src/services/recurring.service.ts | 439 +++++++++++++ backend/src/services/stats.service.ts | 441 +++++++++++++ backend/src/services/user.service.ts | 388 +++++++++++ backend/src/utils/constants.ts | 36 ++ backend/src/utils/jwt.ts | 20 +- backend/src/utils/ranking.ts | 246 +++++++ backend/src/validators/match.validator.ts | 85 +++ backend/src/validators/social.validator.ts | 88 +++ backend/src/validators/user.validator.ts | 67 ++ docs/roadmap/FASE-02.md | 210 +++++- 34 files changed, 6081 insertions(+), 15 deletions(-) create mode 100644 backend/prisma/migrations/20260131081522_add_user_profile_fields_and_level_history/migration.sql create mode 100644 backend/prisma/migrations/20260131081926_add_social_recurring_features/migration.sql create mode 100644 backend/prisma/seed-fase2.ts create mode 100644 backend/src/controllers/friend.controller.ts create mode 100644 backend/src/controllers/group.controller.ts create mode 100644 backend/src/controllers/match.controller.ts create mode 100644 backend/src/controllers/ranking.controller.ts create mode 100644 backend/src/controllers/recurring.controller.ts create mode 100644 backend/src/controllers/stats.controller.ts create mode 100644 backend/src/controllers/user.controller.ts create mode 100644 backend/src/routes/friend.routes.ts create mode 100644 backend/src/routes/group.routes.ts create mode 100644 backend/src/routes/match.routes.ts create mode 100644 backend/src/routes/ranking.routes.ts create mode 100644 backend/src/routes/recurring.routes.ts create mode 100644 backend/src/routes/stats.routes.ts create mode 100644 backend/src/routes/user.routes.ts create mode 100644 backend/src/services/friend.service.ts create mode 100644 backend/src/services/group.service.ts create mode 100644 backend/src/services/match.service.ts create mode 100644 backend/src/services/ranking.service.ts create mode 100644 backend/src/services/recurring.service.ts create mode 100644 backend/src/services/stats.service.ts create mode 100644 backend/src/services/user.service.ts create mode 100644 backend/src/utils/ranking.ts create mode 100644 backend/src/validators/match.validator.ts create mode 100644 backend/src/validators/social.validator.ts create mode 100644 backend/src/validators/user.validator.ts diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db index 8397448011cf80fb63b086ad98fbec722465712b..469a13c5bb285ca5fc671933db6ad42079687fe6 100644 GIT binary patch literal 241664 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 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*