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; 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; export type CourtAvailability = z.infer; export type CourtAvailabilityInput = z.infer; export type CourtPriceRule = z.infer; export type CreateCourtInput = z.infer; export type UpdateCourtInput = z.infer;