import { randomInt } from 'node:crypto'; import { Errors } from '@/lib/errors'; import { db } from '@/lib/prisma'; import type { Result } from '@/lib/result'; import { err, ok } from '@/lib/result'; import { isSlotInPast } from '@/lib/slot-validator'; import type { RecurringBookingGroup, UpdateRecurringGroupInput } from '@repo/api-contract'; import { v7 as uuidv7 } from 'uuid'; import { AdminBookingServiceError } from '../../shared/errors'; import { buildSlots, formatIsoDate, minutesToTime, toMinutes } from '../../shared/helpers'; const DAY_INDEX_BY_VALUE: Record = { SUNDAY: 0, MONDAY: 1, TUESDAY: 2, WEDNESDAY: 3, THURSDAY: 4, FRIDAY: 5, SATURDAY: 6, }; const MAX_RECURRING_WEEKS = 52; function generateBookingCode(): string { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let code = ''; for (let i = 0; i < 6; i++) { code += alphabet[randomInt(0, alphabet.length)]; } return code; } function* generateRecurringDates( startDate: Date, endDate: Date | null, dayOfWeek: number ): Generator { const current = new Date(startDate); current.setUTCDate(current.getUTCDate() + ((dayOfWeek - current.getUTCDay() + 7) % 7)); if (current < startDate) { current.setUTCDate(current.getUTCDate() + 7); } const maxDate = endDate ?? new Date(startDate); if (!endDate) { maxDate.setUTCDate(maxDate.getUTCDate() + MAX_RECURRING_WEEKS * 7); } while (current <= maxDate) { yield new Date(current); current.setUTCDate(current.getUTCDate() + 7); } } export async function updateRecurringGroup( userId: string, groupId: string, input: UpdateRecurringGroupInput ): Promise> { const group = await db.recurringBookingGroup.findUnique({ where: { id: groupId }, include: { complex: { select: { id: true, users: { where: { userId }, select: { userId: true } }, }, }, }, }); if (!group) { return err(Errors.notFound('Grupo de turnos fijos no encontrado.')); } if (group.complex.users.length === 0) { return err(Errors.forbidden('No tienes permisos para administrar este complejo.')); } if (group.status === 'CANCELLED') { return err(Errors.conflict('No se puede editar un grupo cancelado.')); } const courtId = input.courtId ?? group.courtId; const dayOfWeek = input.dayOfWeek ?? group.dayOfWeek; const startTime = input.startTime ?? group.startTime; const scheduleChanged = input.courtId !== undefined || input.dayOfWeek !== undefined || input.startTime !== undefined; if (scheduleChanged) { const court = await db.court.findFirst({ where: { id: courtId, complexId: group.complexId }, include: { availabilities: { where: { dayOfWeek: dayOfWeek as typeof group.dayOfWeek }, orderBy: { startTime: 'asc' }, }, }, }); if (!court) { return err(Errors.notFound('La cancha seleccionada no existe en el complejo.')); } const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes); const selectedEndMinutes = toMinutes(startTime) + court.slotDurationMinutes; const endTime = minutesToTime(selectedEndMinutes); const slotExists = validSlots.some( (slot) => slot.startTime === startTime && slot.endTime === endTime ); if (!slotExists) { return err(Errors.conflict('El horario seleccionado no esta disponible para esa cancha.')); } const now = new Date(); const todayStart = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) ); try { await db.$transaction(async (tx) => { await tx.recurringBookingGroup.update({ where: { id: groupId }, data: { courtId, dayOfWeek: dayOfWeek as typeof group.dayOfWeek, startTime, endTime, customerName: input.customerName?.trim() ?? group.customerName, customerPhone: input.customerPhone?.trim() ?? group.customerPhone, customerEmail: input.customerEmail?.trim() ?? group.customerEmail, }, }); const futureBookings = await tx.courtBooking.findMany({ where: { recurringGroupId: groupId, bookingDate: { gte: todayStart }, status: 'CONFIRMED', }, }); for (const booking of futureBookings) { await tx.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', changedAt: new Date(), }, }); await tx.courtBooking.delete({ where: { id: booking.id } }); } const dayIndex = DAY_INDEX_BY_VALUE[dayOfWeek] ?? 0; const recurringDates = Array.from( generateRecurringDates(todayStart, group.endDate, dayIndex) ); for (const date of recurringDates) { const overlappingBooking = await tx.courtBooking.findFirst({ where: { courtId, bookingDate: date, status: 'CONFIRMED', startTime: { lt: endTime }, endTime: { gt: startTime }, }, }); if (overlappingBooking) { throw new AdminBookingServiceError( `El horario seleccionado ya fue reservado para el dia ${formatIsoDate(date)}.` ); } await tx.courtBooking.create({ data: { id: uuidv7(), bookingCode: generateBookingCode(), courtId, bookingDate: date, startTime, endTime, customerName: input.customerName?.trim() ?? group.customerName, customerPhone: input.customerPhone?.trim() ?? group.customerPhone, customerEmail: input.customerEmail?.trim() ?? group.customerEmail, status: 'CONFIRMED', recurringGroupId: groupId, }, include: { court: { select: { id: true, name: true, sport: { select: { id: true, name: true, slug: true } }, complex: { select: { id: true, complexName: true } }, }, }, }, }); } }); } catch (txError) { if (txError instanceof AdminBookingServiceError) { return err(Errors.conflict(txError.message)); } throw txError; } } else { await db.recurringBookingGroup.update({ where: { id: groupId }, data: { ...(input.customerName !== undefined && { customerName: input.customerName.trim() }), ...(input.customerPhone !== undefined && { customerPhone: input.customerPhone.trim() }), ...(input.customerEmail !== undefined && { customerEmail: input.customerEmail.trim() }), }, }); const now = new Date(); const todayStart = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) ); const updateData: Record = {}; if (input.customerName !== undefined) updateData.customerName = input.customerName.trim(); if (input.customerPhone !== undefined) updateData.customerPhone = input.customerPhone.trim(); if (input.customerEmail !== undefined) updateData.customerEmail = input.customerEmail.trim(); if (Object.keys(updateData).length > 0) { await db.courtBooking.updateMany({ where: { recurringGroupId: groupId, bookingDate: { gte: todayStart } }, data: updateData, }); } } const updated = await db.recurringBookingGroup.findUnique({ where: { id: groupId }, include: { court: { select: { id: true, name: true, sport: { select: { id: true, name: true, slug: true } }, complex: { select: { id: true, complexName: true } }, }, }, bookings: { orderBy: { bookingDate: 'asc' }, include: { court: { select: { id: true, name: true, sport: { select: { id: true, name: true, slug: true } }, complex: { select: { id: true, complexName: true } }, }, }, }, }, }, }); if (!updated) { return err(Errors.conflict('Error al actualizar el grupo.')); } return ok({ id: updated.id, complexId: updated.complexId, courtId: updated.courtId, startTime: updated.startTime, endTime: updated.endTime, dayOfWeek: updated.dayOfWeek, startDate: formatIsoDate(updated.startDate), endDate: updated.endDate ? formatIsoDate(updated.endDate) : null, status: updated.status, customerName: updated.customerName, customerPhone: updated.customerPhone, customerEmail: updated.customerEmail, bookings: updated.bookings.map((b) => ({ id: b.id, bookingCode: b.bookingCode, complexId: b.court.complex.id, complexName: b.court.complex.complexName, courtId: b.court.id, courtName: b.court.name, sport: { id: b.court.sport.id, name: b.court.sport.name, slug: b.court.sport.slug }, date: formatIsoDate(b.bookingDate), startTime: b.startTime, endTime: b.endTime, customerName: b.customerName, customerPhone: b.customerPhone, customerEmail: b.customerEmail, price: 0, status: b.status, recurringGroupId: b.recurringGroupId, createdAt: b.createdAt.toISOString(), updatedAt: b.updatedAt.toISOString(), })), createdAt: updated.createdAt.toISOString(), updatedAt: updated.updatedAt.toISOString(), }); }