Initial commit
This commit is contained in:
339
apps/backend/src/modules/court/services/court.service.ts
Normal file
339
apps/backend/src/modules/court/services/court.service.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import type { CreateCourtInput, DayOfWeek, UpdateCourtInput } from '@repo/api-contract'
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
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'
|
||||
|
||||
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, appUserId: string) {
|
||||
const complexUser = await db.complexUser.findUnique({
|
||||
where: {
|
||||
complexId_userId: {
|
||||
complexId,
|
||||
userId: appUserId,
|
||||
},
|
||||
},
|
||||
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, appUserId: string) {
|
||||
return db.court.findFirst({
|
||||
where: {
|
||||
id: courtId,
|
||||
complex: {
|
||||
users: {
|
||||
some: {
|
||||
userId: appUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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, appUserId: string) {
|
||||
await ensureComplexAccess(complexId, appUserId)
|
||||
|
||||
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(
|
||||
appUserId: string,
|
||||
complexId: string,
|
||||
input: CreateCourtInput,
|
||||
) {
|
||||
await ensureComplexAccess(complexId, appUserId)
|
||||
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, appUserId)
|
||||
|
||||
if (!court) {
|
||||
throw new CourtServiceError('No se pudo obtener la cancha creada.', 404)
|
||||
}
|
||||
|
||||
return mapCourtResponse(court)
|
||||
}
|
||||
|
||||
export async function updateCourt(
|
||||
appUserId: string,
|
||||
courtId: string,
|
||||
input: UpdateCourtInput,
|
||||
) {
|
||||
const existingCourt = await getCourtByIdForUser(courtId, appUserId)
|
||||
|
||||
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, appUserId)
|
||||
|
||||
if (!updatedCourt) {
|
||||
throw new CourtServiceError('Cancha no encontrada.', 404)
|
||||
}
|
||||
|
||||
return mapCourtResponse(updatedCourt)
|
||||
}
|
||||
Reference in New Issue
Block a user