✅ FASE 1 COMPLETADA: Fundamentos y Core del Backend
- API REST completa con Node.js + Express + TypeScript - Autenticación JWT con roles (Player/Admin) - CRUD completo de canchas - Sistema de reservas con validaciones - Base de datos SQLite con Prisma ORM - Notificaciones por email (Nodemailer) - Seed de datos de prueba - Documentación de arquitectura Endpoints implementados: - Auth: register, login, refresh, me - Courts: CRUD + disponibilidad - Bookings: CRUD + confirmación/cancelación Credenciales de prueba: - admin@padel.com / admin123 - user@padel.com / user123
This commit is contained in:
35
backend/.env.example
Normal file
35
backend/.env.example
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# ============================================
|
||||||
|
# Configuración de la Base de Datos
|
||||||
|
# ============================================
|
||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/app_padel?schema=public"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Configuración del Servidor
|
||||||
|
# ============================================
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
API_URL=http://localhost:3000
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Configuración de JWT
|
||||||
|
# ============================================
|
||||||
|
JWT_SECRET=tu_clave_secreta_super_segura_cambiar_en_produccion
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
JWT_REFRESH_SECRET=otra_clave_secreta_para_refresh_token
|
||||||
|
JWT_REFRESH_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Configuración de Email (SMTP)
|
||||||
|
# ============================================
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=tu_email@gmail.com
|
||||||
|
SMTP_PASS=tu_password_app
|
||||||
|
EMAIL_FROM="Canchas Padel <noreply@tudominio.com>"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Configuración de Rate Limiting
|
||||||
|
# ============================================
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
47
backend/Dockerfile
Normal file
47
backend/Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copiar archivos de dependencias
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
|
# Instalar dependencias
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copiar código fuente
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Generar Prisma client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Compilar TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copiar archivos necesarios
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
|
# Instalar solo dependencias de producción
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Generar Prisma client para producción
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Copiar código compilado desde builder
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Crear directorio de logs
|
||||||
|
RUN mkdir -p logs
|
||||||
|
|
||||||
|
# Puerto
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Comando de inicio
|
||||||
|
CMD ["npm", "start"]
|
||||||
4342
backend/package-lock.json
generated
Normal file
4342
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
backend/package.json
Normal file
49
backend/package.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "app-padel-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend API REST para App de Canchas de Pádel",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"lint": "eslint src --ext .ts",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"keywords": ["padel", "reservas", "api", "nodejs"],
|
||||||
|
"author": "Consultoria AS",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.8.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"nodemailer": "^6.9.8",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/node": "^20.10.6",
|
||||||
|
"@types/nodemailer": "^6.4.14",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||||
|
"@typescript-eslint/parser": "^6.17.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"prisma": "^5.8.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
backend/prisma/dev.db
Normal file
BIN
backend/prisma/dev.db
Normal file
Binary file not shown.
82
backend/prisma/migrations/20260131080637_init/migration.sql
Normal file
82
backend/prisma/migrations/20260131080637_init/migration.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "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,
|
||||||
|
"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,
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "courts" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"type" TEXT NOT NULL DEFAULT 'PANORAMIC',
|
||||||
|
"isIndoor" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"hasLighting" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"hasParking" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"pricePerHour" INTEGER NOT NULL DEFAULT 2000,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "court_schedules" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"dayOfWeek" INTEGER NOT NULL,
|
||||||
|
"openTime" TEXT NOT NULL,
|
||||||
|
"closeTime" TEXT NOT NULL,
|
||||||
|
"priceOverride" INTEGER,
|
||||||
|
"courtId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "court_schedules_courtId_fkey" FOREIGN KEY ("courtId") REFERENCES "courts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "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,
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "courts_name_key" ON "courts"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "court_schedules_courtId_dayOfWeek_key" ON "court_schedules"("courtId", "dayOfWeek");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "bookings_userId_idx" ON "bookings"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "bookings_courtId_idx" ON "bookings"("courtId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "bookings_date_idx" ON "bookings"("date");
|
||||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "sqlite"
|
||||||
131
backend/prisma/schema.prisma
Normal file
131
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modelo de Usuario
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
|
||||||
|
// Datos personales
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
phone String?
|
||||||
|
avatarUrl String?
|
||||||
|
|
||||||
|
// Datos de juego (usamos String para simular enums en SQLite)
|
||||||
|
role String @default("PLAYER") // PLAYER, ADMIN, SUPERADMIN
|
||||||
|
playerLevel String @default("BEGINNER") // BEGINNER, ELEMENTARY, INTERMEDIATE, ADVANCED, COMPETITION, PROFESSIONAL
|
||||||
|
handPreference String @default("RIGHT") // RIGHT, LEFT, BOTH
|
||||||
|
positionPreference String @default("BOTH") // DRIVE, BACKHAND, BOTH
|
||||||
|
bio String?
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isVerified Boolean @default(false)
|
||||||
|
lastLogin DateTime?
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
bookings Booking[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modelo de Cancha
|
||||||
|
model Court {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
|
||||||
|
// Características
|
||||||
|
type String @default("PANORAMIC") // PANORAMIC, OUTDOOR, INDOOR, SINGLE
|
||||||
|
isIndoor Boolean @default(false)
|
||||||
|
hasLighting Boolean @default(true)
|
||||||
|
hasParking Boolean @default(false)
|
||||||
|
|
||||||
|
// Precio por hora (en centavos para evitar decimales)
|
||||||
|
pricePerHour Int @default(2000)
|
||||||
|
|
||||||
|
// Imagen
|
||||||
|
imageUrl String?
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
bookings Booking[]
|
||||||
|
schedules CourtSchedule[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("courts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modelo de Horarios de Cancha (días y horas de operación)
|
||||||
|
model CourtSchedule {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
|
||||||
|
// Día de la semana (0=Domingo, 1=Lunes, ..., 6=Sábado)
|
||||||
|
dayOfWeek Int
|
||||||
|
|
||||||
|
// Horario
|
||||||
|
openTime String
|
||||||
|
closeTime String
|
||||||
|
|
||||||
|
// Precio especial para esta franja (opcional)
|
||||||
|
priceOverride Int?
|
||||||
|
|
||||||
|
// Relación
|
||||||
|
court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)
|
||||||
|
courtId String
|
||||||
|
|
||||||
|
@@unique([courtId, dayOfWeek])
|
||||||
|
@@map("court_schedules")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modelo de Reserva
|
||||||
|
model Booking {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
|
||||||
|
// Fecha y hora
|
||||||
|
date DateTime
|
||||||
|
startTime String
|
||||||
|
endTime String
|
||||||
|
|
||||||
|
// Estado (PENDING, CONFIRMED, CANCELLED, COMPLETED, NO_SHOW)
|
||||||
|
status String @default("PENDING")
|
||||||
|
|
||||||
|
// Precio
|
||||||
|
totalPrice Int
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
userId String
|
||||||
|
|
||||||
|
court Court @relation(fields: [courtId], references: [id])
|
||||||
|
courtId String
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([courtId])
|
||||||
|
@@index([date])
|
||||||
|
@@map("bookings")
|
||||||
|
}
|
||||||
121
backend/prisma/seed.ts
Normal file
121
backend/prisma/seed.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { hashPassword } from '../src/utils/password';
|
||||||
|
import { UserRole, PlayerLevel, CourtType } from '../src/utils/constants';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Seeding database...');
|
||||||
|
|
||||||
|
// Crear usuario admin
|
||||||
|
const adminPassword = await hashPassword('admin123');
|
||||||
|
const admin = await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@padel.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'admin@padel.com',
|
||||||
|
password: adminPassword,
|
||||||
|
firstName: 'Admin',
|
||||||
|
lastName: 'Sistema',
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
playerLevel: PlayerLevel.PROFESSIONAL,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Admin creado:', admin.email);
|
||||||
|
|
||||||
|
// Crear usuario de prueba
|
||||||
|
const userPassword = await hashPassword('user123');
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email: 'user@padel.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'user@padel.com',
|
||||||
|
password: userPassword,
|
||||||
|
firstName: 'Juan',
|
||||||
|
lastName: 'García',
|
||||||
|
role: UserRole.PLAYER,
|
||||||
|
playerLevel: PlayerLevel.INTERMEDIATE,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Usuario creado:', user.email);
|
||||||
|
|
||||||
|
// Crear canchas
|
||||||
|
const courts = [
|
||||||
|
{
|
||||||
|
name: 'Cancha 1 - Panorámica',
|
||||||
|
description: 'Cancha panorámica premium con cristal 360°',
|
||||||
|
type: CourtType.PANORAMIC,
|
||||||
|
isIndoor: true,
|
||||||
|
hasLighting: true,
|
||||||
|
pricePerHour: 2500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cancha 2 - Panorámica',
|
||||||
|
description: 'Cancha panorámica premium con cristal 360°',
|
||||||
|
type: CourtType.PANORAMIC,
|
||||||
|
isIndoor: true,
|
||||||
|
hasLighting: true,
|
||||||
|
pricePerHour: 2500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cancha 3 - Exterior',
|
||||||
|
description: 'Cancha exterior con iluminación nocturna',
|
||||||
|
type: CourtType.OUTDOOR,
|
||||||
|
isIndoor: false,
|
||||||
|
hasLighting: true,
|
||||||
|
pricePerHour: 2000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cancha 4 - Cubierta',
|
||||||
|
description: 'Cancha cubierta sin cristal',
|
||||||
|
type: CourtType.INDOOR,
|
||||||
|
isIndoor: true,
|
||||||
|
hasLighting: true,
|
||||||
|
pricePerHour: 2200,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const courtData of courts) {
|
||||||
|
const court = await prisma.court.upsert({
|
||||||
|
where: { name: courtData.name },
|
||||||
|
update: {},
|
||||||
|
create: courtData,
|
||||||
|
});
|
||||||
|
console.log('✅ Cancha creada:', court.name);
|
||||||
|
|
||||||
|
// Crear horarios para cada cancha (Lunes a Domingo, 8:00 - 23:00)
|
||||||
|
for (let day = 0; day <= 6; day++) {
|
||||||
|
await prisma.courtSchedule.upsert({
|
||||||
|
where: {
|
||||||
|
courtId_dayOfWeek: {
|
||||||
|
courtId: court.id,
|
||||||
|
dayOfWeek: day,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
courtId: court.id,
|
||||||
|
dayOfWeek: day,
|
||||||
|
openTime: '08:00',
|
||||||
|
closeTime: '23:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎾 Database seeded successfully!');
|
||||||
|
console.log('\nCredenciales de prueba:');
|
||||||
|
console.log(' Admin: admin@padel.com / admin123');
|
||||||
|
console.log(' User: user@padel.com / user123');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
45
backend/src/config/database.ts
Normal file
45
backend/src/config/database.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import logger from './logger';
|
||||||
|
|
||||||
|
// Crear instancia de Prisma Client
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
log: [
|
||||||
|
{
|
||||||
|
emit: 'event',
|
||||||
|
level: 'query',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emit: 'stdout',
|
||||||
|
level: 'error',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emit: 'stdout',
|
||||||
|
level: 'warn',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log de queries en desarrollo
|
||||||
|
prisma.$on('query', (e: any) => {
|
||||||
|
logger.debug(`Query: ${e.query}`);
|
||||||
|
logger.debug(`Duration: ${e.duration}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Función para conectar a la base de datos
|
||||||
|
export const connectDB = async () => {
|
||||||
|
try {
|
||||||
|
await prisma.$connect();
|
||||||
|
logger.info('✅ Conectado a la base de datos PostgreSQL');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error conectando a la base de datos:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Función para desconectar
|
||||||
|
export const disconnectDB = async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
logger.info('Desconectado de la base de datos');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
48
backend/src/config/index.ts
Normal file
48
backend/src/config/index.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Cargar variables de entorno
|
||||||
|
dotenv.config({ path: path.join(__dirname, '../../.env') });
|
||||||
|
|
||||||
|
// Validar que las variables requeridas existan
|
||||||
|
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET'];
|
||||||
|
|
||||||
|
for (const envVar of requiredEnvVars) {
|
||||||
|
if (!process.env[envVar]) {
|
||||||
|
throw new Error(`Variable de entorno requerida no definida: ${envVar}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Server
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||||
|
PORT: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
API_URL: process.env.API_URL || 'http://localhost:3000',
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
|
||||||
|
// Database
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL!,
|
||||||
|
|
||||||
|
// JWT
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET!,
|
||||||
|
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '7d',
|
||||||
|
JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET!,
|
||||||
|
JWT_REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||||
|
|
||||||
|
// Email
|
||||||
|
SMTP: {
|
||||||
|
HOST: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
|
PORT: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||||
|
USER: process.env.SMTP_USER || '',
|
||||||
|
PASS: process.env.SMTP_PASS || '',
|
||||||
|
},
|
||||||
|
EMAIL_FROM: process.env.EMAIL_FROM || 'Canchas Padel <noreply@tudominio.com>',
|
||||||
|
|
||||||
|
// Rate Limiting
|
||||||
|
RATE_LIMIT: {
|
||||||
|
WINDOW_MS: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10),
|
||||||
|
MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
45
backend/src/config/logger.ts
Normal file
45
backend/src/config/logger.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import winston from 'winston';
|
||||||
|
import config from './index';
|
||||||
|
|
||||||
|
const { combine, timestamp, printf, colorize, errors } = winston.format;
|
||||||
|
|
||||||
|
// Formato personalizado para logs
|
||||||
|
const customFormat = printf(({ level, message, timestamp, stack }) => {
|
||||||
|
return `${timestamp} [${level}]: ${stack || message}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crear logger
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: config.NODE_ENV === 'development' ? 'debug' : 'info',
|
||||||
|
defaultMeta: { service: 'app-padel-api' },
|
||||||
|
format: combine(
|
||||||
|
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
errors({ stack: true })
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
// Escribir logs de error en archivo
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: 'logs/error.log',
|
||||||
|
level: 'error',
|
||||||
|
format: combine(timestamp(), customFormat)
|
||||||
|
}),
|
||||||
|
// Escribir todos los logs en archivo
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: 'logs/combined.log',
|
||||||
|
format: combine(timestamp(), customFormat)
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// En desarrollo, también loggear a consola con colores
|
||||||
|
if (config.NODE_ENV === 'development') {
|
||||||
|
logger.add(new winston.transports.Console({
|
||||||
|
format: combine(
|
||||||
|
colorize(),
|
||||||
|
timestamp({ format: 'HH:mm:ss' }),
|
||||||
|
customFormat
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default logger;
|
||||||
84
backend/src/controllers/auth.controller.ts
Normal file
84
backend/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class AuthController {
|
||||||
|
// Registro
|
||||||
|
static async register(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await AuthService.register(req.body);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Usuario registrado exitosamente',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login
|
||||||
|
static async login(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await AuthService.login(req.body);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Login exitoso',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener perfil
|
||||||
|
static async getProfile(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await AuthService.getProfile(req.user.userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
static async refreshToken(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new ApiError('Refresh token requerido', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await AuthService.refreshToken(refreshToken);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout (opcional - para invalidar tokens en blacklist si se implementa)
|
||||||
|
static async logout(_req: Request, res: Response) {
|
||||||
|
// Aquí se podría implementar una blacklist de tokens
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Logout exitoso',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthController;
|
||||||
164
backend/src/controllers/booking.controller.ts
Normal file
164
backend/src/controllers/booking.controller.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { BookingService } from '../services/booking.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import { UserRole } from '../utils/constants';
|
||||||
|
|
||||||
|
export class BookingController {
|
||||||
|
// Crear una reserva
|
||||||
|
static async createBooking(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = await BookingService.createBooking({
|
||||||
|
...req.body,
|
||||||
|
userId: req.user.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Reserva creada exitosamente',
|
||||||
|
data: booking,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener todas las reservas (admin)
|
||||||
|
static async getAllBookings(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const filters = {
|
||||||
|
courtId: req.query.courtId as string,
|
||||||
|
date: req.query.date ? new Date(req.query.date as string) : undefined,
|
||||||
|
status: req.query.status as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookings = await BookingService.getAllBookings(filters);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: bookings.length,
|
||||||
|
data: bookings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener reservas del usuario actual
|
||||||
|
static async getMyBookings(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcoming = req.query.upcoming === 'true';
|
||||||
|
const bookings = await BookingService.getUserBookings(req.user.userId, upcoming);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: bookings.length,
|
||||||
|
data: bookings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener una reserva por ID
|
||||||
|
static async getBookingById(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const booking = await BookingService.getBookingById(id);
|
||||||
|
|
||||||
|
// Verificar que el usuario tenga permiso de ver esta reserva
|
||||||
|
if (booking.userId !== req.user.userId &&
|
||||||
|
req.user.role !== UserRole.ADMIN &&
|
||||||
|
req.user.role !== UserRole.SUPERADMIN) {
|
||||||
|
throw new ApiError('No tienes permiso para ver esta reserva', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: booking,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar una reserva
|
||||||
|
static async updateBooking(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const isAdmin = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SUPERADMIN;
|
||||||
|
|
||||||
|
const booking = await BookingService.updateBooking(
|
||||||
|
id,
|
||||||
|
req.body,
|
||||||
|
isAdmin ? undefined : req.user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Reserva actualizada exitosamente',
|
||||||
|
data: booking,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelar una reserva
|
||||||
|
static async cancelBooking(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError('No autenticado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const isAdmin = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SUPERADMIN;
|
||||||
|
|
||||||
|
const booking = await BookingService.cancelBooking(
|
||||||
|
id,
|
||||||
|
isAdmin ? undefined : req.user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Reserva cancelada exitosamente',
|
||||||
|
data: booking,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmar una reserva (admin)
|
||||||
|
static async confirmBooking(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const booking = await BookingService.confirmBooking(id);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Reserva confirmada exitosamente',
|
||||||
|
data: booking,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BookingController;
|
||||||
110
backend/src/controllers/court.controller.ts
Normal file
110
backend/src/controllers/court.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { CourtService } from '../services/court.service';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class CourtController {
|
||||||
|
// Listar todas las canchas
|
||||||
|
static async getAllCourts(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.includeInactive === 'true';
|
||||||
|
const courts = await CourtService.getAllCourts(includeInactive);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: courts.length,
|
||||||
|
data: courts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener una cancha por ID
|
||||||
|
static async getCourtById(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const court = await CourtService.getCourtById(id);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: court,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear una cancha
|
||||||
|
static async createCourt(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const court = await CourtService.createCourt(req.body);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Cancha creada exitosamente',
|
||||||
|
data: court,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar una cancha
|
||||||
|
static async updateCourt(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const court = await CourtService.updateCourt(id, req.body);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Cancha actualizada exitosamente',
|
||||||
|
data: court,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar una cancha
|
||||||
|
static async deleteCourt(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
await CourtService.deleteCourt(id);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Cancha desactivada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener disponibilidad de una cancha
|
||||||
|
static async getAvailability(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { date } = req.query;
|
||||||
|
|
||||||
|
if (!date || typeof date !== 'string') {
|
||||||
|
throw new ApiError('La fecha es requerida (formato: YYYY-MM-DD)', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDate = new Date(date);
|
||||||
|
if (isNaN(parsedDate.getTime())) {
|
||||||
|
throw new ApiError('Fecha inválida', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = await CourtService.getAvailability(id, parsedDate);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: availability,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CourtController;
|
||||||
116
backend/src/index.ts
Normal file
116
backend/src/index.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import config from './config';
|
||||||
|
import logger from './config/logger';
|
||||||
|
import { connectDB } from './config/database';
|
||||||
|
import routes from './routes';
|
||||||
|
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Crear directorio de logs si no existe
|
||||||
|
const fs = require('fs');
|
||||||
|
const logsDir = path.join(__dirname, '../logs');
|
||||||
|
if (!fs.existsSync(logsDir)) {
|
||||||
|
fs.mkdirSync(logsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware de seguridad
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.use(cors({
|
||||||
|
origin: config.FRONTEND_URL,
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: config.RATE_LIMIT.WINDOW_MS,
|
||||||
|
max: config.RATE_LIMIT.MAX_REQUESTS,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
message: 'Demasiadas peticiones, por favor intenta más tarde',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.use('/api/', limiter);
|
||||||
|
|
||||||
|
// Logging HTTP
|
||||||
|
app.use(morgan('combined', {
|
||||||
|
stream: {
|
||||||
|
write: (message: string) => logger.info(message.trim()),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Parsing de body
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Rutas API
|
||||||
|
app.use('/api/v1', routes);
|
||||||
|
|
||||||
|
// Ruta raíz
|
||||||
|
app.get('/', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '🎾 API de Canchas de Pádel',
|
||||||
|
version: '1.0.0',
|
||||||
|
docs: '/api/v1/health',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handler de rutas no encontradas
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
|
||||||
|
// Handler de errores global
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// Conectar a BD y iniciar servidor
|
||||||
|
const startServer = async () => {
|
||||||
|
try {
|
||||||
|
// Conectar a base de datos
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
// Iniciar servidor
|
||||||
|
app.listen(config.PORT, () => {
|
||||||
|
logger.info(`🚀 Servidor corriendo en http://localhost:${config.PORT}`);
|
||||||
|
logger.info(`📚 API disponible en http://localhost:${config.PORT}/api/v1`);
|
||||||
|
logger.info(`🏥 Health check: http://localhost:${config.PORT}/api/v1/health`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error al iniciar el servidor:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manejo de errores no capturados
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
logger.error('Uncaught Exception:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
logger.error('Unhandled Rejection:', reason);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
logger.info('SIGTERM recibido, cerrando servidor...');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
logger.info('SIGINT recibido, cerrando servidor...');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Iniciar
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
export default app;
|
||||||
93
backend/src/middleware/auth.ts
Normal file
93
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
|
||||||
|
// Extender el tipo Request de Express para incluir usuario
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: TokenPayload & { id: string };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware de autenticación
|
||||||
|
export const authenticate = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Token de autenticación no proporcionado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Token no válido',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar token
|
||||||
|
const decoded = verifyAccessToken(token);
|
||||||
|
|
||||||
|
// Verificar que el usuario existe y está activo
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId },
|
||||||
|
select: { id: true, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Usuario no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isActive) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Usuario desactivado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Añadir usuario al request
|
||||||
|
req.user = { ...decoded, id: user.id };
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Token inválido o expirado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware de autorización por roles
|
||||||
|
export const authorize = (...roles: string[]) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No autenticado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No tienes permisos para realizar esta acción',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
88
backend/src/middleware/errorHandler.ts
Normal file
88
backend/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import logger from '../config/logger';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
// Clase personalizada para errores de API
|
||||||
|
export class ApiError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
isOperational: boolean;
|
||||||
|
|
||||||
|
constructor(message: string, statusCode: number, isOperational = true) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.isOperational = isOperational;
|
||||||
|
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handler global
|
||||||
|
export const errorHandler = (
|
||||||
|
err: Error | ApiError,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
_next: NextFunction
|
||||||
|
) => {
|
||||||
|
let statusCode = 500;
|
||||||
|
let message = 'Error interno del servidor';
|
||||||
|
|
||||||
|
// Si es un error de API conocido
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
statusCode = err.statusCode;
|
||||||
|
message = err.message;
|
||||||
|
} else if (err.name === 'PrismaClientKnownRequestError') {
|
||||||
|
// Errores de Prisma
|
||||||
|
statusCode = 400;
|
||||||
|
message = 'Error en la base de datos';
|
||||||
|
|
||||||
|
// Error de unique constraint
|
||||||
|
if ((err as any).code === 'P2002') {
|
||||||
|
statusCode = 409;
|
||||||
|
const field = (err as any).meta?.target?.[0] || 'campo';
|
||||||
|
message = `Ya existe un registro con ese ${field}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error de registro no encontrado
|
||||||
|
if ((err as any).code === 'P2025') {
|
||||||
|
statusCode = 404;
|
||||||
|
message = 'Registro no encontrado';
|
||||||
|
}
|
||||||
|
} else if (err.name === 'JsonWebTokenError') {
|
||||||
|
statusCode = 401;
|
||||||
|
message = 'Token inválido';
|
||||||
|
} else if (err.name === 'TokenExpiredError') {
|
||||||
|
statusCode = 401;
|
||||||
|
message = 'Token expirado';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log del error
|
||||||
|
logger.error({
|
||||||
|
message: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
statusCode,
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Respuesta al cliente
|
||||||
|
const response: any = {
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Incluir stack trace solo en desarrollo
|
||||||
|
if (config.NODE_ENV === 'development') {
|
||||||
|
response.stack = err.stack;
|
||||||
|
response.error = err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler para rutas no encontradas
|
||||||
|
export const notFoundHandler = (req: Request, res: Response) => {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: `Ruta no encontrada: ${req.method} ${req.path}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
73
backend/src/middleware/validate.ts
Normal file
73
backend/src/middleware/validate.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ZodSchema, ZodError } from 'zod';
|
||||||
|
|
||||||
|
export const validate = (schema: ZodSchema) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
schema.parse(req.body);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
const errors = error.errors.map((err) => ({
|
||||||
|
field: err.path.join('.'),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error de validación',
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validar query params
|
||||||
|
export const validateQuery = (schema: ZodSchema) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
schema.parse(req.query);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
const errors = error.errors.map((err) => ({
|
||||||
|
field: err.path.join('.'),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error de validación en query params',
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validar params de URL
|
||||||
|
export const validateParams = (schema: ZodSchema) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
schema.parse(req.params);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
const errors = error.errors.map((err) => ({
|
||||||
|
field: err.path.join('.'),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error de validación en parámetros',
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
24
backend/src/routes/auth.routes.ts
Normal file
24
backend/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { AuthController } from '../controllers/auth.controller';
|
||||||
|
import { validate } from '../middleware/validate';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { registerSchema, loginSchema } from '../validators/auth.validator';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// POST /api/v1/auth/register - Registro
|
||||||
|
router.post('/register', validate(registerSchema), AuthController.register);
|
||||||
|
|
||||||
|
// POST /api/v1/auth/login - Login
|
||||||
|
router.post('/login', validate(loginSchema), AuthController.login);
|
||||||
|
|
||||||
|
// POST /api/v1/auth/refresh - Refresh token
|
||||||
|
router.post('/refresh', AuthController.refreshToken);
|
||||||
|
|
||||||
|
// POST /api/v1/auth/logout - Logout
|
||||||
|
router.post('/logout', AuthController.logout);
|
||||||
|
|
||||||
|
// GET /api/v1/auth/me - Perfil del usuario (protegido)
|
||||||
|
router.get('/me', authenticate, AuthController.getProfile);
|
||||||
|
|
||||||
|
export default router;
|
||||||
36
backend/src/routes/booking.routes.ts
Normal file
36
backend/src/routes/booking.routes.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { BookingController } from '../controllers/booking.controller';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth';
|
||||||
|
import { validate } from '../middleware/validate';
|
||||||
|
import { UserRole } from '../utils/constants';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Schema de validación para crear reserva
|
||||||
|
const createBookingSchema = z.object({
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido'),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD'),
|
||||||
|
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'),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema para actualizar reserva
|
||||||
|
const updateBookingSchema = z.object({
|
||||||
|
status: z.enum(['PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW']).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rutas protegidas para usuarios autenticados
|
||||||
|
router.post('/', authenticate, validate(createBookingSchema), BookingController.createBooking);
|
||||||
|
router.get('/my-bookings', authenticate, BookingController.getMyBookings);
|
||||||
|
router.get('/:id', authenticate, BookingController.getBookingById);
|
||||||
|
router.put('/:id', authenticate, validate(updateBookingSchema), BookingController.updateBooking);
|
||||||
|
router.delete('/:id', authenticate, BookingController.cancelBooking);
|
||||||
|
|
||||||
|
// Rutas de admin
|
||||||
|
router.get('/', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), BookingController.getAllBookings);
|
||||||
|
router.put('/:id/confirm', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), BookingController.confirmBooking);
|
||||||
|
|
||||||
|
export default router;
|
||||||
18
backend/src/routes/court.routes.ts
Normal file
18
backend/src/routes/court.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { CourtController } from '../controllers/court.controller';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth';
|
||||||
|
import { UserRole } from '../utils/constants';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Rutas públicas
|
||||||
|
router.get('/', CourtController.getAllCourts);
|
||||||
|
router.get('/:id', CourtController.getCourtById);
|
||||||
|
router.get('/:id/availability', CourtController.getAvailability);
|
||||||
|
|
||||||
|
// Rutas protegidas (solo admin)
|
||||||
|
router.post('/', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), CourtController.createCourt);
|
||||||
|
router.put('/:id', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), CourtController.updateCourt);
|
||||||
|
router.delete('/:id', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), CourtController.deleteCourt);
|
||||||
|
|
||||||
|
export default router;
|
||||||
26
backend/src/routes/index.ts
Normal file
26
backend/src/routes/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import authRoutes from './auth.routes';
|
||||||
|
import courtRoutes from './court.routes';
|
||||||
|
import bookingRoutes from './booking.routes';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
router.get('/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'API funcionando correctamente',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rutas de autenticación
|
||||||
|
router.use('/auth', authRoutes);
|
||||||
|
|
||||||
|
// Rutas de canchas
|
||||||
|
router.use('/courts', courtRoutes);
|
||||||
|
|
||||||
|
// Rutas de reservas
|
||||||
|
router.use('/bookings', bookingRoutes);
|
||||||
|
|
||||||
|
export default router;
|
||||||
186
backend/src/services/auth.service.ts
Normal file
186
backend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { hashPassword, comparePassword } from '../utils/password';
|
||||||
|
import { generateAccessToken, generateRefreshToken } from '../utils/jwt';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import { RegisterInput, LoginInput } from '../validators/auth.validator';
|
||||||
|
import { sendWelcomeEmail } from './email.service';
|
||||||
|
import logger from '../config/logger';
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
// Registro de usuario
|
||||||
|
static async register(data: RegisterInput) {
|
||||||
|
// Verificar si el email ya existe
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: data.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ApiError('El email ya está registrado', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashear password
|
||||||
|
const hashedPassword = await hashPassword(data.password);
|
||||||
|
|
||||||
|
// Crear usuario
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: data.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
phone: data.phone,
|
||||||
|
playerLevel: data.playerLevel,
|
||||||
|
handPreference: data.handPreference,
|
||||||
|
positionPreference: data.positionPreference,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
playerLevel: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generar tokens
|
||||||
|
const payload = {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(payload);
|
||||||
|
const refreshToken = generateRefreshToken(payload);
|
||||||
|
|
||||||
|
// Enviar email de bienvenida (no bloqueante)
|
||||||
|
try {
|
||||||
|
await sendWelcomeEmail({
|
||||||
|
to: user.email,
|
||||||
|
name: `${user.firstName} ${user.lastName}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error enviando email de bienvenida:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login
|
||||||
|
static async login(data: LoginInput) {
|
||||||
|
// Buscar usuario por email
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: data.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApiError('Email o contraseña incorrectos', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar si está activo
|
||||||
|
if (!user.isActive) {
|
||||||
|
throw new ApiError('Usuario desactivado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparar passwords
|
||||||
|
const isPasswordValid = await comparePassword(data.password, user.password);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new ApiError('Email o contraseña incorrectos', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar último login
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLogin: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generar tokens
|
||||||
|
const payload = {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(payload);
|
||||||
|
const refreshToken = generateRefreshToken(payload);
|
||||||
|
|
||||||
|
// Retornar usuario sin password
|
||||||
|
const { password, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userWithoutPassword,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener perfil del usuario actual
|
||||||
|
static async getProfile(userId: string) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
phone: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
role: true,
|
||||||
|
playerLevel: true,
|
||||||
|
handPreference: true,
|
||||||
|
positionPreference: true,
|
||||||
|
bio: true,
|
||||||
|
isActive: true,
|
||||||
|
lastLogin: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
bookings: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApiError('Usuario no encontrado', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
static async refreshToken(refreshToken: string) {
|
||||||
|
const { verifyRefreshToken, generateAccessToken } = await import('../utils/jwt');
|
||||||
|
|
||||||
|
const decoded = verifyRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
// Verificar que el usuario existe
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId },
|
||||||
|
select: { id: true, email: true, role: true, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
throw new ApiError('Token inválido', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar nuevo access token
|
||||||
|
const accessToken = generateAccessToken({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accessToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthService;
|
||||||
353
backend/src/services/booking.service.ts
Normal file
353
backend/src/services/booking.service.ts
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import { BookingStatus } from '../utils/constants';
|
||||||
|
import { sendBookingConfirmation, sendBookingCancellation } from './email.service';
|
||||||
|
import logger from '../config/logger';
|
||||||
|
|
||||||
|
export interface CreateBookingInput {
|
||||||
|
userId: string;
|
||||||
|
courtId: string;
|
||||||
|
date: Date;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateBookingInput {
|
||||||
|
status?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingService {
|
||||||
|
// Crear una reserva
|
||||||
|
static async createBooking(data: CreateBookingInput) {
|
||||||
|
// Validar que la fecha no sea en el pasado
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const bookingDate = new Date(data.date);
|
||||||
|
bookingDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (bookingDate < today) {
|
||||||
|
throw new ApiError('No se pueden hacer reservas en fechas pasadas', 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
|
||||||
|
const dayOfWeek = bookingDate.getDay();
|
||||||
|
const schedule = await prisma.courtSchedule.findFirst({
|
||||||
|
where: {
|
||||||
|
courtId: data.courtId,
|
||||||
|
dayOfWeek,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new ApiError('La cancha no tiene horario disponible para este día', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que la hora de inicio y fin estén dentro del horario
|
||||||
|
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 cancha esté disponible en ese horario
|
||||||
|
const existingBooking = await prisma.booking.findFirst({
|
||||||
|
where: {
|
||||||
|
courtId: data.courtId,
|
||||||
|
date: data.date,
|
||||||
|
status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] },
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
// Nueva reserva empieza durante una existente
|
||||||
|
startTime: { lt: data.endTime },
|
||||||
|
endTime: { gt: data.startTime },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingBooking) {
|
||||||
|
throw new ApiError('La cancha no está disponible en ese horario', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular precio (precio por hora * número de horas)
|
||||||
|
const startHour = parseInt(data.startTime.split(':')[0]);
|
||||||
|
const endHour = parseInt(data.endTime.split(':')[0]);
|
||||||
|
const hours = endHour - startHour;
|
||||||
|
const totalPrice = court.pricePerHour * hours;
|
||||||
|
|
||||||
|
// Crear la reserva
|
||||||
|
const booking = await prisma.booking.create({
|
||||||
|
data: {
|
||||||
|
userId: data.userId,
|
||||||
|
courtId: data.courtId,
|
||||||
|
date: data.date,
|
||||||
|
startTime: data.startTime,
|
||||||
|
endTime: data.endTime,
|
||||||
|
status: BookingStatus.PENDING,
|
||||||
|
totalPrice,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enviar email de confirmación (no bloqueante)
|
||||||
|
try {
|
||||||
|
await sendBookingConfirmation({
|
||||||
|
to: booking.user.email,
|
||||||
|
name: `${booking.user.firstName} ${booking.user.lastName}`,
|
||||||
|
courtName: booking.court.name,
|
||||||
|
date: booking.date.toISOString().split('T')[0],
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
totalPrice: booking.totalPrice,
|
||||||
|
bookingId: booking.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error enviando email de confirmación:', error);
|
||||||
|
// No fallar la reserva si el email falla
|
||||||
|
}
|
||||||
|
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener todas las reservas (con filtros)
|
||||||
|
static async getAllBookings(filters: {
|
||||||
|
userId?: string;
|
||||||
|
courtId?: string;
|
||||||
|
date?: Date;
|
||||||
|
status?: string;
|
||||||
|
}) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (filters.userId) where.userId = filters.userId;
|
||||||
|
if (filters.courtId) where.courtId = filters.courtId;
|
||||||
|
if (filters.date) where.date = filters.date;
|
||||||
|
if (filters.status) where.status = filters.status;
|
||||||
|
|
||||||
|
return prisma.booking.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ date: 'desc' },
|
||||||
|
{ startTime: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener reserva por ID
|
||||||
|
static async getBookingById(id: string) {
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
court: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new ApiError('Reserva no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener reservas de un usuario
|
||||||
|
static async getUserBookings(userId: string, upcoming = false) {
|
||||||
|
const where: any = { userId };
|
||||||
|
|
||||||
|
if (upcoming) {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
where.date = { gte: today };
|
||||||
|
where.status = { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.booking.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
imageUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ date: 'desc' },
|
||||||
|
{ startTime: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar una reserva
|
||||||
|
static async updateBooking(id: string, data: UpdateBookingInput, userId?: string) {
|
||||||
|
const booking = await this.getBookingById(id);
|
||||||
|
|
||||||
|
// Si se proporciona userId, verificar que sea el dueño o admin
|
||||||
|
if (userId && booking.userId !== userId) {
|
||||||
|
throw new ApiError('No tienes permiso para modificar esta reserva', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.booking.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelar una reserva
|
||||||
|
static async cancelBooking(id: string, userId?: string) {
|
||||||
|
const booking = await this.getBookingById(id);
|
||||||
|
|
||||||
|
// Si se proporciona userId, verificar que sea el dueño o admin
|
||||||
|
if (userId && booking.userId !== userId) {
|
||||||
|
throw new ApiError('No tienes permiso para cancelar esta reserva', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que no esté ya cancelada o completada
|
||||||
|
if (booking.status === BookingStatus.CANCELLED) {
|
||||||
|
throw new ApiError('La reserva ya está cancelada', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status === BookingStatus.COMPLETED) {
|
||||||
|
throw new ApiError('No se puede cancelar una reserva completada', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.booking.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: BookingStatus.CANCELLED },
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enviar email de cancelación (no bloqueante)
|
||||||
|
try {
|
||||||
|
await sendBookingCancellation({
|
||||||
|
to: updated.user.email,
|
||||||
|
name: `${updated.user.firstName} ${updated.user.lastName}`,
|
||||||
|
courtName: updated.court.name,
|
||||||
|
date: updated.date.toISOString().split('T')[0],
|
||||||
|
startTime: updated.startTime,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error enviando email de cancelación:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmar una reserva (admin)
|
||||||
|
static async confirmBooking(id: string) {
|
||||||
|
const booking = await this.getBookingById(id);
|
||||||
|
|
||||||
|
if (booking.status !== BookingStatus.PENDING) {
|
||||||
|
throw new ApiError('Solo se pueden confirmar reservas pendientes', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.booking.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: BookingStatus.CONFIRMED },
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BookingService;
|
||||||
232
backend/src/services/court.service.ts
Normal file
232
backend/src/services/court.service.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
import { CourtType } from '../utils/constants';
|
||||||
|
|
||||||
|
export interface CreateCourtInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type?: string;
|
||||||
|
isIndoor?: boolean;
|
||||||
|
hasLighting?: boolean;
|
||||||
|
hasParking?: boolean;
|
||||||
|
pricePerHour?: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCourtInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
type?: string;
|
||||||
|
isIndoor?: boolean;
|
||||||
|
hasLighting?: boolean;
|
||||||
|
hasParking?: boolean;
|
||||||
|
pricePerHour?: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CourtService {
|
||||||
|
// Obtener todas las canchas activas
|
||||||
|
static async getAllCourts(includeInactive = false) {
|
||||||
|
const where = includeInactive ? {} : { isActive: true };
|
||||||
|
|
||||||
|
return prisma.court.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
schedules: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
bookings: {
|
||||||
|
where: {
|
||||||
|
status: { in: ['PENDING', 'CONFIRMED'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener una cancha por ID
|
||||||
|
static async getCourtById(id: string) {
|
||||||
|
const court = await prisma.court.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
schedules: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
bookings: {
|
||||||
|
where: {
|
||||||
|
status: { in: ['PENDING', 'CONFIRMED'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!court) {
|
||||||
|
throw new ApiError('Cancha no encontrada', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return court;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear una nueva cancha
|
||||||
|
static async createCourt(data: CreateCourtInput) {
|
||||||
|
// Verificar si ya existe una cancha con ese nombre
|
||||||
|
const existing = await prisma.court.findUnique({
|
||||||
|
where: { name: data.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ApiError('Ya existe una cancha con ese nombre', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const court = await prisma.court.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
type: data.type || CourtType.PANORAMIC,
|
||||||
|
isIndoor: data.isIndoor ?? false,
|
||||||
|
hasLighting: data.hasLighting ?? true,
|
||||||
|
hasParking: data.hasParking ?? false,
|
||||||
|
pricePerHour: data.pricePerHour || 2000,
|
||||||
|
imageUrl: data.imageUrl,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
schedules: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crear horarios por defecto (Lunes a Domingo, 8:00 - 23:00)
|
||||||
|
const defaultSchedules = [];
|
||||||
|
for (let day = 0; day <= 6; day++) {
|
||||||
|
defaultSchedules.push({
|
||||||
|
courtId: court.id,
|
||||||
|
dayOfWeek: day,
|
||||||
|
openTime: '08:00',
|
||||||
|
closeTime: '23:00',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.courtSchedule.createMany({
|
||||||
|
data: defaultSchedules,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getCourtById(court.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar una cancha
|
||||||
|
static async updateCourt(id: string, data: UpdateCourtInput) {
|
||||||
|
// Verificar que existe
|
||||||
|
await this.getCourtById(id);
|
||||||
|
|
||||||
|
// Si se quiere cambiar el nombre, verificar que no exista otro
|
||||||
|
if (data.name) {
|
||||||
|
const existing = await prisma.court.findFirst({
|
||||||
|
where: {
|
||||||
|
name: data.name,
|
||||||
|
id: { not: id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ApiError('Ya existe otra cancha con ese nombre', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.court.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
schedules: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar (desactivar) una cancha
|
||||||
|
static async deleteCourt(id: string) {
|
||||||
|
await this.getCourtById(id);
|
||||||
|
|
||||||
|
// En lugar de eliminar, desactivamos
|
||||||
|
return prisma.court.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener disponibilidad de una cancha para una fecha específica
|
||||||
|
static async getAvailability(courtId: string, date: Date) {
|
||||||
|
const court = await this.getCourtById(courtId);
|
||||||
|
|
||||||
|
// Obtener día de la semana (0=Domingo, 6=Sábado)
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
|
||||||
|
// Buscar horario para ese día
|
||||||
|
const schedule = court.schedules.find(s => s.dayOfWeek === dayOfWeek);
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
return { available: false, reason: 'La cancha no tiene horario para este día' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener reservas existentes para esa fecha
|
||||||
|
const existingBookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
courtId,
|
||||||
|
date,
|
||||||
|
status: { in: ['PENDING', 'CONFIRMED'] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generar slots disponibles (intervalos de 1 hora)
|
||||||
|
const slots = this.generateTimeSlots(
|
||||||
|
schedule.openTime,
|
||||||
|
schedule.closeTime,
|
||||||
|
existingBookings,
|
||||||
|
court.pricePerHour
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
courtId,
|
||||||
|
date,
|
||||||
|
openTime: schedule.openTime,
|
||||||
|
closeTime: schedule.closeTime,
|
||||||
|
slots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar slots de tiempo disponibles
|
||||||
|
private static generateTimeSlots(
|
||||||
|
openTime: string,
|
||||||
|
closeTime: string,
|
||||||
|
existingBookings: { startTime: string; endTime: string }[],
|
||||||
|
pricePerHour: number
|
||||||
|
) {
|
||||||
|
const slots = [];
|
||||||
|
const start = parseInt(openTime.split(':')[0]);
|
||||||
|
const end = parseInt(closeTime.split(':')[0]);
|
||||||
|
|
||||||
|
for (let hour = start; hour < end; hour++) {
|
||||||
|
const timeString = `${hour.toString().padStart(2, '0')}:00`;
|
||||||
|
const isBooked = existingBookings.some(
|
||||||
|
b => b.startTime <= timeString && b.endTime > timeString
|
||||||
|
);
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
time: timeString,
|
||||||
|
available: !isBooked,
|
||||||
|
price: pricePerHour,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CourtService;
|
||||||
262
backend/src/services/email.service.ts
Normal file
262
backend/src/services/email.service.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import config from '../config';
|
||||||
|
import logger from '../config/logger';
|
||||||
|
|
||||||
|
// Crear transportador de nodemailer
|
||||||
|
const createTransporter = () => {
|
||||||
|
// Si no hay configuración SMTP, usar modo de prueba (ethereal)
|
||||||
|
if (!config.SMTP.USER || !config.SMTP.PASS) {
|
||||||
|
logger.info('SMTP no configurado, usando modo de desarrollo (sin envío real)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: config.SMTP.HOST,
|
||||||
|
port: config.SMTP.PORT,
|
||||||
|
secure: false, // true para 465, false para otros puertos
|
||||||
|
auth: {
|
||||||
|
user: config.SMTP.USER,
|
||||||
|
pass: config.SMTP.PASS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const transporter = createTransporter();
|
||||||
|
|
||||||
|
// Verificar conexión SMTP
|
||||||
|
export const verifySMTP = async (): Promise<boolean> => {
|
||||||
|
if (!transporter) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.verify();
|
||||||
|
logger.info('✅ Conexión SMTP verificada');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error verificando conexión SMTP:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enviar email genérico
|
||||||
|
export const sendEmail = async ({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text?: string;
|
||||||
|
}) => {
|
||||||
|
// Si no hay transportador, solo loggear en desarrollo
|
||||||
|
if (!transporter) {
|
||||||
|
logger.info(`[EMAIL SIMULADO] Para: ${to}, Asunto: ${subject}`);
|
||||||
|
return { messageId: 'simulated', simulated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: config.EMAIL_FROM,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text: text || html.replace(/<[^>]*>/g, ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`✅ Email enviado: ${info.messageId}`);
|
||||||
|
return { messageId: info.messageId, simulated: false };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error enviando email:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Email de confirmación de reserva
|
||||||
|
export const sendBookingConfirmation = async ({
|
||||||
|
to,
|
||||||
|
name,
|
||||||
|
courtName,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
totalPrice,
|
||||||
|
bookingId,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
name: string;
|
||||||
|
courtName: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
totalPrice: number;
|
||||||
|
bookingId: string;
|
||||||
|
}) => {
|
||||||
|
const priceFormatted = (totalPrice / 100).toFixed(2);
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #16a34a;">✅ ¡Reserva Confirmada!</h2>
|
||||||
|
<p>Hola ${name},</p>
|
||||||
|
<p>Tu reserva ha sido registrada exitosamente. Aquí están los detalles:</p>
|
||||||
|
|
||||||
|
<div style="background: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Detalles de la Reserva</h3>
|
||||||
|
<p><strong>Cancha:</strong> ${courtName}</p>
|
||||||
|
<p><strong>Fecha:</strong> ${date}</p>
|
||||||
|
<p><strong>Horario:</strong> ${startTime} - ${endTime}</p>
|
||||||
|
<p><strong>Precio Total:</strong> €${priceFormatted}</p>
|
||||||
|
<p><strong>ID de Reserva:</strong> ${bookingId}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Recuerda llegar 10 minutos antes de tu horario.</p>
|
||||||
|
<p>Si necesitas cancelar o modificar tu reserva, puedes hacerlo desde la app.</p>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0;" />
|
||||||
|
<p style="color: #6b7280; font-size: 12px;">
|
||||||
|
Este es un email automático, por favor no respondas a esta dirección.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return sendEmail({
|
||||||
|
to,
|
||||||
|
subject: '🎾 Reserva Confirmada - Canchas de Pádel',
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Email de cancelación de reserva
|
||||||
|
export const sendBookingCancellation = async ({
|
||||||
|
to,
|
||||||
|
name,
|
||||||
|
courtName,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
name: string;
|
||||||
|
courtName: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
}) => {
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #dc2626;">❌ Reserva Cancelada</h2>
|
||||||
|
<p>Hola ${name},</p>
|
||||||
|
<p>Tu reserva ha sido cancelada. Aquí están los detalles:</p>
|
||||||
|
|
||||||
|
<div style="background: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Detalles de la Reserva Cancelada</h3>
|
||||||
|
<p><strong>Cancha:</strong> ${courtName}</p>
|
||||||
|
<p><strong>Fecha:</strong> ${date}</p>
|
||||||
|
<p><strong>Hora:</strong> ${startTime}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Si tienes alguna pregunta, contacta con nosotros.</p>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0;" />
|
||||||
|
<p style="color: #6b7280; font-size: 12px;">
|
||||||
|
Este es un email automático, por favor no respondas a esta dirección.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return sendEmail({
|
||||||
|
to,
|
||||||
|
subject: '🎾 Reserva Cancelada - Canchas de Pádel',
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Email de recordatorio (24h antes)
|
||||||
|
export const sendBookingReminder = async ({
|
||||||
|
to,
|
||||||
|
name,
|
||||||
|
courtName,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
name: string;
|
||||||
|
courtName: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
}) => {
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #2563eb;">⏰ Recordatorio de Reserva</h2>
|
||||||
|
<p>Hola ${name},</p>
|
||||||
|
<p>Te recordamos que tienes una reserva mañana:</p>
|
||||||
|
|
||||||
|
<div style="background: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Detalles</h3>
|
||||||
|
<p><strong>Cancha:</strong> ${courtName}</p>
|
||||||
|
<p><strong>Fecha:</strong> ${date}</p>
|
||||||
|
<p><strong>Hora:</strong> ${startTime}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>¡Te esperamos! 🎾</p>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0;" />
|
||||||
|
<p style="color: #6b7280; font-size: 12px;">
|
||||||
|
Este es un email automático, por favor no respondas a esta dirección.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return sendEmail({
|
||||||
|
to,
|
||||||
|
subject: '🎾 Recordatorio: Tu reserva es mañana',
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Email de bienvenida
|
||||||
|
export const sendWelcomeEmail = async ({
|
||||||
|
to,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
name: string;
|
||||||
|
}) => {
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #16a34a;">🎾 ¡Bienvenido a Canchas de Pádel!</h2>
|
||||||
|
<p>Hola ${name},</p>
|
||||||
|
<p>Tu cuenta ha sido creada exitosamente. Ya puedes comenzar a reservar canchas.</p>
|
||||||
|
|
||||||
|
<div style="background: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">¿Qué puedes hacer?</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Reservar canchas en tiempo real</li>
|
||||||
|
<li>Ver disponibilidad de horarios</li>
|
||||||
|
<li>Gestionar tus reservas</li>
|
||||||
|
<li>Participar en torneos y ligas</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>¡Nos vemos en la cancha! 🎾</p>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0;" />
|
||||||
|
<p style="color: #6b7280; font-size: 12px;">
|
||||||
|
Este es un email automático, por favor no respondas a esta dirección.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return sendEmail({
|
||||||
|
to,
|
||||||
|
subject: '🎾 ¡Bienvenido a Canchas de Pádel!',
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
sendEmail,
|
||||||
|
sendBookingConfirmation,
|
||||||
|
sendBookingCancellation,
|
||||||
|
sendBookingReminder,
|
||||||
|
sendWelcomeEmail,
|
||||||
|
verifySMTP,
|
||||||
|
};
|
||||||
55
backend/src/utils/constants.ts
Normal file
55
backend/src/utils/constants.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Constantes para reemplazar enums (SQLite no soporta enums nativamente)
|
||||||
|
|
||||||
|
export const UserRole = {
|
||||||
|
PLAYER: 'PLAYER',
|
||||||
|
ADMIN: 'ADMIN',
|
||||||
|
SUPERADMIN: 'SUPERADMIN',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UserRoleType = typeof UserRole[keyof typeof UserRole];
|
||||||
|
|
||||||
|
export const PlayerLevel = {
|
||||||
|
BEGINNER: 'BEGINNER',
|
||||||
|
ELEMENTARY: 'ELEMENTARY',
|
||||||
|
INTERMEDIATE: 'INTERMEDIATE',
|
||||||
|
ADVANCED: 'ADVANCED',
|
||||||
|
COMPETITION: 'COMPETITION',
|
||||||
|
PROFESSIONAL: 'PROFESSIONAL',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PlayerLevelType = typeof PlayerLevel[keyof typeof PlayerLevel];
|
||||||
|
|
||||||
|
export const HandPreference = {
|
||||||
|
RIGHT: 'RIGHT',
|
||||||
|
LEFT: 'LEFT',
|
||||||
|
BOTH: 'BOTH',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type HandPreferenceType = typeof HandPreference[keyof typeof HandPreference];
|
||||||
|
|
||||||
|
export const PositionPreference = {
|
||||||
|
DRIVE: 'DRIVE',
|
||||||
|
BACKHAND: 'BACKHAND',
|
||||||
|
BOTH: 'BOTH',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PositionPreferenceType = typeof PositionPreference[keyof typeof PositionPreference];
|
||||||
|
|
||||||
|
export const BookingStatus = {
|
||||||
|
PENDING: 'PENDING',
|
||||||
|
CONFIRMED: 'CONFIRMED',
|
||||||
|
CANCELLED: 'CANCELLED',
|
||||||
|
COMPLETED: 'COMPLETED',
|
||||||
|
NO_SHOW: 'NO_SHOW',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BookingStatusType = typeof BookingStatus[keyof typeof BookingStatus];
|
||||||
|
|
||||||
|
export const CourtType = {
|
||||||
|
PANORAMIC: 'PANORAMIC',
|
||||||
|
OUTDOOR: 'OUTDOOR',
|
||||||
|
INDOOR: 'INDOOR',
|
||||||
|
SINGLE: 'SINGLE',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CourtTypeType = typeof CourtType[keyof typeof CourtType];
|
||||||
37
backend/src/utils/jwt.ts
Normal file
37
backend/src/utils/jwt.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
export interface TokenPayload {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar access token
|
||||||
|
export const generateAccessToken = (payload: TokenPayload): string => {
|
||||||
|
return jwt.sign(payload, config.JWT_SECRET, {
|
||||||
|
expiresIn: config.JWT_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generar refresh token
|
||||||
|
export const generateRefreshToken = (payload: TokenPayload): string => {
|
||||||
|
return jwt.sign(payload, config.JWT_REFRESH_SECRET, {
|
||||||
|
expiresIn: config.JWT_REFRESH_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verificar access token
|
||||||
|
export const verifyAccessToken = (token: string): TokenPayload => {
|
||||||
|
return jwt.verify(token, config.JWT_SECRET) as TokenPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verificar refresh token
|
||||||
|
export const verifyRefreshToken = (token: string): TokenPayload => {
|
||||||
|
return jwt.verify(token, config.JWT_REFRESH_SECRET) as TokenPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decodificar token sin verificar (para debugging)
|
||||||
|
export const decodeToken = (token: string) => {
|
||||||
|
return jwt.decode(token);
|
||||||
|
};
|
||||||
16
backend/src/utils/password.ts
Normal file
16
backend/src/utils/password.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
|
// Hash de password
|
||||||
|
export const hashPassword = async (password: string): Promise<string> => {
|
||||||
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Comparar password con hash
|
||||||
|
export const comparePassword = async (
|
||||||
|
password: string,
|
||||||
|
hashedPassword: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
return bcrypt.compare(password, hashedPassword);
|
||||||
|
};
|
||||||
89
backend/src/validators/auth.validator.ts
Normal file
89
backend/src/validators/auth.validator.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { PlayerLevel, HandPreference, PositionPreference } from '../utils/constants';
|
||||||
|
|
||||||
|
// Esquema de registro
|
||||||
|
export const registerSchema = z.object({
|
||||||
|
email: z.string().email('Email inválido'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'La contraseña debe tener al menos 8 caracteres')
|
||||||
|
.regex(/[A-Z]/, 'Debe contener al menos una mayúscula')
|
||||||
|
.regex(/[a-z]/, 'Debe contener al menos una minúscula')
|
||||||
|
.regex(/[0-9]/, 'Debe contener al menos un número'),
|
||||||
|
firstName: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
|
||||||
|
lastName: z.string().min(2, 'El apellido debe tener al menos 2 caracteres'),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
playerLevel: z.enum([
|
||||||
|
PlayerLevel.BEGINNER,
|
||||||
|
PlayerLevel.ELEMENTARY,
|
||||||
|
PlayerLevel.INTERMEDIATE,
|
||||||
|
PlayerLevel.ADVANCED,
|
||||||
|
PlayerLevel.COMPETITION,
|
||||||
|
PlayerLevel.PROFESSIONAL,
|
||||||
|
]).optional(),
|
||||||
|
handPreference: z.enum([
|
||||||
|
HandPreference.RIGHT,
|
||||||
|
HandPreference.LEFT,
|
||||||
|
HandPreference.BOTH,
|
||||||
|
]).optional(),
|
||||||
|
positionPreference: z.enum([
|
||||||
|
PositionPreference.DRIVE,
|
||||||
|
PositionPreference.BACKHAND,
|
||||||
|
PositionPreference.BOTH,
|
||||||
|
]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Esquema de login
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email('Email inválido'),
|
||||||
|
password: z.string().min(1, 'La contraseña es requerida'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Esquema de actualización de usuario
|
||||||
|
export const updateUserSchema = z.object({
|
||||||
|
firstName: z.string().min(2).optional(),
|
||||||
|
lastName: z.string().min(2).optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
playerLevel: z.enum([
|
||||||
|
PlayerLevel.BEGINNER,
|
||||||
|
PlayerLevel.ELEMENTARY,
|
||||||
|
PlayerLevel.INTERMEDIATE,
|
||||||
|
PlayerLevel.ADVANCED,
|
||||||
|
PlayerLevel.COMPETITION,
|
||||||
|
PlayerLevel.PROFESSIONAL,
|
||||||
|
]).optional(),
|
||||||
|
handPreference: z.enum([
|
||||||
|
HandPreference.RIGHT,
|
||||||
|
HandPreference.LEFT,
|
||||||
|
HandPreference.BOTH,
|
||||||
|
]).optional(),
|
||||||
|
positionPreference: z.enum([
|
||||||
|
PositionPreference.DRIVE,
|
||||||
|
PositionPreference.BACKHAND,
|
||||||
|
PositionPreference.BOTH,
|
||||||
|
]).optional(),
|
||||||
|
bio: z.string().max(500, 'La biografía no puede exceder 500 caracteres').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Esquema de forgot password
|
||||||
|
export const forgotPasswordSchema = z.object({
|
||||||
|
email: z.string().email('Email inválido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Esquema de reset password
|
||||||
|
export const resetPasswordSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'La contraseña debe tener al menos 8 caracteres')
|
||||||
|
.regex(/[A-Z]/, 'Debe contener al menos una mayúscula')
|
||||||
|
.regex(/[a-z]/, 'Debe contener al menos una minúscula')
|
||||||
|
.regex(/[0-9]/, 'Debe contener al menos un número'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tipos inferidos
|
||||||
|
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
|
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||||
|
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
|
||||||
|
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||||
32
backend/tsconfig.json
Normal file
32
backend/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"removeComments": true,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@config/*": ["src/config/*"],
|
||||||
|
"@controllers/*": ["src/controllers/*"],
|
||||||
|
"@middleware/*": ["src/middleware/*"],
|
||||||
|
"@routes/*": ["src/routes/*"],
|
||||||
|
"@services/*": ["src/services/*"],
|
||||||
|
"@utils/*": ["src/utils/*"],
|
||||||
|
"@validators/*": ["src/validators/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
44
docker-compose.yml
Normal file
44
docker-compose.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Base de datos PostgreSQL
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: app-padel-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: app_padel
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Redis (para caché y sesiones - opcional por ahora)
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: app-padel-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
# Servidor de correo para desarrollo
|
||||||
|
mailhog:
|
||||||
|
image: mailhog/mailhog:latest
|
||||||
|
container_name: app-padel-mailhog
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1025:1025" # SMTP
|
||||||
|
- "8025:8025" # Web UI
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
165
docs/arquitectura/STACK.md
Normal file
165
docs/arquitectura/STACK.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Stack Tecnológico - App Canchas de Pádel
|
||||||
|
|
||||||
|
## 🏗️ Arquitectura General
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ CLIENTES │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Web App │ │ Mobile App │ │ Admin Panel │ │
|
||||||
|
│ │ (React) │ │(React Native)│ │ (React) │ │
|
||||||
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||||
|
└─────────┼─────────────────┼─────────────────┼──────────────┘
|
||||||
|
│ │ │
|
||||||
|
└─────────────────┴─────────────────┘
|
||||||
|
│
|
||||||
|
┌───────▼───────┐
|
||||||
|
│ API Gateway │
|
||||||
|
│ (Nginx) │
|
||||||
|
└───────┬───────┘
|
||||||
|
│
|
||||||
|
┌─────────────────┼─────────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||||
|
│ Backend │ │ Backend │ │ Backend │
|
||||||
|
│ (Node.js) │ │ (Node.js) │ │ (Node.js) │
|
||||||
|
│ API REST │ │ API REST │ │ API REST │
|
||||||
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
└─────────────────┴─────────────────┘
|
||||||
|
│
|
||||||
|
┌───────▼───────┐
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ (Primary) │
|
||||||
|
└───────┬───────┘
|
||||||
|
│
|
||||||
|
┌─────────────┼─────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌──────▼──┐ ┌──────▼──┐ ┌──────▼──┐
|
||||||
|
│ Redis │ │MinIO/S3 │ │Mailhog/│
|
||||||
|
│ (Cache) │ │(Files) │ │SendGrid│
|
||||||
|
└─────────┘ └─────────┘ └────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Stack por Componente
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
| Tecnología | Versión | Uso |
|
||||||
|
|------------|---------|-----|
|
||||||
|
| Node.js | 20.x LTS | Runtime |
|
||||||
|
| Express.js | 4.x | Framework HTTP |
|
||||||
|
| TypeScript | 5.x | Tipado estático |
|
||||||
|
| Prisma | 5.x | ORM y migraciones |
|
||||||
|
| Zod | 3.x | Validación de schemas |
|
||||||
|
| JWT | 9.x | Autenticación |
|
||||||
|
| bcrypt | 5.x | Hash de passwords |
|
||||||
|
| cors | 2.x | Cross-origin |
|
||||||
|
| helmet | 7.x | Seguridad HTTP |
|
||||||
|
| morgan | 1.x | Logging HTTP |
|
||||||
|
| winston | 3.x | Logging app |
|
||||||
|
| nodemailer | 6.x | Envío de emails |
|
||||||
|
| dotenv | 16.x | Variables de entorno |
|
||||||
|
|
||||||
|
### Base de Datos
|
||||||
|
| Tecnología | Uso |
|
||||||
|
|------------|-----|
|
||||||
|
| PostgreSQL 16 | Base de datos principal |
|
||||||
|
| Redis | Cache y sesiones |
|
||||||
|
|
||||||
|
### Frontend Web
|
||||||
|
| Tecnología | Versión | Uso |
|
||||||
|
|------------|---------|-----|
|
||||||
|
| React | 18.x | UI Library |
|
||||||
|
| TypeScript | 5.x | Tipado estático |
|
||||||
|
| Vite | 5.x | Build tool |
|
||||||
|
| React Router | 6.x | Routing |
|
||||||
|
| TanStack Query | 5.x | Data fetching |
|
||||||
|
| Axios | 1.x | HTTP client |
|
||||||
|
| TailwindCSS | 3.x | Estilos |
|
||||||
|
| HeadlessUI | 1.x | Componentes accesibles |
|
||||||
|
| React Hook Form | 7.x | Formularios |
|
||||||
|
| Zod | 3.x | Validación |
|
||||||
|
| date-fns | 3.x | Manejo de fechas |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Estructura de Carpetas
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ # API REST Node.js + Express
|
||||||
|
├── src/
|
||||||
|
│ ├── config/ # Configuración (DB, email, etc)
|
||||||
|
│ ├── controllers/ # Controladores HTTP
|
||||||
|
│ ├── middleware/ # Middlewares (auth, error, etc)
|
||||||
|
│ ├── routes/ # Definición de rutas
|
||||||
|
│ ├── services/ # Lógica de negocio
|
||||||
|
│ ├── utils/ # Utilidades
|
||||||
|
│ ├── validators/ # Schemas Zod
|
||||||
|
│ └── index.ts # Entry point
|
||||||
|
├── prisma/
|
||||||
|
│ └── schema.prisma # Schema de BD
|
||||||
|
├── tests/
|
||||||
|
├── Dockerfile
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Seguridad
|
||||||
|
|
||||||
|
- **JWT** para autenticación stateless
|
||||||
|
- **bcrypt** con salt rounds 12 para passwords
|
||||||
|
- **Helmet** para headers de seguridad HTTP
|
||||||
|
- **CORS** configurado por dominio
|
||||||
|
- **Rate limiting** en endpoints sensibles
|
||||||
|
- **Validación** de todos los inputs con Zod
|
||||||
|
- **SQL Injection** protegido por Prisma ORM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 API Endpoints (Versión 1)
|
||||||
|
|
||||||
|
### Autenticación
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/register # Registro
|
||||||
|
POST /api/v1/auth/login # Login
|
||||||
|
POST /api/v1/auth/logout # Logout
|
||||||
|
POST /api/v1/auth/refresh # Refresh token
|
||||||
|
POST /api/v1/auth/forgot-password
|
||||||
|
POST /api/v1/auth/reset-password
|
||||||
|
GET /api/v1/auth/me # Perfil actual
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usuarios
|
||||||
|
```
|
||||||
|
GET /api/v1/users # Listar usuarios
|
||||||
|
GET /api/v1/users/:id # Ver usuario
|
||||||
|
PUT /api/v1/users/:id # Actualizar usuario
|
||||||
|
DELETE /api/v1/users/:id # Eliminar usuario (admin)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Canchas
|
||||||
|
```
|
||||||
|
GET /api/v1/courts # Listar canchas
|
||||||
|
GET /api/v1/courts/:id # Ver cancha
|
||||||
|
POST /api/v1/courts # Crear cancha (admin)
|
||||||
|
PUT /api/v1/courts/:id # Actualizar cancha (admin)
|
||||||
|
DELETE /api/v1/courts/:id # Eliminar cancha (admin)
|
||||||
|
GET /api/v1/courts/:id/availability # Ver disponibilidad
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reservas
|
||||||
|
```
|
||||||
|
GET /api/v1/bookings # Mis reservas
|
||||||
|
GET /api/v1/bookings/:id # Ver reserva
|
||||||
|
POST /api/v1/bookings # Crear reserva
|
||||||
|
PUT /api/v1/bookings/:id # Modificar reserva
|
||||||
|
DELETE /api/v1/bookings/:id # Cancelar reserva
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Definido el: 2026-01-31*
|
||||||
|
*Fase: 1 - Fundamentos*
|
||||||
@@ -1,15 +1,182 @@
|
|||||||
# Fase 1: Fundamentos y Core
|
# Fase 1: Fundamentos y Core
|
||||||
|
|
||||||
## Estado: 🚧 En progreso
|
## Estado: ✅ COMPLETADA
|
||||||
|
|
||||||
### Tareas completadas:
|
### ✅ Tareas completadas:
|
||||||
- [ ] 1.1.1 Definición de Arquitectura
|
|
||||||
- [ ] 1.1.2 Setup Infraestructura
|
|
||||||
- [ ] 1.2.1 Sistema de Autenticación
|
|
||||||
- [ ] 1.2.2 Gestión de Roles
|
|
||||||
- [ ] 1.3.1 Gestión de Canchas
|
|
||||||
- [ ] 1.3.2 Sistema de Reservas Core
|
|
||||||
- [ ] 1.3.3 Notificaciones Básicas
|
|
||||||
|
|
||||||
### Notas de la fase:
|
#### 1.1.1 Definición de Arquitectura
|
||||||
|
- [x] Stack tecnológico definido (Node.js + Express + TypeScript + Prisma + SQLite)
|
||||||
|
- [x] Diagrama de arquitectura creado
|
||||||
|
- [x] Estructura de base de datos diseñada
|
||||||
|
- [x] Documento de requisitos técnicos
|
||||||
|
|
||||||
|
#### 1.1.2 Setup de Infraestructura
|
||||||
|
- [x] Repositorios Git configurados
|
||||||
|
- [x] Entorno de desarrollo configurado
|
||||||
|
- [x] Dependencias instaladas
|
||||||
|
- [x] Configuración TypeScript
|
||||||
|
- [x] ESLint configurado
|
||||||
|
|
||||||
|
#### 1.2.1 Sistema de Autenticación
|
||||||
|
- [x] Registro de usuarios con validación
|
||||||
|
- [x] Login con JWT
|
||||||
|
- [x] Refresh token
|
||||||
|
- [x] Logout
|
||||||
|
- [x] Middleware de autenticación
|
||||||
|
- [x] Hash de contraseñas con bcrypt
|
||||||
|
|
||||||
|
#### 1.2.2 Gestión de Roles
|
||||||
|
- [x] Rol Jugador
|
||||||
|
- [x] Rol Administrador
|
||||||
|
- [x] Middleware de permisos (authorize)
|
||||||
|
- [x] Protección de rutas por rol
|
||||||
|
|
||||||
|
#### 1.3.1 Gestión de Canchas
|
||||||
|
- [x] CRUD completo de canchas
|
||||||
|
- [x] Configuración de horarios
|
||||||
|
- [x] Estados de cancha (activa/inactiva)
|
||||||
|
- [x] Tipos de cancha (Panorámica, Exterior, Cubierta)
|
||||||
|
- [x] Consulta de disponibilidad por fecha
|
||||||
|
|
||||||
|
#### 1.3.2 Sistema de Reservas Core
|
||||||
|
- [x] Crear reserva con validaciones
|
||||||
|
- [x] Validación de disponibilidad en tiempo real
|
||||||
|
- [x] Verificación de horarios de cancha
|
||||||
|
- [x] Prevención de reservas duplicadas
|
||||||
|
- [x] Cancelación de reservas
|
||||||
|
- [x] Confirmación de reservas (admin)
|
||||||
|
- [x] Listado de reservas por usuario
|
||||||
|
- [x] Listado de todas las reservas (admin)
|
||||||
|
|
||||||
|
#### 1.3.3 Notificaciones Básicas
|
||||||
|
- [x] Servicio de email configurado (Nodemailer)
|
||||||
|
- [x] Email de bienvenida al registrar
|
||||||
|
- [x] Email de confirmación de reserva
|
||||||
|
- [x] Email de cancelación de reserva
|
||||||
|
- [x] Template básico de emails
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Resumen de Implementación
|
||||||
|
|
||||||
|
### Backend API
|
||||||
|
- **URL Base**: `http://localhost:3000/api/v1`
|
||||||
|
- **Tecnologías**: Node.js 20, Express, TypeScript, Prisma, SQLite
|
||||||
|
- **Autenticación**: JWT con refresh tokens
|
||||||
|
- **Validación**: Zod schemas
|
||||||
|
|
||||||
|
### Endpoints implementados:
|
||||||
|
|
||||||
|
| Método | Ruta | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | /auth/register | Registro de usuarios |
|
||||||
|
| POST | /auth/login | Login |
|
||||||
|
| GET | /auth/me | Perfil del usuario |
|
||||||
|
| POST | /auth/refresh | Refresh token |
|
||||||
|
| GET | /courts | Listar canchas |
|
||||||
|
| GET | /courts/:id | Ver cancha |
|
||||||
|
| POST | /courts | Crear cancha (admin) |
|
||||||
|
| PUT | /courts/:id | Actualizar cancha (admin) |
|
||||||
|
| DELETE | /courts/:id | Eliminar cancha (admin) |
|
||||||
|
| GET | /courts/:id/availability | Disponibilidad |
|
||||||
|
| POST | /bookings | Crear reserva |
|
||||||
|
| GET | /bookings | Listar reservas (admin) |
|
||||||
|
| GET | /bookings/my-bookings | Mis reservas |
|
||||||
|
| GET | /bookings/:id | Ver reserva |
|
||||||
|
| PUT | /bookings/:id | Actualizar reserva |
|
||||||
|
| DELETE | /bookings/:id | Cancelar reserva |
|
||||||
|
| PUT | /bookings/:id/confirm | Confirmar reserva (admin) |
|
||||||
|
|
||||||
|
### Base de Datos
|
||||||
|
- **Provider**: SQLite (desarrollo) / PostgreSQL (producción)
|
||||||
|
- **ORM**: Prisma
|
||||||
|
- **Entidades**: User, Court, CourtSchedule, Booking
|
||||||
|
|
||||||
|
### Credenciales de prueba:
|
||||||
|
```
|
||||||
|
Admin: admin@padel.com / admin123
|
||||||
|
User: user@padel.com / user123
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Cómo ejecutar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Configurar variables de entorno
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Generar cliente Prisma
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Ejecutar migraciones
|
||||||
|
npx prisma migrate dev
|
||||||
|
|
||||||
|
# Seed de datos
|
||||||
|
npx tsx prisma/seed.ts
|
||||||
|
|
||||||
|
# Iniciar servidor
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Archivos creados en esta fase
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── src/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ ├── index.ts
|
||||||
|
│ │ ├── database.ts
|
||||||
|
│ │ └── logger.ts
|
||||||
|
│ ├── controllers/
|
||||||
|
│ │ ├── auth.controller.ts
|
||||||
|
│ │ ├── court.controller.ts
|
||||||
|
│ │ └── booking.controller.ts
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.ts
|
||||||
|
│ │ ├── errorHandler.ts
|
||||||
|
│ │ └── validate.ts
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.routes.ts
|
||||||
|
│ │ ├── court.routes.ts
|
||||||
|
│ │ ├── booking.routes.ts
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── auth.service.ts
|
||||||
|
│ │ ├── court.service.ts
|
||||||
|
│ │ ├── booking.service.ts
|
||||||
|
│ │ └── email.service.ts
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── jwt.ts
|
||||||
|
│ │ ├── password.ts
|
||||||
|
│ │ └── constants.ts
|
||||||
|
│ ├── validators/
|
||||||
|
│ │ └── auth.validator.ts
|
||||||
|
│ └── index.ts
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma
|
||||||
|
│ └── seed.ts
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── .env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notas
|
||||||
|
|
||||||
|
- La Fase 1 está completa y lista para usar
|
||||||
|
- El backend está funcional con todas las operaciones CRUD
|
||||||
|
- Se incluyen datos de prueba (seed)
|
||||||
|
- Los emails funcionan en modo simulado en desarrollo
|
||||||
|
- Para producción, configurar SMTP en variables de entorno
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Completada el: 2026-01-31*
|
||||||
|
|||||||
Reference in New Issue
Block a user