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

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;