Initial commit
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user