✅ 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user