✅ 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
|
lastName String
|
||||||
phone String?
|
phone String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
city String?
|
||||||
|
birthDate DateTime?
|
||||||
|
|
||||||
// Datos de juego (usamos String para simular enums en SQLite)
|
// Datos de juego (usamos String para simular enums en SQLite)
|
||||||
role String @default("PLAYER") // PLAYER, ADMIN, SUPERADMIN
|
role String @default("PLAYER") // PLAYER, ADMIN, SUPERADMIN
|
||||||
@@ -28,6 +30,13 @@ model User {
|
|||||||
handPreference String @default("RIGHT") // RIGHT, LEFT, BOTH
|
handPreference String @default("RIGHT") // RIGHT, LEFT, BOTH
|
||||||
positionPreference String @default("BOTH") // DRIVE, BACKHAND, BOTH
|
positionPreference String @default("BOTH") // DRIVE, BACKHAND, BOTH
|
||||||
bio String?
|
bio String?
|
||||||
|
yearsPlaying Int?
|
||||||
|
|
||||||
|
// Estadísticas globales
|
||||||
|
matchesPlayed Int @default(0)
|
||||||
|
matchesWon Int @default(0)
|
||||||
|
matchesLost Int @default(0)
|
||||||
|
totalPoints Int @default(0)
|
||||||
|
|
||||||
// Estado
|
// Estado
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
@@ -35,13 +44,58 @@ model User {
|
|||||||
lastLogin DateTime?
|
lastLogin DateTime?
|
||||||
|
|
||||||
// Relaciones
|
// Relaciones
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
createdAt DateTime @default(now())
|
levelHistory LevelHistory[]
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
// 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")
|
@@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
|
// Modelo de Cancha
|
||||||
model Court {
|
model Court {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
@@ -66,6 +120,7 @@ model Court {
|
|||||||
// Relaciones
|
// Relaciones
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
schedules CourtSchedule[]
|
schedules CourtSchedule[]
|
||||||
|
recurringBookings RecurringBooking[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -120,6 +175,13 @@ model Booking {
|
|||||||
court Court @relation(fields: [courtId], references: [id])
|
court Court @relation(fields: [courtId], references: [id])
|
||||||
courtId String
|
courtId String
|
||||||
|
|
||||||
|
// Relación con MatchResult
|
||||||
|
matchResult MatchResult?
|
||||||
|
|
||||||
|
// Referencia a reserva recurrente (si aplica)
|
||||||
|
recurringBooking RecurringBooking? @relation(fields: [recurringBookingId], references: [id])
|
||||||
|
recurringBookingId String?
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -127,5 +189,195 @@ model Booking {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([courtId])
|
@@index([courtId])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
|
@@index([recurringBookingId])
|
||||||
@@map("bookings")
|
@@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 authRoutes from './auth.routes';
|
||||||
import courtRoutes from './court.routes';
|
import courtRoutes from './court.routes';
|
||||||
import bookingRoutes from './booking.routes';
|
import bookingRoutes from './booking.routes';
|
||||||
|
import matchRoutes from './match.routes';
|
||||||
|
import rankingRoutes from './ranking.routes';
|
||||||
|
import statsRoutes from './stats.routes';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -23,4 +26,13 @@ router.use('/courts', courtRoutes);
|
|||||||
// Rutas de reservas
|
// Rutas de reservas
|
||||||
router.use('/bookings', bookingRoutes);
|
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;
|
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;
|
} as const;
|
||||||
|
|
||||||
export type CourtTypeType = typeof CourtType[keyof typeof CourtType];
|
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';
|
import config from '../config';
|
||||||
|
|
||||||
export interface TokenPayload {
|
export interface TokenPayload {
|
||||||
@@ -9,26 +9,28 @@ export interface TokenPayload {
|
|||||||
|
|
||||||
// Generar access token
|
// Generar access token
|
||||||
export const generateAccessToken = (payload: TokenPayload): string => {
|
export const generateAccessToken = (payload: TokenPayload): string => {
|
||||||
return jwt.sign(payload, config.JWT_SECRET, {
|
const options: SignOptions = {
|
||||||
expiresIn: config.JWT_EXPIRES_IN,
|
expiresIn: config.JWT_EXPIRES_IN as SignOptions['expiresIn'],
|
||||||
});
|
};
|
||||||
|
return jwt.sign(payload, config.JWT_SECRET as Secret, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generar refresh token
|
// Generar refresh token
|
||||||
export const generateRefreshToken = (payload: TokenPayload): string => {
|
export const generateRefreshToken = (payload: TokenPayload): string => {
|
||||||
return jwt.sign(payload, config.JWT_REFRESH_SECRET, {
|
const options: SignOptions = {
|
||||||
expiresIn: config.JWT_REFRESH_EXPIRES_IN,
|
expiresIn: config.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'],
|
||||||
});
|
};
|
||||||
|
return jwt.sign(payload, config.JWT_REFRESH_SECRET as Secret, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verificar access token
|
// Verificar access token
|
||||||
export const verifyAccessToken = (token: string): TokenPayload => {
|
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
|
// Verificar refresh token
|
||||||
export const verifyRefreshToken = (token: string): TokenPayload => {
|
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)
|
// 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