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'; import { SubscriptionService } from './subscription.service'; 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 base (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 basePrice = court.pricePerHour * hours; // Verificar y aplicar beneficios de suscripción let finalPrice = basePrice; let discountApplied = 0; let usedFreeBooking = false; // TODO: Re-activar cuando se implemente Fase 4.3 (Suscripciones) /* try { const benefits = await SubscriptionService.checkAndApplyBenefits( data.userId, { totalPrice: basePrice, courtId: data.courtId, date: data.date, startTime: data.startTime, endTime: data.endTime, } ); finalPrice = benefits.finalPrice; discountApplied = benefits.discountApplied; usedFreeBooking = benefits.usedFreeBooking; if (discountApplied > 0) { logger.info(`Descuento aplicado a reserva: ${discountApplied} centavos para usuario ${data.userId}`); } if (usedFreeBooking) { logger.info(`Reserva gratis aplicada para usuario ${data.userId}`); } } catch (error) { logger.error('Error aplicando beneficios de suscripción:', error); // Continuar con precio normal si falla la aplicación de beneficios } */ // Fin TODO Fase 4.3 // 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: finalPrice, 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 } // Retornar reserva con información de beneficios aplicados return { ...booking, benefitsApplied: { basePrice, discountApplied, usedFreeBooking, finalPrice, }, }; } // 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, }, }, }, }); } // Calcular precio con beneficios de suscripción (preview) static async calculatePriceWithBenefits( userId: string, courtId: string, startTime: string, endTime: string ) { // Obtener información de la cancha const court = await prisma.court.findUnique({ where: { id: courtId }, }); if (!court) { throw new ApiError('Cancha no encontrada', 404); } // Calcular precio base const startHour = parseInt(startTime.split(':')[0]); const endHour = parseInt(endTime.split(':')[0]); const hours = endHour - startHour; const basePrice = court.pricePerHour * hours; // Obtener beneficios de suscripción const subscriptionBenefits = await SubscriptionService.getCurrentBenefits(userId); // Calcular precio final let finalPrice = basePrice; let discountApplied = 0; if (subscriptionBenefits.freeBookingsRemaining > 0) { finalPrice = 0; discountApplied = basePrice; } else if (subscriptionBenefits.discountPercentage > 0) { discountApplied = Math.round((basePrice * subscriptionBenefits.discountPercentage) / 100); finalPrice = basePrice - discountApplied; } return { basePrice, finalPrice, discountApplied, subscriptionBenefits, }; } } export default BookingService;