import { randomInt } from 'node:crypto'; import { CourtBookingStatus } from '@/generated/prisma/enums'; import { db } from '@/lib/prisma'; import { isSlotInPast } from '@/lib/slot-validator'; import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import type { DayOfWeek } from '@repo/api-contract'; import type { AdminBooking, CreateAdminBookingInput, ListAdminBookingsQuery, UpdateAdminBookingStatusInput, } from '@repo/api-contract'; import { v7 as uuidv7 } from 'uuid'; type Slot = { startTime: string; endTime: string; }; const DAY_OF_WEEK_BY_INDEX = [ 'SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', ] as const; const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const BOOKING_CODE_LENGTH = 6; export class AdminBookingServiceError extends Error { status: 400 | 403 | 404 | 409; constructor(message: string, status: 400 | 403 | 404 | 409) { super(message); this.name = 'AdminBookingServiceError'; 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): Date { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); if (!match) { throw new AdminBookingServiceError('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 AdminBookingServiceError('La fecha enviada no es valida.', 400); } return bookingDate; } function getDayOfWeek(date: Date) { const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()]; if (!dayOfWeek) { throw new AdminBookingServiceError('No se pudo resolver el dia de la semana.', 400); } return 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 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; } async function ensureComplexAccess(complexId: string, userId: string) { const complexUser = await db.complexUser.findUnique({ where: { complexId_userId: { complexId, userId, }, }, include: { complex: { select: { id: true, complexName: true, plan: { select: { rules: true, }, }, }, }, }, }); if (!complexUser) { throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403); } return complexUser.complex; } function resolvePrice( court: { basePrice: unknown; priceRules: Array<{ dayOfWeek: string | null; startTime: string | null; endTime: string | null; price: unknown; }>; }, dayOfWeek: string, startTime: string, endTime: string ): number { const slotStart = toMinutes(startTime); const slotEnd = toMinutes(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(booking: { id: string; bookingCode: string; bookingDate: Date; startTime: string; endTime: string; customerName: string; customerPhone: string; customerEmail: string; status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW'; createdAt: Date; updatedAt: Date; court: { id: string; name: string; complex: { id: string; complexName: string; }; sport: { id: string; name: string; slug: string; }; }; price?: number; }): AdminBooking { return { id: booking.id, bookingCode: booking.bookingCode, complexId: booking.court.complex.id, complexName: booking.court.complex.complexName, courtId: booking.court.id, courtName: booking.court.name, sport: { id: booking.court.sport.id, name: booking.court.sport.name, slug: booking.court.sport.slug, }, date: formatIsoDate(booking.bookingDate), startTime: booking.startTime, endTime: booking.endTime, customerName: booking.customerName, customerPhone: booking.customerPhone, customerEmail: booking.customerEmail, price: booking.price ?? 0, status: booking.status, createdAt: booking.createdAt.toISOString(), updatedAt: booking.updatedAt.toISOString(), }; } export async function listAdminBookings( userId: string, complexId: string, query: ListAdminBookingsQuery ) { await ensureComplexAccess(complexId, userId); const fromDate = parseIsoDate(query.fromDate); const bookings = await db.courtBooking.findMany({ where: { bookingDate: { gte: fromDate, }, court: { complexId, }, }, include: { court: { select: { id: true, name: true, sport: { select: { id: true, name: true, slug: true, }, }, complex: { select: { id: true, complexName: true, }, }, }, }, }, orderBy: [{ bookingDate: 'asc' }, { startTime: 'asc' }, { createdAt: 'asc' }], }); const response = bookings.map((booking) => mapBookingResponse(booking)); return { bookings: response, }; } export async function createAdminBooking( userId: string, complexId: string, input: CreateAdminBookingInput ) { const complex = await ensureComplexAccess(complexId, userId); const adminUser = await db.user.findUnique({ where: { id: userId }, select: { emailVerified: true }, }); if (!adminUser?.emailVerified) { throw new AdminBookingServiceError('Debés verificar tu email para poder crear reservas.', 403); } const bookingDate = parseIsoDate(input.date); const dayOfWeek = getDayOfWeek(bookingDate); const court = await db.court.findFirst({ where: { id: input.courtId, complexId, }, include: { availabilities: { where: { dayOfWeek, }, orderBy: { startTime: 'asc', }, }, sport: { select: { id: true, name: true, slug: true, }, }, priceRules: { where: { isActive: true }, orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, }, }); if (!court) { throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404); } const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes); const selectedStartMinutes = toMinutes(input.startTime); const selectedEndMinutes = selectedStartMinutes + court.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 AdminBookingServiceError( 'El horario seleccionado no esta disponible para esa cancha.', 409 ); } if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) { throw new AdminBookingServiceError('No se pueden crear reservas en el pasado.', 400); } 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, }, }, }); const courtsCount = await tx.court.count({ where: { complexId, }, }); const violations = evaluatePlanUsage(rules, { courtsCount, bookingsToday: bookingsForDate, }); const maxBookingsViolation = violations.find( (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED' ); if (maxBookingsViolation) { throw new AdminBookingServiceError(maxBookingsViolation.message, 409); } } const overlappingBooking = await tx.courtBooking.findFirst({ where: { courtId: court.id, bookingDate, status: 'CONFIRMED', startTime: { lt: selectedSlot.endTime, }, endTime: { gt: selectedSlot.startTime, }, }, }); if (overlappingBooking) { throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409); } return tx.courtBooking.create({ data: { id: uuidv7(), bookingCode: generateBookingCode(), courtId: court.id, bookingDate, startTime: selectedSlot.startTime, endTime: selectedSlot.endTime, customerName: input.customerName.trim(), customerPhone: input.customerPhone.trim(), customerEmail: input.customerEmail?.trim() ?? '', status: 'CONFIRMED', }, include: { court: { select: { id: true, name: true, sport: { select: { id: true, name: true, slug: true, }, }, complex: { select: { id: true, complexName: true, }, }, }, }, }, }); }); const price = resolvePrice(court, dayOfWeek, selectedSlot.startTime, selectedSlot.endTime); return mapBookingResponse({ ...booking, price }); } catch (error) { if (error instanceof AdminBookingServiceError) { 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 AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409); } throw error; } } throw new AdminBookingServiceError( 'No se pudo generar un codigo de reserva unico. Intenta nuevamente.', 409 ); } export async function updateAdminBookingStatus( userId: string, bookingId: string, input: UpdateAdminBookingStatusInput ) { const booking = await db.courtBooking.findFirst({ where: { id: bookingId, court: { complex: { users: { some: { userId, }, }, }, }, }, include: { court: { select: { id: true, name: true, sport: { select: { id: true, name: true, slug: true, }, }, complex: { select: { id: true, complexName: true, }, }, }, }, }, }); if (!booking) { throw new AdminBookingServiceError('Reserva no encontrada.', 404); } if (input.status === 'CANCELLED' && booking.status !== 'CONFIRMED') { throw new AdminBookingServiceError( 'Solo se pueden cancelar reservas en estado confirmada.', 409 ); } if (input.status === 'COMPLETED' && booking.status !== 'CONFIRMED') { throw new AdminBookingServiceError( 'Solo se pueden marcar como cumplidas las reservas confirmadas.', 409 ); } if (input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') { throw new AdminBookingServiceError( 'Solo se pueden marcar como no show las reservas confirmadas.', 409 ); } await db.courtBookingLog.create({ data: { id: uuidv7(), bookingCode: booking.bookingCode, courtId: booking.court.id, bookingDate: booking.bookingDate, startTime: booking.startTime, endTime: booking.endTime, customerName: booking.customerName, customerPhone: booking.customerPhone, customerEmail: booking.customerEmail, previousStatus: booking.status, newStatus: input.status, changedAt: new Date(), }, }); if (input.status === 'COMPLETED' || input.status === 'NOSHOW') { await db.courtBooking.update({ where: { id: booking.id }, data: { status: input.status, }, }); } else { // if the booking is cancelled we delete it to free up the slot, but we keep a log of it with the cancelled status await db.courtBooking.delete({ where: { id: booking.id }, }); } return mapBookingResponse({ ...booking, status: input.status }); }