617 lines
16 KiB
TypeScript
617 lines
16 KiB
TypeScript
import type {
|
|
CreatePublicBookingInput,
|
|
DayOfWeek,
|
|
PublicAvailabilityQuery,
|
|
} from '@repo/api-contract'
|
|
import { randomInt } from 'node:crypto'
|
|
import { v7 as uuidv7 } from 'uuid'
|
|
import { db } from '@/lib/prisma'
|
|
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'
|
|
|
|
type Slot = {
|
|
startTime: string
|
|
endTime: string
|
|
}
|
|
|
|
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,
|
|
plan: {
|
|
select: {
|
|
rules: true,
|
|
},
|
|
},
|
|
courts: {
|
|
include: {
|
|
sport: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
isActive: true,
|
|
},
|
|
},
|
|
availabilities: {
|
|
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 mapBookingResponse(input: {
|
|
bookingId: string
|
|
bookingCode: string
|
|
bookingDate: Date
|
|
createdAt: Date
|
|
startTime: string
|
|
endTime: string
|
|
customerName: string
|
|
customerPhone: string
|
|
status: 'CONFIRMED' | 'CANCELLED'
|
|
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,
|
|
status: input.status,
|
|
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,
|
|
status: true,
|
|
createdAt: true,
|
|
court: {
|
|
select: {
|
|
name: true,
|
|
sport: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
complex: {
|
|
select: {
|
|
complexName: true,
|
|
complexSlug: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!booking) {
|
|
throw new PublicBookingServiceError('Reserva no encontrada.', 404)
|
|
}
|
|
|
|
return {
|
|
bookingCode: booking.bookingCode,
|
|
complexName: booking.court.complex.complexName,
|
|
complexSlug: booking.court.complex.complexSlug,
|
|
date: formatIsoDate(booking.bookingDate),
|
|
startTime: booking.startTime,
|
|
endTime: booking.endTime,
|
|
courtName: booking.court.name,
|
|
sport: {
|
|
id: booking.court.sport.id,
|
|
name: booking.court.sport.name,
|
|
slug: booking.court.sport.slug,
|
|
},
|
|
status: booking.status,
|
|
createdAt: booking.createdAt.toISOString(),
|
|
}
|
|
}
|
|
|
|
export async function listPublicAvailability(
|
|
complexSlug: string,
|
|
query: PublicAvailabilityQuery,
|
|
) {
|
|
const { bookingDate, dayOfWeek } = parseIsoDate(query.date)
|
|
const complex = await getComplexWithBookingData(complexSlug)
|
|
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,
|
|
}
|
|
})
|
|
.filter((court) => court.availableSlots.length > 0)
|
|
|
|
return {
|
|
complexId: complex.id,
|
|
complexName: complex.complexName,
|
|
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)
|
|
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(),
|
|
status: 'CONFIRMED',
|
|
},
|
|
select: {
|
|
id: true,
|
|
bookingCode: true,
|
|
bookingDate: true,
|
|
createdAt: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
customerName: true,
|
|
customerPhone: true,
|
|
status: true,
|
|
},
|
|
})
|
|
})
|
|
|
|
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,
|
|
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,
|
|
)
|
|
}
|