✅ 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:
Binary file not shown.
@@ -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");
|
||||
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
192
backend/prisma/seed-fase2.ts
Normal file
192
backend/prisma/seed-fase2.ts
Normal 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();
|
||||
});
|
||||
143
backend/src/controllers/friend.controller.ts
Normal file
143
backend/src/controllers/friend.controller.ts
Normal 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;
|
||||
201
backend/src/controllers/group.controller.ts
Normal file
201
backend/src/controllers/group.controller.ts
Normal 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;
|
||||
127
backend/src/controllers/match.controller.ts
Normal file
127
backend/src/controllers/match.controller.ts
Normal 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;
|
||||
122
backend/src/controllers/ranking.controller.ts
Normal file
122
backend/src/controllers/ranking.controller.ts
Normal 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;
|
||||
185
backend/src/controllers/recurring.controller.ts
Normal file
185
backend/src/controllers/recurring.controller.ts
Normal 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;
|
||||
112
backend/src/controllers/stats.controller.ts
Normal file
112
backend/src/controllers/stats.controller.ts
Normal 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;
|
||||
128
backend/src/controllers/user.controller.ts
Normal file
128
backend/src/controllers/user.controller.ts
Normal 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;
|
||||
58
backend/src/routes/friend.routes.ts
Normal file
58
backend/src/routes/friend.routes.ts
Normal 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;
|
||||
82
backend/src/routes/group.routes.ts
Normal file
82
backend/src/routes/group.routes.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
73
backend/src/routes/match.routes.ts
Normal file
73
backend/src/routes/match.routes.ts
Normal 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;
|
||||
71
backend/src/routes/ranking.routes.ts
Normal file
71
backend/src/routes/ranking.routes.ts
Normal 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;
|
||||
70
backend/src/routes/recurring.routes.ts
Normal file
70
backend/src/routes/recurring.routes.ts
Normal 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;
|
||||
62
backend/src/routes/stats.routes.ts
Normal file
62
backend/src/routes/stats.routes.ts
Normal 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;
|
||||
40
backend/src/routes/user.routes.ts
Normal file
40
backend/src/routes/user.routes.ts
Normal 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;
|
||||
351
backend/src/services/friend.service.ts
Normal file
351
backend/src/services/friend.service.ts
Normal 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;
|
||||
448
backend/src/services/group.service.ts
Normal file
448
backend/src/services/group.service.ts
Normal 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;
|
||||
605
backend/src/services/match.service.ts
Normal file
605
backend/src/services/match.service.ts
Normal 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;
|
||||
507
backend/src/services/ranking.service.ts
Normal file
507
backend/src/services/ranking.service.ts
Normal 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;
|
||||
439
backend/src/services/recurring.service.ts
Normal file
439
backend/src/services/recurring.service.ts
Normal 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;
|
||||
441
backend/src/services/stats.service.ts
Normal file
441
backend/src/services/stats.service.ts
Normal 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;
|
||||
388
backend/src/services/user.service.ts
Normal file
388
backend/src/services/user.service.ts
Normal 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;
|
||||
@@ -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];
|
||||
|
||||
@@ -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)
|
||||
|
||||
246
backend/src/utils/ranking.ts
Normal file
246
backend/src/utils/ranking.ts
Normal 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';
|
||||
}
|
||||
85
backend/src/validators/match.validator.ts
Normal file
85
backend/src/validators/match.validator.ts
Normal 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>;
|
||||
88
backend/src/validators/social.validator.ts
Normal file
88
backend/src/validators/social.validator.ts
Normal 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>;
|
||||
67
backend/src/validators/user.validator.ts
Normal file
67
backend/src/validators/user.validator.ts
Normal 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>;
|
||||
@@ -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*
|
||||
|
||||
Reference in New Issue
Block a user