316 lines
8.0 KiB
TypeScript
316 lines
8.0 KiB
TypeScript
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<DayOfWeek, Array<{ start: number; end: number }>>();
|
|
|
|
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);
|
|
}
|