✅ 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:
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;
|
||||
Reference in New Issue
Block a user