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:
2026-01-31 08:11:53 +00:00
parent a83f4f39e9
commit b558372810
35 changed files with 7428 additions and 10 deletions

35
backend/.env.example Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

49
backend/package.json Normal file
View 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

Binary file not shown.

View 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");

View 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"

View 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
View 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();
});

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

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

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

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

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

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

View 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();
};
};

View 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}`,
});
};

View 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);
}
};
};

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

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

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

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

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

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

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

View 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,
};

View 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
View 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);
};

View 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);
};

View 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
View 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
View 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
View 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*

View File

@@ -1,15 +1,182 @@
# Fase 1: Fundamentos y Core
## Estado: 🚧 En progreso
## Estado: ✅ COMPLETADA
### 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
### Tareas completadas:
### 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*