Files
playzer/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts
2026-05-06 17:03:42 -03:00

536 lines
13 KiB
TypeScript

import { randomInt } from 'node:crypto';
import { CourtBookingStatus } from '@/generated/prisma/enums';
import { db } from '@/lib/prisma';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
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 mapBookingResponse(booking: {
id: string;
bookingCode: string;
bookingDate: Date;
startTime: string;
endTime: string;
customerName: string;
customerPhone: 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;
};
};
}): 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,
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 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,
},
},
},
});
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
);
}
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(),
status: 'CONFIRMED',
},
include: {
court: {
select: {
id: true,
name: true,
sport: {
select: {
id: true,
name: true,
slug: true,
},
},
complex: {
select: {
id: true,
complexName: true,
},
},
},
},
},
});
});
return mapBookingResponse(booking);
} 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,
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 });
}