- Implemented email confirmation for admin and public bookings. - Added customerEmail field to booking schemas and services. - Created email templates for booking confirmation, cancellation, and no-show notifications. - Updated booking handlers to send emails upon booking creation and status updates. - Enhanced frontend forms to capture customer email during booking creation.
715 lines
19 KiB
TypeScript
715 lines
19 KiB
TypeScript
import { randomInt } from 'node:crypto';
|
|
import { db } from '@/lib/prisma';
|
|
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
|
import type {
|
|
CreatePublicBookingInput,
|
|
DayOfWeek,
|
|
PublicAvailabilityQuery,
|
|
} from '@repo/api-contract';
|
|
import { v7 as uuidv7 } from 'uuid';
|
|
|
|
type Slot = {
|
|
startTime: string;
|
|
endTime: string;
|
|
};
|
|
|
|
type PriceableCourt = {
|
|
basePrice: unknown;
|
|
priceRules: Array<{
|
|
dayOfWeek: DayOfWeek | null;
|
|
startTime: string | null;
|
|
endTime: string | null;
|
|
price: unknown;
|
|
}>;
|
|
};
|
|
|
|
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>;
|
|
|
|
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
|
'SUNDAY',
|
|
'MONDAY',
|
|
'TUESDAY',
|
|
'WEDNESDAY',
|
|
'THURSDAY',
|
|
'FRIDAY',
|
|
'SATURDAY',
|
|
];
|
|
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
const BOOKING_CODE_LENGTH = 6;
|
|
|
|
export class PublicBookingServiceError extends Error {
|
|
status: 400 | 403 | 404 | 409;
|
|
|
|
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
|
super(message);
|
|
this.name = 'PublicBookingServiceError';
|
|
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): { bookingDate: Date; dayOfWeek: DayOfWeek } {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
|
|
if (!match) {
|
|
throw new PublicBookingServiceError('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 PublicBookingServiceError('La fecha enviada no es valida.', 400);
|
|
}
|
|
|
|
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()];
|
|
|
|
if (!dayOfWeek) {
|
|
throw new PublicBookingServiceError('No se pudo resolver el dia de la semana.', 400);
|
|
}
|
|
|
|
return { bookingDate, 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 hasOverlap(slot: Slot, existing: Slot): boolean {
|
|
return slot.startTime < existing.endTime && slot.endTime > existing.startTime;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function resolveSports(data: ComplexWithPublicBookingData) {
|
|
const map = new Map<string, { id: string; name: string; slug: string }>();
|
|
|
|
for (const court of data.courts) {
|
|
if (!court.sport.isActive) {
|
|
continue;
|
|
}
|
|
|
|
if (!map.has(court.sport.id)) {
|
|
map.set(court.sport.id, {
|
|
id: court.sport.id,
|
|
name: court.sport.name,
|
|
slug: court.sport.slug,
|
|
});
|
|
}
|
|
}
|
|
|
|
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'es'));
|
|
}
|
|
|
|
function validateSportSelection(
|
|
availableSports: Array<{ id: string }>,
|
|
sportId: string | undefined
|
|
): { sportSelectionRequired: boolean; selectedSportId: string | undefined } {
|
|
const sportSelectionRequired = availableSports.length > 1;
|
|
|
|
if (sportSelectionRequired && !sportId) {
|
|
throw new PublicBookingServiceError(
|
|
'Debes seleccionar un deporte para consultar disponibilidad.',
|
|
400
|
|
);
|
|
}
|
|
|
|
if (sportId && !availableSports.some((sport) => sport.id === sportId)) {
|
|
throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400);
|
|
}
|
|
|
|
if (sportSelectionRequired) {
|
|
return {
|
|
sportSelectionRequired,
|
|
selectedSportId: sportId,
|
|
};
|
|
}
|
|
|
|
return {
|
|
sportSelectionRequired,
|
|
selectedSportId: sportId ?? availableSports[0]?.id,
|
|
};
|
|
}
|
|
|
|
async function getComplexWithBookingData(complexSlug: string) {
|
|
const complex = await db.complex.findUnique({
|
|
where: { complexSlug },
|
|
select: {
|
|
id: true,
|
|
complexName: true,
|
|
complexSlug: true,
|
|
physicalAddress: true,
|
|
plan: {
|
|
select: {
|
|
rules: true,
|
|
},
|
|
},
|
|
courts: {
|
|
include: {
|
|
sport: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
isActive: true,
|
|
},
|
|
},
|
|
availabilities: {
|
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
|
},
|
|
priceRules: {
|
|
where: { isActive: true },
|
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!complex) {
|
|
throw new PublicBookingServiceError('Complejo no encontrado.', 404);
|
|
}
|
|
|
|
if (complex.plan) {
|
|
const rules = parsePlanRules(complex.plan.rules);
|
|
|
|
if (!rules.features.publicBookingPage) {
|
|
throw new PublicBookingServiceError(
|
|
'El complejo no tiene habilitada la reserva publica.',
|
|
403
|
|
);
|
|
}
|
|
}
|
|
|
|
return complex;
|
|
}
|
|
|
|
function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number {
|
|
const slotStart = toMinutes(slot.startTime);
|
|
const slotEnd = toMinutes(slot.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(input: {
|
|
bookingId: string;
|
|
bookingCode: string;
|
|
bookingDate: Date;
|
|
createdAt: Date;
|
|
startTime: string;
|
|
endTime: string;
|
|
customerName: string;
|
|
customerPhone: string;
|
|
customerEmail: string | null;
|
|
price: number;
|
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
|
court: {
|
|
id: string;
|
|
name: string;
|
|
complexId: string;
|
|
complexName: string;
|
|
complexSlug: string;
|
|
sport: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
};
|
|
};
|
|
}) {
|
|
return {
|
|
id: input.bookingId,
|
|
bookingCode: input.bookingCode,
|
|
complexId: input.court.complexId,
|
|
complexName: input.court.complexName,
|
|
complexSlug: input.court.complexSlug,
|
|
courtId: input.court.id,
|
|
courtName: input.court.name,
|
|
sport: input.court.sport,
|
|
date: formatIsoDate(input.bookingDate),
|
|
startTime: input.startTime,
|
|
endTime: input.endTime,
|
|
customerName: input.customerName,
|
|
customerPhone: input.customerPhone,
|
|
customerEmail: input.customerEmail ?? undefined,
|
|
status: input.status,
|
|
price: input.price,
|
|
createdAt: input.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
export async function getPublicBookingConfirmation(complexSlug: string, bookingCode: string) {
|
|
const normalizedBookingCode = bookingCode.toUpperCase();
|
|
const booking = await db.courtBooking.findFirst({
|
|
where: {
|
|
bookingCode: normalizedBookingCode,
|
|
court: {
|
|
complex: {
|
|
complexSlug,
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
bookingCode: true,
|
|
bookingDate: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
customerEmail: true,
|
|
status: true,
|
|
createdAt: true,
|
|
court: {
|
|
select: {
|
|
name: true,
|
|
basePrice: true,
|
|
priceRules: {
|
|
where: { isActive: true },
|
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
|
},
|
|
sport: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
complex: {
|
|
select: {
|
|
complexName: true,
|
|
physicalAddress: true,
|
|
complexSlug: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!booking) {
|
|
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
|
}
|
|
|
|
const date = formatIsoDate(booking.bookingDate);
|
|
const { dayOfWeek } = parseIsoDate(date);
|
|
const price = resolveSlotPrice(booking.court, dayOfWeek, {
|
|
startTime: booking.startTime,
|
|
endTime: booking.endTime,
|
|
});
|
|
|
|
return {
|
|
bookingCode: booking.bookingCode,
|
|
complexName: booking.court.complex.complexName,
|
|
complexAddress: booking.court.complex.physicalAddress ?? undefined,
|
|
complexSlug: booking.court.complex.complexSlug,
|
|
date,
|
|
startTime: booking.startTime,
|
|
endTime: booking.endTime,
|
|
price,
|
|
courtName: booking.court.name,
|
|
sport: {
|
|
id: booking.court.sport.id,
|
|
name: booking.court.sport.name,
|
|
slug: booking.court.sport.slug,
|
|
},
|
|
status: booking.status,
|
|
customerEmail: booking.customerEmail ?? undefined,
|
|
createdAt: booking.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async function hasVerifiedAdmin(complexId: string): Promise<boolean> {
|
|
const verifiedAdmin = await db.complexUser.findFirst({
|
|
where: {
|
|
complexId,
|
|
role: 'ADMIN',
|
|
user: { emailVerified: true },
|
|
},
|
|
});
|
|
return verifiedAdmin !== null;
|
|
}
|
|
|
|
export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) {
|
|
const { bookingDate, dayOfWeek } = parseIsoDate(query.date);
|
|
const complex = await getComplexWithBookingData(complexSlug);
|
|
|
|
if (!(await hasVerifiedAdmin(complex.id))) {
|
|
return {
|
|
complexId: complex.id,
|
|
complexName: complex.complexName,
|
|
complexAddress: complex.physicalAddress ?? null,
|
|
complexSlug: complex.complexSlug,
|
|
date: query.date,
|
|
sportSelectionRequired: false,
|
|
sports: [],
|
|
courts: [],
|
|
};
|
|
}
|
|
|
|
const sports = resolveSports(complex);
|
|
const sportSelectionRequired = sports.length > 1;
|
|
|
|
if (query.sportId && !sports.some((sport) => sport.id === query.sportId)) {
|
|
throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400);
|
|
}
|
|
|
|
const selectedSportId = sportSelectionRequired ? query.sportId : (query.sportId ?? sports[0]?.id);
|
|
|
|
const candidateCourts = complex.courts.filter((court) => {
|
|
if (!court.sport.isActive) {
|
|
return false;
|
|
}
|
|
|
|
if (selectedSportId) {
|
|
return court.sportId === selectedSportId;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
const courtIds = candidateCourts.map((court) => court.id);
|
|
|
|
const bookings =
|
|
courtIds.length > 0
|
|
? await db.courtBooking.findMany({
|
|
where: {
|
|
bookingDate,
|
|
status: 'CONFIRMED',
|
|
courtId: {
|
|
in: courtIds,
|
|
},
|
|
},
|
|
select: {
|
|
courtId: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
},
|
|
})
|
|
: [];
|
|
|
|
const bookingsByCourt = new Map<string, Slot[]>();
|
|
|
|
for (const booking of bookings) {
|
|
const current = bookingsByCourt.get(booking.courtId) ?? [];
|
|
current.push({
|
|
startTime: booking.startTime,
|
|
endTime: booking.endTime,
|
|
});
|
|
bookingsByCourt.set(booking.courtId, current);
|
|
}
|
|
|
|
const courts = candidateCourts
|
|
.map((court) => {
|
|
const dayAvailability = court.availabilities.filter(
|
|
(availability) => availability.dayOfWeek === dayOfWeek
|
|
);
|
|
|
|
const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes);
|
|
const occupiedSlots = bookingsByCourt.get(court.id) ?? [];
|
|
|
|
const availableSlots = allSlots.filter(
|
|
(slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied))
|
|
);
|
|
|
|
return {
|
|
courtId: court.id,
|
|
courtName: court.name,
|
|
sport: {
|
|
id: court.sport.id,
|
|
name: court.sport.name,
|
|
slug: court.sport.slug,
|
|
},
|
|
slotDurationMinutes: court.slotDurationMinutes,
|
|
availabilityDay: dayOfWeek,
|
|
availableSlots: availableSlots.map((slot) => ({
|
|
...slot,
|
|
price: resolveSlotPrice(court, dayOfWeek, slot),
|
|
})),
|
|
};
|
|
})
|
|
.filter((court) => court.availableSlots.length > 0);
|
|
|
|
return {
|
|
complexId: complex.id,
|
|
complexName: complex.complexName,
|
|
complexAddress: complex.physicalAddress ?? null,
|
|
complexSlug: complex.complexSlug,
|
|
date: query.date,
|
|
sportSelectionRequired,
|
|
sports,
|
|
courts,
|
|
};
|
|
}
|
|
|
|
export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) {
|
|
const { bookingDate, dayOfWeek } = parseIsoDate(input.date);
|
|
const complex = await getComplexWithBookingData(complexSlug);
|
|
|
|
if (!(await hasVerifiedAdmin(complex.id))) {
|
|
throw new PublicBookingServiceError(
|
|
'El complejo no puede recibir reservas hasta que un administrador verifique su email.',
|
|
403
|
|
);
|
|
}
|
|
|
|
const sports = resolveSports(complex);
|
|
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId);
|
|
|
|
const selectedCourt = complex.courts.find(
|
|
(court) => court.id === input.courtId && court.sport.isActive
|
|
);
|
|
|
|
if (!selectedCourt) {
|
|
throw new PublicBookingServiceError('La cancha seleccionada no existe en el complejo.', 404);
|
|
}
|
|
|
|
if (sportSelectionRequired && !input.sportId) {
|
|
throw new PublicBookingServiceError(
|
|
'Debes seleccionar un deporte para reservar en este complejo.',
|
|
400
|
|
);
|
|
}
|
|
|
|
if (input.sportId && input.sportId !== selectedCourt.sportId) {
|
|
throw new PublicBookingServiceError(
|
|
'La cancha seleccionada no corresponde al deporte indicado.',
|
|
400
|
|
);
|
|
}
|
|
|
|
const dayAvailability = selectedCourt.availabilities.filter(
|
|
(availability) => availability.dayOfWeek === dayOfWeek
|
|
);
|
|
|
|
const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes);
|
|
const selectedStartMinutes = toMinutes(input.startTime);
|
|
const selectedEndMinutes = selectedStartMinutes + selectedCourt.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 PublicBookingServiceError(
|
|
'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: complex.id,
|
|
},
|
|
},
|
|
});
|
|
|
|
const violations = evaluatePlanUsage(rules, {
|
|
courtsCount: complex.courts.length,
|
|
bookingsToday: bookingsForDate,
|
|
});
|
|
|
|
const maxBookingsViolation = violations.find(
|
|
(violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED'
|
|
);
|
|
|
|
if (maxBookingsViolation) {
|
|
throw new PublicBookingServiceError(maxBookingsViolation.message, 409);
|
|
}
|
|
}
|
|
|
|
const overlappingBooking = await tx.courtBooking.findFirst({
|
|
where: {
|
|
courtId: selectedCourt.id,
|
|
bookingDate,
|
|
status: 'CONFIRMED',
|
|
startTime: {
|
|
lt: selectedSlot.endTime,
|
|
},
|
|
endTime: {
|
|
gt: selectedSlot.startTime,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (overlappingBooking) {
|
|
throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409);
|
|
}
|
|
|
|
return tx.courtBooking.create({
|
|
data: {
|
|
id: uuidv7(),
|
|
bookingCode: generateBookingCode(),
|
|
courtId: selectedCourt.id,
|
|
bookingDate,
|
|
startTime: selectedSlot.startTime,
|
|
endTime: selectedSlot.endTime,
|
|
customerName: input.customerName.trim(),
|
|
customerPhone: input.customerPhone.trim(),
|
|
customerEmail: input.customerEmail?.trim() || null,
|
|
status: 'CONFIRMED',
|
|
},
|
|
select: {
|
|
id: true,
|
|
bookingCode: true,
|
|
bookingDate: true,
|
|
createdAt: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
customerName: true,
|
|
customerPhone: true,
|
|
customerEmail: true,
|
|
status: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
const price = resolveSlotPrice(selectedCourt, dayOfWeek, selectedSlot);
|
|
|
|
return mapBookingResponse({
|
|
bookingId: booking.id,
|
|
bookingCode: booking.bookingCode,
|
|
bookingDate: booking.bookingDate,
|
|
createdAt: booking.createdAt,
|
|
startTime: booking.startTime,
|
|
endTime: booking.endTime,
|
|
customerName: booking.customerName,
|
|
customerPhone: booking.customerPhone,
|
|
customerEmail: booking.customerEmail,
|
|
price,
|
|
status: booking.status,
|
|
court: {
|
|
id: selectedCourt.id,
|
|
name: selectedCourt.name,
|
|
complexId: complex.id,
|
|
complexName: complex.complexName,
|
|
complexSlug: complex.complexSlug,
|
|
sport: {
|
|
id: selectedCourt.sport.id,
|
|
name: selectedCourt.sport.name,
|
|
slug: selectedCourt.sport.slug,
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof PublicBookingServiceError) {
|
|
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 PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
throw new PublicBookingServiceError(
|
|
'No se pudo generar un codigo de reserva unico. Intenta nuevamente.',
|
|
409
|
|
);
|
|
}
|