- Implemented rescheduleAdminBooking service to allow users to change court and time for confirmed bookings. - Added validation for court availability, maintenance status, and overlapping bookings. - Created reschedule-admin-booking handler to process rescheduling requests and send confirmation emails. - Updated booking email service to include rescheduling notifications. - Enhanced frontend components to support booking rescheduling, including a new dialog for selecting new court and time. - Added tests for rescheduling logic, covering various scenarios including validation errors and successful reschedules. - Updated Prisma schema to log previous court and time for audit purposes.
194 lines
5.5 KiB
TypeScript
194 lines
5.5 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
const TIME_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
|
|
function toMinutes(value: string): number {
|
|
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
|
return hours * 60 + minutes;
|
|
}
|
|
|
|
function hasOverlaps(
|
|
availability: Array<{
|
|
dayOfWeek: DayOfWeek;
|
|
startTime: string;
|
|
endTime: string;
|
|
}>
|
|
): boolean {
|
|
const grouped = new Map<
|
|
DayOfWeek,
|
|
Array<{
|
|
start: number;
|
|
end: number;
|
|
}>
|
|
>();
|
|
|
|
for (const range of availability) {
|
|
const start = toMinutes(range.startTime);
|
|
const end = toMinutes(range.endTime);
|
|
const current = grouped.get(range.dayOfWeek) ?? [];
|
|
current.push({ start, end });
|
|
grouped.set(range.dayOfWeek, current);
|
|
}
|
|
|
|
for (const ranges of grouped.values()) {
|
|
ranges.sort((a, b) => a.start - b.start);
|
|
|
|
for (let index = 1; index < ranges.length; index += 1) {
|
|
const previous = ranges[index - 1];
|
|
const current = ranges[index];
|
|
|
|
if (previous.end > current.start) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function refineAvailability(
|
|
availability: Array<{
|
|
dayOfWeek: DayOfWeek;
|
|
startTime: string;
|
|
endTime: string;
|
|
}>,
|
|
context: z.RefinementCtx
|
|
) {
|
|
for (const range of availability) {
|
|
if (toMinutes(range.startTime) >= toMinutes(range.endTime)) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: `El rango ${range.startTime}-${range.endTime} es invalido. La hora de inicio debe ser menor a la de fin.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (hasOverlaps(availability)) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'Hay rangos horarios superpuestos para el mismo dia.',
|
|
});
|
|
}
|
|
}
|
|
|
|
export const dayOfWeekSchema = z.enum([
|
|
'MONDAY',
|
|
'TUESDAY',
|
|
'WEDNESDAY',
|
|
'THURSDAY',
|
|
'FRIDAY',
|
|
'SATURDAY',
|
|
'SUNDAY',
|
|
]);
|
|
|
|
export type DayOfWeek = z.infer<typeof dayOfWeekSchema>;
|
|
|
|
export const courtAvailabilitySchema = z.object({
|
|
id: z.uuid(),
|
|
dayOfWeek: dayOfWeekSchema,
|
|
startTime: z.string().regex(TIME_REGEX, 'La hora debe tener formato HH:mm.'),
|
|
endTime: z.string().regex(TIME_REGEX, 'La hora debe tener formato HH:mm.'),
|
|
});
|
|
|
|
export const courtAvailabilityInputSchema = z.object({
|
|
dayOfWeek: dayOfWeekSchema,
|
|
startTime: z.string().regex(TIME_REGEX, 'La hora debe tener formato HH:mm.'),
|
|
endTime: z.string().regex(TIME_REGEX, 'La hora debe tener formato HH:mm.'),
|
|
});
|
|
|
|
export const courtPriceRuleSchema = z.object({
|
|
id: z.uuid(),
|
|
dayOfWeek: dayOfWeekSchema.nullable(),
|
|
startTime: z.string().nullable(),
|
|
endTime: z.string().nullable(),
|
|
price: z.number().nonnegative(),
|
|
isActive: z.boolean(),
|
|
});
|
|
|
|
export const courtSportSchema = z.object({
|
|
id: z.uuid(),
|
|
name: z.string(),
|
|
slug: z.string(),
|
|
});
|
|
|
|
export const courtSchema = z.object({
|
|
id: z.uuid(),
|
|
complexId: z.uuid(),
|
|
name: z.string(),
|
|
isUnderMaintenance: z.boolean(),
|
|
sportId: z.uuid(),
|
|
sport: courtSportSchema,
|
|
slotDurationMinutes: z.int().positive(),
|
|
basePrice: z.number().nonnegative(),
|
|
availability: z.array(courtAvailabilitySchema),
|
|
priceRules: z.array(courtPriceRuleSchema),
|
|
hasCustomPricing: z.boolean(),
|
|
createdAt: z.string().datetime(),
|
|
updatedAt: z.string().datetime(),
|
|
});
|
|
|
|
export const createCourtSchema = z
|
|
.object({
|
|
name: z
|
|
.string()
|
|
.trim()
|
|
.min(2, 'La cancha debe tener al menos 2 caracteres.')
|
|
.max(120, 'La cancha no puede superar los 120 caracteres.'),
|
|
sportId: z.uuid('El sportId debe ser un UUID valido.'),
|
|
slotDurationMinutes: z
|
|
.int()
|
|
.min(15, 'La duracion minima por turno es 15 minutos.')
|
|
.max(480, 'La duracion maxima por turno es 480 minutos.'),
|
|
basePrice: z.number().nonnegative('El precio base no puede ser negativo.'),
|
|
availability: z
|
|
.array(courtAvailabilityInputSchema)
|
|
.min(1, 'Debes definir al menos un rango horario.'),
|
|
})
|
|
.superRefine((payload, context) => refineAvailability(payload.availability, context));
|
|
|
|
export const updateCourtSchema = z
|
|
.object({
|
|
name: z
|
|
.string()
|
|
.trim()
|
|
.min(2, 'La cancha debe tener al menos 2 caracteres.')
|
|
.max(120, 'La cancha no puede superar los 120 caracteres.')
|
|
.optional(),
|
|
sportId: z.uuid('El sportId debe ser un UUID valido.').optional(),
|
|
slotDurationMinutes: z
|
|
.int()
|
|
.min(15, 'La duracion minima por turno es 15 minutos.')
|
|
.max(480, 'La duracion maxima por turno es 480 minutos.')
|
|
.optional(),
|
|
basePrice: z.number().nonnegative('El precio base no puede ser negativo.').optional(),
|
|
availability: z
|
|
.array(courtAvailabilityInputSchema)
|
|
.min(1, 'Debes definir al menos un rango horario.')
|
|
.optional(),
|
|
})
|
|
.superRefine((payload, context) => {
|
|
if (
|
|
payload.name === undefined &&
|
|
payload.sportId === undefined &&
|
|
payload.slotDurationMinutes === undefined &&
|
|
payload.basePrice === undefined &&
|
|
payload.availability === undefined
|
|
) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'Debes enviar al menos un campo para actualizar.',
|
|
});
|
|
}
|
|
|
|
if (payload.availability) {
|
|
refineAvailability(payload.availability, context);
|
|
}
|
|
});
|
|
|
|
export type Court = z.infer<typeof courtSchema>;
|
|
export type CourtAvailability = z.infer<typeof courtAvailabilitySchema>;
|
|
export type CourtAvailabilityInput = z.infer<typeof courtAvailabilityInputSchema>;
|
|
export type CourtPriceRule = z.infer<typeof courtPriceRuleSchema>;
|
|
export type CreateCourtInput = z.infer<typeof createCourtSchema>;
|
|
export type UpdateCourtInput = z.infer<typeof updateCourtSchema>;
|