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

Binary file not shown.

View File

@@ -0,0 +1,228 @@
-- CreateTable
CREATE TABLE "level_history" (
"id" TEXT NOT NULL PRIMARY KEY,
"oldLevel" TEXT NOT NULL,
"newLevel" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"changedBy" TEXT NOT NULL,
"reason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "level_history_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "friends" (
"id" TEXT NOT NULL PRIMARY KEY,
"requesterId" TEXT NOT NULL,
"addresseeId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "friends_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "friends_addresseeId_fkey" FOREIGN KEY ("addresseeId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "groups" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "groups_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "group_members" (
"id" TEXT NOT NULL PRIMARY KEY,
"groupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'MEMBER',
"joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "group_members_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "group_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "recurring_bookings" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"courtId" TEXT NOT NULL,
"dayOfWeek" INTEGER NOT NULL,
"startTime" TEXT NOT NULL,
"endTime" TEXT NOT NULL,
"startDate" DATETIME NOT NULL,
"endDate" DATETIME,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "recurring_bookings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "recurring_bookings_courtId_fkey" FOREIGN KEY ("courtId") REFERENCES "courts" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "match_results" (
"id" TEXT NOT NULL PRIMARY KEY,
"bookingId" TEXT,
"team1Player1Id" TEXT NOT NULL,
"team1Player2Id" TEXT NOT NULL,
"team2Player1Id" TEXT NOT NULL,
"team2Player2Id" TEXT NOT NULL,
"team1Score" INTEGER NOT NULL,
"team2Score" INTEGER NOT NULL,
"winner" TEXT NOT NULL,
"playedAt" DATETIME NOT NULL,
"confirmedBy" TEXT NOT NULL DEFAULT '[]',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "match_results_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "match_results_team1Player1Id_fkey" FOREIGN KEY ("team1Player1Id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "match_results_team1Player2Id_fkey" FOREIGN KEY ("team1Player2Id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "match_results_team2Player1Id_fkey" FOREIGN KEY ("team2Player1Id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "match_results_team2Player2Id_fkey" FOREIGN KEY ("team2Player2Id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "user_stats" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"period" TEXT NOT NULL,
"periodValue" TEXT NOT NULL,
"matchesPlayed" INTEGER NOT NULL DEFAULT 0,
"matchesWon" INTEGER NOT NULL DEFAULT 0,
"matchesLost" INTEGER NOT NULL DEFAULT 0,
"tournamentsPlayed" INTEGER NOT NULL DEFAULT 0,
"tournamentsWon" INTEGER NOT NULL DEFAULT 0,
"points" INTEGER NOT NULL DEFAULT 0,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "user_stats_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_bookings" (
"id" TEXT NOT NULL PRIMARY KEY,
"date" DATETIME NOT NULL,
"startTime" TEXT NOT NULL,
"endTime" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"totalPrice" INTEGER NOT NULL,
"notes" TEXT,
"userId" TEXT NOT NULL,
"courtId" TEXT NOT NULL,
"recurringBookingId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "bookings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "bookings_courtId_fkey" FOREIGN KEY ("courtId") REFERENCES "courts" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "bookings_recurringBookingId_fkey" FOREIGN KEY ("recurringBookingId") REFERENCES "recurring_bookings" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_bookings" ("courtId", "createdAt", "date", "endTime", "id", "notes", "startTime", "status", "totalPrice", "updatedAt", "userId") SELECT "courtId", "createdAt", "date", "endTime", "id", "notes", "startTime", "status", "totalPrice", "updatedAt", "userId" FROM "bookings";
DROP TABLE "bookings";
ALTER TABLE "new_bookings" RENAME TO "bookings";
CREATE INDEX "bookings_userId_idx" ON "bookings"("userId");
CREATE INDEX "bookings_courtId_idx" ON "bookings"("courtId");
CREATE INDEX "bookings_date_idx" ON "bookings"("date");
CREATE INDEX "bookings_recurringBookingId_idx" ON "bookings"("recurringBookingId");
CREATE TABLE "new_users" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"phone" TEXT,
"avatarUrl" TEXT,
"city" TEXT,
"birthDate" DATETIME,
"role" TEXT NOT NULL DEFAULT 'PLAYER',
"playerLevel" TEXT NOT NULL DEFAULT 'BEGINNER',
"handPreference" TEXT NOT NULL DEFAULT 'RIGHT',
"positionPreference" TEXT NOT NULL DEFAULT 'BOTH',
"bio" TEXT,
"yearsPlaying" INTEGER,
"matchesPlayed" INTEGER NOT NULL DEFAULT 0,
"matchesWon" INTEGER NOT NULL DEFAULT 0,
"matchesLost" INTEGER NOT NULL DEFAULT 0,
"totalPoints" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isVerified" BOOLEAN NOT NULL DEFAULT false,
"lastLogin" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_users" ("avatarUrl", "bio", "createdAt", "email", "firstName", "handPreference", "id", "isActive", "isVerified", "lastLogin", "lastName", "password", "phone", "playerLevel", "positionPreference", "role", "updatedAt") SELECT "avatarUrl", "bio", "createdAt", "email", "firstName", "handPreference", "id", "isActive", "isVerified", "lastLogin", "lastName", "password", "phone", "playerLevel", "positionPreference", "role", "updatedAt" FROM "users";
DROP TABLE "users";
ALTER TABLE "new_users" RENAME TO "users";
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "level_history_userId_idx" ON "level_history"("userId");
-- CreateIndex
CREATE INDEX "friends_requesterId_idx" ON "friends"("requesterId");
-- CreateIndex
CREATE INDEX "friends_addresseeId_idx" ON "friends"("addresseeId");
-- CreateIndex
CREATE INDEX "friends_status_idx" ON "friends"("status");
-- CreateIndex
CREATE UNIQUE INDEX "friends_requesterId_addresseeId_key" ON "friends"("requesterId", "addresseeId");
-- CreateIndex
CREATE INDEX "groups_createdById_idx" ON "groups"("createdById");
-- CreateIndex
CREATE INDEX "group_members_groupId_idx" ON "group_members"("groupId");
-- CreateIndex
CREATE INDEX "group_members_userId_idx" ON "group_members"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "group_members_groupId_userId_key" ON "group_members"("groupId", "userId");
-- CreateIndex
CREATE INDEX "recurring_bookings_userId_idx" ON "recurring_bookings"("userId");
-- CreateIndex
CREATE INDEX "recurring_bookings_courtId_idx" ON "recurring_bookings"("courtId");
-- CreateIndex
CREATE INDEX "recurring_bookings_dayOfWeek_idx" ON "recurring_bookings"("dayOfWeek");
-- CreateIndex
CREATE INDEX "recurring_bookings_isActive_idx" ON "recurring_bookings"("isActive");
-- CreateIndex
CREATE UNIQUE INDEX "match_results_bookingId_key" ON "match_results"("bookingId");
-- CreateIndex
CREATE INDEX "match_results_team1Player1Id_idx" ON "match_results"("team1Player1Id");
-- CreateIndex
CREATE INDEX "match_results_team1Player2Id_idx" ON "match_results"("team1Player2Id");
-- CreateIndex
CREATE INDEX "match_results_team2Player1Id_idx" ON "match_results"("team2Player1Id");
-- CreateIndex
CREATE INDEX "match_results_team2Player2Id_idx" ON "match_results"("team2Player2Id");
-- CreateIndex
CREATE INDEX "match_results_playedAt_idx" ON "match_results"("playedAt");
-- CreateIndex
CREATE INDEX "user_stats_userId_idx" ON "user_stats"("userId");
-- CreateIndex
CREATE INDEX "user_stats_period_periodValue_idx" ON "user_stats"("period", "periodValue");
-- CreateIndex
CREATE INDEX "user_stats_points_idx" ON "user_stats"("points");
-- CreateIndex
CREATE UNIQUE INDEX "user_stats_userId_period_periodValue_key" ON "user_stats"("userId", "period", "periodValue");

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -21,6 +21,8 @@ model User {
lastName String
phone String?
avatarUrl String?
city String?
birthDate DateTime?
// Datos de juego (usamos String para simular enums en SQLite)
role String @default("PLAYER") // PLAYER, ADMIN, SUPERADMIN
@@ -28,6 +30,13 @@ model User {
handPreference String @default("RIGHT") // RIGHT, LEFT, BOTH
positionPreference String @default("BOTH") // DRIVE, BACKHAND, BOTH
bio String?
yearsPlaying Int?
// Estadísticas globales
matchesPlayed Int @default(0)
matchesWon Int @default(0)
matchesLost Int @default(0)
totalPoints Int @default(0)
// Estado
isActive Boolean @default(true)
@@ -36,12 +45,57 @@ model User {
// Relaciones
bookings Booking[]
levelHistory LevelHistory[]
// Relaciones con MatchResult
team1Player1Matches MatchResult[] @relation("Team1Player1")
team1Player2Matches MatchResult[] @relation("Team1Player2")
team2Player1Matches MatchResult[] @relation("Team2Player1")
team2Player2Matches MatchResult[] @relation("Team2Player2")
// Relación con UserStats
userStats UserStats[]
// Amistades
friendsSent Friend[] @relation("FriendRequestsSent")
friendsReceived Friend[] @relation("FriendRequestsReceived")
// Grupos
groupsCreated Group[] @relation("GroupsCreated")
groupMembers GroupMember[] @relation("GroupMemberships")
// Reservas recurrentes
recurringBookings RecurringBooking[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
// Modelo de Historial de Niveles
model LevelHistory {
id String @id @default(uuid())
// Niveles
oldLevel String
newLevel String
// Referencias
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
// Quién realizó el cambio (admin)
changedBy String
// Metadata
reason String?
createdAt DateTime @default(now())
@@index([userId])
@@map("level_history")
}
// Modelo de Cancha
model Court {
id String @id @default(uuid())
@@ -66,6 +120,7 @@ model Court {
// Relaciones
bookings Booking[]
schedules CourtSchedule[]
recurringBookings RecurringBooking[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -120,6 +175,13 @@ model Booking {
court Court @relation(fields: [courtId], references: [id])
courtId String
// Relación con MatchResult
matchResult MatchResult?
// Referencia a reserva recurrente (si aplica)
recurringBooking RecurringBooking? @relation(fields: [recurringBookingId], references: [id])
recurringBookingId String?
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -127,5 +189,195 @@ model Booking {
@@index([userId])
@@index([courtId])
@@index([date])
@@index([recurringBookingId])
@@map("bookings")
}
// Modelo de Amistad
model Friend {
id String @id @default(uuid())
// Quien envía la solicitud
requester User @relation("FriendRequestsSent", fields: [requesterId], references: [id])
requesterId String
// Quien recibe la solicitud
addressee User @relation("FriendRequestsReceived", fields: [addresseeId], references: [id])
addresseeId String
// Estado (PENDING, ACCEPTED, REJECTED, BLOCKED)
status String @default("PENDING")
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([requesterId, addresseeId])
@@index([requesterId])
@@index([addresseeId])
@@index([status])
@@map("friends")
}
// Modelo de Grupo
model Group {
id String @id @default(uuid())
name String
description String?
// Creador del grupo
createdBy User @relation("GroupsCreated", fields: [createdById], references: [id])
createdById String
// Relaciones
members GroupMember[]
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([createdById])
@@map("groups")
}
// Modelo de Miembro de Grupo
model GroupMember {
id String @id @default(uuid())
// Grupo
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
groupId String
// Usuario
user User @relation("GroupMemberships", fields: [userId], references: [id])
userId String
// Rol (ADMIN, MEMBER)
role String @default("MEMBER")
// Fecha de unión
joinedAt DateTime @default(now())
@@unique([groupId, userId])
@@index([groupId])
@@index([userId])
@@map("group_members")
}
// Modelo de Reserva Recurrente
model RecurringBooking {
id String @id @default(uuid())
// Usuario que crea la reserva recurrente
user User @relation(fields: [userId], references: [id])
userId String
// Cancha
court Court @relation(fields: [courtId], references: [id])
courtId String
// Día de la semana (0=Domingo, 1=Lunes, ..., 6=Sábado)
dayOfWeek Int
// Horario
startTime String
endTime String
// Rango de fechas
startDate DateTime
endDate DateTime?
// Estado
isActive Boolean @default(true)
// Relaciones
bookings Booking[]
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([courtId])
@@index([dayOfWeek])
@@index([isActive])
@@map("recurring_bookings")
}
// Modelo de Resultado de Partido
model MatchResult {
id String @id @default(uuid())
// Relación opcional con reserva
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
bookingId String? @unique
// Jugadores del Equipo 1
team1Player1 User @relation("Team1Player1", fields: [team1Player1Id], references: [id])
team1Player1Id String
team1Player2 User @relation("Team1Player2", fields: [team1Player2Id], references: [id])
team1Player2Id String
// Jugadores del Equipo 2
team2Player1 User @relation("Team2Player1", fields: [team2Player1Id], references: [id])
team2Player1Id String
team2Player2 User @relation("Team2Player2", fields: [team2Player2Id], references: [id])
team2Player2Id String
// Resultado
team1Score Int
team2Score Int
winner String // TEAM1, TEAM2, DRAW
// Fecha en que se jugó el partido
playedAt DateTime
// Confirmaciones (JSON array de userIds)
confirmedBy String @default("[]")
// Timestamps
createdAt DateTime @default(now())
@@index([team1Player1Id])
@@index([team1Player2Id])
@@index([team2Player1Id])
@@index([team2Player2Id])
@@index([playedAt])
@@map("match_results")
}
// Modelo de Estadísticas de Usuario por Período
model UserStats {
id String @id @default(uuid())
// Usuario
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
// Período (MONTH, YEAR, ALL_TIME)
period String
// Valor del período (ej: "2024-01" para mes, "2024" para año)
periodValue String
// Estadísticas de partidos
matchesPlayed Int @default(0)
matchesWon Int @default(0)
matchesLost Int @default(0)
// Estadísticas de torneos
tournamentsPlayed Int @default(0)
tournamentsWon Int @default(0)
// Puntos para ranking
points Int @default(0)
// Timestamps
updatedAt DateTime @updatedAt
@@unique([userId, period, periodValue])
@@index([userId])
@@index([period, periodValue])
@@index([points])
@@map("user_stats")
}

View File

@@ -0,0 +1,192 @@
import { PrismaClient } from '@prisma/client';
import { hashPassword } from '../src/utils/password';
import { UserRole, PlayerLevel, FriendStatus, GroupRole, CourtType } from '../src/utils/constants';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding Fase 2 data...\n');
// Crear jugadores adicionales para pruebas sociales
const usersData = [
{ email: 'carlos@padel.com', firstName: 'Carlos', lastName: 'Martínez', level: PlayerLevel.ADVANCED },
{ email: 'ana@padel.com', firstName: 'Ana', lastName: 'López', level: PlayerLevel.INTERMEDIATE },
{ email: 'pedro@padel.com', firstName: 'Pedro', lastName: 'Sánchez', level: PlayerLevel.BEGINNER },
{ email: 'maria@padel.com', firstName: 'María', lastName: 'García', level: PlayerLevel.COMPETITION },
];
const createdUsers = [];
for (const userData of usersData) {
const password = await hashPassword('123456');
const user = await prisma.user.upsert({
where: { email: userData.email },
update: {},
create: {
email: userData.email,
password,
firstName: userData.firstName,
lastName: userData.lastName,
role: UserRole.PLAYER,
playerLevel: userData.level,
city: 'Madrid',
yearsPlaying: Math.floor(Math.random() * 10) + 1,
matchesPlayed: Math.floor(Math.random() * 50),
matchesWon: Math.floor(Math.random() * 30),
isActive: true,
},
});
createdUsers.push(user);
console.log(`✅ Usuario creado: ${user.firstName} ${user.lastName} (${user.email})`);
}
// Obtener el admin y user original
const admin = await prisma.user.findUnique({ where: { email: 'admin@padel.com' } });
const user = await prisma.user.findUnique({ where: { email: 'user@padel.com' } });
if (admin && user && createdUsers.length >= 2) {
// Crear relaciones de amistad
const friendships = [
{ requester: user.id, addressee: createdUsers[0].id }, // Juan - Carlos
{ requester: createdUsers[1].id, addressee: user.id }, // Ana - Juan (pendiente)
{ requester: user.id, addressee: createdUsers[2].id }, // Juan - Pedro
];
for (const friendship of friendships) {
await prisma.friend.upsert({
where: {
requesterId_addresseeId: {
requesterId: friendship.requester,
addresseeId: friendship.addressee,
},
},
update: {},
create: {
requesterId: friendship.requester,
addresseeId: friendship.addressee,
status: FriendStatus.ACCEPTED,
},
});
}
console.log('\n✅ Amistades creadas');
// Crear un grupo
const group = await prisma.group.upsert({
where: { id: 'group-1' },
update: {},
create: {
id: 'group-1',
name: 'Los Padelistas',
description: 'Grupo de jugadores regulares los fines de semana',
createdBy: admin.id,
members: {
create: [
{ userId: admin.id, role: GroupRole.ADMIN },
{ userId: user.id, role: GroupRole.MEMBER },
{ userId: createdUsers[0].id, role: GroupRole.MEMBER },
{ userId: createdUsers[1].id, role: GroupRole.MEMBER },
],
},
},
});
console.log(`✅ Grupo creado: ${group.name}`);
// Crear reserva recurrente
const court = await prisma.court.findFirst({ where: { isActive: true } });
if (court) {
const recurring = await prisma.recurringBooking.create({
data: {
userId: user.id,
courtId: court.id,
dayOfWeek: 6, // Sábado
startTime: '10:00',
endTime: '12:00',
startDate: new Date(),
isActive: true,
},
});
console.log(`✅ Reserva recurrente creada: Sábados ${recurring.startTime}-${recurring.endTime}`);
}
// Registrar algunos partidos
const matchResults = [
{
team1: [user.id, createdUsers[0].id],
team2: [createdUsers[1].id, createdUsers[2].id],
score1: 6,
score2: 4,
playedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Hace 1 semana
},
{
team1: [user.id, createdUsers[1].id],
team2: [createdUsers[0].id, createdUsers[2].id],
score1: 3,
score2: 6,
playedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // Hace 3 días
},
];
for (const match of matchResults) {
await prisma.matchResult.create({
data: {
team1Player1Id: match.team1[0],
team1Player2Id: match.team1[1],
team2Player1Id: match.team2[0],
team2Player2Id: match.team2[1],
team1Score: match.score1,
team2Score: match.score2,
winner: match.score1 > match.score2 ? 'TEAM1' : 'TEAM2',
playedAt: match.playedAt,
confirmedBy: [match.team1[0], match.team2[0]],
},
});
}
console.log(`✅ Partidos registrados: ${matchResults.length}`);
// Crear estadísticas de usuario
for (const u of [user, ...createdUsers.slice(0, 2)]) {
await prisma.userStats.upsert({
where: {
userId_period_periodValue: {
userId: u.id,
period: 'ALL_TIME',
periodValue: 'all',
},
},
update: {},
create: {
userId: u.id,
period: 'ALL_TIME',
periodValue: 'all',
matchesPlayed: Math.floor(Math.random() * 30) + 5,
matchesWon: Math.floor(Math.random() * 20),
matchesLost: Math.floor(Math.random() * 10),
points: Math.floor(Math.random() * 500) + 100,
},
});
}
console.log(`✅ Estadísticas de usuarios creadas`);
}
// Actualizar puntos totales de usuarios
await prisma.user.updateMany({
data: {
totalPoints: { increment: 100 },
},
});
console.log('\n🎾 Fase 2 seed completado!');
console.log('\nNuevos usuarios de prueba:');
console.log(' carlos@padel.com / 123456');
console.log(' ana@padel.com / 123456');
console.log(' pedro@padel.com / 123456');
console.log(' maria@padel.com / 123456');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

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

View File

@@ -1,6 +1,210 @@
# Fase 2: Perfiles
# Fase 2: Gestión de Jugadores y Perfiles
## Estado: ⏳ Pendiente
## Estado: ✅ COMPLETADA
*Esta fase comenzará al finalizar la Fase 1*
### ✅ Tareas completadas:
#### 2.1.1: Perfil Completo
- [x] Datos personales extendidos (ciudad, fecha nacimiento)
- [x] Datos de juego (años jugando, mano, posición)
- [x] Estadísticas de partidos (jugados, ganados, perdidos)
- [x] Biografía y foto de perfil
#### 2.1.2: Sistema de Niveles
- [x] Escala de niveles con descripciones (BEGINNER a PROFESSIONAL)
- [x] Modelo LevelHistory para tracking de cambios
- [x] Validación de nivel por administradores
- [x] Historial de cambios de nivel
#### 2.2.1: Sistema de Amigos/Grupos
- [x] Enviar/recibir solicitudes de amistad
- [x] Estados: PENDING, ACCEPTED, REJECTED, BLOCKED
- [x] Crear grupos de jugadores
- [x] Roles en grupos: ADMIN, MEMBER
- [x] Invitar jugadores a grupo
- [x] Reserva grupal (para fases posteriores)
#### 2.2.2: Reservas Recurrentes
- [x] Configurar fijo semanal (mismo día/hora)
- [x] Modelo RecurringBooking
- [x] Generación automática de reservas desde recurrentes
- [x] Cancelación de series recurrentes
#### 2.3.1: Historial de Actividad
- [x] Modelo MatchResult para resultados de partidos
- [x] Registro de partidos jugados (2v2)
- [x] Sistema de confirmación de resultados (2+ jugadores)
- [x] Estadísticas de asistencia
#### 2.3.2: Ranking Interno
- [x] Sistema de puntos (Victoria=10, Derrota=2, Participación=1)
- [x] Bonus por ganar a nivel superior (+5 pts)
- [x] Multiplicadores por nivel de jugador
- [x] Tabla de clasificación visible
- [x] Filtros por período (mensual, anual, global)
- [x] Modelo UserStats para estadísticas agregadas
---
## 📊 Resumen de Implementación
### Modelos de Base de Datos
| Modelo | Descripción |
|--------|-------------|
| User | Extendido con city, birthDate, yearsPlaying, matchesPlayed/Won/Lost, totalPoints |
| LevelHistory | Tracking de cambios de nivel |
| Friend | Solicitudes de amistad con estados |
| Group | Grupos de jugadores |
| GroupMember | Miembros de grupos con roles |
| RecurringBooking | Reservas recurrentes semanales |
| MatchResult | Resultados de partidos 2v2 |
| UserStats | Estadísticas agregadas por período |
### Nuevos Endpoints
#### Perfiles
```
GET /api/v1/users/me - Mi perfil
PUT /api/v1/users/me - Actualizar perfil
GET /api/v1/users/search - Buscar usuarios
GET /api/v1/users/:id - Ver perfil público
PUT /api/v1/users/:id/level - Cambiar nivel (admin)
GET /api/v1/users/:id/level-history - Historial de niveles
```
#### Amigos
```
POST /api/v1/friends/request - Enviar solicitud
PUT /api/v1/friends/:id/accept - Aceptar solicitud
PUT /api/v1/friends/:id/reject - Rechazar solicitud
GET /api/v1/friends - Mis amigos
GET /api/v1/friends/pending - Solicitudes pendientes
DELETE /api/v1/friends/:id - Eliminar amigo
```
#### Grupos
```
POST /api/v1/groups - Crear grupo
GET /api/v1/groups - Mis grupos
GET /api/v1/groups/:id - Ver grupo
PUT /api/v1/groups/:id - Actualizar grupo
DELETE /api/v1/groups/:id - Eliminar grupo
POST /api/v1/groups/:id/members - Agregar miembro
DELETE /api/v1/groups/:id/members/:userId - Eliminar miembro
```
#### Reservas Recurrentes
```
POST /api/v1/recurring - Crear recurrente
GET /api/v1/recurring - Mis recurrentes
DELETE /api/v1/recurring/:id - Cancelar recurrente
POST /api/v1/recurring/:id/generate - Generar reservas
```
#### Partidos
```
POST /api/v1/matches - Registrar partido
GET /api/v1/matches/my-matches - Mis partidos
GET /api/v1/matches/:id - Ver partido
PUT /api/v1/matches/:id/confirm - Confirmar resultado
```
#### Ranking y Estadísticas
```
GET /api/v1/ranking - Ranking general
GET /api/v1/ranking/me - Mi posición
GET /api/v1/ranking/top - Top jugadores
GET /api/v1/stats/my-stats - Mis estadísticas
GET /api/v1/stats/users/:id - Stats de usuario
GET /api/v1/stats/courts/:id - Stats de cancha
GET /api/v1/stats/global - Stats globales
```
### Sistema de Puntos
| Concepto | Puntos |
|----------|--------|
| Victoria | 10 |
| Derrota | 2 |
| Participación | 1 |
| Ganar a superior | +5 |
**Multiplicadores por nivel:**
- BEGINNER: 1.0x
- ELEMENTARY: 1.1x
- INTERMEDIATE: 1.2x
- ADVANCED: 1.3x
- COMPETITION: 1.5x
- PROFESSIONAL: 2.0x
### Rangos
| Puntos | Título |
|--------|--------|
| 0-99 | Bronce |
| 100-299 | Plata |
| 300-599 | Oro |
| 600-999 | Platino |
| 1000+ | Diamante |
---
## 🚀 Cómo probar
```bash
cd backend
npm run dev
```
### Credenciales de prueba
| Email | Password | Nivel |
|-------|----------|-------|
| admin@padel.com | admin123 | ADMIN |
| user@padel.com | user123 | INTERMEDIATE |
| carlos@padel.com | 123456 | ADVANCED |
| ana@padel.com | 123456 | INTERMEDIATE |
| pedro@padel.com | 123456 | BEGINNER |
| maria@padel.com | 123456 | COMPETITION |
---
## 📁 Archivos creados en esta fase
```
backend/src/
├── services/
│ ├── user.service.ts # Perfiles y búsqueda
│ ├── friend.service.ts # Sistema de amigos
│ ├── group.service.ts # Grupos de jugadores
│ ├── recurring.service.ts # Reservas recurrentes
│ ├── match.service.ts # Registro de partidos
│ ├── ranking.service.ts # Cálculo de ranking
│ └── stats.service.ts # Estadísticas
├── controllers/
│ ├── user.controller.ts
│ ├── friend.controller.ts
│ ├── group.controller.ts
│ ├── recurring.controller.ts
│ ├── match.controller.ts
│ ├── ranking.controller.ts
│ └── stats.controller.ts
├── routes/
│ ├── user.routes.ts
│ ├── friend.routes.ts
│ ├── group.routes.ts
│ ├── recurring.routes.ts
│ ├── match.routes.ts
│ ├── ranking.routes.ts
│ └── stats.routes.ts
├── validators/
│ ├── user.validator.ts
│ └── social.validator.ts
└── utils/
└── ranking.ts # Cálculo de puntos y rangos
```
---
*Completada el: 2026-01-31*