import type { Court, CourtAvailability, CourtPriceRule, Sport } from '@/generated/prisma/client'; import { db } from '@/lib/prisma'; import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import type { CreateCourtInput, DayOfWeek, UpdateCourtInput } from '@repo/api-contract'; import { v7 as uuidv7 } from 'uuid'; type CourtWithRelations = Court & { sport: Sport; availabilities: CourtAvailability[]; priceRules: CourtPriceRule[]; }; export class CourtServiceError extends Error { status: 400 | 403 | 404 | 409; constructor(message: string, status: 400 | 403 | 404 | 409) { super(message); this.name = 'CourtServiceError'; this.status = status; } } function toMinutes(value: string): number { const [hours, minutes] = value.split(':').map((part) => Number(part)); return hours * 60 + minutes; } function assertAvailabilityRanges( availability: Array<{ dayOfWeek: DayOfWeek; startTime: string; endTime: string; }> ) { const grouped = new Map>(); for (const range of availability) { const start = toMinutes(range.startTime); const end = toMinutes(range.endTime); if (start >= end) { throw new CourtServiceError(`El rango ${range.startTime}-${range.endTime} es invalido.`, 400); } 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) { throw new CourtServiceError('Hay rangos horarios superpuestos para el mismo dia.', 400); } } } } async function ensureComplexAccess(complexId: string, userId: string) { const complexUser = await db.complexUser.findUnique({ where: { complexId_userId: { complexId, userId, }, }, include: { complex: { select: { id: true, plan: { select: { rules: true, }, }, }, }, }, }); if (!complexUser) { throw new CourtServiceError('No tienes permisos para administrar este complejo.', 403); } return complexUser.complex; } async function ensureActiveSport(sportId: string) { const sport = await db.sport.findFirst({ where: { id: sportId, isActive: true, }, select: { id: true }, }); if (!sport) { throw new CourtServiceError('El deporte seleccionado no existe o esta inactivo.', 400); } } async function enforcePlanCourtLimit(complexId: string) { const complex = await db.complex.findUnique({ where: { id: complexId }, select: { plan: { select: { rules: true, }, }, }, }); if (!complex?.plan) { return; } const rules = parsePlanRules(complex.plan.rules); const courtsCount = await db.court.count({ where: { complexId }, }); const violations = evaluatePlanUsage(rules, { courtsCount, bookingsToday: 0, }); const maxCourtViolation = violations.find((violation) => violation.code === 'MAX_COURTS_REACHED'); if (maxCourtViolation) { throw new CourtServiceError(maxCourtViolation.message, 409); } } async function getCourtByIdForUser(courtId: string, userId: string) { return db.court.findFirst({ where: { id: courtId, complex: { users: { some: { userId, }, }, }, }, include: { sport: true, availabilities: { orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, priceRules: { where: { isActive: true }, orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, }, }); } function mapCourtResponse(court: CourtWithRelations) { return { id: court.id, complexId: court.complexId, name: court.name, sportId: court.sportId, sport: { id: court.sport.id, name: court.sport.name, slug: court.sport.slug, }, slotDurationMinutes: court.slotDurationMinutes, basePrice: Number(court.basePrice), availability: court.availabilities.map((availability) => ({ id: availability.id, dayOfWeek: availability.dayOfWeek, startTime: availability.startTime, endTime: availability.endTime, })), priceRules: court.priceRules.map((rule) => ({ id: rule.id, dayOfWeek: rule.dayOfWeek, startTime: rule.startTime, endTime: rule.endTime, price: Number(rule.price), isActive: rule.isActive, })), hasCustomPricing: court.priceRules.length > 0, createdAt: court.createdAt.toISOString(), updatedAt: court.updatedAt.toISOString(), }; } export async function listCourtsByComplex(complexId: string, userId: string) { await ensureComplexAccess(complexId, userId); const courts = await db.court.findMany({ where: { complexId }, include: { sport: true, availabilities: { orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, priceRules: { where: { isActive: true }, orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, }, orderBy: { createdAt: 'asc', }, }); return courts.map((court) => mapCourtResponse(court)); } export async function createCourt(userId: string, complexId: string, input: CreateCourtInput) { await ensureComplexAccess(complexId, userId); await ensureActiveSport(input.sportId); await enforcePlanCourtLimit(complexId); assertAvailabilityRanges(input.availability); const createdCourt = await db.$transaction(async (tx) => { const court = await tx.court.create({ data: { id: uuidv7(), complexId, sportId: input.sportId, name: input.name.trim(), slotDurationMinutes: input.slotDurationMinutes, basePrice: input.basePrice, }, }); await tx.courtAvailability.createMany({ data: input.availability.map((availability) => ({ id: uuidv7(), courtId: court.id, dayOfWeek: availability.dayOfWeek, startTime: availability.startTime, endTime: availability.endTime, })), }); return court; }); const court = await getCourtByIdForUser(createdCourt.id, userId); if (!court) { throw new CourtServiceError('No se pudo obtener la cancha creada.', 404); } return mapCourtResponse(court); } export async function updateCourt(userId: string, courtId: string, input: UpdateCourtInput) { const existingCourt = await getCourtByIdForUser(courtId, userId); if (!existingCourt) { throw new CourtServiceError('Cancha no encontrada.', 404); } if (input.sportId) { await ensureActiveSport(input.sportId); } if (input.availability) { assertAvailabilityRanges(input.availability); } await db.$transaction(async (tx) => { await tx.court.update({ where: { id: courtId, }, data: { ...(input.name !== undefined ? { name: input.name.trim() } : {}), ...(input.sportId !== undefined ? { sportId: input.sportId } : {}), ...(input.slotDurationMinutes !== undefined ? { slotDurationMinutes: input.slotDurationMinutes } : {}), ...(input.basePrice !== undefined ? { basePrice: input.basePrice } : {}), }, }); if (input.availability) { await tx.courtAvailability.deleteMany({ where: { courtId }, }); await tx.courtAvailability.createMany({ data: input.availability.map((availability) => ({ id: uuidv7(), courtId, dayOfWeek: availability.dayOfWeek, startTime: availability.startTime, endTime: availability.endTime, })), }); } }); const updatedCourt = await getCourtByIdForUser(courtId, userId); if (!updatedCourt) { throw new CourtServiceError('Cancha no encontrada.', 404); } return mapCourtResponse(updatedCourt); }