179 lines
5.3 KiB
TypeScript
179 lines
5.3 KiB
TypeScript
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 { AdminBooking, RescheduleAdminBookingInput } from '@repo/api-contract';
|
|
import { v7 as uuidv7 } from 'uuid';
|
|
import {
|
|
buildSlots,
|
|
getDayOfWeek,
|
|
mapBookingResponse,
|
|
minutesToTime,
|
|
resolvePrice,
|
|
toMinutes,
|
|
} from '../../shared/helpers';
|
|
|
|
export async function rescheduleAdminBooking(
|
|
userId: string,
|
|
bookingId: string,
|
|
input: RescheduleAdminBookingInput
|
|
): Promise<Result<AdminBooking>> {
|
|
const booking = await db.courtBooking.findFirst({
|
|
where: {
|
|
id: bookingId,
|
|
court: {
|
|
complex: { users: { some: { userId } } },
|
|
},
|
|
},
|
|
include: {
|
|
court: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
complexId: true,
|
|
slotDurationMinutes: true,
|
|
sport: { select: { id: true, name: true, slug: true } },
|
|
complex: { select: { id: true, complexName: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!booking) {
|
|
return err(Errors.notFound('Reserva no encontrada.'));
|
|
}
|
|
|
|
if (booking.status !== 'CONFIRMED') {
|
|
return err(Errors.conflict('Solo se pueden reprogramar reservas en estado confirmada.'));
|
|
}
|
|
|
|
const targetCourtId = input.courtId ?? booking.courtId;
|
|
const targetStartTime = input.startTime ?? booking.startTime;
|
|
|
|
if (targetCourtId === booking.courtId && targetStartTime === booking.startTime) {
|
|
return err(Errors.validation('Debe proporcionar al menos una cancha o un horario diferente.'));
|
|
}
|
|
|
|
const dayOfWeekResult = getDayOfWeek(booking.bookingDate);
|
|
if (!dayOfWeekResult.ok) return err(dayOfWeekResult.error);
|
|
const dayOfWeek = dayOfWeekResult.value;
|
|
|
|
const targetCourt = await db.court.findFirst({
|
|
where: { id: targetCourtId, complexId: booking.court.complexId },
|
|
include: {
|
|
availabilities: {
|
|
where: { dayOfWeek },
|
|
orderBy: { startTime: 'asc' },
|
|
},
|
|
priceRules: {
|
|
where: { isActive: true },
|
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
|
},
|
|
sport: { select: { id: true, name: true, slug: true } },
|
|
},
|
|
});
|
|
|
|
if (!targetCourt) {
|
|
return err(Errors.notFound('La cancha seleccionada no existe en el complejo.'));
|
|
}
|
|
|
|
if (targetCourt.sport.id !== booking.court.sport.id) {
|
|
return err(
|
|
Errors.conflict('La cancha seleccionada no es del mismo deporte que la reserva original.')
|
|
);
|
|
}
|
|
|
|
if (targetCourt.isUnderMaintenance) {
|
|
return err(Errors.conflict('La cancha seleccionada se encuentra en mantenimiento.'));
|
|
}
|
|
|
|
const validSlots = buildSlots(targetCourt.availabilities, targetCourt.slotDurationMinutes);
|
|
const selectedEndMinutes = toMinutes(targetStartTime) + targetCourt.slotDurationMinutes;
|
|
const selectedSlot = { startTime: targetStartTime, endTime: minutesToTime(selectedEndMinutes) };
|
|
|
|
const slotExists = validSlots.some(
|
|
(slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime
|
|
);
|
|
|
|
if (!slotExists) {
|
|
return err(Errors.conflict('El horario seleccionado no está disponible para esa cancha.'));
|
|
}
|
|
|
|
if (isSlotInPast(booking.bookingDate, targetStartTime, targetCourt.slotDurationMinutes)) {
|
|
return err(Errors.validation('No se pueden reprogramar reservas en el pasado.'));
|
|
}
|
|
|
|
const overlappingBooking = await db.courtBooking.findFirst({
|
|
where: {
|
|
id: { not: bookingId },
|
|
courtId: targetCourtId,
|
|
bookingDate: booking.bookingDate,
|
|
status: 'CONFIRMED',
|
|
startTime: { lt: selectedSlot.endTime },
|
|
endTime: { gt: selectedSlot.startTime },
|
|
},
|
|
});
|
|
|
|
if (overlappingBooking) {
|
|
return err(Errors.conflict('El horario seleccionado ya fue reservado por otra reserva.'));
|
|
}
|
|
|
|
const newPrice = resolvePrice(
|
|
targetCourt,
|
|
dayOfWeek,
|
|
selectedSlot.startTime,
|
|
selectedSlot.endTime
|
|
);
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.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: booking.status,
|
|
previousCourtId: booking.court.id,
|
|
previousStartTime: booking.startTime,
|
|
previousEndTime: booking.endTime,
|
|
changedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
await tx.courtBooking.update({
|
|
where: { id: booking.id },
|
|
data: {
|
|
courtId: targetCourtId,
|
|
startTime: selectedSlot.startTime,
|
|
endTime: selectedSlot.endTime,
|
|
},
|
|
});
|
|
});
|
|
|
|
const updatedBooking = {
|
|
...booking,
|
|
startTime: selectedSlot.startTime,
|
|
endTime: selectedSlot.endTime,
|
|
price: newPrice,
|
|
};
|
|
|
|
if (targetCourtId !== booking.courtId) {
|
|
updatedBooking.court = {
|
|
id: targetCourt.id,
|
|
name: targetCourt.name,
|
|
slotDurationMinutes: targetCourt.slotDurationMinutes,
|
|
sport: targetCourt.sport,
|
|
complex: booking.court.complex,
|
|
} as typeof booking.court;
|
|
}
|
|
|
|
return ok(mapBookingResponse(updatedBooking));
|
|
}
|