import { randomInt } from 'node:crypto'; import { db } from '@/lib/prisma'; import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import type { CancelPublicBookingInput, CreatePublicBookingInput, DayOfWeek, PublicAvailabilityQuery, } from '@repo/api-contract'; import { v7 as uuidv7 } from 'uuid'; type Slot = { startTime: string; endTime: string; }; type PriceableCourt = { basePrice: unknown; priceRules: Array<{ dayOfWeek: DayOfWeek | null; startTime: string | null; endTime: string | null; price: unknown; }>; }; type ComplexWithPublicBookingData = Awaited>; const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [ 'SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', ]; const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const BOOKING_CODE_LENGTH = 6; export class PublicBookingServiceError extends Error { status: 400 | 403 | 404 | 409; constructor(message: string, status: 400 | 403 | 404 | 409) { super(message); this.status = status; } } function toMinutes(value: string): number { const [hours, minutes] = value.split(':').map((part) => Number(part)); return hours * 60 + minutes; } function minutesToTime(minutes: number): string { const safeMinutes = Math.max(0, minutes); const hours = Math.floor(safeMinutes / 60); const mins = safeMinutes % 60; return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; } function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); if (!match) { throw new PublicBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400); } const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); const bookingDate = new Date(Date.UTC(year, month - 1, day)); if ( Number.isNaN(bookingDate.getTime()) || bookingDate.getUTCFullYear() !== year || bookingDate.getUTCMonth() + 1 !== month || bookingDate.getUTCDate() !== day ) { throw new PublicBookingServiceError('La fecha enviada no es valida.', 400); } const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()]; if (!dayOfWeek) { throw new PublicBookingServiceError('No se pudo resolver el dia de la semana.', 400); } return { bookingDate, dayOfWeek }; } function formatIsoDate(date: Date): string { return date.toISOString().slice(0, 10); } function generateBookingCode(): string { let code = ''; for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; } return code; } function hasOverlap(slot: Slot, existing: Slot): boolean { return slot.startTime < existing.endTime && slot.endTime > existing.startTime; } function buildSlots( availability: Array<{ startTime: string; endTime: string; }>, slotDurationMinutes: number ): Slot[] { const slots: Slot[] = []; for (const range of availability) { const start = toMinutes(range.startTime); const end = toMinutes(range.endTime); for ( let current = start; current + slotDurationMinutes <= end; current += slotDurationMinutes ) { slots.push({ startTime: minutesToTime(current), endTime: minutesToTime(current + slotDurationMinutes), }); } } return slots; } function resolveSports(data: ComplexWithPublicBookingData) { const map = new Map(); for (const court of data.courts) { if (!court.sport.isActive) { continue; } if (!map.has(court.sport.id)) { map.set(court.sport.id, { id: court.sport.id, name: court.sport.name, slug: court.sport.slug, }); } } return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'es')); } function validateSportSelection( availableSports: Array<{ id: string }>, sportId: string | undefined ): { sportSelectionRequired: boolean; selectedSportId: string | undefined } { const sportSelectionRequired = availableSports.length > 1; if (sportSelectionRequired && !sportId) { throw new PublicBookingServiceError( 'Debes seleccionar un deporte para consultar disponibilidad.', 400 ); } if (sportId && !availableSports.some((sport) => sport.id === sportId)) { throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400); } if (sportSelectionRequired) { return { sportSelectionRequired, selectedSportId: sportId, }; } return { sportSelectionRequired, selectedSportId: sportId ?? availableSports[0]?.id, }; } async function getComplexWithBookingData(complexSlug: string) { const complex = await db.complex.findUnique({ where: { complexSlug }, select: { id: true, complexName: true, complexSlug: true, physicalAddress: true, plan: { select: { rules: true, }, }, courts: { include: { sport: { select: { id: true, name: true, slug: true, isActive: true, }, }, availabilities: { orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, priceRules: { where: { isActive: true }, orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, }, orderBy: { createdAt: 'asc' }, }, }, }); if (!complex) { throw new PublicBookingServiceError('Complejo no encontrado.', 404); } if (complex.plan) { const rules = parsePlanRules(complex.plan.rules); if (!rules.features.publicBookingPage) { throw new PublicBookingServiceError( 'El complejo no tiene habilitada la reserva publica.', 403 ); } } return complex; } function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number { const slotStart = toMinutes(slot.startTime); const slotEnd = toMinutes(slot.endTime); const matchingRules = court.priceRules .filter((rule) => { if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) { return false; } if (!rule.startTime || !rule.endTime) { return true; } return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime); }) .sort((first, second) => { const firstSpecificity = (first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0); const secondSpecificity = (second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0); return secondSpecificity - firstSpecificity; }); return Number(matchingRules[0]?.price ?? court.basePrice); } function mapBookingResponse(input: { bookingId: string; bookingCode: string; bookingDate: Date; createdAt: Date; startTime: string; endTime: string; customerName: string; customerPhone: string; customerEmail: string; price: number; status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW'; court: { id: string; name: string; complexId: string; complexName: string; complexSlug: string; sport: { id: string; name: string; slug: string; }; }; }) { return { id: input.bookingId, bookingCode: input.bookingCode, complexId: input.court.complexId, complexName: input.court.complexName, complexSlug: input.court.complexSlug, courtId: input.court.id, courtName: input.court.name, sport: input.court.sport, date: formatIsoDate(input.bookingDate), startTime: input.startTime, endTime: input.endTime, customerName: input.customerName, customerPhone: input.customerPhone, customerEmail: input.customerEmail, status: input.status, price: input.price, createdAt: input.createdAt.toISOString(), }; } export async function getPublicBookingConfirmation(complexSlug: string, bookingCode: string) { const normalizedBookingCode = bookingCode.toUpperCase(); const booking = await db.courtBooking.findFirst({ where: { bookingCode: normalizedBookingCode, court: { complex: { complexSlug, }, }, }, select: { bookingCode: true, bookingDate: true, startTime: true, endTime: true, customerEmail: true, status: true, createdAt: true, court: { select: { name: true, basePrice: true, priceRules: { where: { isActive: true }, orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, sport: { select: { id: true, name: true, slug: true, }, }, complex: { select: { complexName: true, physicalAddress: true, complexSlug: true, }, }, }, }, }, }); if (!booking) { throw new PublicBookingServiceError('Reserva no encontrada.', 404); } const date = formatIsoDate(booking.bookingDate); const { dayOfWeek } = parseIsoDate(date); const price = resolveSlotPrice(booking.court, dayOfWeek, { startTime: booking.startTime, endTime: booking.endTime, }); return { bookingCode: booking.bookingCode, complexName: booking.court.complex.complexName, complexAddress: booking.court.complex.physicalAddress ?? undefined, complexSlug: booking.court.complex.complexSlug, date, startTime: booking.startTime, endTime: booking.endTime, price, courtName: booking.court.name, sport: { id: booking.court.sport.id, name: booking.court.sport.name, slug: booking.court.sport.slug, }, status: booking.status, customerEmail: booking.customerEmail, createdAt: booking.createdAt.toISOString(), }; } async function hasVerifiedAdmin(complexId: string): Promise { const verifiedAdmin = await db.complexUser.findFirst({ where: { complexId, role: 'ADMIN', user: { emailVerified: true }, }, }); return verifiedAdmin !== null; } export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) { const { bookingDate, dayOfWeek } = parseIsoDate(query.date); const complex = await getComplexWithBookingData(complexSlug); if (!(await hasVerifiedAdmin(complex.id))) { return { complexId: complex.id, complexName: complex.complexName, complexAddress: complex.physicalAddress ?? null, complexSlug: complex.complexSlug, date: query.date, sportSelectionRequired: false, sports: [], courts: [], }; } const sports = resolveSports(complex); const sportSelectionRequired = sports.length > 1; if (query.sportId && !sports.some((sport) => sport.id === query.sportId)) { throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400); } const selectedSportId = sportSelectionRequired ? query.sportId : (query.sportId ?? sports[0]?.id); const candidateCourts = complex.courts.filter((court) => { if (!court.sport.isActive) { return false; } if (selectedSportId) { return court.sportId === selectedSportId; } return true; }); const courtIds = candidateCourts.map((court) => court.id); const bookings = courtIds.length > 0 ? await db.courtBooking.findMany({ where: { bookingDate, status: 'CONFIRMED', courtId: { in: courtIds, }, }, select: { courtId: true, startTime: true, endTime: true, }, }) : []; const bookingsByCourt = new Map(); for (const booking of bookings) { const current = bookingsByCourt.get(booking.courtId) ?? []; current.push({ startTime: booking.startTime, endTime: booking.endTime, }); bookingsByCourt.set(booking.courtId, current); } const courts = candidateCourts .map((court) => { const dayAvailability = court.availabilities.filter( (availability) => availability.dayOfWeek === dayOfWeek ); const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes); const occupiedSlots = bookingsByCourt.get(court.id) ?? []; const availableSlots = allSlots.filter( (slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied)) ); return { courtId: court.id, courtName: court.name, sport: { id: court.sport.id, name: court.sport.name, slug: court.sport.slug, }, slotDurationMinutes: court.slotDurationMinutes, availabilityDay: dayOfWeek, availableSlots: availableSlots.map((slot) => ({ ...slot, price: resolveSlotPrice(court, dayOfWeek, slot), })), }; }) .filter((court) => court.availableSlots.length > 0); return { complexId: complex.id, complexName: complex.complexName, complexAddress: complex.physicalAddress ?? null, complexSlug: complex.complexSlug, date: query.date, sportSelectionRequired, sports, courts, }; } export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) { const { bookingDate, dayOfWeek } = parseIsoDate(input.date); const complex = await getComplexWithBookingData(complexSlug); if (!(await hasVerifiedAdmin(complex.id))) { throw new PublicBookingServiceError( 'El complejo no puede recibir reservas hasta que un administrador verifique su email.', 403 ); } const sports = resolveSports(complex); const { sportSelectionRequired } = validateSportSelection(sports, input.sportId); const selectedCourt = complex.courts.find( (court) => court.id === input.courtId && court.sport.isActive ); if (!selectedCourt) { throw new PublicBookingServiceError('La cancha seleccionada no existe en el complejo.', 404); } if (sportSelectionRequired && !input.sportId) { throw new PublicBookingServiceError( 'Debes seleccionar un deporte para reservar en este complejo.', 400 ); } if (input.sportId && input.sportId !== selectedCourt.sportId) { throw new PublicBookingServiceError( 'La cancha seleccionada no corresponde al deporte indicado.', 400 ); } const dayAvailability = selectedCourt.availabilities.filter( (availability) => availability.dayOfWeek === dayOfWeek ); const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes); const selectedStartMinutes = toMinutes(input.startTime); const selectedEndMinutes = selectedStartMinutes + selectedCourt.slotDurationMinutes; const selectedSlot = { startTime: input.startTime, endTime: minutesToTime(selectedEndMinutes), }; const slotExists = validSlots.some( (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime ); if (!slotExists) { throw new PublicBookingServiceError( 'El horario seleccionado no esta disponible para esa cancha.', 409 ); } for (let attempt = 0; attempt < 5; attempt += 1) { try { const booking = await db.$transaction(async (tx) => { if (complex.plan) { const rules = parsePlanRules(complex.plan.rules); const bookingsForDate = await tx.courtBooking.count({ where: { bookingDate, status: 'CONFIRMED', court: { complexId: complex.id, }, }, }); const violations = evaluatePlanUsage(rules, { courtsCount: complex.courts.length, bookingsToday: bookingsForDate, }); const maxBookingsViolation = violations.find( (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED' ); if (maxBookingsViolation) { throw new PublicBookingServiceError(maxBookingsViolation.message, 409); } } const overlappingBooking = await tx.courtBooking.findFirst({ where: { courtId: selectedCourt.id, bookingDate, status: 'CONFIRMED', startTime: { lt: selectedSlot.endTime, }, endTime: { gt: selectedSlot.startTime, }, }, }); if (overlappingBooking) { throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409); } return tx.courtBooking.create({ data: { id: uuidv7(), bookingCode: generateBookingCode(), courtId: selectedCourt.id, bookingDate, startTime: selectedSlot.startTime, endTime: selectedSlot.endTime, customerName: input.customerName.trim(), customerPhone: input.customerPhone.trim(), customerEmail: input.customerEmail.trim(), status: 'CONFIRMED', }, select: { id: true, bookingCode: true, bookingDate: true, createdAt: true, startTime: true, endTime: true, customerName: true, customerPhone: true, customerEmail: true, status: true, }, }); }); const price = resolveSlotPrice(selectedCourt, dayOfWeek, selectedSlot); return mapBookingResponse({ bookingId: booking.id, bookingCode: booking.bookingCode, bookingDate: booking.bookingDate, createdAt: booking.createdAt, startTime: booking.startTime, endTime: booking.endTime, customerName: booking.customerName, customerPhone: booking.customerPhone, customerEmail: booking.customerEmail, price, status: booking.status, court: { id: selectedCourt.id, name: selectedCourt.name, complexId: complex.id, complexName: complex.complexName, complexSlug: complex.complexSlug, sport: { id: selectedCourt.sport.id, name: selectedCourt.sport.name, slug: selectedCourt.sport.slug, }, }, }); } catch (error) { if (error instanceof PublicBookingServiceError) { throw error; } const prismaError = error as { code?: string; meta?: { target?: string[] | string; }; }; if (prismaError.code === 'P2002') { const targets = Array.isArray(prismaError.meta?.target) ? prismaError.meta?.target : [prismaError.meta?.target]; const isBookingCodeCollision = targets.some((target) => String(target).includes('booking_code') ); if (isBookingCodeCollision) { continue; } throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409); } throw error; } } throw new PublicBookingServiceError( 'No se pudo generar un codigo de reserva unico. Intenta nuevamente.', 409 ); } export async function cancelPublicBooking(complexSlug: string, input: CancelPublicBookingInput) { const normalizedBookingCode = input.bookingCode.toUpperCase(); const booking = await db.courtBooking.findFirst({ where: { bookingCode: normalizedBookingCode, court: { complex: { complexSlug, }, }, }, include: { court: { select: { id: true, name: true, basePrice: true, priceRules: { where: { isActive: true }, orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, sport: { select: { id: true, name: true, slug: true, }, }, complex: { select: { id: true, complexName: true, complexSlug: true, }, }, }, }, }, }); if (!booking) { throw new PublicBookingServiceError('Reserva no encontrada.', 404); } if (booking.status !== 'CONFIRMED') { throw new PublicBookingServiceError( 'Solo se pueden cancelar reservas en estado confirmada.', 409 ); } if (booking.customerPhone !== input.customerPhone.trim()) { throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 403); } const date = formatIsoDate(booking.bookingDate); const { dayOfWeek } = parseIsoDate(date); const price = resolveSlotPrice(booking.court, dayOfWeek, { startTime: booking.startTime, endTime: booking.endTime, }); await db.courtBookingLog.create({ data: { id: uuidv7(), bookingCode: booking.bookingCode, courtId: booking.courtId, bookingDate: booking.bookingDate, startTime: booking.startTime, endTime: booking.endTime, customerName: booking.customerName, customerPhone: booking.customerPhone, customerEmail: booking.customerEmail, previousStatus: booking.status, newStatus: 'CANCELLED', }, }); await db.courtBooking.delete({ where: { id: booking.id }, }); return mapBookingResponse({ bookingId: booking.id, bookingCode: booking.bookingCode, bookingDate: booking.bookingDate, createdAt: booking.createdAt, startTime: booking.startTime, endTime: booking.endTime, customerName: booking.customerName, customerPhone: booking.customerPhone, customerEmail: booking.customerEmail, price, status: 'CANCELLED', court: { id: booking.court.id, name: booking.court.name, complexId: booking.court.complex.id, complexName: booking.court.complex.complexName, complexSlug: booking.court.complex.complexSlug, sport: booking.court.sport, }, }); }