593 lines
15 KiB
TypeScript
593 lines
15 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 { DayOfWeek } from '@repo/api-contract';
|
|
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 resolvePrice(
|
|
court: {
|
|
basePrice: unknown;
|
|
priceRules: Array<{
|
|
dayOfWeek: string | null;
|
|
startTime: string | null;
|
|
endTime: string | null;
|
|
price: unknown;
|
|
}>;
|
|
},
|
|
dayOfWeek: string,
|
|
startTime: string,
|
|
endTime: string
|
|
): number {
|
|
const slotStart = toMinutes(startTime);
|
|
const slotEnd = toMinutes(endTime);
|
|
|
|
const matchingRules = court.priceRules
|
|
.filter((rule) => {
|
|
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) return false;
|
|
if (!rule.startTime || !rule.endTime) return true;
|
|
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
|
|
})
|
|
.sort((first, second) => {
|
|
const firstSpecificity =
|
|
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
|
|
const secondSpecificity =
|
|
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
|
|
return secondSpecificity - firstSpecificity;
|
|
});
|
|
|
|
return Number(matchingRules[0]?.price ?? court.basePrice);
|
|
}
|
|
|
|
function mapBookingResponse(booking: {
|
|
id: string;
|
|
bookingCode: string;
|
|
bookingDate: Date;
|
|
startTime: string;
|
|
endTime: string;
|
|
customerName: string;
|
|
customerPhone: string;
|
|
customerEmail: 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;
|
|
};
|
|
};
|
|
price?: number;
|
|
}): 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,
|
|
customerEmail: booking.customerEmail,
|
|
price: booking.price ?? 0,
|
|
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 adminUser = await db.user.findUnique({
|
|
where: { id: userId },
|
|
select: { emailVerified: true },
|
|
});
|
|
|
|
if (!adminUser?.emailVerified) {
|
|
throw new AdminBookingServiceError('Debés verificar tu email para poder crear reservas.', 403);
|
|
}
|
|
|
|
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,
|
|
},
|
|
},
|
|
priceRules: {
|
|
where: { isActive: true },
|
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
|
},
|
|
},
|
|
});
|
|
|
|
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(),
|
|
customerEmail: input.customerEmail?.trim() ?? '',
|
|
status: 'CONFIRMED',
|
|
},
|
|
include: {
|
|
court: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
sport: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
complex: {
|
|
select: {
|
|
id: true,
|
|
complexName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
const price = resolvePrice(court, dayOfWeek, selectedSlot.startTime, selectedSlot.endTime);
|
|
|
|
return mapBookingResponse({ ...booking, price });
|
|
} 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,
|
|
customerEmail: booking.customerEmail,
|
|
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 });
|
|
}
|