Files
playzer/apps/backend/src/modules/admin-booking/features/update-recurring-group/update-recurring-group.business.ts
2026-06-26 14:05:37 -03:00

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(),
});
}