FASE 2 COMPLETADA: Perfiles, Social y Ranking

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
This commit is contained in:
2026-01-31 08:22:41 +00:00
parent b558372810
commit e20c5b956b
34 changed files with 6081 additions and 15 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<boolean> {
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<boolean> {
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<CreateGroupInput>
) {
// 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;

View File

@@ -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;

View File

@@ -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<UserRankingResult[]> {
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<UserRankingResult & { nextRank: { title: string; pointsNeeded: number } | null }> {
// 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<number> {
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<UserRankingResult[]> {
return this.calculateRanking({
period,
level,
limit,
});
}
/**
* Recalcular todos los rankings basados en partidos confirmados
*/
static async recalculateAllRankings(): Promise<void> {
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<void> {
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<string, string> = {
'Bronce': '#CD7F32',
'Plata': '#C0C0C0',
'Oro': '#FFD700',
'Platino': '#E5E4E2',
'Diamante': '#B9F2FF',
'Maestro': '#FF6B35',
'Gran Maestro': '#9B59B6',
'Leyenda': '#FFD700',
};
return colors[title] || '#CD7F32';
}
}
export default RankingService;

View File

@@ -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<CreateRecurringInput>
) {
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;

View File

@@ -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<UserStatsResult> {
// 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<CourtStatsResult> {
// 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<string, number> = {};
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<string, number> = {};
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<GlobalStatsResult> {
// 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<string, { name: string; count: number }> = {};
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<string, number> = {};
const matchesByDate: Record<string, number> = {};
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;

View File

@@ -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;

View File

@@ -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];

View File

@@ -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)

View File

@@ -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<string, number> = {
[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<RankTitleType, string> = {
[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';
}

View File

@@ -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<typeof recordMatchSchema>;
export type MatchHistoryQueryInput = z.infer<typeof matchHistoryQuerySchema>;
export type RankingQueryInput = z.infer<typeof rankingQuerySchema>;
export type ConfirmMatchInput = z.infer<typeof confirmMatchSchema>;

View File

@@ -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<typeof sendFriendRequestSchema>;
export type FriendRequestActionInput = z.infer<typeof friendRequestActionSchema>;
export type CreateGroupInput = z.infer<typeof createGroupSchema>;
export type UpdateGroupInput = z.infer<typeof updateGroupSchema>;
export type AddMemberInput = z.infer<typeof addMemberSchema>;
export type UpdateMemberRoleInput = z.infer<typeof updateMemberRoleSchema>;
export type CreateRecurringInput = z.infer<typeof createRecurringSchema>;
export type UpdateRecurringInput = z.infer<typeof updateRecurringSchema>;
export type GenerateBookingsInput = z.infer<typeof generateBookingsSchema>;

View File

@@ -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<typeof updateProfileSchema>;
export type UpdateLevelInput = z.infer<typeof updateLevelSchema>;
export type SearchUsersInput = z.infer<typeof searchUsersSchema>;
export type UserIdParamInput = z.infer<typeof userIdParamSchema>;