import type { CreatePublicBookingInput, DayOfWeek, PublicAvailabilityQuery, } from '@repo/api-contract' import { randomInt } from 'node:crypto' import { v7 as uuidv7 } from 'uuid' import { db } from '@/lib/prisma' import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service' type Slot = { startTime: string endTime: string } 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.name = 'PublicBookingServiceError' 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, plan: { select: { rules: true, }, }, courts: { include: { sport: { select: { id: true, name: true, slug: true, isActive: true, }, }, availabilities: { 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 mapBookingResponse(input: { bookingId: string bookingCode: string bookingDate: Date createdAt: Date startTime: string endTime: string customerName: string customerPhone: string status: 'CONFIRMED' | 'CANCELLED' 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, status: input.status, 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, status: true, createdAt: true, court: { select: { name: true, sport: { select: { id: true, name: true, slug: true, }, }, complex: { select: { complexName: true, complexSlug: true, }, }, }, }, }, }) if (!booking) { throw new PublicBookingServiceError('Reserva no encontrada.', 404) } return { bookingCode: booking.bookingCode, complexName: booking.court.complex.complexName, complexSlug: booking.court.complex.complexSlug, date: formatIsoDate(booking.bookingDate), startTime: booking.startTime, endTime: booking.endTime, courtName: booking.court.name, sport: { id: booking.court.sport.id, name: booking.court.sport.name, slug: booking.court.sport.slug, }, status: booking.status, createdAt: booking.createdAt.toISOString(), } } export async function listPublicAvailability( complexSlug: string, query: PublicAvailabilityQuery, ) { const { bookingDate, dayOfWeek } = parseIsoDate(query.date) const complex = await getComplexWithBookingData(complexSlug) 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, } }) .filter((court) => court.availableSlots.length > 0) return { complexId: complex.id, complexName: complex.complexName, 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) 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(), status: 'CONFIRMED', }, select: { id: true, bookingCode: true, bookingDate: true, createdAt: true, startTime: true, endTime: true, customerName: true, customerPhone: true, status: true, }, }) }) 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, 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, ) }