319 lines
9.9 KiB
TypeScript
319 lines
9.9 KiB
TypeScript
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<string, number> = {
|
|
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<Date> {
|
|
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<Result<RecurringBookingGroup>> {
|
|
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<string, string> = {};
|
|
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(),
|
|
});
|
|
}
|