diff --git a/apps/backend/src/lib/booking-utils.ts b/apps/backend/src/lib/booking-utils.ts new file mode 100644 index 0000000..6b37db0 --- /dev/null +++ b/apps/backend/src/lib/booking-utils.ts @@ -0,0 +1,176 @@ +import { randomInt } from 'node:crypto'; +import type { DayOfWeek } from '@repo/api-contract'; + +export type Slot = { + startTime: string; + endTime: string; +}; + +const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const BOOKING_CODE_LENGTH = 6; + +export const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [ + 'SUNDAY', + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', +]; + +export function toMinutes(value: string): number { + const [hours, minutes] = value.split(':').map((part) => Number(part)); + return hours * 60 + minutes; +} + +export function minutesToTime(minutes: number): string { + const safeMinutes = Math.max(0, minutes); + const hours = Math.floor(safeMinutes / 60); + const mins = safeMinutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +export function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + + if (!match) { + throw new Error('La fecha debe tener formato YYYY-MM-DD.'); + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + + const bookingDate = new Date(Date.UTC(year, month - 1, day)); + + if ( + Number.isNaN(bookingDate.getTime()) || + bookingDate.getUTCFullYear() !== year || + bookingDate.getUTCMonth() + 1 !== month || + bookingDate.getUTCDate() !== day + ) { + throw new Error('La fecha enviada no es valida.'); + } + + const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()]; + + if (!dayOfWeek) { + throw new Error('No se pudo resolver el dia de la semana.'); + } + + return { bookingDate, dayOfWeek }; +} + +export function formatIsoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export function getDayOfWeekValue(date: Date): DayOfWeek { + const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()]; + if (!dayOfWeek) { + throw new Error('No se pudo resolver el dia de la semana.'); + } + return dayOfWeek; +} + +export function generateBookingCode(): string { + let code = ''; + for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { + code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; + } + return code; +} + +export function buildSlots( + availability: Array<{ startTime: string; endTime: string }>, + slotDurationMinutes: number +): Slot[] { + const slots: Slot[] = []; + for (const range of availability) { + const start = toMinutes(range.startTime); + const end = toMinutes(range.endTime); + for ( + let current = start; + current + slotDurationMinutes <= end; + current += slotDurationMinutes + ) { + slots.push({ + startTime: minutesToTime(current), + endTime: minutesToTime(current + slotDurationMinutes), + }); + } + } + return slots; +} + +export function hasOverlap(slot: Slot, existing: Slot): boolean { + return slot.startTime < existing.endTime && slot.endTime > existing.startTime; +} + +type PriceableCourt = { + basePrice: unknown; + priceRules: Array<{ + dayOfWeek: DayOfWeek | null; + startTime: string | null; + endTime: string | null; + price: unknown; + }>; +}; + +export function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number { + const slotStart = toMinutes(slot.startTime); + const slotEnd = toMinutes(slot.endTime); + + const matchingRules = court.priceRules + .filter((rule) => { + if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) return false; + if (!rule.startTime || !rule.endTime) return true; + return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime); + }) + .sort((first, second) => { + const firstSpecificity = + (first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0); + const secondSpecificity = + (second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0); + return secondSpecificity - firstSpecificity; + }); + + return Number(matchingRules[0]?.price ?? court.basePrice); +} + +export 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 Error(`El rango ${range.startTime}-${range.endTime} es invalido.`); + } + + 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 Error('Hay rangos horarios superpuestos para el mismo dia.'); + } + } + } +} diff --git a/apps/backend/src/lib/complex-access.ts b/apps/backend/src/lib/complex-access.ts new file mode 100644 index 0000000..16694d8 --- /dev/null +++ b/apps/backend/src/lib/complex-access.ts @@ -0,0 +1,41 @@ +import { db } from '@/lib/prisma'; + +export class ComplexAccessError extends Error { + status: 403 | 404; + + constructor(message: string, status: 403 | 404 = 403) { + super(message); + this.name = 'ComplexAccessError'; + this.status = status; + } +} + +export async function ensureComplexAccess(complexId: string, userId: string) { + const complexUser = await db.complexUser.findUnique({ + where: { + complexId_userId: { + complexId, + userId, + }, + }, + include: { + complex: { + select: { + id: true, + complexName: true, + plan: { + select: { + rules: true, + }, + }, + }, + }, + }, + }); + + if (!complexUser) { + throw new ComplexAccessError('No tienes permisos para administrar este complejo.', 403); + } + + return complexUser.complex; +} diff --git a/apps/backend/src/lib/slug.ts b/apps/backend/src/lib/slug.ts new file mode 100644 index 0000000..c4bc545 --- /dev/null +++ b/apps/backend/src/lib/slug.ts @@ -0,0 +1,31 @@ +import { v7 as uuidv7 } from 'uuid'; + +export function slugify(value: string): string { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); +} + +export async function buildUniqueSlug( + source: string, + findExisting: (slug: string) => Promise<{ id: string } | null> +): Promise { + const base = slugify(source); + const fallback = base.length > 0 ? base : `resource-${uuidv7().slice(0, 8)}`; + + let candidate = fallback; + let index = 1; + + while (true) { + const existing = await findExisting(candidate); + if (!existing) return candidate; + + index += 1; + candidate = `${fallback}-${index}`; + } +} diff --git a/apps/backend/src/modules/admin-booking/admin-booking.routes.ts b/apps/backend/src/modules/admin-booking/admin-booking.routes.ts index 97382af..dcdf787 100644 --- a/apps/backend/src/modules/admin-booking/admin-booking.routes.ts +++ b/apps/backend/src/modules/admin-booking/admin-booking.routes.ts @@ -1,12 +1,12 @@ import { requireAuth } from '@/middlewares/require-auth.middleware'; -import { cancelRecurringGroupHandler } from '@/modules/admin-booking/handlers/cancel-recurring-group.handler'; -import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/create-admin-booking.handler'; -import { createAdminRecurringBookingHandler } from '@/modules/admin-booking/handlers/create-admin-recurring-booking.handler'; -import { listAdminBookingsHandler } from '@/modules/admin-booking/handlers/list-admin-bookings.handler'; -import { listRecurringGroupsHandler } from '@/modules/admin-booking/handlers/list-recurring-groups.handler'; -import { rescheduleAdminBookingHandler } from '@/modules/admin-booking/handlers/reschedule-admin-booking.handler'; -import { updateAdminBookingStatusHandler } from '@/modules/admin-booking/handlers/update-admin-booking-status.handler'; -import { updateRecurringGroupHandler } from '@/modules/admin-booking/handlers/update-recurring-group.handler'; +import { cancelRecurringGroupHandler } from '@/modules/admin-booking/features/cancel-recurring-group/cancel-recurring-group.handler'; +import { createAdminBookingHandler } from '@/modules/admin-booking/features/create-admin-booking/create-admin-booking.handler'; +import { createAdminRecurringBookingHandler } from '@/modules/admin-booking/features/create-admin-recurring-booking/create-admin-recurring-booking.handler'; +import { listAdminBookingsHandler } from '@/modules/admin-booking/features/list-admin-bookings/list-admin-bookings.handler'; +import { listRecurringGroupsHandler } from '@/modules/admin-booking/features/list-recurring-groups/list-recurring-groups.handler'; +import { rescheduleAdminBookingHandler } from '@/modules/admin-booking/features/reschedule-admin-booking/reschedule-admin-booking.handler'; +import { updateAdminBookingStatusHandler } from '@/modules/admin-booking/features/update-admin-booking-status/update-admin-booking-status.handler'; +import { updateRecurringGroupHandler } from '@/modules/admin-booking/features/update-recurring-group/update-recurring-group.handler'; import type { AppEnv } from '@/types/hono'; import { zValidator } from '@hono/zod-validator'; import { diff --git a/apps/backend/src/modules/admin-booking/features/cancel-recurring-group/cancel-recurring-group.business.ts b/apps/backend/src/modules/admin-booking/features/cancel-recurring-group/cancel-recurring-group.business.ts new file mode 100644 index 0000000..f80de48 --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/cancel-recurring-group/cancel-recurring-group.business.ts @@ -0,0 +1,75 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import type { Result } from '@/lib/result'; +import { err, ok } from '@/lib/result'; +import { v7 as uuidv7 } from 'uuid'; + +export async function cancelRecurringGroup( + userId: string, + groupId: string +): Promise> { + const group = await db.recurringBookingGroup.findUnique({ + where: { id: groupId }, + include: { + complex: { + select: { + id: true, + users: { where: { userId }, select: { userId: true } }, + }, + }, + }, + }); + + if (!group) { + return err(Errors.notFound('Grupo de reservas no encontrado.')); + } + + if (group.complex.users.length === 0) { + return err(Errors.forbidden('No tienes permisos para administrar este complejo.')); + } + + if (group.status === 'CANCELLED') { + return err(Errors.conflict('El grupo ya fue cancelado anteriormente.')); + } + + const now = new Date(); + const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + + await db.$transaction(async (tx) => { + await tx.recurringBookingGroup.update({ + where: { id: groupId }, + data: { status: 'CANCELLED' }, + }); + + const futureBookings = await tx.courtBooking.findMany({ + where: { + recurringGroupId: groupId, + bookingDate: { gte: todayStart }, + status: 'CONFIRMED', + }, + }); + + for (const booking of futureBookings) { + await tx.courtBookingLog.create({ + data: { + id: uuidv7(), + bookingCode: booking.bookingCode, + courtId: booking.courtId, + bookingDate: booking.bookingDate, + startTime: booking.startTime, + endTime: booking.endTime, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + customerEmail: booking.customerEmail, + previousStatus: booking.status, + newStatus: 'CANCELLED', + changedAt: new Date(), + }, + }); + + await tx.courtBooking.delete({ where: { id: booking.id } }); + } + }); + + return ok({ ok: true }); +} diff --git a/apps/backend/src/modules/admin-booking/features/cancel-recurring-group/cancel-recurring-group.handler.ts b/apps/backend/src/modules/admin-booking/features/cancel-recurring-group/cancel-recurring-group.handler.ts new file mode 100644 index 0000000..34a069d --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/cancel-recurring-group/cancel-recurring-group.handler.ts @@ -0,0 +1,12 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import { cancelRecurringGroup } from './cancel-recurring-group.business'; + +type GroupIdParams = { groupId: string }; + +export async function cancelRecurringGroupHandler(c: AppContext) { + const { groupId } = c.req.valid('param' as never) as GroupIdParams; + const user = c.get('user'); + + return handleResult(c, await cancelRecurringGroup(user.id, groupId)); +} diff --git a/apps/backend/src/modules/admin-booking/features/create-admin-booking/create-admin-booking.business.ts b/apps/backend/src/modules/admin-booking/features/create-admin-booking/create-admin-booking.business.ts new file mode 100644 index 0000000..5d07a03 --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/create-admin-booking/create-admin-booking.business.ts @@ -0,0 +1,187 @@ +import { randomInt } from 'node:crypto'; +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import type { Result } from '@/lib/result'; +import { err, ok } from '@/lib/result'; +import { isSlotInPast } from '@/lib/slot-validator'; +import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; +import type { AdminBooking, CreateAdminBookingInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { AdminBookingServiceError } from '../../shared/errors'; +import { + buildSlots, + ensureComplexAccess, + getDayOfWeek, + mapBookingResponse, + minutesToTime, + parseIsoDate, + resolvePrice, + toMinutes, +} from '../../shared/helpers'; + +const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const BOOKING_CODE_LENGTH = 6; + +function generateBookingCode(): string { + let code = ''; + for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { + code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; + } + return code; +} + +export async function createAdminBooking( + userId: string, + complexId: string, + input: CreateAdminBookingInput +): Promise> { + const complexResult = await ensureComplexAccess(complexId, userId); + if (!complexResult.ok) return err(complexResult.error); + const complex = complexResult.value; + + const adminUser = await db.user.findUnique({ + where: { id: userId }, + select: { emailVerified: true }, + }); + + if (!adminUser?.emailVerified) { + return err(Errors.forbidden('Debés verificar tu email para poder crear reservas.')); + } + + const dateResult = parseIsoDate(input.date); + if (!dateResult.ok) return err(dateResult.error); + const bookingDate = dateResult.value; + + const dayOfWeekResult = getDayOfWeek(bookingDate); + if (!dayOfWeekResult.ok) return err(dayOfWeekResult.error); + const dayOfWeek = dayOfWeekResult.value; + + const court = await db.court.findFirst({ + where: { id: input.courtId, complexId }, + include: { + availabilities: { + where: { dayOfWeek }, + orderBy: { startTime: 'asc' }, + }, + sport: { select: { id: true, name: true, slug: true } }, + priceRules: { + where: { isActive: true }, + orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], + }, + }, + }); + + if (!court) { + return err(Errors.notFound('La cancha seleccionada no existe en el complejo.')); + } + + const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes); + const selectedStartMinutes = toMinutes(input.startTime); + const selectedEndMinutes = selectedStartMinutes + court.slotDurationMinutes; + const selectedSlot = { startTime: input.startTime, endTime: minutesToTime(selectedEndMinutes) }; + + const slotExists = validSlots.some( + (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime + ); + + if (!slotExists) { + return err(Errors.conflict('El horario seleccionado no esta disponible para esa cancha.')); + } + + if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) { + return err(Errors.validation('No se pueden crear reservas en el pasado.')); + } + + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const booking = await db.$transaction(async (tx) => { + if (complex.plan) { + const rules = parsePlanRules(complex.plan.rules); + const bookingsForDate = await tx.courtBooking.count({ + where: { + bookingDate, + status: 'CONFIRMED', + court: { complexId }, + }, + }); + + const courtsCount = await tx.court.count({ where: { complexId } }); + + const violations = evaluatePlanUsage(rules, { + courtsCount, + bookingsToday: bookingsForDate, + }); + const maxBookingsViolation = violations.find( + (v) => v.code === 'MAX_BOOKINGS_PER_DAY_REACHED' + ); + + if (maxBookingsViolation) { + throw new AdminBookingServiceError(maxBookingsViolation.message); + } + } + + const overlappingBooking = await tx.courtBooking.findFirst({ + where: { + courtId: court.id, + bookingDate, + status: 'CONFIRMED', + startTime: { lt: selectedSlot.endTime }, + endTime: { gt: selectedSlot.startTime }, + }, + }); + + if (overlappingBooking) { + throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.'); + } + + return tx.courtBooking.create({ + data: { + id: uuidv7(), + bookingCode: generateBookingCode(), + courtId: court.id, + bookingDate, + startTime: selectedSlot.startTime, + endTime: selectedSlot.endTime, + customerName: input.customerName.trim(), + customerPhone: input.customerPhone.trim(), + customerEmail: input.customerEmail?.trim() ?? '', + status: 'CONFIRMED', + recurringGroupId: null, + }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + }, + }); + }); + + const price = resolvePrice(court, dayOfWeek, selectedSlot.startTime, selectedSlot.endTime); + return ok(mapBookingResponse({ ...booking, price })); + } catch (error) { + if (error instanceof AdminBookingServiceError) { + return err(Errors.conflict(error.message)); + } + + const prismaError = error as { code?: string; meta?: { target?: string[] | string } }; + if (prismaError.code === 'P2002') { + const targets = Array.isArray(prismaError.meta?.target) + ? prismaError.meta?.target + : [prismaError.meta?.target]; + const isBookingCodeCollision = targets.some((target) => + String(target).includes('booking_code') + ); + if (isBookingCodeCollision) continue; + return err(Errors.conflict('El horario seleccionado ya fue reservado.')); + } + throw error; + } + } + + return err(Errors.conflict('No se pudo generar un codigo de reserva unico. Intenta nuevamente.')); +} diff --git a/apps/backend/src/modules/admin-booking/handlers/create-admin-booking.handler.ts b/apps/backend/src/modules/admin-booking/features/create-admin-booking/create-admin-booking.handler.ts similarity index 73% rename from apps/backend/src/modules/admin-booking/handlers/create-admin-booking.handler.ts rename to apps/backend/src/modules/admin-booking/features/create-admin-booking/create-admin-booking.handler.ts index 8120b50..f62339f 100644 --- a/apps/backend/src/modules/admin-booking/handlers/create-admin-booking.handler.ts +++ b/apps/backend/src/modules/admin-booking/features/create-admin-booking/create-admin-booking.handler.ts @@ -1,11 +1,9 @@ +import { handleResult } from '@/lib/http/handle-result'; import { db } from '@/lib/prisma'; -import { - AdminBookingServiceError, - createAdminBooking, -} from '@/modules/admin-booking/services/admin-booking.service'; import { sendBookingConfirmation } from '@/services/booking-email.service'; import type { AppContext } from '@/types/hono'; import type { CreateAdminBookingInput } from '@repo/api-contract'; +import { createAdminBooking } from './create-admin-booking.business'; type ComplexIdParams = { complexId: string }; @@ -14,8 +12,10 @@ export async function createAdminBookingHandler(c: AppContext) { const payload = c.req.valid('json' as never) as CreateAdminBookingInput; const user = c.get('user'); - try { - const booking = await createAdminBooking(user.id, complexId, payload); + const result = await createAdminBooking(user.id, complexId, payload); + + if (result.ok) { + const booking = result.value; const complex = await db.complex.findUnique({ where: { id: complexId }, @@ -35,12 +35,7 @@ export async function createAdminBookingHandler(c: AppContext) { customerEmail: booking.customerEmail, price: booking.price, }); - - return c.json(booking, 201); - } catch (error) { - if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; } + + return handleResult(c, result, 201); } diff --git a/apps/backend/src/modules/admin-booking/features/create-admin-recurring-booking/create-admin-recurring-booking.business.ts b/apps/backend/src/modules/admin-booking/features/create-admin-recurring-booking/create-admin-recurring-booking.business.ts new file mode 100644 index 0000000..240bdf7 --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/create-admin-recurring-booking/create-admin-recurring-booking.business.ts @@ -0,0 +1,329 @@ +import { randomInt } from 'node:crypto'; +import { DayOfWeek as DayOfWeekEnum } from '@/generated/prisma/enums'; +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import type { Result } from '@/lib/result'; +import { err, ok } from '@/lib/result'; +import { isSlotInPast } from '@/lib/slot-validator'; +import { + evaluatePlanUsage, + isFeatureEnabled, + parsePlanRules, +} from '@/modules/plan/services/plan-rules.service'; +import type { CreateRecurringBookingInput, RecurringBookingGroup } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { AdminBookingServiceError } from '../../shared/errors'; +import { + buildSlots, + ensureComplexAccess, + formatIsoDate, + minutesToTime, + parseIsoDate, + toMinutes, +} from '../../shared/helpers'; + +const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const BOOKING_CODE_LENGTH = 6; +const MAX_RECURRING_WEEKS = 52; + +const DAY_OF_WEEK_BY_INDEX = [ + DayOfWeekEnum.SUNDAY, + DayOfWeekEnum.MONDAY, + DayOfWeekEnum.TUESDAY, + DayOfWeekEnum.WEDNESDAY, + DayOfWeekEnum.THURSDAY, + DayOfWeekEnum.FRIDAY, + DayOfWeekEnum.SATURDAY, +] as const; + +type DayOfWeek = (typeof DAY_OF_WEEK_BY_INDEX)[number]; + +function generateBookingCode(): string { + let code = ''; + for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { + code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; + } + return code; +} + +function* generateRecurringDates( + startDate: Date, + endDate: Date | null, + dayOfWeek: number +): Generator { + const current = new Date(startDate); + current.setUTCDate(current.getUTCDate() + ((dayOfWeek - current.getUTCDay() + 7) % 7)); + + if (current < startDate) { + current.setUTCDate(current.getUTCDate() + 7); + } + + const maxDate = endDate ?? new Date(startDate); + if (!endDate) { + maxDate.setUTCDate(maxDate.getUTCDate() + MAX_RECURRING_WEEKS * 7); + } + + while (current <= maxDate) { + yield new Date(current); + current.setUTCDate(current.getUTCDate() + 7); + } +} + +function getDayOfWeekValue(date: Date): Result { + const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()]; + if (!dayOfWeek) { + return err(Errors.validation('No se pudo resolver el dia de la semana.')); + } + return ok(dayOfWeek); +} + +export async function createAdminRecurringBooking( + userId: string, + complexId: string, + input: CreateRecurringBookingInput +): Promise> { + const complexResult = await ensureComplexAccess(complexId, userId); + if (!complexResult.ok) return err(complexResult.error); + const complex = complexResult.value; + + const adminUser = await db.user.findUnique({ + where: { id: userId }, + select: { emailVerified: true }, + }); + + if (!adminUser?.emailVerified) { + return err(Errors.forbidden('Debés verificar tu email para poder crear reservas.')); + } + + if (!complex.plan) { + return err(Errors.forbidden('El complejo no tiene un plan asignado.')); + } + + const rules = parsePlanRules(complex.plan.rules); + + if (!isFeatureEnabled(rules, 'fixedSlots')) { + return err( + Errors.forbidden( + 'Tu plan no permite la creación de turnos fijos. Comunicate con el administrador.' + ) + ); + } + + const startDateResult = parseIsoDate(input.date); + if (!startDateResult.ok) return err(startDateResult.error); + const startDate = startDateResult.value; + + const dayOfWeekResult = getDayOfWeekValue(startDate); + if (!dayOfWeekResult.ok) return err(dayOfWeekResult.error); + const dayOfWeek = dayOfWeekResult.value; + + const endDateResult = input.recurringEndDate ? parseIsoDate(input.recurringEndDate) : null; + if (endDateResult && !endDateResult.ok) return err(endDateResult.error); + const endDate = endDateResult?.value ?? null; + + if (endDate && endDate <= startDate) { + return err(Errors.validation('La fecha de fin debe ser posterior a la fecha de inicio.')); + } + + const court = await db.court.findFirst({ + where: { id: input.courtId, complexId }, + include: { + availabilities: { + where: { dayOfWeek }, + orderBy: { startTime: 'asc' }, + }, + sport: { select: { id: true, name: true, slug: true } }, + priceRules: { + where: { isActive: true }, + orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], + }, + }, + }); + + if (!court) { + return err(Errors.notFound('La cancha seleccionada no existe en el complejo.')); + } + + const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes); + const selectedEndMinutes = toMinutes(input.startTime) + court.slotDurationMinutes; + const selectedSlot = { startTime: input.startTime, endTime: minutesToTime(selectedEndMinutes) }; + + const slotExists = validSlots.some( + (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime + ); + + if (!slotExists) { + return err(Errors.conflict('El horario seleccionado no esta disponible para esa cancha.')); + } + + if (isSlotInPast(startDate, input.startTime, court.slotDurationMinutes)) { + return err(Errors.validation('La fecha de inicio no puede estar en el pasado.')); + } + + const recurringDates = Array.from( + generateRecurringDates(startDate, endDate, startDate.getUTCDay()) + ); + + if (recurringDates.length === 0) { + return err( + Errors.validation( + 'No se generaron fechas para la reserva periódica. Verifica las fechas ingresadas.' + ) + ); + } + + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const result = await db.$transaction(async (tx) => { + const groupId = uuidv7(); + + const bookingsForDate = await tx.courtBooking.count({ + where: { + startTime: selectedSlot.startTime, + bookingDate: startDate, + status: 'CONFIRMED', + court: { complexId }, + }, + }); + + const courtsCount = await tx.court.count({ where: { complexId } }); + const violations = evaluatePlanUsage(rules, { + courtsCount, + bookingsToday: bookingsForDate, + }); + const maxBookingsViolation = violations.find( + (v) => v.code === 'MAX_BOOKINGS_PER_DAY_REACHED' + ); + + if (maxBookingsViolation) { + throw new AdminBookingServiceError(maxBookingsViolation.message); + } + + const group = await tx.recurringBookingGroup.create({ + data: { + id: groupId, + complexId, + courtId: court.id, + startTime: selectedSlot.startTime, + endTime: selectedSlot.endTime, + dayOfWeek, + startDate, + endDate, + status: 'ACTIVE', + customerName: input.customerName.trim(), + customerPhone: input.customerPhone.trim(), + customerEmail: input.customerEmail?.trim() ?? '', + }, + }); + + const createdBookings = []; + + for (const date of recurringDates) { + if (isSlotInPast(date, input.startTime, court.slotDurationMinutes)) continue; + + const overlappingBooking = await tx.courtBooking.findFirst({ + where: { + courtId: court.id, + bookingDate: date, + status: 'CONFIRMED', + startTime: { lt: selectedSlot.endTime }, + endTime: { gt: selectedSlot.startTime }, + }, + }); + + if (overlappingBooking) { + throw new AdminBookingServiceError( + `El horario seleccionado ya fue reservado para el dia ${formatIsoDate(date)}.` + ); + } + + const booking = await tx.courtBooking.create({ + data: { + id: uuidv7(), + bookingCode: generateBookingCode(), + courtId: court.id, + bookingDate: date, + startTime: selectedSlot.startTime, + endTime: selectedSlot.endTime, + customerName: input.customerName.trim(), + customerPhone: input.customerPhone.trim(), + customerEmail: input.customerEmail?.trim() ?? '', + status: 'CONFIRMED', + recurringGroupId: groupId, + }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + }, + }); + + createdBookings.push(booking); + } + + return { group, bookings: createdBookings }; + }); + + return ok({ + id: result.group.id, + complexId, + courtId: court.id, + startTime: result.group.startTime, + endTime: result.group.endTime, + dayOfWeek: result.group.dayOfWeek, + startDate: formatIsoDate(result.group.startDate), + endDate: result.group.endDate ? formatIsoDate(result.group.endDate) : null, + status: 'ACTIVE' as const, + customerName: result.group.customerName, + customerPhone: result.group.customerPhone, + customerEmail: result.group.customerEmail, + bookings: result.bookings.map((b) => ({ + id: b.id, + bookingCode: b.bookingCode, + complexId: b.court.complex.id, + complexName: b.court.complex.complexName, + courtId: b.court.id, + courtName: b.court.name, + sport: { id: b.court.sport.id, name: b.court.sport.name, slug: b.court.sport.slug }, + date: formatIsoDate(b.bookingDate), + startTime: b.startTime, + endTime: b.endTime, + customerName: b.customerName, + customerPhone: b.customerPhone, + customerEmail: b.customerEmail, + price: 0, + status: b.status as 'CONFIRMED', + recurringGroupId: groupId, + createdAt: b.createdAt.toISOString(), + updatedAt: b.updatedAt.toISOString(), + })), + createdAt: result.group.createdAt.toISOString(), + updatedAt: result.group.updatedAt.toISOString(), + }); + } catch (error) { + if (error instanceof AdminBookingServiceError) { + return err(Errors.conflict(error.message)); + } + + const prismaError = error as { code?: string; meta?: { target?: string[] | string } }; + if (prismaError.code === 'P2002') { + const targets = Array.isArray(prismaError.meta?.target) + ? prismaError.meta?.target + : [prismaError.meta?.target]; + const isBookingCodeCollision = targets.some((target) => + String(target).includes('booking_code') + ); + if (isBookingCodeCollision) continue; + return err(Errors.conflict('El horario seleccionado ya fue reservado.')); + } + throw error; + } + } + + return err(Errors.conflict('No se pudo generar un codigo de reserva unico. Intenta nuevamente.')); +} diff --git a/apps/backend/src/modules/admin-booking/handlers/create-admin-recurring-booking.handler.ts b/apps/backend/src/modules/admin-booking/features/create-admin-recurring-booking/create-admin-recurring-booking.handler.ts similarity index 68% rename from apps/backend/src/modules/admin-booking/handlers/create-admin-recurring-booking.handler.ts rename to apps/backend/src/modules/admin-booking/features/create-admin-recurring-booking/create-admin-recurring-booking.handler.ts index 08bea79..2db5f31 100644 --- a/apps/backend/src/modules/admin-booking/handlers/create-admin-recurring-booking.handler.ts +++ b/apps/backend/src/modules/admin-booking/features/create-admin-recurring-booking/create-admin-recurring-booking.handler.ts @@ -1,8 +1,8 @@ -import { AdminBookingServiceError } from '@/modules/admin-booking/services/admin-booking.service'; -import { createAdminRecurringBooking } from '@/modules/admin-booking/services/admin-recurring-booking.service'; +import { handleResult } from '@/lib/http/handle-result'; import { sendBookingConfirmation } from '@/services/booking-email.service'; import type { AppContext } from '@/types/hono'; import type { CreateRecurringBookingInput } from '@repo/api-contract'; +import { createAdminRecurringBooking } from './create-admin-recurring-booking.business'; type ComplexIdParams = { complexId: string }; @@ -11,8 +11,10 @@ export async function createAdminRecurringBookingHandler(c: AppContext) { const payload = c.req.valid('json' as never) as CreateRecurringBookingInput; const user = c.get('user'); - try { - const group = await createAdminRecurringBooking(user.id, complexId, payload); + const result = await createAdminRecurringBooking(user.id, complexId, payload); + + if (result.ok) { + const group = result.value; const firstBooking = group.bookings[0]; if (firstBooking?.customerEmail) { @@ -29,12 +31,7 @@ export async function createAdminRecurringBookingHandler(c: AppContext) { price: firstBooking.price, }); } - - return c.json(group, 201); - } catch (error) { - if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; } + + return handleResult(c, result, 201); } diff --git a/apps/backend/src/modules/admin-booking/features/list-admin-bookings/list-admin-bookings.business.ts b/apps/backend/src/modules/admin-booking/features/list-admin-bookings/list-admin-bookings.business.ts new file mode 100644 index 0000000..aaeadc9 --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/list-admin-bookings/list-admin-bookings.business.ts @@ -0,0 +1,38 @@ +import { db } from '@/lib/prisma'; +import type { Result } from '@/lib/result'; +import { err, ok } from '@/lib/result'; +import type { ListAdminBookingsQuery } from '@repo/api-contract'; +import { ensureComplexAccess, mapBookingResponse, parseIsoDate } from '../../shared/helpers'; + +export async function listAdminBookings( + userId: string, + complexId: string, + query: ListAdminBookingsQuery +): Promise> { + const accessResult = await ensureComplexAccess(complexId, userId); + if (!accessResult.ok) return err(accessResult.error); + + const dateResult = parseIsoDate(query.fromDate); + if (!dateResult.ok) return err(dateResult.error); + const fromDate = dateResult.value; + + const bookings = await db.courtBooking.findMany({ + where: { + bookingDate: { gte: fromDate }, + court: { complexId }, + }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + }, + orderBy: [{ bookingDate: 'asc' }, { startTime: 'asc' }, { createdAt: 'asc' }], + }); + + return ok({ bookings: bookings.map((b) => mapBookingResponse(b)) }); +} diff --git a/apps/backend/src/modules/admin-booking/handlers/list-admin-bookings.handler.ts b/apps/backend/src/modules/admin-booking/features/list-admin-bookings/list-admin-bookings.handler.ts similarity index 50% rename from apps/backend/src/modules/admin-booking/handlers/list-admin-bookings.handler.ts rename to apps/backend/src/modules/admin-booking/features/list-admin-bookings/list-admin-bookings.handler.ts index 3bd4eaf..9096bc2 100644 --- a/apps/backend/src/modules/admin-booking/handlers/list-admin-bookings.handler.ts +++ b/apps/backend/src/modules/admin-booking/features/list-admin-bookings/list-admin-bookings.handler.ts @@ -1,9 +1,7 @@ -import { - AdminBookingServiceError, - listAdminBookings, -} from '@/modules/admin-booking/services/admin-booking.service'; +import { handleResult } from '@/lib/http/handle-result'; import type { AppContext } from '@/types/hono'; import type { ListAdminBookingsQuery } from '@repo/api-contract'; +import { listAdminBookings } from './list-admin-bookings.business'; type ComplexIdParams = { complexId: string }; @@ -12,13 +10,5 @@ export async function listAdminBookingsHandler(c: AppContext) { const query = c.req.valid('query' as never) as ListAdminBookingsQuery; const user = c.get('user'); - try { - const response = await listAdminBookings(user.id, complexId, query); - return c.json(response); - } catch (error) { - if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; - } + return handleResult(c, await listAdminBookings(user.id, complexId, query)); } diff --git a/apps/backend/src/modules/admin-booking/features/list-recurring-groups/list-recurring-groups.business.ts b/apps/backend/src/modules/admin-booking/features/list-recurring-groups/list-recurring-groups.business.ts new file mode 100644 index 0000000..116cb61 --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/list-recurring-groups/list-recurring-groups.business.ts @@ -0,0 +1,112 @@ +import { db } from '@/lib/prisma'; +import type { Result } from '@/lib/result'; +import { ok } from '@/lib/result'; +import { formatIsoDate } from '../../shared/helpers'; + +export async function listRecurringGroups(complexId: string): Promise< + Result< + Array<{ + id: string; + complexId: string; + courtId: string; + startTime: string; + endTime: string; + dayOfWeek: string; + startDate: string; + endDate: string | null; + status: string; + customerName: string; + customerPhone: string; + customerEmail: string; + bookings: Array<{ + id: string; + bookingCode: string; + complexId: string; + complexName: string; + courtId: string; + courtName: string; + sport: { id: string; name: string; slug: string }; + date: string; + startTime: string; + endTime: string; + customerName: string; + customerPhone: string; + customerEmail: string; + price: number; + status: string; + recurringGroupId: string | null; + createdAt: string; + updatedAt: string; + }>; + createdAt: string; + updatedAt: string; + }> + > +> { + const groups = await db.recurringBookingGroup.findMany({ + where: { complexId, status: 'ACTIVE' }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + bookings: { + orderBy: { bookingDate: 'asc' }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return ok( + groups.map((group) => ({ + id: group.id, + complexId: group.complexId, + courtId: group.courtId, + startTime: group.startTime, + endTime: group.endTime, + dayOfWeek: group.dayOfWeek, + startDate: formatIsoDate(group.startDate), + endDate: group.endDate ? formatIsoDate(group.endDate) : null, + status: group.status, + customerName: group.customerName, + customerPhone: group.customerPhone, + customerEmail: group.customerEmail, + bookings: group.bookings.map((b) => ({ + id: b.id, + bookingCode: b.bookingCode, + complexId: b.court.complex.id, + complexName: b.court.complex.complexName, + courtId: b.court.id, + courtName: b.court.name, + sport: { id: b.court.sport.id, name: b.court.sport.name, slug: b.court.sport.slug }, + date: formatIsoDate(b.bookingDate), + startTime: b.startTime, + endTime: b.endTime, + customerName: b.customerName, + customerPhone: b.customerPhone, + customerEmail: b.customerEmail, + price: 0, + status: b.status, + recurringGroupId: b.recurringGroupId, + createdAt: b.createdAt.toISOString(), + updatedAt: b.updatedAt.toISOString(), + })), + createdAt: group.createdAt.toISOString(), + updatedAt: group.updatedAt.toISOString(), + })) + ); +} diff --git a/apps/backend/src/modules/admin-booking/features/list-recurring-groups/list-recurring-groups.handler.ts b/apps/backend/src/modules/admin-booking/features/list-recurring-groups/list-recurring-groups.handler.ts new file mode 100644 index 0000000..35e12df --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/list-recurring-groups/list-recurring-groups.handler.ts @@ -0,0 +1,18 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { Result } from '@/lib/result'; +import type { AppContext } from '@/types/hono'; +import { listRecurringGroups } from './list-recurring-groups.business'; + +type ComplexIdParams = { complexId: string }; + +export async function listRecurringGroupsHandler(c: AppContext) { + const { complexId } = c.req.valid('param' as never) as ComplexIdParams; + + const result = await listRecurringGroups(complexId); + return handleResult( + c, + result.ok + ? ({ ok: true as const, value: { groups: result.value } } as Result<{ groups: unknown }>) + : result + ); +} diff --git a/apps/backend/src/modules/admin-booking/features/reschedule-admin-booking/reschedule-admin-booking.business.ts b/apps/backend/src/modules/admin-booking/features/reschedule-admin-booking/reschedule-admin-booking.business.ts new file mode 100644 index 0000000..8cd2852 --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/reschedule-admin-booking/reschedule-admin-booking.business.ts @@ -0,0 +1,178 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import type { Result } from '@/lib/result'; +import { err, ok } from '@/lib/result'; +import { isSlotInPast } from '@/lib/slot-validator'; +import type { AdminBooking, RescheduleAdminBookingInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { + buildSlots, + getDayOfWeek, + mapBookingResponse, + minutesToTime, + resolvePrice, + toMinutes, +} from '../../shared/helpers'; + +export async function rescheduleAdminBooking( + userId: string, + bookingId: string, + input: RescheduleAdminBookingInput +): Promise> { + const booking = await db.courtBooking.findFirst({ + where: { + id: bookingId, + court: { + complex: { users: { some: { userId } } }, + }, + }, + include: { + court: { + select: { + id: true, + name: true, + complexId: true, + slotDurationMinutes: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + }, + }); + + if (!booking) { + return err(Errors.notFound('Reserva no encontrada.')); + } + + if (booking.status !== 'CONFIRMED') { + return err(Errors.conflict('Solo se pueden reprogramar reservas en estado confirmada.')); + } + + const targetCourtId = input.courtId ?? booking.courtId; + const targetStartTime = input.startTime ?? booking.startTime; + + if (targetCourtId === booking.courtId && targetStartTime === booking.startTime) { + return err(Errors.validation('Debe proporcionar al menos una cancha o un horario diferente.')); + } + + const dayOfWeekResult = getDayOfWeek(booking.bookingDate); + if (!dayOfWeekResult.ok) return err(dayOfWeekResult.error); + const dayOfWeek = dayOfWeekResult.value; + + const targetCourt = await db.court.findFirst({ + where: { id: targetCourtId, complexId: booking.court.complexId }, + include: { + availabilities: { + where: { dayOfWeek }, + orderBy: { startTime: 'asc' }, + }, + priceRules: { + where: { isActive: true }, + orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], + }, + sport: { select: { id: true, name: true, slug: true } }, + }, + }); + + if (!targetCourt) { + return err(Errors.notFound('La cancha seleccionada no existe en el complejo.')); + } + + if (targetCourt.sport.id !== booking.court.sport.id) { + return err( + Errors.conflict('La cancha seleccionada no es del mismo deporte que la reserva original.') + ); + } + + if (targetCourt.isUnderMaintenance) { + return err(Errors.conflict('La cancha seleccionada se encuentra en mantenimiento.')); + } + + const validSlots = buildSlots(targetCourt.availabilities, targetCourt.slotDurationMinutes); + const selectedEndMinutes = toMinutes(targetStartTime) + targetCourt.slotDurationMinutes; + const selectedSlot = { startTime: targetStartTime, endTime: minutesToTime(selectedEndMinutes) }; + + const slotExists = validSlots.some( + (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime + ); + + if (!slotExists) { + return err(Errors.conflict('El horario seleccionado no está disponible para esa cancha.')); + } + + if (isSlotInPast(booking.bookingDate, targetStartTime, targetCourt.slotDurationMinutes)) { + return err(Errors.validation('No se pueden reprogramar reservas en el pasado.')); + } + + const overlappingBooking = await db.courtBooking.findFirst({ + where: { + id: { not: bookingId }, + courtId: targetCourtId, + bookingDate: booking.bookingDate, + status: 'CONFIRMED', + startTime: { lt: selectedSlot.endTime }, + endTime: { gt: selectedSlot.startTime }, + }, + }); + + if (overlappingBooking) { + return err(Errors.conflict('El horario seleccionado ya fue reservado por otra reserva.')); + } + + const newPrice = resolvePrice( + targetCourt, + dayOfWeek, + selectedSlot.startTime, + selectedSlot.endTime + ); + + await db.$transaction(async (tx) => { + await tx.courtBookingLog.create({ + data: { + id: uuidv7(), + bookingCode: booking.bookingCode, + courtId: booking.court.id, + bookingDate: booking.bookingDate, + startTime: booking.startTime, + endTime: booking.endTime, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + customerEmail: booking.customerEmail, + previousStatus: booking.status, + newStatus: booking.status, + previousCourtId: booking.court.id, + previousStartTime: booking.startTime, + previousEndTime: booking.endTime, + changedAt: new Date(), + }, + }); + + await tx.courtBooking.update({ + where: { id: booking.id }, + data: { + courtId: targetCourtId, + startTime: selectedSlot.startTime, + endTime: selectedSlot.endTime, + }, + }); + }); + + const updatedBooking = { + ...booking, + startTime: selectedSlot.startTime, + endTime: selectedSlot.endTime, + price: newPrice, + }; + + if (targetCourtId !== booking.courtId) { + updatedBooking.court = { + id: targetCourt.id, + name: targetCourt.name, + slotDurationMinutes: targetCourt.slotDurationMinutes, + sport: targetCourt.sport, + complex: booking.court.complex, + } as typeof booking.court; + } + + return ok(mapBookingResponse(updatedBooking)); +} diff --git a/apps/backend/src/modules/admin-booking/handlers/reschedule-admin-booking.handler.ts b/apps/backend/src/modules/admin-booking/features/reschedule-admin-booking/reschedule-admin-booking.handler.ts similarity index 78% rename from apps/backend/src/modules/admin-booking/handlers/reschedule-admin-booking.handler.ts rename to apps/backend/src/modules/admin-booking/features/reschedule-admin-booking/reschedule-admin-booking.handler.ts index d572e8a..fbd1e51 100644 --- a/apps/backend/src/modules/admin-booking/handlers/reschedule-admin-booking.handler.ts +++ b/apps/backend/src/modules/admin-booking/features/reschedule-admin-booking/reschedule-admin-booking.handler.ts @@ -1,12 +1,10 @@ +import { handleResult } from '@/lib/http/handle-result'; import { db } from '@/lib/prisma'; import { sseManager } from '@/lib/sse'; -import { - AdminBookingServiceError, - rescheduleAdminBooking, -} from '@/modules/admin-booking/services/admin-booking.service'; import { sendBookingRescheduled } from '@/services/booking-email.service'; import type { AppContext } from '@/types/hono'; import type { RescheduleAdminBookingInput } from '@repo/api-contract'; +import { rescheduleAdminBooking } from './reschedule-admin-booking.business'; type BookingIdParams = { id: string }; @@ -25,8 +23,10 @@ export async function rescheduleAdminBookingHandler(c: AppContext) { }, }); - try { - const booking = await rescheduleAdminBooking(user.id, id, payload); + const result = await rescheduleAdminBooking(user.id, id, payload); + + if (result.ok) { + const booking = result.value; if (previous) { void sendBookingRescheduled({ @@ -49,12 +49,7 @@ export async function rescheduleAdminBookingHandler(c: AppContext) { `complex:${booking.complexId}`, JSON.stringify({ type: 'reschedule', booking }) ); - - return c.json(booking); - } catch (error) { - if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; } + + return handleResult(c, result); } diff --git a/apps/backend/src/modules/admin-booking/features/update-admin-booking-status/update-admin-booking-status.business.ts b/apps/backend/src/modules/admin-booking/features/update-admin-booking-status/update-admin-booking-status.business.ts new file mode 100644 index 0000000..97a754d --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/update-admin-booking-status/update-admin-booking-status.business.ts @@ -0,0 +1,76 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import type { Result } from '@/lib/result'; +import { err, ok } from '@/lib/result'; +import type { UpdateAdminBookingStatusInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { mapBookingResponse } from '../../shared/helpers'; + +export async function updateAdminBookingStatus( + userId: string, + bookingId: string, + input: UpdateAdminBookingStatusInput +): Promise>> { + const booking = await db.courtBooking.findFirst({ + where: { + id: bookingId, + court: { + complex: { users: { some: { userId } } }, + }, + }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + }, + }); + + if (!booking) { + return err(Errors.notFound('Reserva no encontrada.')); + } + + if (input.status === 'CANCELLED' && booking.status !== 'CONFIRMED') { + return err(Errors.conflict('Solo se pueden cancelar reservas en estado confirmada.')); + } + + if (input.status === 'COMPLETED' && booking.status !== 'CONFIRMED') { + return err(Errors.conflict('Solo se pueden marcar como cumplidas las reservas confirmadas.')); + } + + if (input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') { + return err(Errors.conflict('Solo se pueden marcar como no show las reservas confirmadas.')); + } + + await db.courtBookingLog.create({ + data: { + id: uuidv7(), + bookingCode: booking.bookingCode, + courtId: booking.court.id, + bookingDate: booking.bookingDate, + startTime: booking.startTime, + endTime: booking.endTime, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + customerEmail: booking.customerEmail, + previousStatus: booking.status, + newStatus: input.status, + changedAt: new Date(), + }, + }); + + if (input.status === 'COMPLETED' || input.status === 'NOSHOW') { + await db.courtBooking.update({ + where: { id: booking.id }, + data: { status: input.status }, + }); + } else { + await db.courtBooking.delete({ where: { id: booking.id } }); + } + + return ok(mapBookingResponse({ ...booking, status: input.status })); +} diff --git a/apps/backend/src/modules/admin-booking/handlers/update-admin-booking-status.handler.ts b/apps/backend/src/modules/admin-booking/features/update-admin-booking-status/update-admin-booking-status.handler.ts similarity index 77% rename from apps/backend/src/modules/admin-booking/handlers/update-admin-booking-status.handler.ts rename to apps/backend/src/modules/admin-booking/features/update-admin-booking-status/update-admin-booking-status.handler.ts index 047ed4e..3486cbd 100644 --- a/apps/backend/src/modules/admin-booking/handlers/update-admin-booking-status.handler.ts +++ b/apps/backend/src/modules/admin-booking/features/update-admin-booking-status/update-admin-booking-status.handler.ts @@ -1,10 +1,8 @@ -import { - AdminBookingServiceError, - updateAdminBookingStatus, -} from '@/modules/admin-booking/services/admin-booking.service'; +import { handleResult } from '@/lib/http/handle-result'; import { sendBookingCancelled, sendBookingNoShow } from '@/services/booking-email.service'; import type { AppContext } from '@/types/hono'; import type { UpdateAdminBookingStatusInput } from '@repo/api-contract'; +import { updateAdminBookingStatus } from './update-admin-booking-status.business'; type BookingIdParams = { id: string }; @@ -13,8 +11,10 @@ export async function updateAdminBookingStatusHandler(c: AppContext) { const payload = c.req.valid('json' as never) as UpdateAdminBookingStatusInput; const user = c.get('user'); - try { - const booking = await updateAdminBookingStatus(user.id, id, payload); + const result = await updateAdminBookingStatus(user.id, id, payload); + + if (result.ok) { + const booking = result.value; if (payload.status === 'CANCELLED') { void sendBookingCancelled({ @@ -41,12 +41,7 @@ export async function updateAdminBookingStatusHandler(c: AppContext) { customerEmail: booking.customerEmail, }); } - - return c.json(booking); - } catch (error) { - if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; } + + return handleResult(c, result); } diff --git a/apps/backend/src/modules/admin-booking/features/update-recurring-group/update-recurring-group.business.ts b/apps/backend/src/modules/admin-booking/features/update-recurring-group/update-recurring-group.business.ts new file mode 100644 index 0000000..76d788a --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/update-recurring-group/update-recurring-group.business.ts @@ -0,0 +1,318 @@ +import { randomInt } from 'node:crypto'; +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import type { Result } from '@/lib/result'; +import { err, ok } from '@/lib/result'; +import { isSlotInPast } from '@/lib/slot-validator'; +import type { RecurringBookingGroup, UpdateRecurringGroupInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { AdminBookingServiceError } from '../../shared/errors'; +import { buildSlots, formatIsoDate, minutesToTime, toMinutes } from '../../shared/helpers'; + +const DAY_INDEX_BY_VALUE: Record = { + SUNDAY: 0, + MONDAY: 1, + TUESDAY: 2, + WEDNESDAY: 3, + THURSDAY: 4, + FRIDAY: 5, + SATURDAY: 6, +}; + +const MAX_RECURRING_WEEKS = 52; + +function generateBookingCode(): string { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let code = ''; + for (let i = 0; i < 6; i++) { + code += alphabet[randomInt(0, alphabet.length)]; + } + return code; +} + +function* generateRecurringDates( + startDate: Date, + endDate: Date | null, + dayOfWeek: number +): Generator { + const current = new Date(startDate); + current.setUTCDate(current.getUTCDate() + ((dayOfWeek - current.getUTCDay() + 7) % 7)); + + if (current < startDate) { + current.setUTCDate(current.getUTCDate() + 7); + } + + const maxDate = endDate ?? new Date(startDate); + if (!endDate) { + maxDate.setUTCDate(maxDate.getUTCDate() + MAX_RECURRING_WEEKS * 7); + } + + while (current <= maxDate) { + yield new Date(current); + current.setUTCDate(current.getUTCDate() + 7); + } +} + +export async function updateRecurringGroup( + userId: string, + groupId: string, + input: UpdateRecurringGroupInput +): Promise> { + const group = await db.recurringBookingGroup.findUnique({ + where: { id: groupId }, + include: { + complex: { + select: { + id: true, + users: { where: { userId }, select: { userId: true } }, + }, + }, + }, + }); + + if (!group) { + return err(Errors.notFound('Grupo de turnos fijos no encontrado.')); + } + + if (group.complex.users.length === 0) { + return err(Errors.forbidden('No tienes permisos para administrar este complejo.')); + } + + if (group.status === 'CANCELLED') { + return err(Errors.conflict('No se puede editar un grupo cancelado.')); + } + + const courtId = input.courtId ?? group.courtId; + const dayOfWeek = input.dayOfWeek ?? group.dayOfWeek; + const startTime = input.startTime ?? group.startTime; + + const scheduleChanged = + input.courtId !== undefined || input.dayOfWeek !== undefined || input.startTime !== undefined; + + if (scheduleChanged) { + const court = await db.court.findFirst({ + where: { id: courtId, complexId: group.complexId }, + include: { + availabilities: { + where: { dayOfWeek: dayOfWeek as typeof group.dayOfWeek }, + orderBy: { startTime: 'asc' }, + }, + }, + }); + + if (!court) { + return err(Errors.notFound('La cancha seleccionada no existe en el complejo.')); + } + + const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes); + const selectedEndMinutes = toMinutes(startTime) + court.slotDurationMinutes; + const endTime = minutesToTime(selectedEndMinutes); + + const slotExists = validSlots.some( + (slot) => slot.startTime === startTime && slot.endTime === endTime + ); + + if (!slotExists) { + return err(Errors.conflict('El horario seleccionado no esta disponible para esa cancha.')); + } + + const now = new Date(); + const todayStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ); + + try { + await db.$transaction(async (tx) => { + await tx.recurringBookingGroup.update({ + where: { id: groupId }, + data: { + courtId, + dayOfWeek: dayOfWeek as typeof group.dayOfWeek, + startTime, + endTime, + customerName: input.customerName?.trim() ?? group.customerName, + customerPhone: input.customerPhone?.trim() ?? group.customerPhone, + customerEmail: input.customerEmail?.trim() ?? group.customerEmail, + }, + }); + + const futureBookings = await tx.courtBooking.findMany({ + where: { + recurringGroupId: groupId, + bookingDate: { gte: todayStart }, + status: 'CONFIRMED', + }, + }); + + for (const booking of futureBookings) { + await tx.courtBookingLog.create({ + data: { + id: uuidv7(), + bookingCode: booking.bookingCode, + courtId: booking.courtId, + bookingDate: booking.bookingDate, + startTime: booking.startTime, + endTime: booking.endTime, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + customerEmail: booking.customerEmail, + previousStatus: booking.status, + newStatus: 'CANCELLED', + changedAt: new Date(), + }, + }); + + await tx.courtBooking.delete({ where: { id: booking.id } }); + } + + const dayIndex = DAY_INDEX_BY_VALUE[dayOfWeek] ?? 0; + const recurringDates = Array.from( + generateRecurringDates(todayStart, group.endDate, dayIndex) + ); + + for (const date of recurringDates) { + const overlappingBooking = await tx.courtBooking.findFirst({ + where: { + courtId, + bookingDate: date, + status: 'CONFIRMED', + startTime: { lt: endTime }, + endTime: { gt: startTime }, + }, + }); + + if (overlappingBooking) { + throw new AdminBookingServiceError( + `El horario seleccionado ya fue reservado para el dia ${formatIsoDate(date)}.` + ); + } + + await tx.courtBooking.create({ + data: { + id: uuidv7(), + bookingCode: generateBookingCode(), + courtId, + bookingDate: date, + startTime, + endTime, + customerName: input.customerName?.trim() ?? group.customerName, + customerPhone: input.customerPhone?.trim() ?? group.customerPhone, + customerEmail: input.customerEmail?.trim() ?? group.customerEmail, + status: 'CONFIRMED', + recurringGroupId: groupId, + }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + }, + }); + } + }); + } catch (txError) { + if (txError instanceof AdminBookingServiceError) { + return err(Errors.conflict(txError.message)); + } + throw txError; + } + } else { + await db.recurringBookingGroup.update({ + where: { id: groupId }, + data: { + ...(input.customerName !== undefined && { customerName: input.customerName.trim() }), + ...(input.customerPhone !== undefined && { customerPhone: input.customerPhone.trim() }), + ...(input.customerEmail !== undefined && { customerEmail: input.customerEmail.trim() }), + }, + }); + + const now = new Date(); + const todayStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ); + + const updateData: Record = {}; + if (input.customerName !== undefined) updateData.customerName = input.customerName.trim(); + if (input.customerPhone !== undefined) updateData.customerPhone = input.customerPhone.trim(); + if (input.customerEmail !== undefined) updateData.customerEmail = input.customerEmail.trim(); + + if (Object.keys(updateData).length > 0) { + await db.courtBooking.updateMany({ + where: { recurringGroupId: groupId, bookingDate: { gte: todayStart } }, + data: updateData, + }); + } + } + + const updated = await db.recurringBookingGroup.findUnique({ + where: { id: groupId }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + bookings: { + orderBy: { bookingDate: 'asc' }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + }, + }, + }, + }); + + if (!updated) { + return err(Errors.conflict('Error al actualizar el grupo.')); + } + + return ok({ + id: updated.id, + complexId: updated.complexId, + courtId: updated.courtId, + startTime: updated.startTime, + endTime: updated.endTime, + dayOfWeek: updated.dayOfWeek, + startDate: formatIsoDate(updated.startDate), + endDate: updated.endDate ? formatIsoDate(updated.endDate) : null, + status: updated.status, + customerName: updated.customerName, + customerPhone: updated.customerPhone, + customerEmail: updated.customerEmail, + bookings: updated.bookings.map((b) => ({ + id: b.id, + bookingCode: b.bookingCode, + complexId: b.court.complex.id, + complexName: b.court.complex.complexName, + courtId: b.court.id, + courtName: b.court.name, + sport: { id: b.court.sport.id, name: b.court.sport.name, slug: b.court.sport.slug }, + date: formatIsoDate(b.bookingDate), + startTime: b.startTime, + endTime: b.endTime, + customerName: b.customerName, + customerPhone: b.customerPhone, + customerEmail: b.customerEmail, + price: 0, + status: b.status, + recurringGroupId: b.recurringGroupId, + createdAt: b.createdAt.toISOString(), + updatedAt: b.updatedAt.toISOString(), + })), + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + }); +} diff --git a/apps/backend/src/modules/admin-booking/features/update-recurring-group/update-recurring-group.handler.ts b/apps/backend/src/modules/admin-booking/features/update-recurring-group/update-recurring-group.handler.ts new file mode 100644 index 0000000..7b1507e --- /dev/null +++ b/apps/backend/src/modules/admin-booking/features/update-recurring-group/update-recurring-group.handler.ts @@ -0,0 +1,14 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import type { UpdateRecurringGroupInput } from '@repo/api-contract'; +import { updateRecurringGroup } from './update-recurring-group.business'; + +type GroupIdParams = { groupId: string }; + +export async function updateRecurringGroupHandler(c: AppContext) { + const { groupId } = c.req.valid('param' as never) as GroupIdParams; + const payload = c.req.valid('json' as never) as UpdateRecurringGroupInput; + const user = c.get('user'); + + return handleResult(c, await updateRecurringGroup(user.id, groupId, payload)); +} diff --git a/apps/backend/src/modules/admin-booking/handlers/cancel-recurring-group.handler.ts b/apps/backend/src/modules/admin-booking/handlers/cancel-recurring-group.handler.ts deleted file mode 100644 index 0d1c698..0000000 --- a/apps/backend/src/modules/admin-booking/handlers/cancel-recurring-group.handler.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { AdminBookingServiceError } from '@/modules/admin-booking/services/admin-booking.service'; -import { cancelRecurringGroup } from '@/modules/admin-booking/services/admin-recurring-booking.service'; -import type { AppContext } from '@/types/hono'; - -type GroupIdParams = { groupId: string }; - -export async function cancelRecurringGroupHandler(c: AppContext) { - const { groupId } = c.req.valid('param' as never) as GroupIdParams; - const user = c.get('user'); - - try { - const result = await cancelRecurringGroup(user.id, groupId); - return c.json(result); - } catch (error) { - if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; - } -} diff --git a/apps/backend/src/modules/admin-booking/handlers/list-recurring-groups.handler.ts b/apps/backend/src/modules/admin-booking/handlers/list-recurring-groups.handler.ts deleted file mode 100644 index 6f79004..0000000 --- a/apps/backend/src/modules/admin-booking/handlers/list-recurring-groups.handler.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { AdminBookingServiceError } from '@/modules/admin-booking/services/admin-booking.service'; -import { listRecurringGroups } from '@/modules/admin-booking/services/admin-recurring-booking.service'; -import type { AppContext } from '@/types/hono'; - -type ComplexIdParams = { complexId: string }; - -export async function listRecurringGroupsHandler(c: AppContext) { - const { complexId } = c.req.valid('param' as never) as ComplexIdParams; - - try { - const groups = await listRecurringGroups(complexId); - return c.json({ groups }); - } catch (error) { - if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; - } -} diff --git a/apps/backend/src/modules/admin-booking/handlers/update-recurring-group.handler.ts b/apps/backend/src/modules/admin-booking/handlers/update-recurring-group.handler.ts deleted file mode 100644 index 7df3c30..0000000 --- a/apps/backend/src/modules/admin-booking/handlers/update-recurring-group.handler.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AdminBookingServiceError } from '@/modules/admin-booking/services/admin-booking.service'; -import { updateRecurringGroup } from '@/modules/admin-booking/services/admin-recurring-booking.service'; -import type { AppContext } from '@/types/hono'; -import type { UpdateRecurringGroupInput } from '@repo/api-contract'; - -type GroupIdParams = { groupId: string }; - -export async function updateRecurringGroupHandler(c: AppContext) { - const { groupId } = c.req.valid('param' as never) as GroupIdParams; - const payload = c.req.valid('json' as never) as UpdateRecurringGroupInput; - const user = c.get('user'); - - try { - const group = await updateRecurringGroup(user.id, groupId, payload); - return c.json(group); - } catch (error) { - if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; - } -} diff --git a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts deleted file mode 100644 index 0031d95..0000000 --- a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts +++ /dev/null @@ -1,794 +0,0 @@ -import { randomInt } from 'node:crypto'; -import { CourtBookingStatus } from '@/generated/prisma/enums'; -import { db } from '@/lib/prisma'; -import { isSlotInPast } from '@/lib/slot-validator'; -import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; -import type { DayOfWeek } from '@repo/api-contract'; -import type { - AdminBooking, - CreateAdminBookingInput, - ListAdminBookingsQuery, - RescheduleAdminBookingInput, - UpdateAdminBookingStatusInput, -} from '@repo/api-contract'; -import { v7 as uuidv7 } from 'uuid'; - -type Slot = { - startTime: string; - endTime: string; -}; - -const DAY_OF_WEEK_BY_INDEX = [ - 'SUNDAY', - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', -] as const; -const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -const BOOKING_CODE_LENGTH = 6; - -export class AdminBookingServiceError extends Error { - status: 400 | 403 | 404 | 409; - - constructor(message: string, status: 400 | 403 | 404 | 409) { - super(message); - this.name = 'AdminBookingServiceError'; - this.status = status; - } -} - -function toMinutes(value: string): number { - const [hours, minutes] = value.split(':').map((part) => Number(part)); - return hours * 60 + minutes; -} - -function minutesToTime(minutes: number): string { - const safeMinutes = Math.max(0, minutes); - const hours = Math.floor(safeMinutes / 60); - const mins = safeMinutes % 60; - return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; -} - -function parseIsoDate(date: string): Date { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); - - if (!match) { - throw new AdminBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400); - } - - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - - const bookingDate = new Date(Date.UTC(year, month - 1, day)); - - if ( - Number.isNaN(bookingDate.getTime()) || - bookingDate.getUTCFullYear() !== year || - bookingDate.getUTCMonth() + 1 !== month || - bookingDate.getUTCDate() !== day - ) { - throw new AdminBookingServiceError('La fecha enviada no es valida.', 400); - } - - return bookingDate; -} - -function getDayOfWeek(date: Date) { - const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()]; - - if (!dayOfWeek) { - throw new AdminBookingServiceError('No se pudo resolver el dia de la semana.', 400); - } - - return dayOfWeek; -} - -function formatIsoDate(date: Date): string { - return date.toISOString().slice(0, 10); -} - -function generateBookingCode(): string { - let code = ''; - - for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { - code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; - } - - return code; -} - -function buildSlots( - availability: Array<{ - startTime: string; - endTime: string; - }>, - slotDurationMinutes: number -): Slot[] { - const slots: Slot[] = []; - - for (const range of availability) { - const start = toMinutes(range.startTime); - const end = toMinutes(range.endTime); - - for ( - let current = start; - current + slotDurationMinutes <= end; - current += slotDurationMinutes - ) { - slots.push({ - startTime: minutesToTime(current), - endTime: minutesToTime(current + slotDurationMinutes), - }); - } - } - - return slots; -} - -async function ensureComplexAccess(complexId: string, userId: string) { - const complexUser = await db.complexUser.findUnique({ - where: { - complexId_userId: { - complexId, - userId, - }, - }, - include: { - complex: { - select: { - id: true, - complexName: true, - plan: { - select: { - rules: true, - }, - }, - }, - }, - }, - }); - - if (!complexUser) { - throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403); - } - - return complexUser.complex; -} - -function resolvePrice( - court: { - basePrice: unknown; - priceRules: Array<{ - dayOfWeek: string | null; - startTime: string | null; - endTime: string | null; - price: unknown; - }>; - }, - dayOfWeek: string, - startTime: string, - endTime: string -): number { - const slotStart = toMinutes(startTime); - const slotEnd = toMinutes(endTime); - - const matchingRules = court.priceRules - .filter((rule) => { - if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) return false; - if (!rule.startTime || !rule.endTime) return true; - return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime); - }) - .sort((first, second) => { - const firstSpecificity = - (first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0); - const secondSpecificity = - (second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0); - return secondSpecificity - firstSpecificity; - }); - - return Number(matchingRules[0]?.price ?? court.basePrice); -} - -function mapBookingResponse(booking: { - id: string; - bookingCode: string; - bookingDate: Date; - startTime: string; - endTime: string; - customerName: string; - customerPhone: string; - customerEmail: string; - status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW'; - recurringGroupId: string | null; - createdAt: Date; - updatedAt: Date; - court: { - id: string; - name: string; - complex: { - id: string; - complexName: string; - }; - sport: { - id: string; - name: string; - slug: string; - }; - }; - price?: number; -}): AdminBooking { - return { - id: booking.id, - bookingCode: booking.bookingCode, - complexId: booking.court.complex.id, - complexName: booking.court.complex.complexName, - courtId: booking.court.id, - courtName: booking.court.name, - sport: { - id: booking.court.sport.id, - name: booking.court.sport.name, - slug: booking.court.sport.slug, - }, - date: formatIsoDate(booking.bookingDate), - startTime: booking.startTime, - endTime: booking.endTime, - customerName: booking.customerName, - customerPhone: booking.customerPhone, - customerEmail: booking.customerEmail, - price: booking.price ?? 0, - status: booking.status, - recurringGroupId: booking.recurringGroupId, - createdAt: booking.createdAt.toISOString(), - updatedAt: booking.updatedAt.toISOString(), - }; -} - -export async function listAdminBookings( - userId: string, - complexId: string, - query: ListAdminBookingsQuery -) { - await ensureComplexAccess(complexId, userId); - const fromDate = parseIsoDate(query.fromDate); - - const bookings = await db.courtBooking.findMany({ - where: { - bookingDate: { - gte: fromDate, - }, - court: { - complexId, - }, - }, - include: { - court: { - select: { - id: true, - name: true, - sport: { - select: { - id: true, - name: true, - slug: true, - }, - }, - complex: { - select: { - id: true, - complexName: true, - }, - }, - }, - }, - }, - orderBy: [{ bookingDate: 'asc' }, { startTime: 'asc' }, { createdAt: 'asc' }], - }); - - const response = bookings.map((booking) => mapBookingResponse(booking)); - - return { - bookings: response, - }; -} - -export async function createAdminBooking( - userId: string, - complexId: string, - input: CreateAdminBookingInput -) { - const complex = await ensureComplexAccess(complexId, userId); - - const adminUser = await db.user.findUnique({ - where: { id: userId }, - select: { emailVerified: true }, - }); - - if (!adminUser?.emailVerified) { - throw new AdminBookingServiceError('Debés verificar tu email para poder crear reservas.', 403); - } - - const bookingDate = parseIsoDate(input.date); - const dayOfWeek = getDayOfWeek(bookingDate); - - const court = await db.court.findFirst({ - where: { - id: input.courtId, - complexId, - }, - include: { - availabilities: { - where: { - dayOfWeek, - }, - orderBy: { - startTime: 'asc', - }, - }, - sport: { - select: { - id: true, - name: true, - slug: true, - }, - }, - priceRules: { - where: { isActive: true }, - orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], - }, - }, - }); - - if (!court) { - throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404); - } - - const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes); - const selectedStartMinutes = toMinutes(input.startTime); - const selectedEndMinutes = selectedStartMinutes + court.slotDurationMinutes; - const selectedSlot = { - startTime: input.startTime, - endTime: minutesToTime(selectedEndMinutes), - }; - - const slotExists = validSlots.some( - (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime - ); - - if (!slotExists) { - throw new AdminBookingServiceError( - 'El horario seleccionado no esta disponible para esa cancha.', - 409 - ); - } - - if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) { - throw new AdminBookingServiceError('No se pueden crear reservas en el pasado.', 400); - } - - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - const booking = await db.$transaction(async (tx) => { - if (complex.plan) { - const rules = parsePlanRules(complex.plan.rules); - const bookingsForDate = await tx.courtBooking.count({ - where: { - bookingDate, - status: 'CONFIRMED', - court: { - complexId, - }, - }, - }); - - const courtsCount = await tx.court.count({ - where: { - complexId, - }, - }); - - const violations = evaluatePlanUsage(rules, { - courtsCount, - bookingsToday: bookingsForDate, - }); - - const maxBookingsViolation = violations.find( - (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED' - ); - - if (maxBookingsViolation) { - throw new AdminBookingServiceError(maxBookingsViolation.message, 409); - } - } - - const overlappingBooking = await tx.courtBooking.findFirst({ - where: { - courtId: court.id, - bookingDate, - status: 'CONFIRMED', - startTime: { - lt: selectedSlot.endTime, - }, - endTime: { - gt: selectedSlot.startTime, - }, - }, - }); - - if (overlappingBooking) { - throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409); - } - - return tx.courtBooking.create({ - data: { - id: uuidv7(), - bookingCode: generateBookingCode(), - courtId: court.id, - bookingDate, - startTime: selectedSlot.startTime, - endTime: selectedSlot.endTime, - customerName: input.customerName.trim(), - customerPhone: input.customerPhone.trim(), - customerEmail: input.customerEmail?.trim() ?? '', - status: 'CONFIRMED', - recurringGroupId: null, - }, - include: { - court: { - select: { - id: true, - name: true, - sport: { - select: { - id: true, - name: true, - slug: true, - }, - }, - complex: { - select: { - id: true, - complexName: true, - }, - }, - }, - }, - }, - }); - }); - - const price = resolvePrice(court, dayOfWeek, selectedSlot.startTime, selectedSlot.endTime); - - return mapBookingResponse({ ...booking, price }); - } catch (error) { - if (error instanceof AdminBookingServiceError) { - throw error; - } - - const prismaError = error as { - code?: string; - meta?: { - target?: string[] | string; - }; - }; - - if (prismaError.code === 'P2002') { - const targets = Array.isArray(prismaError.meta?.target) - ? prismaError.meta?.target - : [prismaError.meta?.target]; - const isBookingCodeCollision = targets.some((target) => - String(target).includes('booking_code') - ); - - if (isBookingCodeCollision) { - continue; - } - - throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409); - } - - throw error; - } - } - - throw new AdminBookingServiceError( - 'No se pudo generar un codigo de reserva unico. Intenta nuevamente.', - 409 - ); -} - -export async function updateAdminBookingStatus( - userId: string, - bookingId: string, - input: UpdateAdminBookingStatusInput -) { - const booking = await db.courtBooking.findFirst({ - where: { - id: bookingId, - court: { - complex: { - users: { - some: { - userId, - }, - }, - }, - }, - }, - include: { - court: { - select: { - id: true, - name: true, - sport: { - select: { - id: true, - name: true, - slug: true, - }, - }, - complex: { - select: { - id: true, - complexName: true, - }, - }, - }, - }, - }, - }); - - if (!booking) { - throw new AdminBookingServiceError('Reserva no encontrada.', 404); - } - - if (input.status === 'CANCELLED' && booking.status !== 'CONFIRMED') { - throw new AdminBookingServiceError( - 'Solo se pueden cancelar reservas en estado confirmada.', - 409 - ); - } - - if (input.status === 'COMPLETED' && booking.status !== 'CONFIRMED') { - throw new AdminBookingServiceError( - 'Solo se pueden marcar como cumplidas las reservas confirmadas.', - 409 - ); - } - - if (input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') { - throw new AdminBookingServiceError( - 'Solo se pueden marcar como no show las reservas confirmadas.', - 409 - ); - } - - await db.courtBookingLog.create({ - data: { - id: uuidv7(), - bookingCode: booking.bookingCode, - courtId: booking.court.id, - bookingDate: booking.bookingDate, - startTime: booking.startTime, - endTime: booking.endTime, - customerName: booking.customerName, - customerPhone: booking.customerPhone, - customerEmail: booking.customerEmail, - previousStatus: booking.status, - newStatus: input.status, - changedAt: new Date(), - }, - }); - - if (input.status === 'COMPLETED' || input.status === 'NOSHOW') { - await db.courtBooking.update({ - where: { id: booking.id }, - data: { - status: input.status, - }, - }); - } else { - // if the booking is cancelled we delete it to free up the slot, but we keep a log of it with the cancelled status - await db.courtBooking.delete({ - where: { id: booking.id }, - }); - } - - return mapBookingResponse({ ...booking, status: input.status }); -} - -export async function rescheduleAdminBooking( - userId: string, - bookingId: string, - input: RescheduleAdminBookingInput -): Promise { - const booking = await db.courtBooking.findFirst({ - where: { - id: bookingId, - court: { - complex: { - users: { - some: { userId }, - }, - }, - }, - }, - include: { - court: { - select: { - id: true, - name: true, - complexId: true, - slotDurationMinutes: true, - sport: { - select: { id: true, name: true, slug: true }, - }, - complex: { - select: { id: true, complexName: true }, - }, - }, - }, - }, - }); - - if (!booking) { - throw new AdminBookingServiceError('Reserva no encontrada.', 404); - } - - if (booking.status !== 'CONFIRMED') { - throw new AdminBookingServiceError( - 'Solo se pueden reprogramar reservas en estado confirmada.', - 409 - ); - } - - const targetCourtId = input.courtId ?? booking.courtId; - const targetStartTime = input.startTime ?? booking.startTime; - - if (targetCourtId === booking.courtId && targetStartTime === booking.startTime) { - throw new AdminBookingServiceError( - 'Debe proporcionar al menos una cancha o un horario diferente.', - 400 - ); - } - - const dayOfWeek = getDayOfWeek(booking.bookingDate); - - const targetCourt = await db.court.findFirst({ - where: { - id: targetCourtId, - complexId: booking.court.complexId, - }, - include: { - availabilities: { - where: { dayOfWeek }, - orderBy: { startTime: 'asc' }, - }, - priceRules: { - where: { isActive: true }, - orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], - }, - sport: { - select: { id: true, name: true, slug: true }, - }, - }, - }); - - if (!targetCourt) { - throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404); - } - - if (targetCourt.sport.id !== booking.court.sport.id) { - throw new AdminBookingServiceError( - 'La cancha seleccionada no es del mismo deporte que la reserva original.', - 409 - ); - } - - if (targetCourt.isUnderMaintenance) { - throw new AdminBookingServiceError( - 'La cancha seleccionada se encuentra en mantenimiento.', - 409 - ); - } - - const validSlots = buildSlots(targetCourt.availabilities, targetCourt.slotDurationMinutes); - const selectedEndMinutes = toMinutes(targetStartTime) + targetCourt.slotDurationMinutes; - const selectedSlot = { - startTime: targetStartTime, - endTime: minutesToTime(selectedEndMinutes), - }; - - const slotExists = validSlots.some( - (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime - ); - - if (!slotExists) { - throw new AdminBookingServiceError( - 'El horario seleccionado no está disponible para esa cancha.', - 409 - ); - } - - if (isSlotInPast(booking.bookingDate, targetStartTime, targetCourt.slotDurationMinutes)) { - throw new AdminBookingServiceError('No se pueden reprogramar reservas en el pasado.', 400); - } - - const overlappingBooking = await db.courtBooking.findFirst({ - where: { - id: { not: bookingId }, - courtId: targetCourtId, - bookingDate: booking.bookingDate, - status: 'CONFIRMED', - startTime: { lt: selectedSlot.endTime }, - endTime: { gt: selectedSlot.startTime }, - }, - }); - - if (overlappingBooking) { - throw new AdminBookingServiceError( - 'El horario seleccionado ya fue reservado por otra reserva.', - 409 - ); - } - - const newPrice = resolvePrice( - targetCourt, - dayOfWeek, - selectedSlot.startTime, - selectedSlot.endTime - ); - - await db.$transaction(async (tx) => { - await tx.courtBookingLog.create({ - data: { - id: uuidv7(), - bookingCode: booking.bookingCode, - courtId: booking.court.id, - bookingDate: booking.bookingDate, - startTime: booking.startTime, - endTime: booking.endTime, - customerName: booking.customerName, - customerPhone: booking.customerPhone, - customerEmail: booking.customerEmail, - previousStatus: booking.status, - newStatus: booking.status, - previousCourtId: booking.court.id, - previousStartTime: booking.startTime, - previousEndTime: booking.endTime, - changedAt: new Date(), - }, - }); - - await tx.courtBooking.update({ - where: { id: booking.id }, - data: { - courtId: targetCourtId, - startTime: selectedSlot.startTime, - endTime: selectedSlot.endTime, - }, - }); - }); - - const updatedBooking = { - ...booking, - startTime: selectedSlot.startTime, - endTime: selectedSlot.endTime, - price: newPrice, - }; - - if (targetCourtId !== booking.courtId) { - updatedBooking.court = { - id: targetCourt.id, - name: targetCourt.name, - slotDurationMinutes: targetCourt.slotDurationMinutes, - sport: targetCourt.sport, - complex: booking.court.complex, - } as typeof booking.court; - } - - return mapBookingResponse(updatedBooking); -} diff --git a/apps/backend/src/modules/admin-booking/services/admin-recurring-booking.service.ts b/apps/backend/src/modules/admin-booking/services/admin-recurring-booking.service.ts deleted file mode 100644 index 5793a84..0000000 --- a/apps/backend/src/modules/admin-booking/services/admin-recurring-booking.service.ts +++ /dev/null @@ -1,893 +0,0 @@ -import { randomInt } from 'node:crypto'; -import { DayOfWeek as DayOfWeekEnum } from '@/generated/prisma/enums'; -import { db } from '@/lib/prisma'; -import { isSlotInPast } from '@/lib/slot-validator'; -import { - evaluatePlanUsage, - isFeatureEnabled, - parsePlanRules, -} from '@/modules/plan/services/plan-rules.service'; -import type { - CreateRecurringBookingInput, - RecurringBookingGroup, - UpdateRecurringGroupInput, -} from '@repo/api-contract'; -import { v7 as uuidv7 } from 'uuid'; -import { AdminBookingServiceError } from './admin-booking.service'; - -const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -const BOOKING_CODE_LENGTH = 6; -const MAX_RECURRING_WEEKS = 52; - -const DAY_INDEX_BY_VALUE: Record = { - SUNDAY: 0, - MONDAY: 1, - TUESDAY: 2, - WEDNESDAY: 3, - THURSDAY: 4, - FRIDAY: 5, - SATURDAY: 6, -}; - -const DAY_OF_WEEK_BY_INDEX = [ - DayOfWeekEnum.SUNDAY, - DayOfWeekEnum.MONDAY, - DayOfWeekEnum.TUESDAY, - DayOfWeekEnum.WEDNESDAY, - DayOfWeekEnum.THURSDAY, - DayOfWeekEnum.FRIDAY, - DayOfWeekEnum.SATURDAY, -] as const; - -type DayOfWeek = (typeof DAY_OF_WEEK_BY_INDEX)[number]; - -function toMinutes(value: string): number { - const [hours, minutes] = value.split(':').map((part) => Number(part)); - return hours * 60 + minutes; -} - -function minutesToTime(minutes: number): string { - const safeMinutes = Math.max(0, minutes); - const hours = Math.floor(safeMinutes / 60); - const mins = safeMinutes % 60; - return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; -} - -function parseIsoDate(date: string): Date { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); - - if (!match) { - throw new AdminBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400); - } - - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - - const parsed = new Date(Date.UTC(year, month - 1, day)); - - if ( - Number.isNaN(parsed.getTime()) || - parsed.getUTCFullYear() !== year || - parsed.getUTCMonth() + 1 !== month || - parsed.getUTCDate() !== day - ) { - throw new AdminBookingServiceError('La fecha enviada no es valida.', 400); - } - - return parsed; -} - -function formatIsoDate(date: Date): string { - return date.toISOString().slice(0, 10); -} - -function getDayOfWeekValue(date: Date): DayOfWeek { - const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()]; - - if (!dayOfWeek) { - throw new AdminBookingServiceError('No se pudo resolver el dia de la semana.', 400); - } - - return dayOfWeek; -} - -function generateBookingCode(): string { - let code = ''; - - for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { - code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; - } - - return code; -} - -function buildSlots( - availability: Array<{ - startTime: string; - endTime: string; - }>, - slotDurationMinutes: number -) { - const slots: Array<{ startTime: string; endTime: string }> = []; - - for (const range of availability) { - const start = toMinutes(range.startTime); - const end = toMinutes(range.endTime); - - for ( - let current = start; - current + slotDurationMinutes <= end; - current += slotDurationMinutes - ) { - slots.push({ - startTime: minutesToTime(current), - endTime: minutesToTime(current + slotDurationMinutes), - }); - } - } - - return slots; -} - -async function ensureComplexAccess(complexId: string, userId: string) { - const complexUser = await db.complexUser.findUnique({ - where: { - complexId_userId: { - complexId, - userId, - }, - }, - include: { - complex: { - select: { - id: true, - complexName: true, - plan: { - select: { - rules: true, - }, - }, - }, - }, - }, - }); - - if (!complexUser) { - throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403); - } - - return complexUser.complex; -} - -function* generateRecurringDates( - startDate: Date, - endDate: Date | null, - dayOfWeek: number -): Generator { - const current = new Date(startDate); - current.setUTCDate(current.getUTCDate() + ((dayOfWeek - current.getUTCDay() + 7) % 7)); - - if (current < startDate) { - current.setUTCDate(current.getUTCDate() + 7); - } - - const maxDate = endDate ?? new Date(startDate); - if (!endDate) { - maxDate.setUTCDate(maxDate.getUTCDate() + MAX_RECURRING_WEEKS * 7); - } - - while (current <= maxDate) { - yield new Date(current); - current.setUTCDate(current.getUTCDate() + 7); - } -} - -function mapBookingResponse(booking: { - id: string; - bookingCode: string; - bookingDate: Date; - startTime: string; - endTime: string; - customerName: string; - customerPhone: string; - customerEmail: string; - status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW'; - recurringGroupId: string | null; - createdAt: Date; - updatedAt: Date; - court: { - id: string; - name: string; - complex: { - id: string; - complexName: string; - }; - sport: { - id: string; - name: string; - slug: string; - }; - }; - price?: number; -}) { - return { - id: booking.id, - bookingCode: booking.bookingCode, - complexId: booking.court.complex.id, - complexName: booking.court.complex.complexName, - courtId: booking.court.id, - courtName: booking.court.name, - sport: { - id: booking.court.sport.id, - name: booking.court.sport.name, - slug: booking.court.sport.slug, - }, - date: formatIsoDate(booking.bookingDate), - startTime: booking.startTime, - endTime: booking.endTime, - customerName: booking.customerName, - customerPhone: booking.customerPhone, - customerEmail: booking.customerEmail, - price: booking.price ?? 0, - status: booking.status, - recurringGroupId: booking.recurringGroupId, - createdAt: booking.createdAt.toISOString(), - updatedAt: booking.updatedAt.toISOString(), - }; -} - -export async function createAdminRecurringBooking( - userId: string, - complexId: string, - input: CreateRecurringBookingInput -): Promise { - const complex = await ensureComplexAccess(complexId, userId); - - const adminUser = await db.user.findUnique({ - where: { id: userId }, - select: { emailVerified: true }, - }); - - if (!adminUser?.emailVerified) { - throw new AdminBookingServiceError('Debés verificar tu email para poder crear reservas.', 403); - } - - if (!complex.plan) { - throw new AdminBookingServiceError('El complejo no tiene un plan asignado.', 403); - } - - const rules = parsePlanRules(complex.plan.rules); - - if (!isFeatureEnabled(rules, 'fixedSlots')) { - throw new AdminBookingServiceError( - 'Tu plan no permite la creación de turnos fijos. Comunicate con el administrador.', - 403 - ); - } - - const startDate = parseIsoDate(input.date); - const dayOfWeek = getDayOfWeekValue(startDate); - const endDate = input.recurringEndDate ? parseIsoDate(input.recurringEndDate) : null; - - if (endDate && endDate <= startDate) { - throw new AdminBookingServiceError( - 'La fecha de fin debe ser posterior a la fecha de inicio.', - 400 - ); - } - - const court = await db.court.findFirst({ - where: { - id: input.courtId, - complexId, - }, - include: { - availabilities: { - where: { - dayOfWeek, - }, - orderBy: { - startTime: 'asc', - }, - }, - sport: { - select: { - id: true, - name: true, - slug: true, - }, - }, - priceRules: { - where: { isActive: true }, - orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], - }, - }, - }); - - if (!court) { - throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404); - } - - const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes); - const selectedEndMinutes = toMinutes(input.startTime) + court.slotDurationMinutes; - const selectedSlot = { - startTime: input.startTime, - endTime: minutesToTime(selectedEndMinutes), - }; - - const slotExists = validSlots.some( - (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime - ); - - if (!slotExists) { - throw new AdminBookingServiceError( - 'El horario seleccionado no esta disponible para esa cancha.', - 409 - ); - } - - if (isSlotInPast(startDate, input.startTime, court.slotDurationMinutes)) { - throw new AdminBookingServiceError('La fecha de inicio no puede estar en el pasado.', 400); - } - - const recurringDates = Array.from( - generateRecurringDates(startDate, endDate, startDate.getUTCDay()) - ); - - if (recurringDates.length === 0) { - throw new AdminBookingServiceError( - 'No se generaron fechas para la reserva periódica. Verifica las fechas ingresadas.', - 400 - ); - } - - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - const result = await db.$transaction(async (tx) => { - const groupId = uuidv7(); - - const bookingsForDate = await tx.courtBooking.count({ - where: { - startTime: selectedSlot.startTime, - bookingDate: startDate, - status: 'CONFIRMED', - court: { - complexId, - }, - }, - }); - - const courtsCount = await tx.court.count({ - where: { complexId }, - }); - - const violations = evaluatePlanUsage(rules, { - courtsCount, - bookingsToday: bookingsForDate, - }); - - const maxBookingsViolation = violations.find( - (v) => v.code === 'MAX_BOOKINGS_PER_DAY_REACHED' - ); - - if (maxBookingsViolation) { - throw new AdminBookingServiceError(maxBookingsViolation.message, 409); - } - - const group = await tx.recurringBookingGroup.create({ - data: { - id: groupId, - complexId, - courtId: court.id, - startTime: selectedSlot.startTime, - endTime: selectedSlot.endTime, - dayOfWeek, - startDate, - endDate, - status: 'ACTIVE', - customerName: input.customerName.trim(), - customerPhone: input.customerPhone.trim(), - customerEmail: input.customerEmail?.trim() ?? '', - }, - }); - - const createdBookings = []; - - for (const date of recurringDates) { - if (isSlotInPast(date, input.startTime, court.slotDurationMinutes)) { - continue; - } - - const overlappingBooking = await tx.courtBooking.findFirst({ - where: { - courtId: court.id, - bookingDate: date, - status: 'CONFIRMED', - startTime: { - lt: selectedSlot.endTime, - }, - endTime: { - gt: selectedSlot.startTime, - }, - }, - }); - - if (overlappingBooking) { - throw new AdminBookingServiceError( - `El horario seleccionado ya fue reservado para el dia ${formatIsoDate(date)}.`, - 409 - ); - } - - const booking = await tx.courtBooking.create({ - data: { - id: uuidv7(), - bookingCode: generateBookingCode(), - courtId: court.id, - bookingDate: date, - startTime: selectedSlot.startTime, - endTime: selectedSlot.endTime, - customerName: input.customerName.trim(), - customerPhone: input.customerPhone.trim(), - customerEmail: input.customerEmail?.trim() ?? '', - status: 'CONFIRMED', - recurringGroupId: groupId, - }, - include: { - court: { - select: { - id: true, - name: true, - sport: { - select: { - id: true, - name: true, - slug: true, - }, - }, - complex: { - select: { - id: true, - complexName: true, - }, - }, - }, - }, - }, - }); - - createdBookings.push(booking); - } - - return { group, bookings: createdBookings }; - }); - - return { - id: result.group.id, - complexId, - courtId: court.id, - startTime: result.group.startTime, - endTime: result.group.endTime, - dayOfWeek: result.group.dayOfWeek, - startDate: formatIsoDate(result.group.startDate), - endDate: result.group.endDate ? formatIsoDate(result.group.endDate) : null, - status: 'ACTIVE' as const, - customerName: result.group.customerName, - customerPhone: result.group.customerPhone, - customerEmail: result.group.customerEmail, - bookings: result.bookings.map((b) => mapBookingResponse(b)), - createdAt: result.group.createdAt.toISOString(), - updatedAt: result.group.updatedAt.toISOString(), - }; - } catch (error) { - if (error instanceof AdminBookingServiceError) { - throw error; - } - - const prismaError = error as { - code?: string; - meta?: { - target?: string[] | string; - }; - }; - - if (prismaError.code === 'P2002') { - const targets = Array.isArray(prismaError.meta?.target) - ? prismaError.meta?.target - : [prismaError.meta?.target]; - const isBookingCodeCollision = targets.some((target) => - String(target).includes('booking_code') - ); - - if (isBookingCodeCollision) { - continue; - } - - throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409); - } - - throw error; - } - } - - throw new AdminBookingServiceError( - 'No se pudo generar un codigo de reserva unico. Intenta nuevamente.', - 409 - ); -} - -export async function cancelRecurringGroup(userId: string, groupId: string) { - const group = await db.recurringBookingGroup.findUnique({ - where: { id: groupId }, - include: { - complex: { - select: { - id: true, - users: { - where: { userId }, - select: { userId: true }, - }, - }, - }, - }, - }); - - if (!group) { - throw new AdminBookingServiceError('Grupo de reservas no encontrado.', 404); - } - - if (group.complex.users.length === 0) { - throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403); - } - - if (group.status === 'CANCELLED') { - throw new AdminBookingServiceError('El grupo ya fue cancelado anteriormente.', 409); - } - - const now = new Date(); - const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - - await db.$transaction(async (tx) => { - await tx.recurringBookingGroup.update({ - where: { id: groupId }, - data: { status: 'CANCELLED' }, - }); - - const futureBookings = await tx.courtBooking.findMany({ - where: { - recurringGroupId: groupId, - bookingDate: { gte: todayStart }, - status: 'CONFIRMED', - }, - }); - - for (const booking of futureBookings) { - await tx.courtBookingLog.create({ - data: { - id: uuidv7(), - bookingCode: booking.bookingCode, - courtId: booking.courtId, - bookingDate: booking.bookingDate, - startTime: booking.startTime, - endTime: booking.endTime, - customerName: booking.customerName, - customerPhone: booking.customerPhone, - customerEmail: booking.customerEmail, - previousStatus: booking.status, - newStatus: 'CANCELLED', - changedAt: new Date(), - }, - }); - - await tx.courtBooking.delete({ - where: { id: booking.id }, - }); - } - }); - - return { ok: true }; -} - -export async function listRecurringGroups(complexId: string): Promise { - const groups = await db.recurringBookingGroup.findMany({ - where: { complexId, status: 'ACTIVE' }, - include: { - court: { - select: { - id: true, - name: true, - sport: { - select: { id: true, name: true, slug: true }, - }, - complex: { - select: { id: true, complexName: true }, - }, - }, - }, - bookings: { - orderBy: { bookingDate: 'asc' }, - include: { - court: { - select: { - id: true, - name: true, - sport: { - select: { id: true, name: true, slug: true }, - }, - complex: { - select: { id: true, complexName: true }, - }, - }, - }, - }, - }, - }, - orderBy: { createdAt: 'desc' }, - }); - - return groups.map((group) => ({ - id: group.id, - complexId: group.complexId, - courtId: group.courtId, - startTime: group.startTime, - endTime: group.endTime, - dayOfWeek: group.dayOfWeek, - startDate: formatIsoDate(group.startDate), - endDate: group.endDate ? formatIsoDate(group.endDate) : null, - status: group.status, - customerName: group.customerName, - customerPhone: group.customerPhone, - customerEmail: group.customerEmail, - bookings: group.bookings.map(mapBookingResponse), - createdAt: group.createdAt.toISOString(), - updatedAt: group.updatedAt.toISOString(), - })); -} - -export async function updateRecurringGroup( - userId: string, - groupId: string, - input: UpdateRecurringGroupInput -): Promise { - const group = await db.recurringBookingGroup.findUnique({ - where: { id: groupId }, - include: { - complex: { - select: { - id: true, - users: { - where: { userId }, - select: { userId: true }, - }, - }, - }, - }, - }); - - if (!group) { - throw new AdminBookingServiceError('Grupo de turnos fijos no encontrado.', 404); - } - - if (group.complex.users.length === 0) { - throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403); - } - - if (group.status === 'CANCELLED') { - throw new AdminBookingServiceError('No se puede editar un grupo cancelado.', 409); - } - - const courtId = input.courtId ?? group.courtId; - const dayOfWeek = input.dayOfWeek ?? group.dayOfWeek; - const startTime = input.startTime ?? group.startTime; - - const scheduleChanged = - input.courtId !== undefined || input.dayOfWeek !== undefined || input.startTime !== undefined; - - if (scheduleChanged) { - const court = await db.court.findFirst({ - where: { id: courtId, complexId: group.complexId }, - include: { - availabilities: { - where: { dayOfWeek: dayOfWeek as DayOfWeek }, - orderBy: { startTime: 'asc' }, - }, - }, - }); - - if (!court) { - throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404); - } - - const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes); - const selectedEndMinutes = toMinutes(startTime) + court.slotDurationMinutes; - const endTime = minutesToTime(selectedEndMinutes); - - const slotExists = validSlots.some( - (slot) => slot.startTime === startTime && slot.endTime === endTime - ); - - if (!slotExists) { - throw new AdminBookingServiceError( - 'El horario seleccionado no esta disponible para esa cancha.', - 409 - ); - } - - const now = new Date(); - const todayStart = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - ); - - await db.$transaction(async (tx) => { - await tx.recurringBookingGroup.update({ - where: { id: groupId }, - data: { - courtId, - dayOfWeek: dayOfWeek as DayOfWeek, - startTime, - endTime, - customerName: input.customerName?.trim() ?? group.customerName, - customerPhone: input.customerPhone?.trim() ?? group.customerPhone, - customerEmail: input.customerEmail?.trim() ?? group.customerEmail, - }, - }); - - const futureBookings = await tx.courtBooking.findMany({ - where: { - recurringGroupId: groupId, - bookingDate: { gte: todayStart }, - status: 'CONFIRMED', - }, - }); - - for (const booking of futureBookings) { - await tx.courtBookingLog.create({ - data: { - id: uuidv7(), - bookingCode: booking.bookingCode, - courtId: booking.courtId, - bookingDate: booking.bookingDate, - startTime: booking.startTime, - endTime: booking.endTime, - customerName: booking.customerName, - customerPhone: booking.customerPhone, - customerEmail: booking.customerEmail, - previousStatus: booking.status, - newStatus: 'CANCELLED', - changedAt: new Date(), - }, - }); - - await tx.courtBooking.delete({ - where: { id: booking.id }, - }); - } - - const dayIndex = DAY_INDEX_BY_VALUE[dayOfWeek] ?? 0; - const recurringDates = Array.from( - generateRecurringDates(todayStart, group.endDate, dayIndex) - ); - - for (const date of recurringDates) { - const overlappingBooking = await tx.courtBooking.findFirst({ - where: { - courtId, - bookingDate: date, - status: 'CONFIRMED', - startTime: { lt: endTime }, - endTime: { gt: startTime }, - }, - }); - - if (overlappingBooking) { - throw new AdminBookingServiceError( - `El horario seleccionado ya fue reservado para el dia ${formatIsoDate(date)}.`, - 409 - ); - } - - await tx.courtBooking.create({ - data: { - id: uuidv7(), - bookingCode: generateBookingCode(), - courtId, - bookingDate: date, - startTime, - endTime, - customerName: input.customerName?.trim() ?? group.customerName, - customerPhone: input.customerPhone?.trim() ?? group.customerPhone, - customerEmail: input.customerEmail?.trim() ?? group.customerEmail, - status: 'CONFIRMED', - recurringGroupId: groupId, - }, - include: { - court: { - select: { - id: true, - name: true, - sport: { select: { id: true, name: true, slug: true } }, - complex: { select: { id: true, complexName: true } }, - }, - }, - }, - }); - } - }); - } else { - await db.recurringBookingGroup.update({ - where: { id: groupId }, - data: { - ...(input.customerName !== undefined && { customerName: input.customerName.trim() }), - ...(input.customerPhone !== undefined && { customerPhone: input.customerPhone.trim() }), - ...(input.customerEmail !== undefined && { customerEmail: input.customerEmail.trim() }), - }, - }); - - const now = new Date(); - const todayStart = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - ); - - const updateData: Record = {}; - if (input.customerName !== undefined) updateData.customerName = input.customerName.trim(); - if (input.customerPhone !== undefined) updateData.customerPhone = input.customerPhone.trim(); - if (input.customerEmail !== undefined) updateData.customerEmail = input.customerEmail.trim(); - - if (Object.keys(updateData).length > 0) { - await db.courtBooking.updateMany({ - where: { recurringGroupId: groupId, bookingDate: { gte: todayStart } }, - data: updateData, - }); - } - } - - const updated = await db.recurringBookingGroup.findUnique({ - where: { id: groupId }, - include: { - court: { - select: { - id: true, - name: true, - sport: { select: { id: true, name: true, slug: true } }, - complex: { select: { id: true, complexName: true } }, - }, - }, - bookings: { - orderBy: { bookingDate: 'asc' }, - include: { - court: { - select: { - id: true, - name: true, - sport: { select: { id: true, name: true, slug: true } }, - complex: { select: { id: true, complexName: true } }, - }, - }, - }, - }, - }, - }); - - if (!updated) { - throw new AdminBookingServiceError('Error al actualizar el grupo.', 409); - } - - return { - id: updated.id, - complexId: updated.complexId, - courtId: updated.courtId, - startTime: updated.startTime, - endTime: updated.endTime, - dayOfWeek: updated.dayOfWeek, - startDate: formatIsoDate(updated.startDate), - endDate: updated.endDate ? formatIsoDate(updated.endDate) : null, - status: updated.status, - customerName: updated.customerName, - customerPhone: updated.customerPhone, - customerEmail: updated.customerEmail, - bookings: updated.bookings.map(mapBookingResponse), - createdAt: updated.createdAt.toISOString(), - updatedAt: updated.updatedAt.toISOString(), - }; -} diff --git a/apps/backend/src/modules/admin-booking/shared/errors.ts b/apps/backend/src/modules/admin-booking/shared/errors.ts new file mode 100644 index 0000000..08e274b --- /dev/null +++ b/apps/backend/src/modules/admin-booking/shared/errors.ts @@ -0,0 +1,6 @@ +export class AdminBookingServiceError extends Error { + constructor(message: string) { + super(message); + this.name = 'AdminBookingServiceError'; + } +} diff --git a/apps/backend/src/modules/admin-booking/shared/helpers.ts b/apps/backend/src/modules/admin-booking/shared/helpers.ts new file mode 100644 index 0000000..09fc0ab --- /dev/null +++ b/apps/backend/src/modules/admin-booking/shared/helpers.ts @@ -0,0 +1,216 @@ +import { randomInt } from 'node:crypto'; +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import type { Result } from '@/lib/result'; +import { err, ok } from '@/lib/result'; +import type { AdminBooking, DayOfWeek } from '@repo/api-contract'; + +export type Slot = { startTime: string; endTime: string }; + +const DAY_OF_WEEK_BY_INDEX = [ + 'SUNDAY', + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', +] as const; + +const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const BOOKING_CODE_LENGTH = 6; + +export function toMinutes(value: string): number { + const [hours, minutes] = value.split(':').map((part) => Number(part)); + return hours * 60 + minutes; +} + +export function minutesToTime(minutes: number): string { + const safeMinutes = Math.max(0, minutes); + const hours = Math.floor(safeMinutes / 60); + const mins = safeMinutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +export function parseIsoDate(date: string): Result { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + + if (!match) { + return err(Errors.validation('La fecha debe tener formato YYYY-MM-DD.')); + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + + const bookingDate = new Date(Date.UTC(year, month - 1, day)); + + if ( + Number.isNaN(bookingDate.getTime()) || + bookingDate.getUTCFullYear() !== year || + bookingDate.getUTCMonth() + 1 !== month || + bookingDate.getUTCDate() !== day + ) { + return err(Errors.validation('La fecha enviada no es valida.')); + } + + return ok(bookingDate); +} + +export function formatIsoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export function getDayOfWeek(date: Date): Result { + const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()]; + + if (!dayOfWeek) { + return err(Errors.validation('No se pudo resolver el dia de la semana.')); + } + + return ok(dayOfWeek); +} + +export function generateBookingCode(): string { + let code = ''; + + for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { + code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; + } + + return code; +} + +export function buildSlots( + availability: Array<{ startTime: string; endTime: string }>, + slotDurationMinutes: number +): Slot[] { + const slots: Slot[] = []; + + for (const range of availability) { + const start = toMinutes(range.startTime); + const end = toMinutes(range.endTime); + + for ( + let current = start; + current + slotDurationMinutes <= end; + current += slotDurationMinutes + ) { + slots.push({ + startTime: minutesToTime(current), + endTime: minutesToTime(current + slotDurationMinutes), + }); + } + } + + return slots; +} + +export async function ensureComplexAccess( + complexId: string, + userId: string +): Promise< + Result<{ + id: string; + complexName: string; + plan: { rules: string } | null; + }> +> { + const complexUser = await db.complexUser.findUnique({ + where: { + complexId_userId: { complexId, userId }, + }, + include: { + complex: { + select: { id: true, complexName: true, plan: { select: { rules: true } } }, + }, + }, + }); + + if (!complexUser) { + return err(Errors.forbidden('No tienes permisos para administrar este complejo.')); + } + + return ok(complexUser.complex); +} + +export function resolvePrice( + court: { + basePrice: unknown; + priceRules: Array<{ + dayOfWeek: string | null; + startTime: string | null; + endTime: string | null; + price: unknown; + }>; + }, + dayOfWeek: string, + startTime: string, + endTime: string +): number { + const slotStart = toMinutes(startTime); + const slotEnd = toMinutes(endTime); + + const matchingRules = court.priceRules + .filter((rule) => { + if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) return false; + if (!rule.startTime || !rule.endTime) return true; + return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime); + }) + .sort((first, second) => { + const firstSpecificity = + (first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0); + const secondSpecificity = + (second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0); + return secondSpecificity - firstSpecificity; + }); + + return Number(matchingRules[0]?.price ?? court.basePrice); +} + +export function mapBookingResponse(booking: { + id: string; + bookingCode: string; + bookingDate: Date; + startTime: string; + endTime: string; + customerName: string; + customerPhone: string; + customerEmail: string; + status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW'; + recurringGroupId: string | null; + createdAt: Date; + updatedAt: Date; + court: { + id: string; + name: string; + complex: { id: string; complexName: string }; + sport: { id: string; name: string; slug: string }; + }; + price?: number; +}): AdminBooking { + return { + id: booking.id, + bookingCode: booking.bookingCode, + complexId: booking.court.complex.id, + complexName: booking.court.complex.complexName, + courtId: booking.court.id, + courtName: booking.court.name, + sport: { + id: booking.court.sport.id, + name: booking.court.sport.name, + slug: booking.court.sport.slug, + }, + date: formatIsoDate(booking.bookingDate), + startTime: booking.startTime, + endTime: booking.endTime, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + customerEmail: booking.customerEmail, + price: booking.price ?? 0, + status: booking.status, + recurringGroupId: booking.recurringGroupId, + createdAt: booking.createdAt.toISOString(), + updatedAt: booking.updatedAt.toISOString(), + }; +} diff --git a/apps/backend/src/modules/admin/admin.routes.ts b/apps/backend/src/modules/admin/admin.routes.ts index 569dd8d..b2a08f2 100644 --- a/apps/backend/src/modules/admin/admin.routes.ts +++ b/apps/backend/src/modules/admin/admin.routes.ts @@ -1,17 +1,17 @@ import { validate } from '@/lib/http/validate'; import { requireAuth } from '@/middlewares/require-auth.middleware'; import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware'; -import { blockUserHandler } from '@/modules/admin/handlers/block-user.handler'; -import { createPlanHandler } from '@/modules/admin/handlers/create-plan.handler'; -import { deletePlanHandler } from '@/modules/admin/handlers/delete-plan.handler'; -import { getGeoStatsHandler } from '@/modules/admin/handlers/get-geo-stats.handler'; -import { getUserSessionsHandler } from '@/modules/admin/handlers/get-user-sessions.handler'; -import { listComplexesHandler } from '@/modules/admin/handlers/list-complexes.handler'; -import { listPlansAdminHandler } from '@/modules/admin/handlers/list-plans-admin.handler'; -import { listUsersHandler } from '@/modules/admin/handlers/list-users.handler'; -import { revokeAllSessionsHandler } from '@/modules/admin/handlers/revoke-all-sessions.handler'; -import { unblockUserHandler } from '@/modules/admin/handlers/unblock-user.handler'; -import { updatePlanHandler } from '@/modules/admin/handlers/update-plan.handler'; +import { blockUserHandler } from '@/modules/admin/features/block-user/block-user.handler'; +import { createPlanHandler } from '@/modules/admin/features/create-plan/create-plan.handler'; +import { deletePlanHandler } from '@/modules/admin/features/delete-plan/delete-plan.handler'; +import { getGeoStatsHandler } from '@/modules/admin/features/get-geo-stats/get-geo-stats.handler'; +import { getUserSessionsHandler } from '@/modules/admin/features/get-user-sessions/get-user-sessions.handler'; +import { listComplexesHandler } from '@/modules/admin/features/list-complexes/list-complexes.handler'; +import { listPlansAdminHandler } from '@/modules/admin/features/list-plans-admin/list-plans-admin.handler'; +import { listUsersHandler } from '@/modules/admin/features/list-users/list-users.handler'; +import { revokeAllSessionsHandler } from '@/modules/admin/features/revoke-all-sessions/revoke-all-sessions.handler'; +import { unblockUserHandler } from '@/modules/admin/features/unblock-user/unblock-user.handler'; +import { updatePlanHandler } from '@/modules/admin/features/update-plan/update-plan.handler'; import type { AppEnv } from '@/types/hono'; import { adminBlockUserSchema, diff --git a/apps/backend/src/modules/admin/features/block-user/block-user.business.ts b/apps/backend/src/modules/admin/features/block-user/block-user.business.ts new file mode 100644 index 0000000..a9b1c30 --- /dev/null +++ b/apps/backend/src/modules/admin/features/block-user/block-user.business.ts @@ -0,0 +1,26 @@ +import { db } from '@/lib/prisma'; +import { AdminServiceError } from '../../shared/errors'; + +export async function blockUser(userId: string, banReason?: string): Promise { + const target = await db.user.findUnique({ + where: { id: userId }, + select: { role: true }, + }); + + if (!target) { + throw new AdminServiceError('Usuario no encontrado.', 404); + } + + if (target.role === 'super_admin') { + throw new AdminServiceError('No se puede bloquear un super_admin.', 403); + } + + await db.user.update({ + where: { id: userId }, + data: { + banned: true, + bannedAt: new Date(), + banReason: banReason ?? null, + }, + }); +} diff --git a/apps/backend/src/modules/admin/handlers/block-user.handler.ts b/apps/backend/src/modules/admin/features/block-user/block-user.handler.ts similarity index 85% rename from apps/backend/src/modules/admin/handlers/block-user.handler.ts rename to apps/backend/src/modules/admin/features/block-user/block-user.handler.ts index 4a1f730..b748b15 100644 --- a/apps/backend/src/modules/admin/handlers/block-user.handler.ts +++ b/apps/backend/src/modules/admin/features/block-user/block-user.handler.ts @@ -1,6 +1,7 @@ -import { AdminServiceError, blockUser } from '@/modules/admin/services/admin-users.service'; import type { AppContext } from '@/types/hono'; import type { AdminBlockUserInput } from '@repo/api-contract'; +import { AdminServiceError } from '../../shared/errors'; +import { blockUser } from './block-user.business'; type BlockUserParams = { id: string }; diff --git a/apps/backend/src/modules/admin/features/create-plan/create-plan.business.ts b/apps/backend/src/modules/admin/features/create-plan/create-plan.business.ts new file mode 100644 index 0000000..e9cc9df --- /dev/null +++ b/apps/backend/src/modules/admin/features/create-plan/create-plan.business.ts @@ -0,0 +1,32 @@ +import { db } from '@/lib/prisma'; +import type { AdminCreatePlanInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; + +export async function createPlan(input: AdminCreatePlanInput) { + const existing = await db.plan.findUnique({ + where: { code: input.code }, + }); + + if (existing) { + return { ok: false as const, message: 'Ya existe un plan con ese código.' }; + } + + const plan = await db.plan.create({ + data: { + code: input.code, + name: input.name, + price: input.price, + rules: input.rules, + }, + }); + + return { + ok: true as const, + data: { + code: plan.code, + name: plan.name, + price: Number(plan.price), + rules: plan.rules, + }, + }; +} diff --git a/apps/backend/src/modules/admin/features/create-plan/create-plan.handler.ts b/apps/backend/src/modules/admin/features/create-plan/create-plan.handler.ts new file mode 100644 index 0000000..79c7795 --- /dev/null +++ b/apps/backend/src/modules/admin/features/create-plan/create-plan.handler.ts @@ -0,0 +1,15 @@ +import type { AppContext } from '@/types/hono'; +import type { AdminCreatePlanInput } from '@repo/api-contract'; +import { createPlan } from './create-plan.business'; + +export async function createPlanHandler(c: AppContext) { + const body = c.req.valid('json' as never) as AdminCreatePlanInput; + + const result = await createPlan(body); + + if (!result.ok) { + return c.json({ message: result.message }, 409); + } + + return c.json(result.data, 201); +} diff --git a/apps/backend/src/modules/admin/features/delete-plan/delete-plan.business.ts b/apps/backend/src/modules/admin/features/delete-plan/delete-plan.business.ts new file mode 100644 index 0000000..d51ee9e --- /dev/null +++ b/apps/backend/src/modules/admin/features/delete-plan/delete-plan.business.ts @@ -0,0 +1,25 @@ +import { db } from '@/lib/prisma'; + +export async function deletePlan(code: string) { + const existing = await db.plan.findUnique({ + where: { code }, + include: { + _count: { select: { complexes: true } }, + }, + }); + + if (!existing) { + return { ok: false as const, message: 'Plan no encontrado.' }; + } + + if (existing._count.complexes > 0) { + return { + ok: false as const, + message: 'No se puede eliminar un plan que tiene complejos asignados.', + }; + } + + await db.plan.delete({ where: { code } }); + + return { ok: true as const, message: 'Plan eliminado correctamente.' }; +} diff --git a/apps/backend/src/modules/admin/features/delete-plan/delete-plan.handler.ts b/apps/backend/src/modules/admin/features/delete-plan/delete-plan.handler.ts new file mode 100644 index 0000000..63f5213 --- /dev/null +++ b/apps/backend/src/modules/admin/features/delete-plan/delete-plan.handler.ts @@ -0,0 +1,17 @@ +import type { AppContext } from '@/types/hono'; +import { deletePlan } from './delete-plan.business'; + +type DeletePlanParams = { code: string }; + +export async function deletePlanHandler(c: AppContext) { + const { code } = c.req.valid('param' as never) as DeletePlanParams; + + const result = await deletePlan(code); + + if (!result.ok) { + const status = result.message === 'Plan no encontrado.' ? 404 : 409; + return c.json({ message: result.message }, status); + } + + return c.json({ message: result.message }); +} diff --git a/apps/backend/src/modules/admin/services/admin-geo.service.ts b/apps/backend/src/modules/admin/features/get-geo-stats/get-geo-stats.business.ts similarity index 100% rename from apps/backend/src/modules/admin/services/admin-geo.service.ts rename to apps/backend/src/modules/admin/features/get-geo-stats/get-geo-stats.business.ts diff --git a/apps/backend/src/modules/admin/handlers/get-geo-stats.handler.ts b/apps/backend/src/modules/admin/features/get-geo-stats/get-geo-stats.handler.ts similarity index 74% rename from apps/backend/src/modules/admin/handlers/get-geo-stats.handler.ts rename to apps/backend/src/modules/admin/features/get-geo-stats/get-geo-stats.handler.ts index 5a240d6..fe81ef7 100644 --- a/apps/backend/src/modules/admin/handlers/get-geo-stats.handler.ts +++ b/apps/backend/src/modules/admin/features/get-geo-stats/get-geo-stats.handler.ts @@ -1,6 +1,6 @@ -import { getGeoStats } from '@/modules/admin/services/admin-geo.service'; import type { AppEnv } from '@/types/hono'; import type { Handler } from 'hono'; +import { getGeoStats } from './get-geo-stats.business'; export const getGeoStatsHandler: Handler = async (c) => { const stats = await getGeoStats(); diff --git a/apps/backend/src/modules/admin/features/get-user-sessions/get-user-sessions.business.ts b/apps/backend/src/modules/admin/features/get-user-sessions/get-user-sessions.business.ts new file mode 100644 index 0000000..e613186 --- /dev/null +++ b/apps/backend/src/modules/admin/features/get-user-sessions/get-user-sessions.business.ts @@ -0,0 +1,49 @@ +import { fetchGeoInfo } from '@/lib/geoip'; +import { db } from '@/lib/prisma'; + +type AdminUserSession = { + id: string; + createdAt: string; + expiresAt: string; + ipAddress: string | null; + userAgent: string | null; + city: string | null; + country: string | null; + countryCode: string | null; +}; + +export async function getUserSessions(userId: string): Promise { + const sessions = await db.session.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + + const uniqueIps = [...new Set(sessions.map((s) => s.ipAddress).filter(Boolean))] as string[]; + const geoResults = await Promise.all(uniqueIps.map((ip) => fetchGeoInfo(ip))); + const geoMap = new Map< + string, + { city: string | null; country: string | null; countryCode: string | null } + >(); + for (let i = 0; i < uniqueIps.length; i++) { + const info = geoResults[i]; + geoMap.set(uniqueIps[i], { + city: info?.city ?? null, + country: info?.country ?? null, + countryCode: info?.countryCode ?? null, + }); + } + + return sessions.map((s) => { + const geo = s.ipAddress ? geoMap.get(s.ipAddress) : null; + return { + id: s.id, + createdAt: s.createdAt.toISOString(), + expiresAt: s.expiresAt.toISOString(), + ipAddress: s.ipAddress, + userAgent: s.userAgent, + city: geo?.city ?? null, + country: geo?.country ?? null, + countryCode: geo?.countryCode ?? null, + }; + }); +} diff --git a/apps/backend/src/modules/admin/handlers/get-user-sessions.handler.ts b/apps/backend/src/modules/admin/features/get-user-sessions/get-user-sessions.handler.ts similarity index 79% rename from apps/backend/src/modules/admin/handlers/get-user-sessions.handler.ts rename to apps/backend/src/modules/admin/features/get-user-sessions/get-user-sessions.handler.ts index d3cb9a2..9648140 100644 --- a/apps/backend/src/modules/admin/handlers/get-user-sessions.handler.ts +++ b/apps/backend/src/modules/admin/features/get-user-sessions/get-user-sessions.handler.ts @@ -1,5 +1,5 @@ -import { getUserSessions } from '@/modules/admin/services/admin-users.service'; import type { AppContext } from '@/types/hono'; +import { getUserSessions } from './get-user-sessions.business'; type UserSessionsParams = { id: string }; diff --git a/apps/backend/src/modules/admin/services/complex-stats.service.ts b/apps/backend/src/modules/admin/features/list-complexes/list-complexes.business.ts similarity index 97% rename from apps/backend/src/modules/admin/services/complex-stats.service.ts rename to apps/backend/src/modules/admin/features/list-complexes/list-complexes.business.ts index e5aad11..6b57182 100644 --- a/apps/backend/src/modules/admin/services/complex-stats.service.ts +++ b/apps/backend/src/modules/admin/features/list-complexes/list-complexes.business.ts @@ -26,9 +26,7 @@ export async function getComplexStatsList(): Promise { plan: true, users: true, courts: { - include: { - bookings: true, - }, + include: { bookings: true }, }, }, }); diff --git a/apps/backend/src/modules/admin/handlers/list-complexes.handler.ts b/apps/backend/src/modules/admin/features/list-complexes/list-complexes.handler.ts similarity index 67% rename from apps/backend/src/modules/admin/handlers/list-complexes.handler.ts rename to apps/backend/src/modules/admin/features/list-complexes/list-complexes.handler.ts index f72d8a7..1830c05 100644 --- a/apps/backend/src/modules/admin/handlers/list-complexes.handler.ts +++ b/apps/backend/src/modules/admin/features/list-complexes/list-complexes.handler.ts @@ -1,5 +1,5 @@ -import { getComplexStatsList } from '@/modules/admin/services/complex-stats.service'; import type { AppContext } from '@/types/hono'; +import { getComplexStatsList } from './list-complexes.business'; export async function listComplexesHandler(c: AppContext) { const stats = await getComplexStatsList(); diff --git a/apps/backend/src/modules/admin/features/list-plans-admin/list-plans-admin.business.ts b/apps/backend/src/modules/admin/features/list-plans-admin/list-plans-admin.business.ts new file mode 100644 index 0000000..b350fab --- /dev/null +++ b/apps/backend/src/modules/admin/features/list-plans-admin/list-plans-admin.business.ts @@ -0,0 +1,19 @@ +import { db } from '@/lib/prisma'; + +export async function listPlansAdmin() { + const plans = await db.plan.findMany({ + orderBy: { price: 'asc' }, + include: { + _count: { select: { complexes: true } }, + }, + }); + + return plans.map((plan) => ({ + code: plan.code, + name: plan.name, + price: Number(plan.price), + rules: plan.rules, + lastUpdatedAt: plan.lastUpdatedAt.toISOString(), + complexCount: plan._count.complexes, + })); +} diff --git a/apps/backend/src/modules/admin/features/list-plans-admin/list-plans-admin.handler.ts b/apps/backend/src/modules/admin/features/list-plans-admin/list-plans-admin.handler.ts new file mode 100644 index 0000000..c558f8c --- /dev/null +++ b/apps/backend/src/modules/admin/features/list-plans-admin/list-plans-admin.handler.ts @@ -0,0 +1,7 @@ +import type { AppContext } from '@/types/hono'; +import { listPlansAdmin } from './list-plans-admin.business'; + +export async function listPlansAdminHandler(c: AppContext) { + const plans = await listPlansAdmin(); + return c.json(plans); +} diff --git a/apps/backend/src/modules/admin/features/list-users/list-users.business.ts b/apps/backend/src/modules/admin/features/list-users/list-users.business.ts new file mode 100644 index 0000000..0915f42 --- /dev/null +++ b/apps/backend/src/modules/admin/features/list-users/list-users.business.ts @@ -0,0 +1,44 @@ +import { db } from '@/lib/prisma'; + +type AdminUser = { + id: string; + name: string; + email: string; + role: string; + banned: boolean; + bannedAt: string | null; + banReason: string | null; + createdAt: string; + complexCount: number; + activeSessions: number; +}; + +export async function listAdminUsers(search?: string): Promise { + const users = await db.user.findMany({ + where: search + ? { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + ], + } + : undefined, + include: { + _count: { select: { complexes: true, sessions: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return users.map((user) => ({ + id: user.id, + name: user.name, + email: user.email, + role: user.role, + banned: user.banned ?? false, + bannedAt: user.bannedAt?.toISOString() ?? null, + banReason: user.banReason ?? null, + createdAt: user.createdAt.toISOString(), + complexCount: user._count.complexes, + activeSessions: user._count.sessions, + })); +} diff --git a/apps/backend/src/modules/admin/handlers/list-users.handler.ts b/apps/backend/src/modules/admin/features/list-users/list-users.handler.ts similarity index 73% rename from apps/backend/src/modules/admin/handlers/list-users.handler.ts rename to apps/backend/src/modules/admin/features/list-users/list-users.handler.ts index deaef65..a55bf98 100644 --- a/apps/backend/src/modules/admin/handlers/list-users.handler.ts +++ b/apps/backend/src/modules/admin/features/list-users/list-users.handler.ts @@ -1,5 +1,5 @@ -import { listAdminUsers } from '@/modules/admin/services/admin-users.service'; import type { AppContext } from '@/types/hono'; +import { listAdminUsers } from './list-users.business'; export async function listUsersHandler(c: AppContext) { const search = c.req.query('search'); diff --git a/apps/backend/src/modules/admin/features/revoke-all-sessions/revoke-all-sessions.business.ts b/apps/backend/src/modules/admin/features/revoke-all-sessions/revoke-all-sessions.business.ts new file mode 100644 index 0000000..a1793c5 --- /dev/null +++ b/apps/backend/src/modules/admin/features/revoke-all-sessions/revoke-all-sessions.business.ts @@ -0,0 +1,9 @@ +import { db } from '@/lib/prisma'; + +export async function revokeAllUserSessions(userId: string): Promise { + const result = await db.session.deleteMany({ + where: { userId }, + }); + + return result.count; +} diff --git a/apps/backend/src/modules/admin/handlers/revoke-all-sessions.handler.ts b/apps/backend/src/modules/admin/features/revoke-all-sessions/revoke-all-sessions.handler.ts similarity index 80% rename from apps/backend/src/modules/admin/handlers/revoke-all-sessions.handler.ts rename to apps/backend/src/modules/admin/features/revoke-all-sessions/revoke-all-sessions.handler.ts index 9351954..1f6b73b 100644 --- a/apps/backend/src/modules/admin/handlers/revoke-all-sessions.handler.ts +++ b/apps/backend/src/modules/admin/features/revoke-all-sessions/revoke-all-sessions.handler.ts @@ -1,5 +1,5 @@ -import { revokeAllUserSessions } from '@/modules/admin/services/admin-users.service'; import type { AppContext } from '@/types/hono'; +import { revokeAllUserSessions } from './revoke-all-sessions.business'; type RevokeSessionsParams = { id: string }; diff --git a/apps/backend/src/modules/admin/features/unblock-user/unblock-user.business.ts b/apps/backend/src/modules/admin/features/unblock-user/unblock-user.business.ts new file mode 100644 index 0000000..a43f395 --- /dev/null +++ b/apps/backend/src/modules/admin/features/unblock-user/unblock-user.business.ts @@ -0,0 +1,12 @@ +import { db } from '@/lib/prisma'; + +export async function unblockUser(userId: string): Promise { + await db.user.update({ + where: { id: userId }, + data: { + banned: false, + bannedAt: null, + banReason: null, + }, + }); +} diff --git a/apps/backend/src/modules/admin/handlers/unblock-user.handler.ts b/apps/backend/src/modules/admin/features/unblock-user/unblock-user.handler.ts similarity index 80% rename from apps/backend/src/modules/admin/handlers/unblock-user.handler.ts rename to apps/backend/src/modules/admin/features/unblock-user/unblock-user.handler.ts index 750c18c..311f2f1 100644 --- a/apps/backend/src/modules/admin/handlers/unblock-user.handler.ts +++ b/apps/backend/src/modules/admin/features/unblock-user/unblock-user.handler.ts @@ -1,5 +1,5 @@ -import { unblockUser } from '@/modules/admin/services/admin-users.service'; import type { AppContext } from '@/types/hono'; +import { unblockUser } from './unblock-user.business'; type UnblockUserParams = { id: string }; diff --git a/apps/backend/src/modules/admin/features/update-plan/update-plan.business.ts b/apps/backend/src/modules/admin/features/update-plan/update-plan.business.ts new file mode 100644 index 0000000..76b463c --- /dev/null +++ b/apps/backend/src/modules/admin/features/update-plan/update-plan.business.ts @@ -0,0 +1,30 @@ +import { db } from '@/lib/prisma'; +import type { AdminUpdatePlanInput } from '@repo/api-contract'; + +export async function updatePlan(code: string, input: AdminUpdatePlanInput) { + const existing = await db.plan.findUnique({ where: { code } }); + + if (!existing) { + return { ok: false as const, message: 'Plan no encontrado.' }; + } + + const plan = await db.plan.update({ + where: { code }, + data: { + ...(input.name !== undefined && { name: input.name }), + ...(input.price !== undefined && { price: input.price }), + ...(input.rules !== undefined && { rules: input.rules }), + lastUpdatedAt: new Date(), + }, + }); + + return { + ok: true as const, + data: { + code: plan.code, + name: plan.name, + price: Number(plan.price), + rules: plan.rules, + }, + }; +} diff --git a/apps/backend/src/modules/admin/features/update-plan/update-plan.handler.ts b/apps/backend/src/modules/admin/features/update-plan/update-plan.handler.ts new file mode 100644 index 0000000..7eb2aa3 --- /dev/null +++ b/apps/backend/src/modules/admin/features/update-plan/update-plan.handler.ts @@ -0,0 +1,18 @@ +import type { AppContext } from '@/types/hono'; +import type { AdminUpdatePlanInput } from '@repo/api-contract'; +import { updatePlan } from './update-plan.business'; + +type UpdatePlanParams = { code: string }; + +export async function updatePlanHandler(c: AppContext) { + const { code } = c.req.valid('param' as never) as UpdatePlanParams; + const body = c.req.valid('json' as never) as AdminUpdatePlanInput; + + const result = await updatePlan(code, body); + + if (!result.ok) { + return c.json({ message: result.message }, 404); + } + + return c.json(result.data); +} diff --git a/apps/backend/src/modules/admin/handlers/create-plan.handler.ts b/apps/backend/src/modules/admin/handlers/create-plan.handler.ts deleted file mode 100644 index ff52e11..0000000 --- a/apps/backend/src/modules/admin/handlers/create-plan.handler.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { db } from '@/lib/prisma'; -import type { AppContext } from '@/types/hono'; -import type { AdminCreatePlanInput } from '@repo/api-contract'; - -export async function createPlanHandler(c: AppContext) { - const body = c.req.valid('json' as never) as AdminCreatePlanInput; - - const existing = await db.plan.findUnique({ - where: { code: body.code }, - }); - - if (existing) { - return c.json({ message: 'Ya existe un plan con ese código.' }, 409); - } - - const plan = await db.plan.create({ - data: { - code: body.code, - name: body.name, - price: body.price, - rules: body.rules, - }, - }); - - return c.json( - { - code: plan.code, - name: plan.name, - price: Number(plan.price), - rules: plan.rules, - }, - 201 - ); -} diff --git a/apps/backend/src/modules/admin/handlers/delete-plan.handler.ts b/apps/backend/src/modules/admin/handlers/delete-plan.handler.ts deleted file mode 100644 index 5d03785..0000000 --- a/apps/backend/src/modules/admin/handlers/delete-plan.handler.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { db } from '@/lib/prisma'; -import type { AppContext } from '@/types/hono'; - -type DeletePlanParams = { code: string }; - -export async function deletePlanHandler(c: AppContext) { - const { code } = c.req.valid('param' as never) as DeletePlanParams; - - const existing = await db.plan.findUnique({ - where: { code }, - include: { - _count: { - select: { complexes: true }, - }, - }, - }); - - if (!existing) { - return c.json({ message: 'Plan no encontrado.' }, 404); - } - - if (existing._count.complexes > 0) { - return c.json({ message: 'No se puede eliminar un plan que tiene complejos asignados.' }, 409); - } - - await db.plan.delete({ - where: { code }, - }); - - return c.json({ message: 'Plan eliminado correctamente.' }); -} diff --git a/apps/backend/src/modules/admin/handlers/list-plans-admin.handler.ts b/apps/backend/src/modules/admin/handlers/list-plans-admin.handler.ts deleted file mode 100644 index 74ad372..0000000 --- a/apps/backend/src/modules/admin/handlers/list-plans-admin.handler.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { db } from '@/lib/prisma'; -import type { AppContext } from '@/types/hono'; - -export async function listPlansAdminHandler(c: AppContext) { - const plans = await db.plan.findMany({ - orderBy: { price: 'asc' }, - include: { - _count: { - select: { complexes: true }, - }, - }, - }); - - return c.json( - plans.map((plan) => ({ - code: plan.code, - name: plan.name, - price: Number(plan.price), - rules: plan.rules, - lastUpdatedAt: plan.lastUpdatedAt.toISOString(), - complexCount: plan._count.complexes, - })) - ); -} diff --git a/apps/backend/src/modules/admin/handlers/update-plan.handler.ts b/apps/backend/src/modules/admin/handlers/update-plan.handler.ts deleted file mode 100644 index 322ebb6..0000000 --- a/apps/backend/src/modules/admin/handlers/update-plan.handler.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { db } from '@/lib/prisma'; -import type { AppContext } from '@/types/hono'; -import type { AdminUpdatePlanInput } from '@repo/api-contract'; - -type UpdatePlanParams = { code: string }; - -export async function updatePlanHandler(c: AppContext) { - const { code } = c.req.valid('param' as never) as UpdatePlanParams; - const body = c.req.valid('json' as never) as AdminUpdatePlanInput; - - const existing = await db.plan.findUnique({ - where: { code }, - }); - - if (!existing) { - return c.json({ message: 'Plan no encontrado.' }, 404); - } - - const plan = await db.plan.update({ - where: { code }, - data: { - ...(body.name !== undefined && { name: body.name }), - ...(body.price !== undefined && { price: body.price }), - ...(body.rules !== undefined && { rules: body.rules }), - lastUpdatedAt: new Date(), - }, - }); - - return c.json({ - code: plan.code, - name: plan.name, - price: Number(plan.price), - rules: plan.rules, - }); -} diff --git a/apps/backend/src/modules/admin/services/admin-users.service.ts b/apps/backend/src/modules/admin/services/admin-users.service.ts deleted file mode 100644 index 7c5069e..0000000 --- a/apps/backend/src/modules/admin/services/admin-users.service.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { fetchGeoInfo } from '@/lib/geoip'; -import { db } from '@/lib/prisma'; - -type AdminUser = { - id: string; - name: string; - email: string; - role: string; - banned: boolean; - bannedAt: string | null; - banReason: string | null; - createdAt: string; - complexCount: number; - activeSessions: number; -}; - -export async function listAdminUsers(search?: string): Promise { - const users = await db.user.findMany({ - where: search - ? { - OR: [ - { name: { contains: search, mode: 'insensitive' } }, - { email: { contains: search, mode: 'insensitive' } }, - ], - } - : undefined, - include: { - _count: { - select: { complexes: true, sessions: true }, - }, - }, - orderBy: { createdAt: 'desc' }, - }); - - return users.map((user) => ({ - id: user.id, - name: user.name, - email: user.email, - role: user.role, - banned: user.banned ?? false, - bannedAt: user.bannedAt?.toISOString() ?? null, - banReason: user.banReason ?? null, - createdAt: user.createdAt.toISOString(), - complexCount: user._count.complexes, - activeSessions: user._count.sessions, - })); -} - -type AdminUserSession = { - id: string; - createdAt: string; - expiresAt: string; - ipAddress: string | null; - userAgent: string | null; - city: string | null; - country: string | null; - countryCode: string | null; -}; - -export async function getUserSessions(userId: string): Promise { - const sessions = await db.session.findMany({ - where: { userId }, - orderBy: { createdAt: 'desc' }, - }); - - const uniqueIps = [...new Set(sessions.map((s) => s.ipAddress).filter(Boolean))] as string[]; - const geoResults = await Promise.all(uniqueIps.map((ip) => fetchGeoInfo(ip))); - const geoMap = new Map< - string, - { city: string | null; country: string | null; countryCode: string | null } - >(); - for (let i = 0; i < uniqueIps.length; i++) { - const info = geoResults[i]; - geoMap.set(uniqueIps[i], { - city: info?.city ?? null, - country: info?.country ?? null, - countryCode: info?.countryCode ?? null, - }); - } - - return sessions.map((s) => { - const geo = s.ipAddress ? geoMap.get(s.ipAddress) : null; - return { - id: s.id, - createdAt: s.createdAt.toISOString(), - expiresAt: s.expiresAt.toISOString(), - ipAddress: s.ipAddress, - userAgent: s.userAgent, - city: geo?.city ?? null, - country: geo?.country ?? null, - countryCode: geo?.countryCode ?? null, - }; - }); -} - -export class AdminServiceError extends Error { - status: 400 | 403 | 404; - constructor(message: string, status: 400 | 403 | 404 = 400) { - super(message); - this.status = status; - this.name = 'AdminServiceError'; - } -} - -export async function blockUser(userId: string, banReason?: string): Promise { - const target = await db.user.findUnique({ - where: { id: userId }, - select: { role: true }, - }); - - if (!target) { - throw new AdminServiceError('Usuario no encontrado.', 404); - } - - if (target.role === 'super_admin') { - throw new AdminServiceError('No se puede bloquear un super_admin.', 403); - } - - await db.user.update({ - where: { id: userId }, - data: { - banned: true, - bannedAt: new Date(), - banReason: banReason ?? null, - }, - }); -} - -export async function unblockUser(userId: string): Promise { - await db.user.update({ - where: { id: userId }, - data: { - banned: false, - bannedAt: null, - banReason: null, - }, - }); -} - -export async function revokeAllUserSessions(userId: string): Promise { - const result = await db.session.deleteMany({ - where: { userId }, - }); - - return result.count; -} diff --git a/apps/backend/src/modules/admin/shared/errors.ts b/apps/backend/src/modules/admin/shared/errors.ts new file mode 100644 index 0000000..dd98d7c --- /dev/null +++ b/apps/backend/src/modules/admin/shared/errors.ts @@ -0,0 +1,9 @@ +export class AdminServiceError extends Error { + status: 400 | 403 | 404; + + constructor(message: string, status: 400 | 403 | 404 = 400) { + super(message); + this.status = status; + this.name = 'AdminServiceError'; + } +} diff --git a/apps/backend/src/modules/complex/complex.routes.ts b/apps/backend/src/modules/complex/complex.routes.ts index aa5b201..ad89a5f 100644 --- a/apps/backend/src/modules/complex/complex.routes.ts +++ b/apps/backend/src/modules/complex/complex.routes.ts @@ -1,17 +1,17 @@ import { requireAuth } from '@/middlewares/require-auth.middleware'; -import { acceptComplexInvitationsHandler } from '@/modules/complex/handlers/accept-complex-invitations.handler'; -import { cancelComplexInvitationHandler } from '@/modules/complex/handlers/cancel-complex-invitation.handler'; -import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler'; -import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler'; -import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler'; -import { getCurrentComplexHandler } from '@/modules/complex/handlers/get-current-complex.handler'; -import { inviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler'; -import { listComplexUsersHandler } from '@/modules/complex/handlers/list-complex-users.handler'; -import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler'; -import { resendComplexInvitationHandler } from '@/modules/complex/handlers/resend-complex-invitation.handler'; -import { revokeComplexUserHandler } from '@/modules/complex/handlers/revoke-complex-user.handler'; -import { selectComplexHandler } from '@/modules/complex/handlers/select-complex.handler'; -import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler'; +import { acceptComplexInvitationsHandler } from '@/modules/complex/features/accept-complex-invitations/accept-complex-invitations.handler'; +import { cancelComplexInvitationHandler } from '@/modules/complex/features/cancel-complex-invitation/cancel-complex-invitation.handler'; +import { createComplexHandler } from '@/modules/complex/features/create-complex/create-complex.handler'; +import { getComplexByIdHandler } from '@/modules/complex/features/get-complex-by-id/get-complex-by-id.handler'; +import { getComplexBySlugHandler } from '@/modules/complex/features/get-complex-by-slug/get-complex-by-slug.handler'; +import { getCurrentComplexHandler } from '@/modules/complex/features/get-current-complex/get-current-complex.handler'; +import { inviteComplexUserHandler } from '@/modules/complex/features/invite-complex-user/invite-complex-user.handler'; +import { listComplexUsersHandler } from '@/modules/complex/features/list-complex-users/list-complex-users.handler'; +import { listMyComplexesHandler } from '@/modules/complex/features/list-my-complexes/list-my-complexes.handler'; +import { resendComplexInvitationHandler } from '@/modules/complex/features/resend-complex-invitation/resend-complex-invitation.handler'; +import { revokeComplexUserHandler } from '@/modules/complex/features/revoke-complex-user/revoke-complex-user.handler'; +import { selectComplexHandler } from '@/modules/complex/features/select-complex/select-complex.handler'; +import { updateComplexHandler } from '@/modules/complex/features/update-complex/update-complex.handler'; import type { AppEnv } from '@/types/hono'; import { zValidator } from '@hono/zod-validator'; import { diff --git a/apps/backend/src/modules/complex/features/accept-complex-invitations/accept-complex-invitations.business.ts b/apps/backend/src/modules/complex/features/accept-complex-invitations/accept-complex-invitations.business.ts new file mode 100644 index 0000000..a2eab3f --- /dev/null +++ b/apps/backend/src/modules/complex/features/accept-complex-invitations/accept-complex-invitations.business.ts @@ -0,0 +1,67 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import type { + AcceptComplexInvitationsInput, + AcceptComplexInvitationsResponse, +} from '@repo/api-contract'; +import { hashToken, normalizeEmail } from '../../shared/members-helpers'; + +export async function acceptComplexInvitations( + userId: string, + email: string, + input: AcceptComplexInvitationsInput = {} +): Promise> { + const normalizedEmail = normalizeEmail(email); + const tokenHash = input.inviteToken ? hashToken(input.inviteToken) : null; + + const invitations = await db.complexInvitation.findMany({ + where: { + email: normalizedEmail, + acceptedAt: null, + revokedAt: null, + expiresAt: { gte: new Date() }, + ...(tokenHash ? { tokenHash } : {}), + }, + orderBy: { createdAt: 'asc' }, + }); + + if (tokenHash && invitations.length === 0) { + return err(Errors.notFound('La invitación es inválida o ya venció.')); + } + + let acceptedCount = 0; + + for (const invitation of invitations) { + await db.$transaction(async (tx) => { + const existingMembership = await tx.complexUser.findUnique({ + where: { + complexId_userId: { + complexId: invitation.complexId, + userId, + }, + }, + select: { userId: true }, + }); + + if (!existingMembership) { + await tx.complexUser.create({ + data: { + complexId: invitation.complexId, + userId, + role: 'EMPLOYEE', + }, + }); + } + + await tx.complexInvitation.update({ + where: { id: invitation.id }, + data: { acceptedAt: new Date() }, + }); + }); + + acceptedCount += 1; + } + + return ok({ acceptedCount }); +} diff --git a/apps/backend/src/modules/complex/features/accept-complex-invitations/accept-complex-invitations.handler.ts b/apps/backend/src/modules/complex/features/accept-complex-invitations/accept-complex-invitations.handler.ts new file mode 100644 index 0000000..9a8c302 --- /dev/null +++ b/apps/backend/src/modules/complex/features/accept-complex-invitations/accept-complex-invitations.handler.ts @@ -0,0 +1,11 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import type { AcceptComplexInvitationsInput } from '@repo/api-contract'; +import { acceptComplexInvitations } from './accept-complex-invitations.business'; + +export async function acceptComplexInvitationsHandler(c: AppContext) { + const user = c.get('user'); + const payload = c.req.valid('json' as never) as AcceptComplexInvitationsInput; + + return handleResult(c, await acceptComplexInvitations(user.id, user.email, payload)); +} diff --git a/apps/backend/src/modules/complex/features/cancel-complex-invitation/cancel-complex-invitation.business.ts b/apps/backend/src/modules/complex/features/cancel-complex-invitation/cancel-complex-invitation.business.ts new file mode 100644 index 0000000..4faea76 --- /dev/null +++ b/apps/backend/src/modules/complex/features/cancel-complex-invitation/cancel-complex-invitation.business.ts @@ -0,0 +1,38 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import type { CancelComplexInvitationResponse } from '@repo/api-contract'; +import { ensureComplexAdmin } from '../../shared/members-helpers'; + +export async function cancelComplexInvitation( + userId: string, + complexId: string, + invitationId: string +): Promise> { + const adminResult = await ensureComplexAdmin(userId, complexId); + if (!adminResult.ok) return adminResult; + + const invitation = await db.complexInvitation.findUnique({ + where: { id: invitationId }, + select: { complexId: true, acceptedAt: true, revokedAt: true }, + }); + + if (!invitation || invitation.complexId !== complexId) { + return err(Errors.notFound('La invitación no existe.')); + } + + if (invitation.acceptedAt) { + return err(Errors.conflict('La invitación ya fue aceptada.')); + } + + if (invitation.revokedAt) { + return ok({ ok: true }); + } + + await db.complexInvitation.update({ + where: { id: invitationId }, + data: { revokedAt: new Date() }, + }); + + return ok({ ok: true }); +} diff --git a/apps/backend/src/modules/complex/features/cancel-complex-invitation/cancel-complex-invitation.handler.ts b/apps/backend/src/modules/complex/features/cancel-complex-invitation/cancel-complex-invitation.handler.ts new file mode 100644 index 0000000..c954715 --- /dev/null +++ b/apps/backend/src/modules/complex/features/cancel-complex-invitation/cancel-complex-invitation.handler.ts @@ -0,0 +1,13 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import { z } from 'zod'; +import { cancelComplexInvitation } from './cancel-complex-invitation.business'; + +const cancelInvitationParamsSchema = z.object({ id: z.uuid(), invitationId: z.uuid() }); + +export async function cancelComplexInvitationHandler(c: AppContext) { + const user = c.get('user'); + const params = cancelInvitationParamsSchema.parse(c.req.param()); + + return handleResult(c, await cancelComplexInvitation(user.id, params.id, params.invitationId)); +} diff --git a/apps/backend/src/modules/complex/features/create-complex/create-complex.business.ts b/apps/backend/src/modules/complex/features/create-complex/create-complex.business.ts new file mode 100644 index 0000000..849e074 --- /dev/null +++ b/apps/backend/src/modules/complex/features/create-complex/create-complex.business.ts @@ -0,0 +1,101 @@ +import { db } from '@/lib/prisma'; +import { buildUniqueSlug, slugify } from '@/lib/slug'; +import { ensureActiveSport } from '@/modules/court/shared/guards'; +import type { CreateComplexInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; + +type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY'; + +type CreateComplexInternalInput = CreateComplexInput & { + adminEmail: string; + userId?: string; +}; + +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; + }> +) { + for (const range of availability) { + const start = toMinutes(range.startTime); + const end = toMinutes(range.endTime); + if (start >= end) { + throw new Error(`El rango ${range.startTime}-${range.endTime} es inválido.`); + } + } +} + +export async function createComplex(input: CreateComplexInternalInput) { + return db.$transaction(async (tx) => { + const base = slugify(input.complexName); + const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`; + const complexSlug = await buildUniqueSlug(fallback, (slug) => + tx.complex.findFirst({ where: { complexSlug: slug }, select: { id: true } }) + ); + + const complex = await tx.complex.create({ + data: { + id: uuidv7(), + complexName: input.complexName, + physicalAddress: input.physicalAddress.trim(), + city: input.city?.trim() || null, + state: input.state?.trim() || null, + country: input.country?.trim() || null, + complexSlug, + adminEmail: input.adminEmail, + planCode: input.planCode, + }, + }); + + if (input.userId) { + await tx.complexUser.create({ + data: { + complexId: complex.id, + userId: input.userId, + role: 'ADMIN', + }, + }); + } + + if (input.setupCourts && input.courtSportId) { + const sport = await ensureActiveSport(input.courtSportId); + const availability = (input.courtDaysOfWeek ?? []).map((day) => ({ + dayOfWeek: day as DayOfWeek, + startTime: input.courtStartTime ?? '08:00', + endTime: input.courtEndTime ?? '22:00', + })); + + assertAvailabilityRanges(availability); + + const court = await tx.court.create({ + data: { + id: uuidv7(), + complexId: complex.id, + sportId: input.courtSportId, + name: `${sport.name} 1`, + slotDurationMinutes: 60, + basePrice: 0, + }, + }); + + await tx.courtAvailability.createMany({ + data: availability.map((avail) => ({ + id: uuidv7(), + courtId: court.id, + dayOfWeek: avail.dayOfWeek, + startTime: avail.startTime, + endTime: avail.endTime, + })), + }); + } + + return complex; + }); +} diff --git a/apps/backend/src/modules/complex/handlers/create-complex.handler.ts b/apps/backend/src/modules/complex/features/create-complex/create-complex.handler.ts similarity index 94% rename from apps/backend/src/modules/complex/handlers/create-complex.handler.ts rename to apps/backend/src/modules/complex/features/create-complex/create-complex.handler.ts index eaeae6f..cd82091 100644 --- a/apps/backend/src/modules/complex/handlers/create-complex.handler.ts +++ b/apps/backend/src/modules/complex/features/create-complex/create-complex.handler.ts @@ -1,7 +1,7 @@ import { db } from '@/lib/prisma'; -import { createComplex } from '@/modules/complex/services/complex.service'; import type { AppContext } from '@/types/hono'; import type { CreateComplexInput } from '@repo/api-contract'; +import { createComplex } from './create-complex.business'; export async function createComplexHandler(c: AppContext) { const payload = c.req.valid('json' as never) as CreateComplexInput; diff --git a/apps/backend/src/modules/complex/features/get-complex-by-id/get-complex-by-id.business.ts b/apps/backend/src/modules/complex/features/get-complex-by-id/get-complex-by-id.business.ts new file mode 100644 index 0000000..e13dd77 --- /dev/null +++ b/apps/backend/src/modules/complex/features/get-complex-by-id/get-complex-by-id.business.ts @@ -0,0 +1,5 @@ +import { db } from '@/lib/prisma'; + +export async function getComplexById(id: string) { + return db.complex.findUnique({ where: { id } }); +} diff --git a/apps/backend/src/modules/complex/handlers/get-complex-by-id.handler.ts b/apps/backend/src/modules/complex/features/get-complex-by-id/get-complex-by-id.handler.ts similarity index 83% rename from apps/backend/src/modules/complex/handlers/get-complex-by-id.handler.ts rename to apps/backend/src/modules/complex/features/get-complex-by-id/get-complex-by-id.handler.ts index 4df8248..983cc56 100644 --- a/apps/backend/src/modules/complex/handlers/get-complex-by-id.handler.ts +++ b/apps/backend/src/modules/complex/features/get-complex-by-id/get-complex-by-id.handler.ts @@ -1,5 +1,5 @@ -import { getComplexById } from '@/modules/complex/services/complex.service'; import type { AppContext } from '@/types/hono'; +import { getComplexById } from './get-complex-by-id.business'; type ComplexIdParams = { id: string }; diff --git a/apps/backend/src/modules/complex/features/get-complex-by-slug/get-complex-by-slug.business.ts b/apps/backend/src/modules/complex/features/get-complex-by-slug/get-complex-by-slug.business.ts new file mode 100644 index 0000000..66a5787 --- /dev/null +++ b/apps/backend/src/modules/complex/features/get-complex-by-slug/get-complex-by-slug.business.ts @@ -0,0 +1,5 @@ +import { db } from '@/lib/prisma'; + +export async function getComplexBySlug(slug: string) { + return db.complex.findUnique({ where: { complexSlug: slug } }); +} diff --git a/apps/backend/src/modules/complex/handlers/get-complex-by-slug.handler.ts b/apps/backend/src/modules/complex/features/get-complex-by-slug/get-complex-by-slug.handler.ts similarity index 83% rename from apps/backend/src/modules/complex/handlers/get-complex-by-slug.handler.ts rename to apps/backend/src/modules/complex/features/get-complex-by-slug/get-complex-by-slug.handler.ts index 7990f6f..53017ac 100644 --- a/apps/backend/src/modules/complex/handlers/get-complex-by-slug.handler.ts +++ b/apps/backend/src/modules/complex/features/get-complex-by-slug/get-complex-by-slug.handler.ts @@ -1,5 +1,5 @@ -import { getComplexBySlug } from '@/modules/complex/services/complex.service'; import type { AppContext } from '@/types/hono'; +import { getComplexBySlug } from './get-complex-by-slug.business'; type ComplexSlugParams = { slug: string }; diff --git a/apps/backend/src/modules/complex/features/get-current-complex/get-current-complex.business.ts b/apps/backend/src/modules/complex/features/get-current-complex/get-current-complex.business.ts new file mode 100644 index 0000000..535942b --- /dev/null +++ b/apps/backend/src/modules/complex/features/get-current-complex/get-current-complex.business.ts @@ -0,0 +1,34 @@ +import { db } from '@/lib/prisma'; +import { parsePlanRules } from '@/modules/plan/services/plan-rules.service'; + +export async function getCurrentComplex(userId: string, complexId: string) { + const complexUser = await db.complexUser.findUnique({ + where: { + complexId_userId: { + complexId, + userId, + }, + }, + include: { + complex: { + include: { + plan: true, + }, + }, + }, + }); + + if (!complexUser) return null; + + return { + ...complexUser.complex, + role: complexUser.role, + planFeatures: complexUser.complex.plan + ? parsePlanRules(complexUser.complex.plan.rules).features + : null, + }; +} + +export async function selectComplex(userId: string, complexId: string) { + return getCurrentComplex(userId, complexId); +} diff --git a/apps/backend/src/modules/complex/handlers/get-current-complex.handler.ts b/apps/backend/src/modules/complex/features/get-current-complex/get-current-complex.handler.ts similarity index 85% rename from apps/backend/src/modules/complex/handlers/get-current-complex.handler.ts rename to apps/backend/src/modules/complex/features/get-current-complex/get-current-complex.handler.ts index a2cc5a0..21f4538 100644 --- a/apps/backend/src/modules/complex/handlers/get-current-complex.handler.ts +++ b/apps/backend/src/modules/complex/features/get-current-complex/get-current-complex.handler.ts @@ -1,6 +1,6 @@ -import { getCurrentComplex } from '@/modules/complex/services/complex.service'; import type { AppContext } from '@/types/hono'; import { getCookie } from 'hono/cookie'; +import { getCurrentComplex } from './get-current-complex.business'; const SELECTED_COMPLEX_COOKIE = 'selected-complex-id'; diff --git a/apps/backend/src/modules/complex/features/invite-complex-user/invite-complex-user.business.ts b/apps/backend/src/modules/complex/features/invite-complex-user/invite-complex-user.business.ts new file mode 100644 index 0000000..7ffd115 --- /dev/null +++ b/apps/backend/src/modules/complex/features/invite-complex-user/invite-complex-user.business.ts @@ -0,0 +1,99 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import type { InviteComplexUserInput, InviteComplexUserResponse } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { + createInvitationToken, + ensureComplexAdmin, + formatInvitation, + hashToken, + normalizeEmail, + nowPlusDays, + sendInvitationEmail, +} from '../../shared/members-helpers'; + +export async function inviteComplexUser( + userId: string, + complexId: string, + input: InviteComplexUserInput +): Promise> { + const adminResult = await ensureComplexAdmin(userId, complexId); + if (!adminResult.ok) return adminResult; + + const email = normalizeEmail(input.email); + const complex = await db.complex.findUnique({ + where: { id: complexId }, + select: { complexName: true }, + }); + + if (!complex) { + return err(Errors.notFound('El complejo no existe.')); + } + + const existingUser = await db.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + if (existingUser) { + const existingMembership = await db.complexUser.findUnique({ + where: { + complexId_userId: { + complexId, + userId: existingUser.id, + }, + }, + select: { userId: true }, + }); + + if (existingMembership) { + return err(Errors.conflict('Ese usuario ya pertenece al complejo.')); + } + } + + const token = createInvitationToken(); + + const invitation = await db.$transaction(async (tx) => { + const pendingInvitation = await tx.complexInvitation.findFirst({ + where: { + complexId, + email, + acceptedAt: null, + revokedAt: null, + expiresAt: { gte: new Date() }, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (pendingInvitation) { + return tx.complexInvitation.update({ + where: { id: pendingInvitation.id }, + data: { + tokenHash: hashToken(token), + expiresAt: nowPlusDays(Number(Bun.env.COMPLEX_INVITATION_TTL_DAYS ?? 7)), + acceptedAt: null, + revokedAt: null, + }, + }); + } + + return tx.complexInvitation.create({ + data: { + id: uuidv7(), + complexId, + email, + tokenHash: hashToken(token), + expiresAt: nowPlusDays(Number(Bun.env.COMPLEX_INVITATION_TTL_DAYS ?? 7)), + }, + }); + }); + + await sendInvitationEmail({ + complexName: complex.complexName, + inviteeEmail: email, + token, + }); + + return ok({ invitation: formatInvitation(invitation) }); +} diff --git a/apps/backend/src/modules/complex/features/invite-complex-user/invite-complex-user.handler.ts b/apps/backend/src/modules/complex/features/invite-complex-user/invite-complex-user.handler.ts new file mode 100644 index 0000000..ebd993f --- /dev/null +++ b/apps/backend/src/modules/complex/features/invite-complex-user/invite-complex-user.handler.ts @@ -0,0 +1,25 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import type { InviteComplexUserInput } from '@repo/api-contract'; +import { z } from 'zod'; +import { inviteComplexUser as inviteComplexUserBusiness } from './invite-complex-user.business'; + +const complexParamsSchema = z.object({ id: z.uuid() }); + +type Deps = { + inviteComplexUser: typeof inviteComplexUserBusiness; +}; + +export function createInviteComplexUserHandler( + deps: Deps = { inviteComplexUser: inviteComplexUserBusiness } +) { + return async function inviteComplexUserHandler(c: AppContext) { + const user = c.get('user'); + const params = complexParamsSchema.parse(c.req.param()); + const payload = c.req.valid('json' as never) as InviteComplexUserInput; + + return handleResult(c, await deps.inviteComplexUser(user.id, params.id, payload), 201); + }; +} + +export const inviteComplexUserHandler = createInviteComplexUserHandler(); diff --git a/apps/backend/src/modules/complex/features/list-complex-users/list-complex-users.business.ts b/apps/backend/src/modules/complex/features/list-complex-users/list-complex-users.business.ts new file mode 100644 index 0000000..1cc627c --- /dev/null +++ b/apps/backend/src/modules/complex/features/list-complex-users/list-complex-users.business.ts @@ -0,0 +1,44 @@ +import { resolveAvatarUrl } from '@/lib/avatar'; +import { db } from '@/lib/prisma'; +import { ensureComplexMember, formatInvitation } from '../../shared/members-helpers'; + +export async function listComplexUsers(complexId: string) { + const [members, invitations] = await Promise.all([ + db.complexUser.findMany({ + where: { complexId }, + include: { user: true }, + orderBy: { createdAt: 'asc' }, + }), + db.complexInvitation.findMany({ + where: { + complexId, + acceptedAt: null, + revokedAt: null, + expiresAt: { gte: new Date() }, + }, + orderBy: { createdAt: 'desc' }, + }), + ]); + + const membersWithAvatars = members.map((member) => ({ + userId: member.userId, + fullName: member.user.name, + email: member.user.email, + avatarUrl: resolveAvatarUrl({ + email: member.user.email, + image: member.user.image, + }), + role: member.role, + createdAt: member.createdAt.toISOString(), + })); + + return { + members: membersWithAvatars, + invitations: invitations.map(formatInvitation), + }; +} + +export async function getComplexUsersOverview(userId: string, complexId: string) { + await ensureComplexMember(userId, complexId); + return listComplexUsers(complexId); +} diff --git a/apps/backend/src/modules/complex/handlers/list-complex-users.handler.ts b/apps/backend/src/modules/complex/features/list-complex-users/list-complex-users.handler.ts similarity index 67% rename from apps/backend/src/modules/complex/handlers/list-complex-users.handler.ts rename to apps/backend/src/modules/complex/features/list-complex-users/list-complex-users.handler.ts index 17b43f7..82ac001 100644 --- a/apps/backend/src/modules/complex/handlers/list-complex-users.handler.ts +++ b/apps/backend/src/modules/complex/features/list-complex-users/list-complex-users.handler.ts @@ -1,10 +1,8 @@ -import { getComplexUsersOverview } from '@/modules/complex/services/complex-members.service'; import type { AppContext } from '@/types/hono'; import { z } from 'zod'; +import { getComplexUsersOverview } from './list-complex-users.business'; -const complexParamsSchema = z.object({ - id: z.uuid(), -}); +const complexParamsSchema = z.object({ id: z.uuid() }); export async function listComplexUsersHandler(c: AppContext) { const user = c.get('user'); diff --git a/apps/backend/src/modules/complex/features/list-my-complexes/list-my-complexes.business.ts b/apps/backend/src/modules/complex/features/list-my-complexes/list-my-complexes.business.ts new file mode 100644 index 0000000..a6d882a --- /dev/null +++ b/apps/backend/src/modules/complex/features/list-my-complexes/list-my-complexes.business.ts @@ -0,0 +1,24 @@ +import { db } from '@/lib/prisma'; +import { parsePlanRules } from '@/modules/plan/services/plan-rules.service'; + +export async function listMyComplexes(userId: string) { + const complexUsers = await db.complexUser.findMany({ + where: { userId }, + include: { + complex: { + include: { + plan: true, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + + return complexUsers.map((complexUser) => ({ + ...complexUser.complex, + role: complexUser.role, + planFeatures: complexUser.complex.plan + ? parsePlanRules(complexUser.complex.plan.rules).features + : null, + })); +} diff --git a/apps/backend/src/modules/complex/handlers/list-my-complexes.handler.ts b/apps/backend/src/modules/complex/features/list-my-complexes/list-my-complexes.handler.ts similarity index 74% rename from apps/backend/src/modules/complex/handlers/list-my-complexes.handler.ts rename to apps/backend/src/modules/complex/features/list-my-complexes/list-my-complexes.handler.ts index fb873d1..4641f09 100644 --- a/apps/backend/src/modules/complex/handlers/list-my-complexes.handler.ts +++ b/apps/backend/src/modules/complex/features/list-my-complexes/list-my-complexes.handler.ts @@ -1,5 +1,5 @@ -import { listMyComplexes } from '@/modules/complex/services/complex.service'; import type { AppContext } from '@/types/hono'; +import { listMyComplexes } from './list-my-complexes.business'; export async function listMyComplexesHandler(c: AppContext) { const user = c.get('user'); diff --git a/apps/backend/src/modules/complex/features/resend-complex-invitation/resend-complex-invitation.business.ts b/apps/backend/src/modules/complex/features/resend-complex-invitation/resend-complex-invitation.business.ts new file mode 100644 index 0000000..2d56464 --- /dev/null +++ b/apps/backend/src/modules/complex/features/resend-complex-invitation/resend-complex-invitation.business.ts @@ -0,0 +1,58 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import type { InviteComplexUserResponse } from '@repo/api-contract'; +import { + ensureComplexAdmin, + formatInvitation, + refreshPendingInvitation, + sendInvitationEmail, +} from '../../shared/members-helpers'; + +export async function resendComplexInvitation( + userId: string, + complexId: string, + invitationId: string +): Promise> { + const adminResult = await ensureComplexAdmin(userId, complexId); + if (!adminResult.ok) return adminResult; + + const pendingInvitation = await db.complexInvitation.findUnique({ + where: { id: invitationId }, + include: { + complex: { select: { complexName: true } }, + }, + }); + + if (!pendingInvitation || pendingInvitation.complexId !== complexId) { + return err(Errors.notFound('La invitación no existe.')); + } + + if (pendingInvitation.acceptedAt) { + return err(Errors.conflict('La invitación ya fue aceptada.')); + } + + if (pendingInvitation.revokedAt) { + return err(Errors.conflict('La invitación fue cancelada.')); + } + + const refreshed = await refreshPendingInvitation(invitationId); + + await sendInvitationEmail({ + complexName: refreshed.invitation.complex.complexName, + inviteeEmail: pendingInvitation.email, + token: refreshed.token, + }); + + return ok({ + invitation: formatInvitation({ + id: refreshed.invitation.id, + complexId: refreshed.invitation.complexId, + email: refreshed.invitation.email, + expiresAt: refreshed.invitation.expiresAt, + acceptedAt: refreshed.invitation.acceptedAt, + revokedAt: refreshed.invitation.revokedAt, + createdAt: refreshed.invitation.createdAt, + }), + }); +} diff --git a/apps/backend/src/modules/complex/features/resend-complex-invitation/resend-complex-invitation.handler.ts b/apps/backend/src/modules/complex/features/resend-complex-invitation/resend-complex-invitation.handler.ts new file mode 100644 index 0000000..304a7d5 --- /dev/null +++ b/apps/backend/src/modules/complex/features/resend-complex-invitation/resend-complex-invitation.handler.ts @@ -0,0 +1,13 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import { z } from 'zod'; +import { resendComplexInvitation } from './resend-complex-invitation.business'; + +const resendInvitationParamsSchema = z.object({ id: z.uuid(), invitationId: z.uuid() }); + +export async function resendComplexInvitationHandler(c: AppContext) { + const user = c.get('user'); + const params = resendInvitationParamsSchema.parse(c.req.param()); + + return handleResult(c, await resendComplexInvitation(user.id, params.id, params.invitationId)); +} diff --git a/apps/backend/src/modules/complex/features/revoke-complex-user/revoke-complex-user.business.ts b/apps/backend/src/modules/complex/features/revoke-complex-user/revoke-complex-user.business.ts new file mode 100644 index 0000000..702ff94 --- /dev/null +++ b/apps/backend/src/modules/complex/features/revoke-complex-user/revoke-complex-user.business.ts @@ -0,0 +1,43 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import type { RevokeComplexUserInput } from '@repo/api-contract'; +import { ensureComplexAdmin } from '../../shared/members-helpers'; + +export async function revokeComplexUser( + userId: string, + complexId: string, + input: RevokeComplexUserInput +): Promise> { + const adminResult = await ensureComplexAdmin(userId, complexId); + if (!adminResult.ok) return adminResult; + + const membership = await db.complexUser.findUnique({ + where: { + complexId_userId: { + complexId, + userId: input.userId, + }, + }, + select: { role: true }, + }); + + if (!membership) { + return err(Errors.notFound('Ese usuario no pertenece al complejo.')); + } + + if (membership.role !== 'EMPLOYEE') { + return err(Errors.conflict('Solo podés revocar usuarios con rol EMPLOYEE.')); + } + + await db.complexUser.delete({ + where: { + complexId_userId: { + complexId, + userId: input.userId, + }, + }, + }); + + return ok({ ok: true }); +} diff --git a/apps/backend/src/modules/complex/features/revoke-complex-user/revoke-complex-user.handler.ts b/apps/backend/src/modules/complex/features/revoke-complex-user/revoke-complex-user.handler.ts new file mode 100644 index 0000000..f14dfbe --- /dev/null +++ b/apps/backend/src/modules/complex/features/revoke-complex-user/revoke-complex-user.handler.ts @@ -0,0 +1,13 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import { z } from 'zod'; +import { revokeComplexUser } from './revoke-complex-user.business'; + +const revokeParamsSchema = z.object({ id: z.uuid(), userId: z.string().min(1) }); + +export async function revokeComplexUserHandler(c: AppContext) { + const user = c.get('user'); + const params = revokeParamsSchema.parse(c.req.param()); + + return handleResult(c, await revokeComplexUser(user.id, params.id, { userId: params.userId })); +} diff --git a/apps/backend/src/modules/complex/handlers/select-complex.handler.ts b/apps/backend/src/modules/complex/features/select-complex/select-complex.handler.ts similarity index 88% rename from apps/backend/src/modules/complex/handlers/select-complex.handler.ts rename to apps/backend/src/modules/complex/features/select-complex/select-complex.handler.ts index 700f8e1..a4b14f5 100644 --- a/apps/backend/src/modules/complex/handlers/select-complex.handler.ts +++ b/apps/backend/src/modules/complex/features/select-complex/select-complex.handler.ts @@ -1,4 +1,7 @@ -import { selectComplex } from '@/modules/complex/services/complex.service'; +import { + getCurrentComplex, + selectComplex, +} from '@/modules/complex/features/get-current-complex/get-current-complex.business'; import type { AppContext } from '@/types/hono'; import { setCookie } from 'hono/cookie'; import { z } from 'zod'; diff --git a/apps/backend/src/modules/complex/features/update-complex/update-complex.business.ts b/apps/backend/src/modules/complex/features/update-complex/update-complex.business.ts new file mode 100644 index 0000000..49702d8 --- /dev/null +++ b/apps/backend/src/modules/complex/features/update-complex/update-complex.business.ts @@ -0,0 +1,62 @@ +import { Prisma } from '@/generated/prisma/client'; +import { db } from '@/lib/prisma'; +import { buildUniqueSlug, slugify } from '@/lib/slug'; +import type { UpdateComplexInput } from '@repo/api-contract'; + +export async function getComplexById(id: string) { + return db.complex.findUnique({ where: { id } }); +} + +export async function updateComplex(id: string, input: UpdateComplexInput) { + const data: Record = {}; + + if (input.complexName) { + data.complexName = input.complexName; + const base = slugify(input.complexName); + data.complexSlug = await buildUniqueSlug(base, (slug) => + db.complex.findFirst({ + where: { complexSlug: slug, id: { not: id } }, + select: { id: true }, + }) + ); + } + + if (input.complexSlug) { + const base = slugify(input.complexSlug); + data.complexSlug = await buildUniqueSlug(base, (slug) => + db.complex.findFirst({ + where: { complexSlug: slug, id: { not: id } }, + select: { id: true }, + }) + ); + } + + if (input.adminEmail) { + data.adminEmail = input.adminEmail; + } + + if (input.planCode !== undefined) { + data.planCode = input.planCode; + } + + if (input.physicalAddress !== undefined) { + data.physicalAddress = input.physicalAddress?.trim() ?? null; + } + + if (input.city !== undefined) { + data.city = input.city?.trim() ?? null; + } + + if (input.state !== undefined) { + data.state = input.state?.trim() ?? null; + } + + if (input.country !== undefined) { + data.country = input.country?.trim() ?? null; + } + + return db.complex.update({ + where: { id }, + data: data as Prisma.ComplexUncheckedUpdateInput, + }); +} diff --git a/apps/backend/src/modules/complex/handlers/update-complex.handler.ts b/apps/backend/src/modules/complex/features/update-complex/update-complex.handler.ts similarity index 89% rename from apps/backend/src/modules/complex/handlers/update-complex.handler.ts rename to apps/backend/src/modules/complex/features/update-complex/update-complex.handler.ts index 42f9e2e..89fe429 100644 --- a/apps/backend/src/modules/complex/handlers/update-complex.handler.ts +++ b/apps/backend/src/modules/complex/features/update-complex/update-complex.handler.ts @@ -1,7 +1,7 @@ import { Prisma } from '@/generated/prisma/client'; -import { getComplexById, updateComplex } from '@/modules/complex/services/complex.service'; import type { AppContext } from '@/types/hono'; import type { UpdateComplexInput } from '@repo/api-contract'; +import { getComplexById, updateComplex } from './update-complex.business'; type ComplexIdParams = { id: string }; diff --git a/apps/backend/src/modules/complex/handlers/accept-complex-invitations.handler.ts b/apps/backend/src/modules/complex/handlers/accept-complex-invitations.handler.ts deleted file mode 100644 index 4a0eb48..0000000 --- a/apps/backend/src/modules/complex/handlers/accept-complex-invitations.handler.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - ComplexMembersError, - acceptComplexInvitations, -} from '@/modules/complex/services/complex-members.service'; -import type { AppContext } from '@/types/hono'; -import type { AcceptComplexInvitationsInput } from '@repo/api-contract'; - -export async function acceptComplexInvitationsHandler(c: AppContext) { - const user = c.get('user'); - const payload = c.req.valid('json' as never) as AcceptComplexInvitationsInput; - - try { - const result = await acceptComplexInvitations(user.id, user.email, payload); - return c.json(result); - } catch (error) { - if (error instanceof ComplexMembersError) { - return c.json( - { message: error.message }, - { - status: error.status, - } - ); - } - - throw error; - } -} diff --git a/apps/backend/src/modules/complex/handlers/cancel-complex-invitation.handler.ts b/apps/backend/src/modules/complex/handlers/cancel-complex-invitation.handler.ts deleted file mode 100644 index 7e5de72..0000000 --- a/apps/backend/src/modules/complex/handlers/cancel-complex-invitation.handler.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - ComplexMembersError, - cancelComplexInvitation, -} from '@/modules/complex/services/complex-members.service'; -import type { AppContext } from '@/types/hono'; -import { z } from 'zod'; - -const cancelInvitationParamsSchema = z.object({ - id: z.uuid(), - invitationId: z.uuid(), -}); - -export async function cancelComplexInvitationHandler(c: AppContext) { - const user = c.get('user'); - const params = cancelInvitationParamsSchema.parse(c.req.param()); - - try { - const result = await cancelComplexInvitation(user.id, params.id, params.invitationId); - return c.json(result); - } catch (error) { - if (error instanceof ComplexMembersError) { - return c.json({ message: error.message }, { status: error.status }); - } - - throw error; - } -} diff --git a/apps/backend/src/modules/complex/handlers/invite-complex-user.handler.ts b/apps/backend/src/modules/complex/handlers/invite-complex-user.handler.ts deleted file mode 100644 index 49c4149..0000000 --- a/apps/backend/src/modules/complex/handlers/invite-complex-user.handler.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - ComplexMembersError, - inviteComplexUser, -} from '@/modules/complex/services/complex-members.service'; -import type { AppContext } from '@/types/hono'; -import type { InviteComplexUserInput } from '@repo/api-contract'; -import { z } from 'zod'; - -const complexParamsSchema = z.object({ - id: z.uuid(), -}); - -type InviteComplexUserHandlerDeps = { - inviteComplexUser: typeof inviteComplexUser; - ComplexMembersError: typeof ComplexMembersError; -}; - -export function createInviteComplexUserHandler( - deps: InviteComplexUserHandlerDeps = { - inviteComplexUser, - ComplexMembersError, - } -) { - return async function inviteComplexUserHandler(c: AppContext) { - const user = c.get('user'); - const params = complexParamsSchema.parse(c.req.param()); - const payload = c.req.valid('json' as never) as InviteComplexUserInput; - - try { - const result = await deps.inviteComplexUser(user.id, params.id, payload); - return c.json(result, 201); - } catch (error) { - if (error instanceof deps.ComplexMembersError) { - return c.json( - { message: error.message }, - { - status: error.status, - } - ); - } - - throw error; - } - }; -} - -export const inviteComplexUserHandler = createInviteComplexUserHandler(); diff --git a/apps/backend/src/modules/complex/handlers/resend-complex-invitation.handler.ts b/apps/backend/src/modules/complex/handlers/resend-complex-invitation.handler.ts deleted file mode 100644 index a424fbc..0000000 --- a/apps/backend/src/modules/complex/handlers/resend-complex-invitation.handler.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - ComplexMembersError, - resendComplexInvitation, -} from '@/modules/complex/services/complex-members.service'; -import type { AppContext } from '@/types/hono'; -import { z } from 'zod'; - -const resendInvitationParamsSchema = z.object({ - id: z.uuid(), - invitationId: z.uuid(), -}); - -export async function resendComplexInvitationHandler(c: AppContext) { - const user = c.get('user'); - const params = resendInvitationParamsSchema.parse(c.req.param()); - - try { - const result = await resendComplexInvitation(user.id, params.id, params.invitationId); - return c.json(result); - } catch (error) { - if (error instanceof ComplexMembersError) { - return c.json({ message: error.message }, { status: error.status }); - } - - throw error; - } -} diff --git a/apps/backend/src/modules/complex/handlers/revoke-complex-user.handler.ts b/apps/backend/src/modules/complex/handlers/revoke-complex-user.handler.ts deleted file mode 100644 index c3547eb..0000000 --- a/apps/backend/src/modules/complex/handlers/revoke-complex-user.handler.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - ComplexMembersError, - revokeComplexUser, -} from '@/modules/complex/services/complex-members.service'; -import type { AppContext } from '@/types/hono'; -import { z } from 'zod'; - -const revokeParamsSchema = z.object({ - id: z.uuid(), - userId: z.string().min(1), -}); - -export async function revokeComplexUserHandler(c: AppContext) { - const user = c.get('user'); - const params = revokeParamsSchema.parse(c.req.param()); - - try { - const result = await revokeComplexUser(user.id, params.id, { userId: params.userId }); - return c.json(result); - } catch (error) { - if (error instanceof ComplexMembersError) { - return c.json( - { message: error.message }, - { - status: error.status, - } - ); - } - - throw error; - } -} diff --git a/apps/backend/src/modules/complex/services/complex-members.service.ts b/apps/backend/src/modules/complex/services/complex-members.service.ts deleted file mode 100644 index 76831b6..0000000 --- a/apps/backend/src/modules/complex/services/complex-members.service.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { createHash, randomBytes } from 'node:crypto'; -import { resolveAvatarUrl } from '@/lib/avatar'; -import { sendMail } from '@/lib/mailer'; -import { db } from '@/lib/prisma'; -import type { - AcceptComplexInvitationsInput, - AcceptComplexInvitationsResponse, - CancelComplexInvitationResponse, - ComplexInvitation, - ComplexInvitationStatus, - ComplexUsersOverview, - InviteComplexUserInput, - InviteComplexUserResponse, - RevokeComplexUserInput, -} from '@repo/api-contract'; -import { v7 as uuidv7 } from 'uuid'; - -const INVITATION_TTL_DAYS = Number(Bun.env.COMPLEX_INVITATION_TTL_DAYS ?? 7); -const APP_BASE_URL = Bun.env.APP_BASE_URL ?? 'http://localhost:5173'; - -export class ComplexMembersError extends Error { - status: 400 | 403 | 404 | 409; - - constructor(message: string, status: 400 | 403 | 404 | 409 = 400) { - super(message); - this.name = 'ComplexMembersError'; - this.status = status; - } -} - -function normalizeEmail(email: string): string { - return email.trim().toLowerCase(); -} - -function hashToken(token: string): string { - return createHash('sha256').update(token).digest('hex'); -} - -function createInvitationToken(): string { - return randomBytes(32).toString('hex'); -} - -function nowPlusDays(days: number): Date { - const date = new Date(); - date.setDate(date.getDate() + days); - return date; -} - -function getInvitationStatus(invitation: { - expiresAt: Date; - acceptedAt: Date | null; - revokedAt: Date | null; -}): ComplexInvitationStatus { - if (invitation.acceptedAt) return 'ACCEPTED'; - if (invitation.revokedAt) return 'REVOKED'; - if (invitation.expiresAt < new Date()) return 'EXPIRED'; - return 'PENDING'; -} - -function formatInvitation(invitation: { - id: string; - complexId: string; - email: string; - expiresAt: Date; - acceptedAt: Date | null; - revokedAt: Date | null; - createdAt: Date; -}): ComplexInvitation { - return { - id: invitation.id, - complexId: invitation.complexId, - email: invitation.email, - status: getInvitationStatus(invitation), - createdAt: invitation.createdAt.toISOString(), - expiresAt: invitation.expiresAt.toISOString(), - acceptedAt: invitation.acceptedAt?.toISOString() ?? null, - revokedAt: invitation.revokedAt?.toISOString() ?? null, - }; -} - -async function ensureComplexAdmin(userId: string, complexId: string) { - const membership = await db.complexUser.findUnique({ - where: { - complexId_userId: { - complexId, - userId, - }, - }, - select: { - role: true, - }, - }); - - if (!membership) { - throw new ComplexMembersError('No tenés acceso a este complejo.', 403); - } - - if (membership.role !== 'ADMIN') { - throw new ComplexMembersError('Solo un ADMIN puede administrar usuarios.', 403); - } -} - -async function ensureComplexMember(userId: string, complexId: string) { - const membership = await db.complexUser.findUnique({ - where: { - complexId_userId: { - complexId, - userId, - }, - }, - }); - - if (!membership) { - throw new ComplexMembersError('No tenés acceso a este complejo.', 403); - } - - return membership; -} - -async function sendInvitationEmail(input: { - complexName: string; - inviteeEmail: string; - token: string; -}) { - const inviteUrl = new URL('/invite', APP_BASE_URL); - inviteUrl.searchParams.set('inviteToken', input.token); - inviteUrl.searchParams.set('email', input.inviteeEmail); - - await sendMail({ - to: input.inviteeEmail, - subject: `Te invitaron a ${input.complexName}`, - text: `Te invitaron a sumarte a ${input.complexName}. Abrí este enlace para crear tu cuenta o iniciar sesión: ${inviteUrl.toString()}`, - html: ` -

Te invitaron a ${input.complexName}

-

Hacé click en el siguiente enlace para crear tu cuenta o iniciar sesión y aceptar la invitación:

-

${inviteUrl.toString()}

- `, - }); -} - -async function refreshPendingInvitation(invitationId: string) { - const token = createInvitationToken(); - const tokenHash = hashToken(token); - const expiresAt = nowPlusDays(INVITATION_TTL_DAYS); - - const invitation = await db.complexInvitation.update({ - where: { - id: invitationId, - }, - data: { - tokenHash, - expiresAt, - acceptedAt: null, - revokedAt: null, - }, - include: { - complex: { - select: { - complexName: true, - }, - }, - }, - }); - - return { - invitation, - token, - }; -} - -export async function listComplexUsers(complexId: string) { - const [members, invitations] = await Promise.all([ - db.complexUser.findMany({ - where: { complexId }, - include: { - user: true, - }, - orderBy: { - createdAt: 'asc', - }, - }), - db.complexInvitation.findMany({ - where: { - complexId, - acceptedAt: null, - revokedAt: null, - expiresAt: { - gte: new Date(), - }, - }, - orderBy: { - createdAt: 'desc', - }, - }), - ]); - - const membersWithAvatars = members.map((member) => ({ - userId: member.userId, - fullName: member.user.name, - email: member.user.email, - avatarUrl: resolveAvatarUrl({ - email: member.user.email, - image: member.user.image, - }), - role: member.role, - createdAt: member.createdAt.toISOString(), - })); - - return { - members: membersWithAvatars, - invitations: invitations.map(formatInvitation), - } satisfies ComplexUsersOverview; -} - -export async function inviteComplexUser( - userId: string, - complexId: string, - input: InviteComplexUserInput -): Promise { - await ensureComplexAdmin(userId, complexId); - - const email = normalizeEmail(input.email); - const complex = await db.complex.findUnique({ - where: { id: complexId }, - select: { - complexName: true, - }, - }); - - if (!complex) { - throw new ComplexMembersError('El complejo no existe.', 404); - } - - const existingUser = await db.user.findUnique({ - where: { email }, - select: { id: true }, - }); - - if (existingUser) { - const existingMembership = await db.complexUser.findUnique({ - where: { - complexId_userId: { - complexId, - userId: existingUser.id, - }, - }, - select: { userId: true }, - }); - - if (existingMembership) { - throw new ComplexMembersError('Ese usuario ya pertenece al complejo.', 409); - } - } - - const token = createInvitationToken(); - - const invitation = await db.$transaction(async (tx) => { - const pendingInvitation = await tx.complexInvitation.findFirst({ - where: { - complexId, - email, - acceptedAt: null, - revokedAt: null, - expiresAt: { - gte: new Date(), - }, - }, - orderBy: { - createdAt: 'desc', - }, - }); - - if (pendingInvitation) { - const refreshed = await tx.complexInvitation.update({ - where: { - id: pendingInvitation.id, - }, - data: { - tokenHash: hashToken(token), - expiresAt: nowPlusDays(INVITATION_TTL_DAYS), - acceptedAt: null, - revokedAt: null, - }, - }); - - return refreshed; - } - - return tx.complexInvitation.create({ - data: { - id: uuidv7(), - complexId, - email, - tokenHash: hashToken(token), - expiresAt: nowPlusDays(INVITATION_TTL_DAYS), - }, - }); - }); - - await sendInvitationEmail({ - complexName: complex.complexName, - inviteeEmail: email, - token, - }); - - return { - invitation: formatInvitation(invitation), - }; -} - -export async function resendComplexInvitation( - userId: string, - complexId: string, - invitationId: string -): Promise { - await ensureComplexAdmin(userId, complexId); - - const pendingInvitation = await db.complexInvitation.findUnique({ - where: { - id: invitationId, - }, - include: { - complex: { - select: { - complexName: true, - }, - }, - }, - }); - - if (!pendingInvitation || pendingInvitation.complexId !== complexId) { - throw new ComplexMembersError('La invitación no existe.', 404); - } - - if (pendingInvitation.acceptedAt) { - throw new ComplexMembersError('La invitación ya fue aceptada.', 409); - } - - if (pendingInvitation.revokedAt) { - throw new ComplexMembersError('La invitación fue cancelada.', 409); - } - - const refreshed = await refreshPendingInvitation(invitationId); - - await sendInvitationEmail({ - complexName: refreshed.invitation.complex.complexName, - inviteeEmail: pendingInvitation.email, - token: refreshed.token, - }); - - return { - invitation: formatInvitation({ - id: refreshed.invitation.id, - complexId: refreshed.invitation.complexId, - email: refreshed.invitation.email, - expiresAt: refreshed.invitation.expiresAt, - acceptedAt: refreshed.invitation.acceptedAt, - revokedAt: refreshed.invitation.revokedAt, - createdAt: refreshed.invitation.createdAt, - }), - }; -} - -export async function acceptComplexInvitations( - userId: string, - email: string, - input: AcceptComplexInvitationsInput = {} -): Promise { - const normalizedEmail = normalizeEmail(email); - const tokenHash = input.inviteToken ? hashToken(input.inviteToken) : null; - - const invitations = await db.complexInvitation.findMany({ - where: { - email: normalizedEmail, - acceptedAt: null, - revokedAt: null, - expiresAt: { - gte: new Date(), - }, - ...(tokenHash - ? { - tokenHash, - } - : {}), - }, - orderBy: { - createdAt: 'asc', - }, - }); - - if (tokenHash && invitations.length === 0) { - throw new ComplexMembersError('La invitación es inválida o ya venció.', 404); - } - - let acceptedCount = 0; - - for (const invitation of invitations) { - await db.$transaction(async (tx) => { - const existingMembership = await tx.complexUser.findUnique({ - where: { - complexId_userId: { - complexId: invitation.complexId, - userId, - }, - }, - select: { - userId: true, - }, - }); - - if (!existingMembership) { - await tx.complexUser.create({ - data: { - complexId: invitation.complexId, - userId, - role: 'EMPLOYEE', - }, - }); - } - - await tx.complexInvitation.update({ - where: { - id: invitation.id, - }, - data: { - acceptedAt: new Date(), - }, - }); - }); - - acceptedCount += 1; - } - - return { - acceptedCount, - }; -} - -export async function revokeComplexUser( - userId: string, - complexId: string, - input: RevokeComplexUserInput -) { - await ensureComplexAdmin(userId, complexId); - - const membership = await db.complexUser.findUnique({ - where: { - complexId_userId: { - complexId, - userId: input.userId, - }, - }, - select: { - role: true, - }, - }); - - if (!membership) { - throw new ComplexMembersError('Ese usuario no pertenece al complejo.', 404); - } - - if (membership.role !== 'EMPLOYEE') { - throw new ComplexMembersError('Solo podés revocar usuarios con rol EMPLOYEE.', 409); - } - - await db.complexUser.delete({ - where: { - complexId_userId: { - complexId, - userId: input.userId, - }, - }, - }); - - return { - ok: true, - }; -} - -export async function cancelComplexInvitation( - userId: string, - complexId: string, - invitationId: string -): Promise { - await ensureComplexAdmin(userId, complexId); - - const invitation = await db.complexInvitation.findUnique({ - where: { - id: invitationId, - }, - select: { - complexId: true, - acceptedAt: true, - revokedAt: true, - }, - }); - - if (!invitation || invitation.complexId !== complexId) { - throw new ComplexMembersError('La invitación no existe.', 404); - } - - if (invitation.acceptedAt) { - throw new ComplexMembersError('La invitación ya fue aceptada.', 409); - } - - if (invitation.revokedAt) { - return { ok: true }; - } - - await db.complexInvitation.update({ - where: { - id: invitationId, - }, - data: { - revokedAt: new Date(), - }, - }); - - return { ok: true }; -} - -export async function getComplexUsersOverview(userId: string, complexId: string) { - await ensureComplexMember(userId, complexId); - return listComplexUsers(complexId); -} diff --git a/apps/backend/src/modules/complex/services/complex.service.ts b/apps/backend/src/modules/complex/services/complex.service.ts deleted file mode 100644 index 57aa4c5..0000000 --- a/apps/backend/src/modules/complex/services/complex.service.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { Prisma } from '@/generated/prisma/client'; -import { db } from '@/lib/prisma'; -import { parsePlanRules } from '@/modules/plan/services/plan-rules.service'; -import { v7 as uuidv7 } from 'uuid'; - -export type CreateComplexInput = { - complexName: string; - physicalAddress: string; - adminEmail: string; - userId?: string; - planCode?: string; - city?: string; - state?: string; - country?: string; - setupCourts?: boolean; - courtSportId?: string; - courtStartTime?: string; - courtEndTime?: string; - courtDaysOfWeek?: string[]; -}; - -type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY'; - -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; - }> -) { - for (const range of availability) { - const start = toMinutes(range.startTime); - const end = toMinutes(range.endTime); - if (start >= end) { - throw new Error(`El rango ${range.startTime}-${range.endTime} es inválido.`); - } - } -} - -async function ensureActiveSport(sportId: string) { - const sport = await db.sport.findFirst({ - where: { id: sportId, isActive: true }, - select: { id: true, name: true }, - }); - if (!sport) { - throw new Error('El deporte seleccionado no existe o está inactivo.'); - } - return sport; -} - -export type UpdateComplexInput = { - complexName?: string; - physicalAddress?: string | null; - complexSlug?: string; - adminEmail?: string; - planCode?: string | null; - city?: string | null; - state?: string | null; - country?: string | null; -}; - -function slugify(value: string): string { - return value - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .trim() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-'); -} - -async function buildUniqueSlug(source: string, excludeComplexId?: string): Promise { - const base = slugify(source); - const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`; - - let candidate = fallback; - let index = 1; - - while (true) { - const existing = await db.complex.findFirst({ - where: { - complexSlug: candidate, - ...(excludeComplexId - ? { - id: { - not: excludeComplexId, - }, - } - : {}), - }, - select: { id: true }, - }); - - if (!existing) return candidate; - - index += 1; - candidate = `${fallback}-${index}`; - } -} - -export async function createComplex(input: CreateComplexInput) { - const { userId } = input; - - return db.$transaction(async (tx) => { - const complexSlug = await buildUniqueSlug(input.complexName); - - const complex = await tx.complex.create({ - data: { - id: uuidv7(), - complexName: input.complexName, - physicalAddress: input.physicalAddress.trim(), - city: input.city?.trim() || null, - state: input.state?.trim() || null, - country: input.country?.trim() || null, - complexSlug, - adminEmail: input.adminEmail, - planCode: input.planCode, - }, - }); - - if (userId) { - await tx.complexUser.create({ - data: { - complexId: complex.id, - userId, - role: 'ADMIN', - }, - }); - } - - if (input.setupCourts && input.courtSportId) { - const sport = await ensureActiveSport(input.courtSportId); - const availability = (input.courtDaysOfWeek ?? []).map((day) => ({ - dayOfWeek: day as DayOfWeek, - startTime: input.courtStartTime ?? '08:00', - endTime: input.courtEndTime ?? '22:00', - })); - - assertAvailabilityRanges(availability); - - const court = await tx.court.create({ - data: { - id: uuidv7(), - complexId: complex.id, - sportId: input.courtSportId, - name: `${sport.name} 1`, - slotDurationMinutes: 60, - basePrice: 0, - }, - }); - - await tx.courtAvailability.createMany({ - data: availability.map((avail) => ({ - id: uuidv7(), - courtId: court.id, - dayOfWeek: avail.dayOfWeek, - startTime: avail.startTime, - endTime: avail.endTime, - })), - }); - } - - return complex; - }); -} - -export async function getComplexById(id: string) { - return db.complex.findUnique({ - where: { id }, - }); -} - -export async function getComplexBySlug(slug: string) { - return db.complex.findUnique({ - where: { complexSlug: slug }, - }); -} - -export async function listMyComplexes(userId: string) { - const complexUsers = await db.complexUser.findMany({ - where: { userId }, - include: { - complex: { - include: { - plan: true, - }, - }, - }, - orderBy: { - createdAt: 'asc', - }, - }); - - return complexUsers.map((complexUser) => ({ - ...complexUser.complex, - role: complexUser.role, - planFeatures: complexUser.complex.plan - ? parsePlanRules(complexUser.complex.plan.rules).features - : null, - })); -} - -export async function getCurrentComplex(userId: string, complexId: string) { - const complexUser = await db.complexUser.findUnique({ - where: { - complexId_userId: { - complexId, - userId, - }, - }, - include: { - complex: { - include: { - plan: true, - }, - }, - }, - }); - - if (!complexUser) return null; - - return { - ...complexUser.complex, - role: complexUser.role, - planFeatures: complexUser.complex.plan - ? parsePlanRules(complexUser.complex.plan.rules).features - : null, - }; -} - -export async function selectComplex(userId: string, complexId: string) { - const complexUser = await db.complexUser.findUnique({ - where: { - complexId_userId: { - complexId, - userId, - }, - }, - include: { - complex: { - include: { - plan: true, - }, - }, - }, - }); - - if (!complexUser) return null; - - return { - ...complexUser.complex, - role: complexUser.role, - planFeatures: complexUser.complex.plan - ? parsePlanRules(complexUser.complex.plan.rules).features - : null, - }; -} - -export async function updateComplex(id: string, input: UpdateComplexInput) { - const data: Record = {}; - - if (input.complexName) { - data.complexName = input.complexName; - data.complexSlug = await buildUniqueSlug(input.complexName, id); - } - - if (input.complexSlug) { - data.complexSlug = await buildUniqueSlug(input.complexSlug, id); - } - - if (input.adminEmail) { - data.adminEmail = input.adminEmail; - } - - if (input.planCode !== undefined) { - data.planCode = input.planCode; - } - - if (input.physicalAddress !== undefined) { - data.physicalAddress = input.physicalAddress?.trim() ?? null; - } - - if (input.city !== undefined) { - data.city = input.city?.trim() ?? null; - } - - if (input.state !== undefined) { - data.state = input.state?.trim() ?? null; - } - - if (input.country !== undefined) { - data.country = input.country?.trim() ?? null; - } - - return db.complex.update({ - where: { id }, - data: data as Prisma.ComplexUncheckedUpdateInput, - }); -} diff --git a/apps/backend/src/modules/complex/shared/errors.ts b/apps/backend/src/modules/complex/shared/errors.ts new file mode 100644 index 0000000..2a8ec12 --- /dev/null +++ b/apps/backend/src/modules/complex/shared/errors.ts @@ -0,0 +1,6 @@ +export class ComplexMembersError extends Error { + constructor(message: string) { + super(message); + this.name = 'ComplexMembersError'; + } +} diff --git a/apps/backend/src/modules/complex/shared/members-helpers.ts b/apps/backend/src/modules/complex/shared/members-helpers.ts new file mode 100644 index 0000000..c1318a0 --- /dev/null +++ b/apps/backend/src/modules/complex/shared/members-helpers.ts @@ -0,0 +1,158 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { Errors } from '@/lib/errors'; +import { sendMail } from '@/lib/mailer'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import type { ComplexInvitation, ComplexInvitationStatus } from '@repo/api-contract'; + +const INVITATION_TTL_DAYS = Number(Bun.env.COMPLEX_INVITATION_TTL_DAYS ?? 7); +const APP_BASE_URL = Bun.env.APP_BASE_URL ?? 'http://localhost:5173'; + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +function createInvitationToken(): string { + return randomBytes(32).toString('hex'); +} + +function nowPlusDays(days: number): Date { + const date = new Date(); + date.setDate(date.getDate() + days); + return date; +} + +function getInvitationStatus(invitation: { + expiresAt: Date; + acceptedAt: Date | null; + revokedAt: Date | null; +}): ComplexInvitationStatus { + if (invitation.acceptedAt) return 'ACCEPTED'; + if (invitation.revokedAt) return 'REVOKED'; + if (invitation.expiresAt < new Date()) return 'EXPIRED'; + return 'PENDING'; +} + +function formatInvitation(invitation: { + id: string; + complexId: string; + email: string; + expiresAt: Date; + acceptedAt: Date | null; + revokedAt: Date | null; + createdAt: Date; +}): ComplexInvitation { + return { + id: invitation.id, + complexId: invitation.complexId, + email: invitation.email, + status: getInvitationStatus(invitation), + createdAt: invitation.createdAt.toISOString(), + expiresAt: invitation.expiresAt.toISOString(), + acceptedAt: invitation.acceptedAt?.toISOString() ?? null, + revokedAt: invitation.revokedAt?.toISOString() ?? null, + }; +} + +async function ensureComplexAdmin(userId: string, complexId: string): Promise> { + const membership = await db.complexUser.findUnique({ + where: { + complexId_userId: { + complexId, + userId, + }, + }, + select: { role: true }, + }); + + if (!membership) { + return err(Errors.forbidden('No tenés acceso a este complejo.')); + } + + if (membership.role !== 'ADMIN') { + return err(Errors.forbidden('Solo un ADMIN puede administrar usuarios.')); + } + + return ok(undefined); +} + +async function ensureComplexMember( + userId: string, + complexId: string +): Promise> { + const membership = await db.complexUser.findUnique({ + where: { + complexId_userId: { + complexId, + userId, + }, + }, + }); + + if (!membership) { + return err(Errors.forbidden('No tenés acceso a este complejo.')); + } + + return ok(membership); +} + +async function sendInvitationEmail(input: { + complexName: string; + inviteeEmail: string; + token: string; +}) { + const inviteUrl = new URL('/invite', APP_BASE_URL); + inviteUrl.searchParams.set('inviteToken', input.token); + inviteUrl.searchParams.set('email', input.inviteeEmail); + + await sendMail({ + to: input.inviteeEmail, + subject: `Te invitaron a ${input.complexName}`, + text: `Te invitaron a sumarte a ${input.complexName}. Abrí este enlace para crear tu cuenta o iniciar sesión: ${inviteUrl.toString()}`, + html: ` +

Te invitaron a ${input.complexName}

+

Hacé click en el siguiente enlace para crear tu cuenta o iniciar sesión y aceptar la invitación:

+

${inviteUrl.toString()}

+ `, + }); +} + +async function refreshPendingInvitation(invitationId: string) { + const token = createInvitationToken(); + const tokenHash = hashToken(token); + const expiresAt = nowPlusDays(INVITATION_TTL_DAYS); + + const invitation = await db.complexInvitation.update({ + where: { id: invitationId }, + data: { + tokenHash, + expiresAt, + acceptedAt: null, + revokedAt: null, + }, + include: { + complex: { + select: { complexName: true }, + }, + }, + }); + + return { invitation, token }; +} + +export { + normalizeEmail, + hashToken, + createInvitationToken, + nowPlusDays, + getInvitationStatus, + formatInvitation, + ensureComplexAdmin, + ensureComplexMember, + sendInvitationEmail, + refreshPendingInvitation, +}; diff --git a/apps/backend/src/modules/court/court.routes.ts b/apps/backend/src/modules/court/court.routes.ts index 0d63c58..cabcd67 100644 --- a/apps/backend/src/modules/court/court.routes.ts +++ b/apps/backend/src/modules/court/court.routes.ts @@ -1,7 +1,7 @@ import { requireAuth } from '@/middlewares/require-auth.middleware'; -import { createCourtHandler } from '@/modules/court/handlers/create-court.handler'; -import { listCourtsByComplexHandler } from '@/modules/court/handlers/list-courts-by-complex.handler'; -import { updateCourtHandler } from '@/modules/court/handlers/update-court.handler'; +import { createCourtHandler } from '@/modules/court/features/create-court/create-court.handler'; +import { listCourtsByComplexHandler } from '@/modules/court/features/list-by-complex/list-by-complex.handler'; +import { updateCourtHandler } from '@/modules/court/features/update-court/update-court.handler'; import type { AppEnv } from '@/types/hono'; import { zValidator } from '@hono/zod-validator'; import { createCourtSchema, updateCourtSchema } from '@repo/api-contract'; diff --git a/apps/backend/src/modules/court/features/create-court/create-court.business.ts b/apps/backend/src/modules/court/features/create-court/create-court.business.ts new file mode 100644 index 0000000..7843155 --- /dev/null +++ b/apps/backend/src/modules/court/features/create-court/create-court.business.ts @@ -0,0 +1,104 @@ +import { assertAvailabilityRanges } from '@/lib/booking-utils'; +import { ComplexAccessError, ensureComplexAccess } from '@/lib/complex-access'; +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; +import type { CreateCourtInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { CourtBusinessError } from '../../shared/errors'; +import { ensureActiveSport, getCourtByIdForUser } from '../../shared/guards'; +import { mapCourtResponse } from '../../shared/mappers'; + +async function enforcePlanCourtLimit(complexId: string): Promise> { + const complex = await db.complex.findUnique({ + where: { id: complexId }, + select: { + plan: { + select: { + rules: true, + }, + }, + }, + }); + + if (!complex?.plan) { + return ok(undefined); + } + + 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) { + return err(Errors.conflict(maxCourtViolation.message)); + } + + return ok(undefined); +} + +export async function createCourt( + userId: string, + complexId: string, + input: CreateCourtInput +): Promise>> { + try { + await ensureComplexAccess(complexId, userId); + } catch (error) { + if (error instanceof ComplexAccessError) { + return err(Errors.forbidden(error.message)); + } + throw error; + } + + const sportResult = await ensureActiveSport(input.sportId); + if (!sportResult.ok) return sportResult; + + const planResult = await enforcePlanCourtLimit(complexId); + if (!planResult.ok) return planResult; + + 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) { + return err(Errors.notFound('No se pudo obtener la cancha creada.')); + } + + return ok(mapCourtResponse(court)); +} + +export { CourtBusinessError }; diff --git a/apps/backend/src/modules/court/handlers/create-court.handler.ts b/apps/backend/src/modules/court/features/create-court/create-court.handler.ts similarity index 52% rename from apps/backend/src/modules/court/handlers/create-court.handler.ts rename to apps/backend/src/modules/court/features/create-court/create-court.handler.ts index 7856891..669223a 100644 --- a/apps/backend/src/modules/court/handlers/create-court.handler.ts +++ b/apps/backend/src/modules/court/features/create-court/create-court.handler.ts @@ -1,6 +1,7 @@ -import { CourtServiceError, createCourt } from '@/modules/court/services/court.service'; +import { handleResult } from '@/lib/http/handle-result'; import type { AppContext } from '@/types/hono'; import type { CreateCourtInput } from '@repo/api-contract'; +import { createCourt } from './create-court.business'; type ComplexIdParams = { complexId: string }; @@ -9,13 +10,5 @@ export async function createCourtHandler(c: AppContext) { const payload = c.req.valid('json' as never) as CreateCourtInput; const user = c.get('user'); - try { - const court = await createCourt(user.id, complexId, payload); - return c.json(court, 201); - } catch (error) { - if (error instanceof CourtServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; - } + return handleResult(c, await createCourt(user.id, complexId, payload), 201); } diff --git a/apps/backend/src/modules/court/features/list-by-complex/list-by-complex.business.ts b/apps/backend/src/modules/court/features/list-by-complex/list-by-complex.business.ts new file mode 100644 index 0000000..93f2572 --- /dev/null +++ b/apps/backend/src/modules/court/features/list-by-complex/list-by-complex.business.ts @@ -0,0 +1,81 @@ +import type { Court, CourtAvailability, CourtPriceRule, Sport } from '@/generated/prisma/client'; +import { ComplexAccessError, ensureComplexAccess } from '@/lib/complex-access'; +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import { CourtBusinessError } from '../../shared/errors'; + +type CourtWithRelations = Court & { + sport: Sport; + availabilities: CourtAvailability[]; + priceRules: CourtPriceRule[]; +}; + +function mapCourtResponse(court: CourtWithRelations) { + return { + id: court.id, + complexId: court.complexId, + name: court.name, + isUnderMaintenance: court.isUnderMaintenance, + 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 +): Promise[]>> { + try { + await ensureComplexAccess(complexId, userId); + } catch (error) { + if (error instanceof ComplexAccessError) { + return err(Errors.forbidden(error.message)); + } + throw error; + } + + 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 ok(courts.map((court) => mapCourtResponse(court))); +} + +export { CourtBusinessError }; diff --git a/apps/backend/src/modules/court/features/list-by-complex/list-by-complex.handler.ts b/apps/backend/src/modules/court/features/list-by-complex/list-by-complex.handler.ts new file mode 100644 index 0000000..0fdd602 --- /dev/null +++ b/apps/backend/src/modules/court/features/list-by-complex/list-by-complex.handler.ts @@ -0,0 +1,12 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import { listCourtsByComplex } from './list-by-complex.business'; + +type ComplexIdParams = { complexId: string }; + +export async function listCourtsByComplexHandler(c: AppContext) { + const { complexId } = c.req.valid('param' as never) as ComplexIdParams; + const user = c.get('user'); + + return handleResult(c, await listCourtsByComplex(complexId, user.id)); +} diff --git a/apps/backend/src/modules/court/features/update-court/update-court.business.ts b/apps/backend/src/modules/court/features/update-court/update-court.business.ts new file mode 100644 index 0000000..1d5d70c --- /dev/null +++ b/apps/backend/src/modules/court/features/update-court/update-court.business.ts @@ -0,0 +1,70 @@ +import { assertAvailabilityRanges } from '@/lib/booking-utils'; +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import type { UpdateCourtInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { CourtBusinessError } from '../../shared/errors'; +import { ensureActiveSport, getCourtByIdForUser } from '../../shared/guards'; +import { mapCourtResponse } from '../../shared/mappers'; + +export async function updateCourt( + userId: string, + courtId: string, + input: UpdateCourtInput +): Promise>> { + const existingCourt = await getCourtByIdForUser(courtId, userId); + + if (!existingCourt) { + return err(Errors.notFound('Cancha no encontrada.')); + } + + if (input.sportId) { + const sportResult = await ensureActiveSport(input.sportId); + if (!sportResult.ok) return sportResult; + } + + 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) { + return err(Errors.notFound('Cancha no encontrada.')); + } + + return ok(mapCourtResponse(updatedCourt)); +} diff --git a/apps/backend/src/modules/court/handlers/update-court.handler.ts b/apps/backend/src/modules/court/features/update-court/update-court.handler.ts similarity index 52% rename from apps/backend/src/modules/court/handlers/update-court.handler.ts rename to apps/backend/src/modules/court/features/update-court/update-court.handler.ts index 4e6d957..fec2b41 100644 --- a/apps/backend/src/modules/court/handlers/update-court.handler.ts +++ b/apps/backend/src/modules/court/features/update-court/update-court.handler.ts @@ -1,6 +1,7 @@ -import { CourtServiceError, updateCourt } from '@/modules/court/services/court.service'; +import { handleResult } from '@/lib/http/handle-result'; import type { AppContext } from '@/types/hono'; import type { UpdateCourtInput } from '@repo/api-contract'; +import { updateCourt } from './update-court.business'; type CourtIdParams = { id: string }; @@ -9,13 +10,5 @@ export async function updateCourtHandler(c: AppContext) { const payload = c.req.valid('json' as never) as UpdateCourtInput; const user = c.get('user'); - try { - const court = await updateCourt(user.id, id, payload); - return c.json(court); - } catch (error) { - if (error instanceof CourtServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; - } + return handleResult(c, await updateCourt(user.id, id, payload)); } diff --git a/apps/backend/src/modules/court/handlers/list-courts-by-complex.handler.ts b/apps/backend/src/modules/court/handlers/list-courts-by-complex.handler.ts deleted file mode 100644 index aad374a..0000000 --- a/apps/backend/src/modules/court/handlers/list-courts-by-complex.handler.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CourtServiceError, listCourtsByComplex } from '@/modules/court/services/court.service'; -import type { AppContext } from '@/types/hono'; - -type ComplexIdParams = { complexId: string }; - -export async function listCourtsByComplexHandler(c: AppContext) { - const { complexId } = c.req.valid('param' as never) as ComplexIdParams; - const user = c.get('user'); - - try { - const courts = await listCourtsByComplex(complexId, user.id); - return c.json(courts); - } catch (error) { - if (error instanceof CourtServiceError) { - return c.json({ message: error.message }, error.status); - } - throw error; - } -} diff --git a/apps/backend/src/modules/court/services/court.service.ts b/apps/backend/src/modules/court/services/court.service.ts deleted file mode 100644 index 9ad026b..0000000 --- a/apps/backend/src/modules/court/services/court.service.ts +++ /dev/null @@ -1,316 +0,0 @@ -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, - isUnderMaintenance: court.isUnderMaintenance, - 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); -} diff --git a/apps/backend/src/modules/court/shared/errors.ts b/apps/backend/src/modules/court/shared/errors.ts new file mode 100644 index 0000000..164752c --- /dev/null +++ b/apps/backend/src/modules/court/shared/errors.ts @@ -0,0 +1,6 @@ +export class CourtBusinessError extends Error { + constructor(message: string) { + super(message); + this.name = 'CourtBusinessError'; + } +} diff --git a/apps/backend/src/modules/court/shared/guards.ts b/apps/backend/src/modules/court/shared/guards.ts new file mode 100644 index 0000000..33bb349 --- /dev/null +++ b/apps/backend/src/modules/court/shared/guards.ts @@ -0,0 +1,44 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; + +export async function ensureActiveSport(sportId: string): Promise> { + const sport = await db.sport.findFirst({ + where: { + id: sportId, + isActive: true, + }, + select: { id: true }, + }); + + if (!sport) { + return err(Errors.validation('El deporte seleccionado no existe o esta inactivo.')); + } + + return ok(undefined); +} + +export 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' }], + }, + }, + }); +} diff --git a/apps/backend/src/modules/court/shared/mappers.ts b/apps/backend/src/modules/court/shared/mappers.ts new file mode 100644 index 0000000..3e346ce --- /dev/null +++ b/apps/backend/src/modules/court/shared/mappers.ts @@ -0,0 +1,41 @@ +import type { Court, CourtAvailability, CourtPriceRule, Sport } from '@/generated/prisma/client'; + +export type CourtWithRelations = Court & { + sport: Sport; + availabilities: CourtAvailability[]; + priceRules: CourtPriceRule[]; +}; + +export function mapCourtResponse(court: CourtWithRelations) { + return { + id: court.id, + complexId: court.complexId, + name: court.name, + isUnderMaintenance: court.isUnderMaintenance, + 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(), + }; +} diff --git a/apps/backend/src/modules/password-reset/features/resend/resend.business.ts b/apps/backend/src/modules/password-reset/features/resend/resend.business.ts new file mode 100644 index 0000000..9b4beb7 --- /dev/null +++ b/apps/backend/src/modules/password-reset/features/resend/resend.business.ts @@ -0,0 +1,70 @@ +import { db } from '@/lib/prisma'; +import type { PasswordResetResendInput } from '@repo/api-contract'; +import { PasswordResetError } from '../../shared/errors'; +import { + OTP_TTL_MINUTES, + createOtpCode, + getCooldownSeconds, + hashValue, + nowPlusMinutes, + sendResetOtpEmail, +} from '../../shared/helpers'; + +export type PasswordResetResendResult = { + message: string; + requestId: string; + email: string; + expiresAt: string; + cooldownSeconds: number; +}; + +export async function resendPasswordResetOtp( + input: PasswordResetResendInput +): Promise { + const { requestId } = input; + + const request = await db.passwordResetRequest.findUnique({ + where: { id: requestId }, + }); + + if (!request) { + throw new PasswordResetError('Solicitud inválida.', 404); + } + + if (request.usedAt) { + throw new PasswordResetError('Esta solicitud ya fue utilizada.', 400); + } + + const cooldownSeconds = getCooldownSeconds(new Date(request.otpLastSentAt)); + if (cooldownSeconds > 0) { + throw new PasswordResetError( + `Debes esperar ${cooldownSeconds} segundos antes de reenviar.`, + 429 + ); + } + + const otpCode = createOtpCode(); + const otpHash = hashValue(otpCode); + const otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); + + await db.passwordResetRequest.update({ + where: { id: requestId }, + data: { + otpHash, + otpExpiresAt, + otpAttempts: 0, + otpLastSentAt: new Date(), + otpResendCount: { increment: 1 }, + }, + }); + + await sendResetOtpEmail(request.email, otpCode); + + return { + message: 'Código reenviado.', + requestId, + email: request.email, + expiresAt: otpExpiresAt.toISOString(), + cooldownSeconds: 0, + }; +} diff --git a/apps/backend/src/modules/password-reset/handlers/resend-password-reset.handler.ts b/apps/backend/src/modules/password-reset/features/resend/resend.handler.ts similarity index 77% rename from apps/backend/src/modules/password-reset/handlers/resend-password-reset.handler.ts rename to apps/backend/src/modules/password-reset/features/resend/resend.handler.ts index 4a9b071..231c35e 100644 --- a/apps/backend/src/modules/password-reset/handlers/resend-password-reset.handler.ts +++ b/apps/backend/src/modules/password-reset/features/resend/resend.handler.ts @@ -1,10 +1,9 @@ -import { resendPasswordResetOtp } from '@/modules/password-reset/services/password-reset.service'; import type { AppContext } from '@/types/hono'; import type { PasswordResetResendInput } from '@repo/api-contract'; +import { resendPasswordResetOtp } from './resend.business'; export async function resendPasswordResetHandler(c: AppContext) { const payload = c.req.valid('json' as never) as PasswordResetResendInput; - const result = await resendPasswordResetOtp(payload); return c.json(result, 202); } diff --git a/apps/backend/src/modules/password-reset/features/start/start.business.ts b/apps/backend/src/modules/password-reset/features/start/start.business.ts new file mode 100644 index 0000000..64f2382 --- /dev/null +++ b/apps/backend/src/modules/password-reset/features/start/start.business.ts @@ -0,0 +1,138 @@ +import { db } from '@/lib/prisma'; +import type { PasswordResetStartInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { PasswordResetError } from '../../shared/errors'; +import { + OTP_RESEND_COOLDOWN_SECONDS, + OTP_TTL_MINUTES, + createOtpCode, + getCooldownSeconds, + hashValue, + normalizeEmail, + nowPlusMinutes, + sendResetOtpEmail, +} from '../../shared/helpers'; + +export type PasswordResetStartResult = { + message: string; + requestId: string; + email: string; + expiresAt: string; + cooldownSeconds: number; +}; + +export async function startPasswordReset( + input: PasswordResetStartInput +): Promise { + const email = normalizeEmail(input.email); + + const existingUser = await db.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + if (!existingUser) { + return { + message: 'Si el email existe, recibirás un código de verificación.', + requestId: uuidv7(), + email, + expiresAt: new Date().toISOString(), + cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS, + }; + } + + const existingRequest = await db.passwordResetRequest.findFirst({ + where: { email, usedAt: null }, + orderBy: { createdAt: 'desc' }, + }); + + let requestId: string; + let otpCode: string; + let otpHash: string; + let otpExpiresAt: Date; + let cooldownSeconds = 0; + + if (existingRequest) { + requestId = existingRequest.id; + otpExpiresAt = new Date(existingRequest.otpExpiresAt); + + if (new Date() > otpExpiresAt) { + otpCode = createOtpCode(); + otpHash = hashValue(otpCode); + otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); + + await db.passwordResetRequest.update({ + where: { id: requestId }, + data: { + otpHash, + otpExpiresAt, + otpAttempts: 0, + otpLastSentAt: new Date(), + otpResendCount: { increment: 1 }, + }, + }); + } else { + cooldownSeconds = getCooldownSeconds(new Date(existingRequest.otpLastSentAt)); + + if (cooldownSeconds > 0) { + return { + message: `Debes esperar ${cooldownSeconds} segundos antes de reenviar.`, + requestId, + email, + expiresAt: otpExpiresAt.toISOString(), + cooldownSeconds, + }; + } + + otpCode = createOtpCode(); + otpHash = hashValue(otpCode); + otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); + + await db.passwordResetRequest.update({ + where: { id: requestId }, + data: { + otpHash, + otpExpiresAt, + otpAttempts: 0, + otpLastSentAt: new Date(), + otpResendCount: { increment: 1 }, + }, + }); + + await sendResetOtpEmail(email, otpCode); + + return { + message: 'Código reenviado.', + requestId, + email, + expiresAt: otpExpiresAt.toISOString(), + cooldownSeconds: 0, + }; + } + } else { + requestId = uuidv7(); + otpCode = createOtpCode(); + otpHash = hashValue(otpCode); + otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); + + await db.passwordResetRequest.create({ + data: { + id: requestId, + email, + otpHash, + otpExpiresAt, + otpLastSentAt: new Date(), + }, + }); + } + + await sendResetOtpEmail(email, otpCode); + + return { + message: 'Código enviado successfully.', + requestId, + email, + expiresAt: otpExpiresAt.toISOString(), + cooldownSeconds: 0, + }; +} diff --git a/apps/backend/src/modules/password-reset/handlers/start-password-reset.handler.ts b/apps/backend/src/modules/password-reset/features/start/start.handler.ts similarity index 77% rename from apps/backend/src/modules/password-reset/handlers/start-password-reset.handler.ts rename to apps/backend/src/modules/password-reset/features/start/start.handler.ts index 08569af..6ecd33b 100644 --- a/apps/backend/src/modules/password-reset/handlers/start-password-reset.handler.ts +++ b/apps/backend/src/modules/password-reset/features/start/start.handler.ts @@ -1,10 +1,9 @@ -import { startPasswordReset } from '@/modules/password-reset/services/password-reset.service'; import type { AppContext } from '@/types/hono'; import type { PasswordResetStartInput } from '@repo/api-contract'; +import { startPasswordReset } from './start.business'; export async function startPasswordResetHandler(c: AppContext) { const payload = c.req.valid('json' as never) as PasswordResetStartInput; - const result = await startPasswordReset(payload); return c.json(result, 202); } diff --git a/apps/backend/src/modules/password-reset/features/verify/verify.business.ts b/apps/backend/src/modules/password-reset/features/verify/verify.business.ts new file mode 100644 index 0000000..703f058 --- /dev/null +++ b/apps/backend/src/modules/password-reset/features/verify/verify.business.ts @@ -0,0 +1,98 @@ +import { auth } from '@/lib/auth'; +import { fetchGeoInfo } from '@/lib/geoip'; +import { db } from '@/lib/prisma'; +import type { PasswordResetVerifyInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { PasswordResetError } from '../../shared/errors'; +import { + OTP_TTL_MINUTES, + getRemainingAttempts, + hashValue, + nowPlusMinutes, + sendPasswordChangedEmail, +} from '../../shared/helpers'; + +export type PasswordResetVerifyResult = { + message: string; +}; + +export async function verifyOtpAndResetPassword( + input: PasswordResetVerifyInput, + ip: string | undefined, + userAgent: string | undefined +): Promise { + const { requestId, otp, newPassword } = input; + + const request = await db.passwordResetRequest.findUnique({ + where: { id: requestId }, + }); + + if (!request) { + throw new PasswordResetError('Solicitud inválida.', 404); + } + + if (request.usedAt) { + throw new PasswordResetError('Esta solicitud ya fue utilizada.', 400); + } + + if (new Date() > new Date(request.otpExpiresAt)) { + throw new PasswordResetError('El código ha vencido.', 400); + } + + const remainingAttempts = getRemainingAttempts(request.otpAttempts); + if (remainingAttempts <= 0) { + throw new PasswordResetError('Superaste la cantidad maxima de intentos.', 429); + } + + const otpHash = hashValue(otp); + if (otpHash !== request.otpHash) { + await db.passwordResetRequest.update({ + where: { id: requestId }, + data: { otpAttempts: { increment: 1 } }, + }); + + throw new PasswordResetError('Código incorrecto.', 400); + } + + const existingUser = await db.user.findUnique({ + where: { email: request.email }, + select: { id: true }, + }); + + if (!existingUser) { + throw new PasswordResetError('Usuario no encontrado.', 404); + } + + const resetToken = uuidv7(); + const identifier = `reset-password:${resetToken}`; + const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES); + + await db.verification.create({ + data: { + id: uuidv7(), + identifier, + value: existingUser.id, + expiresAt, + }, + }); + + await auth.api.resetPassword({ + asResponse: false, + body: { + token: resetToken, + newPassword, + }, + }); + + await db.passwordResetRequest.update({ + where: { id: requestId }, + data: { usedAt: new Date() }, + }); + + const geoInfo = await fetchGeoInfo(ip || 'unknown'); + await sendPasswordChangedEmail(request.email, geoInfo, userAgent); + + return { + message: 'Contraseña restablecida correctamente.', + }; +} diff --git a/apps/backend/src/modules/password-reset/handlers/verify-password-reset.handler.ts b/apps/backend/src/modules/password-reset/features/verify/verify.handler.ts similarity index 83% rename from apps/backend/src/modules/password-reset/handlers/verify-password-reset.handler.ts rename to apps/backend/src/modules/password-reset/features/verify/verify.handler.ts index 02ee7af..8528d97 100644 --- a/apps/backend/src/modules/password-reset/handlers/verify-password-reset.handler.ts +++ b/apps/backend/src/modules/password-reset/features/verify/verify.handler.ts @@ -1,6 +1,6 @@ -import { verifyOtpAndResetPassword } from '@/modules/password-reset/services/password-reset.service'; import type { AppContext } from '@/types/hono'; import type { PasswordResetVerifyInput } from '@repo/api-contract'; +import { verifyOtpAndResetPassword } from './verify.business'; export async function verifyPasswordResetHandler(c: AppContext) { const payload = c.req.valid('json' as never) as PasswordResetVerifyInput; diff --git a/apps/backend/src/modules/password-reset/password-reset.routes.ts b/apps/backend/src/modules/password-reset/password-reset.routes.ts index 6aea6e2..0eca9a8 100644 --- a/apps/backend/src/modules/password-reset/password-reset.routes.ts +++ b/apps/backend/src/modules/password-reset/password-reset.routes.ts @@ -1,6 +1,6 @@ -import { resendPasswordResetHandler } from '@/modules/password-reset/handlers/resend-password-reset.handler'; -import { startPasswordResetHandler } from '@/modules/password-reset/handlers/start-password-reset.handler'; -import { verifyPasswordResetHandler } from '@/modules/password-reset/handlers/verify-password-reset.handler'; +import { resendPasswordResetHandler } from '@/modules/password-reset/features/resend/resend.handler'; +import { startPasswordResetHandler } from '@/modules/password-reset/features/start/start.handler'; +import { verifyPasswordResetHandler } from '@/modules/password-reset/features/verify/verify.handler'; import type { AppEnv } from '@/types/hono'; import { zValidator } from '@hono/zod-validator'; import { diff --git a/apps/backend/src/modules/password-reset/services/password-reset.service.ts b/apps/backend/src/modules/password-reset/services/password-reset.service.ts deleted file mode 100644 index 7b0c42e..0000000 --- a/apps/backend/src/modules/password-reset/services/password-reset.service.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { createHash, randomInt } from 'node:crypto'; -import { auth } from '@/lib/auth'; -import { type IpGeoInfo, fetchGeoInfo } from '@/lib/geoip'; -import { sendMail } from '@/lib/mailer'; -import { db } from '@/lib/prisma'; -import type { - PasswordResetResendInput, - PasswordResetStartInput, - PasswordResetVerifyInput, -} from '@repo/api-contract'; -import { v7 as uuidv7 } from 'uuid'; - -const OTP_LENGTH = 6; -const OTP_TTL_MINUTES = Number(Bun.env.PASSWORD_RESET_OTP_TTL_MINUTES ?? 10); -const OTP_MAX_ATTEMPTS = Number(Bun.env.PASSWORD_RESET_OTP_MAX_ATTEMPTS ?? 5); -const OTP_RESEND_COOLDOWN_SECONDS = Number( - Bun.env.PASSWORD_RESET_OTP_RESEND_COOLDOWN_SECONDS ?? 30 -); - -export type PasswordResetStartResult = { - message: string; - requestId: string; - email: string; - expiresAt: string; - cooldownSeconds: number; -}; - -export type PasswordResetVerifyResult = { - message: string; -}; - -export type PasswordResetResendResult = { - message: string; - requestId: string; - email: string; - expiresAt: string; - cooldownSeconds: number; -}; - -export class PasswordResetError extends Error { - status: 400 | 404 | 409 | 429; - - constructor(message: string, status: 400 | 404 | 409 | 429 = 400) { - super(message); - this.name = 'PasswordResetError'; - this.status = status; - } -} - -function nowPlusMinutes(minutes: number): Date { - const date = new Date(); - date.setMinutes(date.getMinutes() + minutes); - return date; -} - -function normalizeEmail(email: string): string { - return email.trim().toLowerCase(); -} - -function createOtpCode(): string { - return randomInt(0, 10 ** OTP_LENGTH) - .toString() - .padStart(OTP_LENGTH, '0'); -} - -function hashValue(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} - -function getRemainingAttempts(otpAttempts: number): number { - return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts); -} - -function getCooldownSeconds(otpLastSentAt: Date): number { - const elapsedMs = Date.now() - otpLastSentAt.getTime(); - const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs; - return Math.max(0, Math.ceil(remainingMs / 1000)); -} - -type UserAgentInfo = { - browser: string; - os: string; - device: string; -}; - -function parseUserAgent(ua: string | undefined): UserAgentInfo { - if (!ua) { - return { browser: 'Desconocido', os: 'Desconocido', device: 'Desconocido' }; - } - - const uaLower = ua.toLowerCase(); - - let browser = 'Desconocido'; - if (uaLower.includes('chrome')) browser = 'Chrome'; - else if (uaLower.includes('firefox')) browser = 'Firefox'; - else if (uaLower.includes('safari')) browser = 'Safari'; - else if (uaLower.includes('edge')) browser = 'Edge'; - else if (uaLower.includes('opera')) browser = 'Opera'; - - let os = 'Desconocido'; - if (uaLower.includes('windows')) os = 'Windows'; - else if (uaLower.includes('mac')) os = 'macOS'; - else if (uaLower.includes('linux')) os = 'Linux'; - else if (uaLower.includes('android')) os = 'Android'; - else if (uaLower.includes('ios') || uaLower.includes('iphone') || uaLower.includes('ipad')) - os = 'iOS'; - - let device = 'Desktop'; - if (uaLower.includes('mobile') || uaLower.includes('android')) device = 'Móvil'; - else if (uaLower.includes('tablet') || uaLower.includes('ipad')) device = 'Tablet'; - - return { browser, os, device }; -} - -async function sendResetOtpEmail(email: string, otpCode: string) { - await sendMail({ - to: email, - subject: 'Código para restablecer tu contraseña', - text: `Tu código OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`, - html: `

Tu código OTP es ${otpCode}.

Vence en ${OTP_TTL_MINUTES} minutos.

`, - }); -} - -async function sendPasswordChangedEmail( - email: string, - ipInfo: IpGeoInfo | null, - userAgent: string | undefined -) { - const deviceInfo = parseUserAgent(userAgent); - const now = new Date(); - const formattedDate = now.toLocaleString('es-AR', { - timeZone: 'America/Argentina/Buenos_Aires', - dateStyle: 'full', - timeStyle: 'short', - }); - - const location = ipInfo - ? `${ipInfo.city}${ipInfo.city !== 'Red local' ? `, ${ipInfo.country}` : ''}` - : 'No disponible'; - const ip = ipInfo?.ip || 'No disponible'; - - await sendMail({ - to: email, - subject: 'Tu contraseña ha sido cambiada', - text: `Tu contraseña fue cambiada el ${formattedDate}. Si no fuiste vos, contactanos inmediatamente.`, - html: ` -

Tu contraseña ha sido cambiada

-

Se ha modificado la contraseña de tu cuenta.

-

Detalles del cambio:

-
    -
  • Fecha y hora: ${formattedDate}
  • -
  • Dirección IP: ${ip}
  • -
  • Ubicación: ${location}
  • -
  • Dispositivo: ${deviceInfo.device} (${deviceInfo.os} - ${deviceInfo.browser})
  • -
-

Si no fuiste vos, contactanos inmediatamente.

- `, - }); -} - -async function findBetterAuthUserByEmail(email: string): Promise<{ id: string } | null> { - const user = await db.user.findUnique({ - where: { email }, - select: { id: true }, - }); - - if (!user) { - return null; - } - - return { id: user.id }; -} - -export async function startPasswordReset( - input: PasswordResetStartInput -): Promise { - const email = normalizeEmail(input.email); - - const existingUser = await findBetterAuthUserByEmail(email); - if (!existingUser) { - return { - message: 'Si el email existe, recibirás un código de verificación.', - requestId: uuidv7(), - email, - expiresAt: new Date().toISOString(), - cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS, - }; - } - - const existingRequest = await db.passwordResetRequest.findFirst({ - where: { email, usedAt: null }, - orderBy: { createdAt: 'desc' }, - }); - - let requestId: string; - let otpCode: string; - let otpHash: string; - let otpExpiresAt: Date; - let cooldownSeconds = 0; - - if (existingRequest) { - requestId = existingRequest.id; - otpExpiresAt = new Date(existingRequest.otpExpiresAt); - - if (new Date() > otpExpiresAt) { - otpCode = createOtpCode(); - otpHash = hashValue(otpCode); - otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); - - await db.passwordResetRequest.update({ - where: { id: requestId }, - data: { - otpHash, - otpExpiresAt, - otpAttempts: 0, - otpLastSentAt: new Date(), - otpResendCount: { increment: 1 }, - }, - }); - } else { - cooldownSeconds = getCooldownSeconds(new Date(existingRequest.otpLastSentAt)); - - if (cooldownSeconds > 0) { - return { - message: `Debes esperar ${cooldownSeconds} segundos antes de reenviar.`, - requestId, - email, - expiresAt: otpExpiresAt.toISOString(), - cooldownSeconds, - }; - } - - otpCode = createOtpCode(); - otpHash = hashValue(otpCode); - otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); - - await db.passwordResetRequest.update({ - where: { id: requestId }, - data: { - otpHash, - otpExpiresAt, - otpAttempts: 0, - otpLastSentAt: new Date(), - otpResendCount: { increment: 1 }, - }, - }); - - await sendResetOtpEmail(email, otpCode); - - return { - message: 'Código reenviado.', - requestId, - email, - expiresAt: otpExpiresAt.toISOString(), - cooldownSeconds: 0, - }; - } - } else { - requestId = uuidv7(); - otpCode = createOtpCode(); - otpHash = hashValue(otpCode); - otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); - - await db.passwordResetRequest.create({ - data: { - id: requestId, - email, - otpHash, - otpExpiresAt, - otpLastSentAt: new Date(), - }, - }); - } - - await sendResetOtpEmail(email, otpCode); - - return { - message: 'Código enviado successfully.', - requestId, - email, - expiresAt: otpExpiresAt.toISOString(), - cooldownSeconds: 0, - }; -} - -export async function verifyOtpAndResetPassword( - input: PasswordResetVerifyInput, - ip: string | undefined, - userAgent: string | undefined -): Promise { - const { requestId, otp, newPassword } = input; - - const request = await db.passwordResetRequest.findUnique({ - where: { id: requestId }, - }); - - if (!request) { - throw new PasswordResetError('Solicitud inválida.', 404); - } - - if (request.usedAt) { - throw new PasswordResetError('Esta solicitud ya fue utilizada.', 400); - } - - if (new Date() > new Date(request.otpExpiresAt)) { - throw new PasswordResetError('El código ha vencido.', 400); - } - - const remainingAttempts = getRemainingAttempts(request.otpAttempts); - if (remainingAttempts <= 0) { - throw new PasswordResetError('Superaste la cantidad maxima de intentos.', 429); - } - - const otpHash = hashValue(otp); - if (otpHash !== request.otpHash) { - await db.passwordResetRequest.update({ - where: { id: requestId }, - data: { otpAttempts: { increment: 1 } }, - }); - - throw new PasswordResetError('Código incorrecto.', 400); - } - - const existingUser = await findBetterAuthUserByEmail(request.email); - if (!existingUser) { - throw new PasswordResetError('Usuario no encontrado.', 404); - } - - const resetToken = uuidv7(); - const identifier = `reset-password:${resetToken}`; - const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES); - - await db.verification.create({ - data: { - id: uuidv7(), - identifier, - value: existingUser.id, - expiresAt, - }, - }); - - await auth.api.resetPassword({ - asResponse: false, - body: { - token: resetToken, - newPassword, - }, - }); - - await db.passwordResetRequest.update({ - where: { id: requestId }, - data: { usedAt: new Date() }, - }); - - const geoInfo = await fetchGeoInfo(ip || 'unknown'); - await sendPasswordChangedEmail(request.email, geoInfo, userAgent); - - return { - message: 'Contraseña restablecida correctamente.', - }; -} - -export async function resendPasswordResetOtp( - input: PasswordResetResendInput -): Promise { - const { requestId } = input; - - const request = await db.passwordResetRequest.findUnique({ - where: { id: requestId }, - }); - - if (!request) { - throw new PasswordResetError('Solicitud inválida.', 404); - } - - if (request.usedAt) { - throw new PasswordResetError('Esta solicitud ya fue utilizada.', 400); - } - - const cooldownSeconds = getCooldownSeconds(new Date(request.otpLastSentAt)); - if (cooldownSeconds > 0) { - throw new PasswordResetError( - `Debes esperar ${cooldownSeconds} segundos antes de reenviar.`, - 429 - ); - } - - const otpCode = createOtpCode(); - const otpHash = hashValue(otpCode); - const otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); - - await db.passwordResetRequest.update({ - where: { id: requestId }, - data: { - otpHash, - otpExpiresAt, - otpAttempts: 0, - otpLastSentAt: new Date(), - otpResendCount: { increment: 1 }, - }, - }); - - await sendResetOtpEmail(request.email, otpCode); - - return { - message: 'Código reenviado.', - requestId, - email: request.email, - expiresAt: otpExpiresAt.toISOString(), - cooldownSeconds: 0, - }; -} diff --git a/apps/backend/src/modules/password-reset/shared/errors.ts b/apps/backend/src/modules/password-reset/shared/errors.ts new file mode 100644 index 0000000..578c4f5 --- /dev/null +++ b/apps/backend/src/modules/password-reset/shared/errors.ts @@ -0,0 +1,9 @@ +export class PasswordResetError extends Error { + status: 400 | 404 | 409 | 429; + + constructor(message: string, status: 400 | 404 | 409 | 429 = 400) { + super(message); + this.name = 'PasswordResetError'; + this.status = status; + } +} diff --git a/apps/backend/src/modules/password-reset/shared/helpers.ts b/apps/backend/src/modules/password-reset/shared/helpers.ts new file mode 100644 index 0000000..c73ad4b --- /dev/null +++ b/apps/backend/src/modules/password-reset/shared/helpers.ts @@ -0,0 +1,121 @@ +import { createHash, randomInt } from 'node:crypto'; +import { type IpGeoInfo, fetchGeoInfo } from '@/lib/geoip'; +import { sendMail } from '@/lib/mailer'; + +export const OTP_LENGTH = 6; +export const OTP_TTL_MINUTES = Number(Bun.env.PASSWORD_RESET_OTP_TTL_MINUTES ?? 10); +export const OTP_MAX_ATTEMPTS = Number(Bun.env.PASSWORD_RESET_OTP_MAX_ATTEMPTS ?? 5); +export const OTP_RESEND_COOLDOWN_SECONDS = Number( + Bun.env.PASSWORD_RESET_OTP_RESEND_COOLDOWN_SECONDS ?? 30 +); + +export function nowPlusMinutes(minutes: number): Date { + const date = new Date(); + date.setMinutes(date.getMinutes() + minutes); + return date; +} + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function createOtpCode(): string { + return randomInt(0, 10 ** OTP_LENGTH) + .toString() + .padStart(OTP_LENGTH, '0'); +} + +export function hashValue(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export function getRemainingAttempts(otpAttempts: number): number { + return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts); +} + +export function getCooldownSeconds(otpLastSentAt: Date): number { + const elapsedMs = Date.now() - otpLastSentAt.getTime(); + const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs; + return Math.max(0, Math.ceil(remainingMs / 1000)); +} + +export async function sendResetOtpEmail(email: string, otpCode: string) { + await sendMail({ + to: email, + subject: 'Código para restablecer tu contraseña', + text: `Tu código OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`, + html: `

Tu código OTP es ${otpCode}.

Vence en ${OTP_TTL_MINUTES} minutos.

`, + }); +} + +type UserAgentInfo = { + browser: string; + os: string; + device: string; +}; + +function parseUserAgent(ua: string | undefined): UserAgentInfo { + if (!ua) { + return { browser: 'Desconocido', os: 'Desconocido', device: 'Desconocido' }; + } + + const uaLower = ua.toLowerCase(); + + let browser = 'Desconocido'; + if (uaLower.includes('chrome')) browser = 'Chrome'; + else if (uaLower.includes('firefox')) browser = 'Firefox'; + else if (uaLower.includes('safari')) browser = 'Safari'; + else if (uaLower.includes('edge')) browser = 'Edge'; + else if (uaLower.includes('opera')) browser = 'Opera'; + + let os = 'Desconocido'; + if (uaLower.includes('windows')) os = 'Windows'; + else if (uaLower.includes('mac')) os = 'macOS'; + else if (uaLower.includes('linux')) os = 'Linux'; + else if (uaLower.includes('android')) os = 'Android'; + else if (uaLower.includes('ios') || uaLower.includes('iphone') || uaLower.includes('ipad')) + os = 'iOS'; + + let device = 'Desktop'; + if (uaLower.includes('mobile') || uaLower.includes('android')) device = 'Móvil'; + else if (uaLower.includes('tablet') || uaLower.includes('ipad')) device = 'Tablet'; + + return { browser, os, device }; +} + +export async function sendPasswordChangedEmail( + email: string, + ipInfo: IpGeoInfo | null, + userAgent: string | undefined +) { + const deviceInfo = parseUserAgent(userAgent); + const now = new Date(); + const formattedDate = now.toLocaleString('es-AR', { + timeZone: 'America/Argentina/Buenos_Aires', + dateStyle: 'full', + timeStyle: 'short', + }); + + const location = ipInfo + ? `${ipInfo.city}${ipInfo.city !== 'Red local' ? `, ${ipInfo.country}` : ''}` + : 'No disponible'; + const ip = ipInfo?.ip || 'No disponible'; + + await sendMail({ + to: email, + subject: 'Tu contraseña ha sido cambiada', + text: `Tu contraseña fue cambiada el ${formattedDate}. Si no fuiste vos, contactanos inmediatamente.`, + html: ` +

Tu contraseña ha sido cambiada

+

Se ha modificado la contraseña de tu cuenta.

+

Detalles del cambio:

+
    +
  • Fecha y hora: ${formattedDate}
  • +
  • Dirección IP: ${ip}
  • +
  • Ubicación: ${location}
  • +
  • Dispositivo: ${deviceInfo.device} (${deviceInfo.os} - ${deviceInfo.browser})
  • +
+

Si no fuiste vos, contactanos inmediatamente.

+ `, + }); +} diff --git a/apps/backend/src/modules/plan/features/list-plans/list-plans.business.ts b/apps/backend/src/modules/plan/features/list-plans/list-plans.business.ts new file mode 100644 index 0000000..6a0cc7a --- /dev/null +++ b/apps/backend/src/modules/plan/features/list-plans/list-plans.business.ts @@ -0,0 +1,24 @@ +import { db } from '@/lib/prisma'; +import { resolvePlanPrice } from '@/modules/plan/services/plan-pricing.service'; +import { parsePlanRules } from '@/modules/plan/services/plan-rules.service'; + +export async function listPlans(countryCode?: string) { + const plans = await db.plan.findMany({ + orderBy: { + price: 'asc', + }, + }); + + return plans.map((plan) => { + const rules = parsePlanRules(plan.rules); + const resolved = resolvePlanPrice(Number(plan.price), rules, countryCode); + return { + code: plan.code, + name: plan.name, + price: resolved.amount, + currency: resolved.currency, + features: rules.features, + limits: rules.limits, + }; + }); +} diff --git a/apps/backend/src/modules/plan/features/list-plans/list-plans.handler.ts b/apps/backend/src/modules/plan/features/list-plans/list-plans.handler.ts new file mode 100644 index 0000000..d36b5f7 --- /dev/null +++ b/apps/backend/src/modules/plan/features/list-plans/list-plans.handler.ts @@ -0,0 +1,19 @@ +import { fetchGeoInfo } from '@/lib/geoip'; +import type { AppContext } from '@/types/hono'; +import { listPlans } from './list-plans.business'; + +export async function listPlansHandler(c: AppContext) { + const countryParam = c.req.query('country'); + let countryCode: string | undefined; + + if (countryParam) { + countryCode = countryParam; + } else { + const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1'; + const geo = await fetchGeoInfo(ip); + countryCode = geo?.countryCode || undefined; + } + + const plans = await listPlans(countryCode); + return c.json(plans); +} diff --git a/apps/backend/src/modules/plan/handlers/list-plans.handler.ts b/apps/backend/src/modules/plan/handlers/list-plans.handler.ts deleted file mode 100644 index fe2738b..0000000 --- a/apps/backend/src/modules/plan/handlers/list-plans.handler.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { fetchGeoInfo } from '@/lib/geoip'; -import { db } from '@/lib/prisma'; -import { resolvePlanPrice } from '@/modules/plan/services/plan-pricing.service'; -import { parsePlanRules } from '@/modules/plan/services/plan-rules.service'; -import type { AppContext } from '@/types/hono'; - -export async function listPlansHandler(c: AppContext) { - const plans = await db.plan.findMany({ - orderBy: { - price: 'asc', - }, - }); - - const countryParam = c.req.query('country'); - let countryCode: string | undefined; - - if (countryParam) { - countryCode = countryParam; - } else { - const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1'; - const geo = await fetchGeoInfo(ip); - countryCode = geo?.countryCode || undefined; - } - - return c.json( - plans.map((plan) => { - const rules = parsePlanRules(plan.rules); - const resolved = resolvePlanPrice(Number(plan.price), rules, countryCode); - return { - code: plan.code, - name: plan.name, - price: resolved.amount, - currency: resolved.currency, - features: rules.features, - limits: rules.limits, - }; - }) - ); -} diff --git a/apps/backend/src/modules/plan/plan.routes.ts b/apps/backend/src/modules/plan/plan.routes.ts index 5bf26dd..c15d476 100644 --- a/apps/backend/src/modules/plan/plan.routes.ts +++ b/apps/backend/src/modules/plan/plan.routes.ts @@ -1,4 +1,4 @@ -import { listPlansHandler } from '@/modules/plan/handlers/list-plans.handler'; +import { listPlansHandler } from '@/modules/plan/features/list-plans/list-plans.handler'; import type { AppEnv } from '@/types/hono'; import { Hono } from 'hono'; diff --git a/apps/backend/src/modules/public-booking/features/cancel-public-booking/cancel-public-booking.business.ts b/apps/backend/src/modules/public-booking/features/cancel-public-booking/cancel-public-booking.business.ts new file mode 100644 index 0000000..6fb4177 --- /dev/null +++ b/apps/backend/src/modules/public-booking/features/cancel-public-booking/cancel-public-booking.business.ts @@ -0,0 +1,107 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import type { CancelPublicBookingInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { + formatIsoDate, + mapBookingResponse, + parseIsoDate, + resolveSlotPrice, +} from '../../shared/helpers'; + +export async function cancelPublicBooking( + complexSlug: string, + input: CancelPublicBookingInput +): Promise>> { + const normalizedBookingCode = input.bookingCode.toUpperCase(); + const booking = await db.courtBooking.findFirst({ + where: { + bookingCode: normalizedBookingCode, + court: { complex: { complexSlug } }, + }, + include: { + court: { + select: { + id: true, + name: true, + basePrice: true, + priceRules: { + where: { isActive: true }, + orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], + }, + sport: { + select: { id: true, name: true, slug: true }, + }, + complex: { + select: { id: true, complexName: true, complexSlug: true }, + }, + }, + }, + }, + }); + + if (!booking) { + return err(Errors.notFound('Reserva no encontrada.')); + } + + if (booking.status !== 'CONFIRMED') { + return err(Errors.conflict('Solo se pueden cancelar reservas en estado confirmada.')); + } + + if (booking.customerPhone !== input.customerPhone.trim()) { + return err(Errors.validation('Los datos ingresados no coinciden con la reserva.')); + } + + const date = formatIsoDate(booking.bookingDate); + const dateResult = parseIsoDate(date); + if (!dateResult.ok) return dateResult; + const { dayOfWeek } = dateResult.value; + + const price = resolveSlotPrice(booking.court, dayOfWeek, { + startTime: booking.startTime, + endTime: booking.endTime, + }); + + await db.courtBookingLog.create({ + data: { + id: uuidv7(), + bookingCode: booking.bookingCode, + courtId: booking.courtId, + bookingDate: booking.bookingDate, + startTime: booking.startTime, + endTime: booking.endTime, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + customerEmail: booking.customerEmail, + previousStatus: booking.status, + newStatus: 'CANCELLED', + }, + }); + + await db.courtBooking.delete({ where: { id: booking.id } }); + + return ok( + mapBookingResponse({ + bookingId: booking.id, + bookingCode: booking.bookingCode, + bookingDate: booking.bookingDate, + createdAt: booking.createdAt, + startTime: booking.startTime, + endTime: booking.endTime, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + customerEmail: booking.customerEmail, + price, + status: 'CANCELLED', + court: { + id: booking.court.id, + name: booking.court.name, + complexId: booking.court.complex.id, + complexName: booking.court.complex.complexName, + complexSlug: booking.court.complex.complexSlug, + sport: booking.court.sport, + }, + }) + ); +} diff --git a/apps/backend/src/modules/public-booking/handlers/cancel-public-booking.handler.ts b/apps/backend/src/modules/public-booking/features/cancel-public-booking/cancel-public-booking.handler.ts similarity index 76% rename from apps/backend/src/modules/public-booking/handlers/cancel-public-booking.handler.ts rename to apps/backend/src/modules/public-booking/features/cancel-public-booking/cancel-public-booking.handler.ts index 38edb31..b32c1dd 100644 --- a/apps/backend/src/modules/public-booking/handlers/cancel-public-booking.handler.ts +++ b/apps/backend/src/modules/public-booking/features/cancel-public-booking/cancel-public-booking.handler.ts @@ -1,11 +1,9 @@ +import { handleResult } from '@/lib/http/handle-result'; import { sseManager } from '@/lib/sse'; -import { - PublicBookingServiceError, - cancelPublicBooking, -} from '@/modules/public-booking/services/public-booking.service'; import { sendBookingCancelled } from '@/services/booking-email.service'; import type { AppContext } from '@/types/hono'; import type { CancelPublicBookingInput } from '@repo/api-contract'; +import { cancelPublicBooking } from './cancel-public-booking.business'; type ComplexSlugParams = { complexSlug: string }; @@ -13,9 +11,10 @@ export async function cancelPublicBookingHandler(c: AppContext) { const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams; const payload = c.req.valid('json' as never) as CancelPublicBookingInput; - try { - const booking = await cancelPublicBooking(complexSlug, payload); + const result = await cancelPublicBooking(complexSlug, payload); + if (result.ok) { + const booking = result.value; const channel = `complex-${booking.complexId}`; sseManager.emit( channel, @@ -43,13 +42,7 @@ export async function cancelPublicBookingHandler(c: AppContext) { customerName: booking.customerName, customerEmail: booking.customerEmail, }); - - return c.json(booking); - } catch (error) { - if (error instanceof PublicBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - - throw error; } + + return handleResult(c, result); } diff --git a/apps/backend/src/modules/public-booking/features/create-public-booking/create-public-booking.business.ts b/apps/backend/src/modules/public-booking/features/create-public-booking/create-public-booking.business.ts new file mode 100644 index 0000000..55f4132 --- /dev/null +++ b/apps/backend/src/modules/public-booking/features/create-public-booking/create-public-booking.business.ts @@ -0,0 +1,216 @@ +import { randomInt } from 'node:crypto'; +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import { isSlotInPast } from '@/lib/slot-validator'; +import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; +import type { CreatePublicBookingInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; +import { PublicBookingServiceError } from '../../shared/errors'; +import { + buildSlots, + getComplexWithBookingData, + hasVerifiedAdmin, + mapBookingResponse, + minutesToTime, + parseIsoDate, + resolveSlotPrice, + resolveSports, + toMinutes, +} from '../../shared/helpers'; + +export async function createPublicBooking( + complexSlug: string, + input: CreatePublicBookingInput +): Promise>> { + const dateResult = parseIsoDate(input.date); + if (!dateResult.ok) return dateResult; + const { bookingDate, dayOfWeek } = dateResult.value; + + const complexResult = await getComplexWithBookingData(complexSlug); + if (!complexResult.ok) return complexResult; + const complex = complexResult.value; + + if (!(await hasVerifiedAdmin(complex.id))) { + return err( + Errors.forbidden( + 'El complejo no puede recibir reservas hasta que un administrador verifique su email.' + ) + ); + } + + const sports = resolveSports(complex); + const sportSelectionRequired = sports.length > 1; + + if (sportSelectionRequired && !input.sportId) { + return err(Errors.validation('Debes seleccionar un deporte para reservar en este complejo.')); + } + + const selectedCourt = complex.courts.find( + (court) => court.id === input.courtId && court.sport.isActive + ); + + if (!selectedCourt) { + return err(Errors.notFound('La cancha seleccionada no existe en el complejo.')); + } + + if (input.sportId && input.sportId !== selectedCourt.sportId) { + return err(Errors.validation('La cancha seleccionada no corresponde al deporte indicado.')); + } + + const dayAvailability = selectedCourt.availabilities.filter( + (avail) => avail.dayOfWeek === dayOfWeek + ); + + const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes); + const selectedEndMinutes = toMinutes(input.startTime) + selectedCourt.slotDurationMinutes; + + const selectedSlot = { + startTime: input.startTime, + endTime: minutesToTime(selectedEndMinutes), + }; + + const slotExists = validSlots.some( + (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime + ); + + if (!slotExists) { + return err(Errors.conflict('El horario seleccionado no esta disponible para esa cancha.')); + } + + if (isSlotInPast(bookingDate, input.startTime, selectedCourt.slotDurationMinutes)) { + return err(Errors.validation('No se pueden crear reservas en el pasado.')); + } + + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const booking = await db.$transaction(async (tx) => { + if (complex.plan) { + const rules = parsePlanRules(complex.plan.rules); + const bookingsForDate = await tx.courtBooking.count({ + where: { + bookingDate, + status: 'CONFIRMED', + court: { complexId: complex.id }, + }, + }); + + const violations = evaluatePlanUsage(rules, { + courtsCount: complex.courts.length, + bookingsToday: bookingsForDate, + }); + + const maxBookingsViolation = violations.find( + (v) => v.code === 'MAX_BOOKINGS_PER_DAY_REACHED' + ); + + if (maxBookingsViolation) { + throw new PublicBookingServiceError(maxBookingsViolation.message); + } + } + + const overlappingBooking = await tx.courtBooking.findFirst({ + where: { + courtId: selectedCourt.id, + bookingDate, + status: 'CONFIRMED', + startTime: { lt: selectedSlot.endTime }, + endTime: { gt: selectedSlot.startTime }, + }, + }); + + if (overlappingBooking) { + throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.'); + } + + return tx.courtBooking.create({ + data: { + id: uuidv7(), + bookingCode: generateBookingCode(), + courtId: selectedCourt.id, + bookingDate, + startTime: selectedSlot.startTime, + endTime: selectedSlot.endTime, + customerName: input.customerName.trim(), + customerPhone: input.customerPhone.trim(), + customerEmail: input.customerEmail.trim(), + status: 'CONFIRMED', + }, + select: { + id: true, + bookingCode: true, + bookingDate: true, + createdAt: true, + startTime: true, + endTime: true, + customerName: true, + customerPhone: true, + customerEmail: true, + status: true, + }, + }); + }); + + const price = resolveSlotPrice(selectedCourt, dayOfWeek, selectedSlot); + + return ok( + mapBookingResponse({ + bookingId: booking.id, + bookingCode: booking.bookingCode, + bookingDate: booking.bookingDate, + createdAt: booking.createdAt, + startTime: booking.startTime, + endTime: booking.endTime, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + customerEmail: booking.customerEmail, + price, + status: booking.status, + court: { + id: selectedCourt.id, + name: selectedCourt.name, + complexId: complex.id, + complexName: complex.complexName, + complexSlug: complex.complexSlug, + sport: { + id: selectedCourt.sport.id, + name: selectedCourt.sport.name, + slug: selectedCourt.sport.slug, + }, + }, + }) + ); + } catch (error) { + if (error instanceof PublicBookingServiceError) { + return err(Errors.conflict(error.message)); + } + + const prismaError = error as { code?: string; meta?: { target?: string[] | string } }; + if (prismaError.code === 'P2002') { + const targets = Array.isArray(prismaError.meta?.target) + ? prismaError.meta?.target + : [prismaError.meta?.target]; + const isBookingCodeCollision = targets.some((target) => + String(target).includes('booking_code') + ); + + if (isBookingCodeCollision) continue; + return err(Errors.conflict('El horario seleccionado ya fue reservado.')); + } + + throw error; + } + } + + return err(Errors.conflict('No se pudo generar un codigo de reserva unico. Intenta nuevamente.')); +} + +function generateBookingCode(): string { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + const length = 6; + let code = ''; + for (let i = 0; i < length; i++) { + code += alphabet[randomInt(0, alphabet.length)]; + } + return code; +} diff --git a/apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts b/apps/backend/src/modules/public-booking/features/create-public-booking/create-public-booking.handler.ts similarity index 76% rename from apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts rename to apps/backend/src/modules/public-booking/features/create-public-booking/create-public-booking.handler.ts index 72c6765..a1eaa5d 100644 --- a/apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts +++ b/apps/backend/src/modules/public-booking/features/create-public-booking/create-public-booking.handler.ts @@ -1,11 +1,9 @@ +import { handleResult } from '@/lib/http/handle-result'; import { sseManager } from '@/lib/sse'; -import { - PublicBookingServiceError, - createPublicBooking, -} from '@/modules/public-booking/services/public-booking.service'; import { sendBookingConfirmation } from '@/services/booking-email.service'; import type { AppContext } from '@/types/hono'; import type { CreatePublicBookingInput } from '@repo/api-contract'; +import { createPublicBooking } from './create-public-booking.business'; type ComplexSlugParams = { complexSlug: string }; @@ -13,9 +11,10 @@ export async function createPublicBookingHandler(c: AppContext) { const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams; const payload = c.req.valid('json' as never) as CreatePublicBookingInput; - try { - const booking = await createPublicBooking(complexSlug, payload); + const result = await createPublicBooking(complexSlug, payload); + if (result.ok) { + const booking = result.value; const channel = `complex-${booking.complexId}`; sseManager.emit( channel, @@ -44,13 +43,7 @@ export async function createPublicBookingHandler(c: AppContext) { customerEmail: booking.customerEmail, price: booking.price, }); - - return c.json(booking, 201); - } catch (error) { - if (error instanceof PublicBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - - throw error; } + + return handleResult(c, result, 201); } diff --git a/apps/backend/src/modules/public-booking/features/get-public-booking-confirmation/get-public-booking-confirmation.business.ts b/apps/backend/src/modules/public-booking/features/get-public-booking-confirmation/get-public-booking-confirmation.business.ts new file mode 100644 index 0000000..b50cadd --- /dev/null +++ b/apps/backend/src/modules/public-booking/features/get-public-booking-confirmation/get-public-booking-confirmation.business.ts @@ -0,0 +1,92 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import { formatIsoDate, parseIsoDate, resolveSlotPrice } from '../../shared/helpers'; + +export async function getPublicBookingConfirmation( + complexSlug: string, + bookingCode: string +): Promise> { + const normalizedBookingCode = bookingCode.toUpperCase(); + const booking = await db.courtBooking.findFirst({ + where: { + bookingCode: normalizedBookingCode, + court: { complex: { complexSlug } }, + }, + select: { + bookingCode: true, + bookingDate: true, + startTime: true, + endTime: true, + customerEmail: true, + status: true, + createdAt: true, + court: { + select: { + name: true, + basePrice: true, + priceRules: { + where: { isActive: true }, + orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], + }, + sport: { + select: { id: true, name: true, slug: true }, + }, + complex: { + select: { complexName: true, physicalAddress: true, complexSlug: true }, + }, + }, + }, + }, + }); + + if (!booking) { + return err(Errors.notFound('Reserva no encontrada.')); + } + + const date = formatIsoDate(booking.bookingDate); + const dateResult = parseIsoDate(date); + if (!dateResult.ok) return dateResult; + const { dayOfWeek } = dateResult.value; + + const price = resolveSlotPrice(booking.court, dayOfWeek, { + startTime: booking.startTime, + endTime: booking.endTime, + }); + + return ok({ + bookingCode: booking.bookingCode, + complexName: booking.court.complex.complexName, + complexAddress: booking.court.complex.physicalAddress ?? undefined, + complexSlug: booking.court.complex.complexSlug, + date, + startTime: booking.startTime, + endTime: booking.endTime, + price, + courtName: booking.court.name, + sport: { + id: booking.court.sport.id, + name: booking.court.sport.name, + slug: booking.court.sport.slug, + }, + status: booking.status, + customerEmail: booking.customerEmail, + createdAt: booking.createdAt.toISOString(), + }); +} + +type ConfirmationResponse = { + bookingCode: string; + complexName: string; + complexAddress: string | undefined; + complexSlug: string; + date: string; + startTime: string; + endTime: string; + price: number; + courtName: string; + sport: { id: string; name: string; slug: string }; + status: string; + customerEmail: string; + createdAt: string; +}; diff --git a/apps/backend/src/modules/public-booking/features/get-public-booking-confirmation/get-public-booking-confirmation.handler.ts b/apps/backend/src/modules/public-booking/features/get-public-booking-confirmation/get-public-booking-confirmation.handler.ts new file mode 100644 index 0000000..87d4803 --- /dev/null +++ b/apps/backend/src/modules/public-booking/features/get-public-booking-confirmation/get-public-booking-confirmation.handler.ts @@ -0,0 +1,11 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import { getPublicBookingConfirmation } from './get-public-booking-confirmation.business'; + +type BookingCodeParams = { complexSlug: string; bookingCode: string }; + +export async function getPublicBookingConfirmationHandler(c: AppContext) { + const { complexSlug, bookingCode } = c.req.valid('param' as never) as BookingCodeParams; + + return handleResult(c, await getPublicBookingConfirmation(complexSlug, bookingCode)); +} diff --git a/apps/backend/src/modules/public-booking/features/list-public-availability/list-public-availability.business.ts b/apps/backend/src/modules/public-booking/features/list-public-availability/list-public-availability.business.ts new file mode 100644 index 0000000..10f55a0 --- /dev/null +++ b/apps/backend/src/modules/public-booking/features/list-public-availability/list-public-availability.business.ts @@ -0,0 +1,131 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import type { PublicAvailabilityQuery } from '@repo/api-contract'; +import { + buildSlots, + getComplexWithBookingData, + hasOverlap, + hasVerifiedAdmin, + parseIsoDate, + resolveSlotPrice, + resolveSports, +} from '../../shared/helpers'; +import type { Slot } from '../../shared/helpers'; + +export async function listPublicAvailability( + complexSlug: string, + query: PublicAvailabilityQuery +): Promise> { + const dateResult = parseIsoDate(query.date); + if (!dateResult.ok) return dateResult; + const { bookingDate, dayOfWeek } = dateResult.value; + + const complexResult = await getComplexWithBookingData(complexSlug); + if (!complexResult.ok) return complexResult; + const complex = complexResult.value; + + if (!(await hasVerifiedAdmin(complex.id))) { + return ok({ + complexId: complex.id, + complexName: complex.complexName, + complexAddress: complex.physicalAddress ?? null, + complexSlug: complex.complexSlug, + date: query.date, + sportSelectionRequired: false, + sports: [], + courts: [], + }); + } + + const sports = resolveSports(complex); + const sportSelectionRequired = sports.length > 1; + + if (query.sportId && !sports.some((sport) => sport.id === query.sportId)) { + return err(Errors.validation('El deporte seleccionado no pertenece al complejo.')); + } + + const selectedSportId = sportSelectionRequired ? query.sportId : (query.sportId ?? sports[0]?.id); + + const candidateCourts = complex.courts.filter((court) => { + if (!court.sport.isActive) return false; + if (selectedSportId) return court.sportId === selectedSportId; + return true; + }); + + const courtIds = candidateCourts.map((court) => court.id); + + const bookings = + courtIds.length > 0 + ? await db.courtBooking.findMany({ + where: { + bookingDate, + status: 'CONFIRMED', + courtId: { in: courtIds }, + }, + select: { courtId: true, startTime: true, endTime: true }, + }) + : []; + + const bookingsByCourt = new Map(); + + for (const booking of bookings) { + const current = bookingsByCourt.get(booking.courtId) ?? []; + current.push({ startTime: booking.startTime, endTime: booking.endTime }); + bookingsByCourt.set(booking.courtId, current); + } + + const courts = candidateCourts + .map((court) => { + const dayAvailability = court.availabilities.filter((avail) => avail.dayOfWeek === dayOfWeek); + + const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes); + const occupiedSlots = bookingsByCourt.get(court.id) ?? []; + + const availableSlots = allSlots.filter( + (slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied)) + ); + + return { + courtId: court.id, + courtName: court.name, + sport: { id: court.sport.id, name: court.sport.name, slug: court.sport.slug }, + slotDurationMinutes: court.slotDurationMinutes, + availabilityDay: dayOfWeek, + availableSlots: availableSlots.map((slot) => ({ + ...slot, + price: resolveSlotPrice(court, dayOfWeek, slot), + })), + }; + }) + .filter((court) => court.availableSlots.length > 0); + + return ok({ + complexId: complex.id, + complexName: complex.complexName, + complexAddress: complex.physicalAddress ?? null, + complexSlug: complex.complexSlug, + date: query.date, + sportSelectionRequired, + sports, + courts, + }); +} + +type AvailabilityResponse = { + complexId: string; + complexName: string; + complexAddress: string | null; + complexSlug: string; + date: string; + sportSelectionRequired: boolean; + sports: Array<{ id: string; name: string; slug: string }>; + courts: Array<{ + courtId: string; + courtName: string; + sport: { id: string; name: string; slug: string }; + slotDurationMinutes: number; + availabilityDay: string; + availableSlots: Array<{ startTime: string; endTime: string; price: number }>; + }>; +}; diff --git a/apps/backend/src/modules/public-booking/features/list-public-availability/list-public-availability.handler.ts b/apps/backend/src/modules/public-booking/features/list-public-availability/list-public-availability.handler.ts new file mode 100644 index 0000000..acd5181 --- /dev/null +++ b/apps/backend/src/modules/public-booking/features/list-public-availability/list-public-availability.handler.ts @@ -0,0 +1,13 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import type { PublicAvailabilityQuery } from '@repo/api-contract'; +import { listPublicAvailability } from './list-public-availability.business'; + +type ComplexSlugParams = { complexSlug: string }; + +export async function listPublicAvailabilityHandler(c: AppContext) { + const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams; + const query = c.req.valid('query' as never) as PublicAvailabilityQuery; + + return handleResult(c, await listPublicAvailability(complexSlug, query)); +} diff --git a/apps/backend/src/modules/public-booking/handlers/get-public-booking-confirmation.handler.ts b/apps/backend/src/modules/public-booking/handlers/get-public-booking-confirmation.handler.ts deleted file mode 100644 index 3911cc7..0000000 --- a/apps/backend/src/modules/public-booking/handlers/get-public-booking-confirmation.handler.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - PublicBookingServiceError, - getPublicBookingConfirmation, -} from '@/modules/public-booking/services/public-booking.service'; -import type { AppContext } from '@/types/hono'; - -type BookingCodeParams = { - complexSlug: string; - bookingCode: string; -}; - -export async function getPublicBookingConfirmationHandler(c: AppContext) { - const { complexSlug, bookingCode } = c.req.valid('param' as never) as BookingCodeParams; - - try { - const booking = await getPublicBookingConfirmation(complexSlug, bookingCode); - return c.json(booking); - } catch (error) { - if (error instanceof PublicBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - - throw error; - } -} diff --git a/apps/backend/src/modules/public-booking/handlers/list-public-availability.handler.ts b/apps/backend/src/modules/public-booking/handlers/list-public-availability.handler.ts deleted file mode 100644 index 8ac4490..0000000 --- a/apps/backend/src/modules/public-booking/handlers/list-public-availability.handler.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - PublicBookingServiceError, - listPublicAvailability, -} from '@/modules/public-booking/services/public-booking.service'; -import type { AppContext } from '@/types/hono'; -import type { PublicAvailabilityQuery } from '@repo/api-contract'; - -type ComplexSlugParams = { complexSlug: string }; - -export async function listPublicAvailabilityHandler(c: AppContext) { - const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams; - const query = c.req.valid('query' as never) as PublicAvailabilityQuery; - - try { - const availability = await listPublicAvailability(complexSlug, query); - return c.json(availability); - } catch (error) { - if (error instanceof PublicBookingServiceError) { - return c.json({ message: error.message }, error.status); - } - - throw error; - } -} diff --git a/apps/backend/src/modules/public-booking/public-booking.routes.ts b/apps/backend/src/modules/public-booking/public-booking.routes.ts index bfc5b68..6ad17fd 100644 --- a/apps/backend/src/modules/public-booking/public-booking.routes.ts +++ b/apps/backend/src/modules/public-booking/public-booking.routes.ts @@ -1,7 +1,7 @@ -import { cancelPublicBookingHandler } from '@/modules/public-booking/handlers/cancel-public-booking.handler'; -import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler'; -import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler'; -import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler'; +import { cancelPublicBookingHandler } from '@/modules/public-booking/features/cancel-public-booking/cancel-public-booking.handler'; +import { createPublicBookingHandler } from '@/modules/public-booking/features/create-public-booking/create-public-booking.handler'; +import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/features/get-public-booking-confirmation/get-public-booking-confirmation.handler'; +import { listPublicAvailabilityHandler } from '@/modules/public-booking/features/list-public-availability/list-public-availability.handler'; import type { AppEnv } from '@/types/hono'; import { zValidator } from '@hono/zod-validator'; import { diff --git a/apps/backend/src/modules/public-booking/services/public-booking.service.ts b/apps/backend/src/modules/public-booking/services/public-booking.service.ts deleted file mode 100644 index 8c43d4f..0000000 --- a/apps/backend/src/modules/public-booking/services/public-booking.service.ts +++ /dev/null @@ -1,824 +0,0 @@ -import { randomInt } from 'node:crypto'; -import { db } from '@/lib/prisma'; -import { isSlotInPast } from '@/lib/slot-validator'; -import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; -import type { - CancelPublicBookingInput, - CreatePublicBookingInput, - DayOfWeek, - PublicAvailabilityQuery, -} from '@repo/api-contract'; -import { v7 as uuidv7 } from 'uuid'; - -type Slot = { - startTime: string; - endTime: string; -}; - -type PriceableCourt = { - basePrice: unknown; - priceRules: Array<{ - dayOfWeek: DayOfWeek | null; - startTime: string | null; - endTime: string | null; - price: unknown; - }>; -}; - -type ComplexWithPublicBookingData = Awaited>; - -const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [ - 'SUNDAY', - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', -]; -const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -const BOOKING_CODE_LENGTH = 6; - -export class PublicBookingServiceError extends Error { - status: 400 | 403 | 404 | 409; - - constructor(message: string, status: 400 | 403 | 404 | 409) { - super(message); - this.status = status; - } -} - -function toMinutes(value: string): number { - const [hours, minutes] = value.split(':').map((part) => Number(part)); - return hours * 60 + minutes; -} - -function minutesToTime(minutes: number): string { - const safeMinutes = Math.max(0, minutes); - const hours = Math.floor(safeMinutes / 60); - const mins = safeMinutes % 60; - return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; -} - -function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); - - if (!match) { - throw new PublicBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400); - } - - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - - const bookingDate = new Date(Date.UTC(year, month - 1, day)); - - if ( - Number.isNaN(bookingDate.getTime()) || - bookingDate.getUTCFullYear() !== year || - bookingDate.getUTCMonth() + 1 !== month || - bookingDate.getUTCDate() !== day - ) { - throw new PublicBookingServiceError('La fecha enviada no es valida.', 400); - } - - const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()]; - - if (!dayOfWeek) { - throw new PublicBookingServiceError('No se pudo resolver el dia de la semana.', 400); - } - - return { bookingDate, dayOfWeek }; -} - -function formatIsoDate(date: Date): string { - return date.toISOString().slice(0, 10); -} - -function generateBookingCode(): string { - let code = ''; - - for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { - code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; - } - - return code; -} - -function hasOverlap(slot: Slot, existing: Slot): boolean { - return slot.startTime < existing.endTime && slot.endTime > existing.startTime; -} - -function buildSlots( - availability: Array<{ - startTime: string; - endTime: string; - }>, - slotDurationMinutes: number -): Slot[] { - const slots: Slot[] = []; - - for (const range of availability) { - const start = toMinutes(range.startTime); - const end = toMinutes(range.endTime); - - for ( - let current = start; - current + slotDurationMinutes <= end; - current += slotDurationMinutes - ) { - slots.push({ - startTime: minutesToTime(current), - endTime: minutesToTime(current + slotDurationMinutes), - }); - } - } - - return slots; -} - -function resolveSports(data: ComplexWithPublicBookingData) { - const map = new Map(); - - for (const court of data.courts) { - if (!court.sport.isActive) { - continue; - } - - if (!map.has(court.sport.id)) { - map.set(court.sport.id, { - id: court.sport.id, - name: court.sport.name, - slug: court.sport.slug, - }); - } - } - - return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'es')); -} - -function validateSportSelection( - availableSports: Array<{ id: string }>, - sportId: string | undefined -): { sportSelectionRequired: boolean; selectedSportId: string | undefined } { - const sportSelectionRequired = availableSports.length > 1; - - if (sportSelectionRequired && !sportId) { - throw new PublicBookingServiceError( - 'Debes seleccionar un deporte para consultar disponibilidad.', - 400 - ); - } - - if (sportId && !availableSports.some((sport) => sport.id === sportId)) { - throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400); - } - - if (sportSelectionRequired) { - return { - sportSelectionRequired, - selectedSportId: sportId, - }; - } - - return { - sportSelectionRequired, - selectedSportId: sportId ?? availableSports[0]?.id, - }; -} - -async function getComplexWithBookingData(complexSlug: string) { - const complex = await db.complex.findUnique({ - where: { complexSlug }, - select: { - id: true, - complexName: true, - complexSlug: true, - physicalAddress: true, - plan: { - select: { - rules: true, - }, - }, - courts: { - include: { - sport: { - select: { - id: true, - name: true, - slug: true, - isActive: true, - }, - }, - availabilities: { - orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], - }, - priceRules: { - where: { isActive: true }, - orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], - }, - }, - orderBy: { createdAt: 'asc' }, - }, - }, - }); - - if (!complex) { - throw new PublicBookingServiceError('Complejo no encontrado.', 404); - } - - if (complex.plan) { - const rules = parsePlanRules(complex.plan.rules); - - if (!rules.features.publicBookingPage) { - throw new PublicBookingServiceError( - 'El complejo no tiene habilitada la reserva publica.', - 403 - ); - } - } - - return complex; -} - -function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number { - const slotStart = toMinutes(slot.startTime); - const slotEnd = toMinutes(slot.endTime); - const matchingRules = court.priceRules - .filter((rule) => { - if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) { - return false; - } - - if (!rule.startTime || !rule.endTime) { - return true; - } - - return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime); - }) - .sort((first, second) => { - const firstSpecificity = - (first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0); - const secondSpecificity = - (second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0); - - return secondSpecificity - firstSpecificity; - }); - - return Number(matchingRules[0]?.price ?? court.basePrice); -} - -function mapBookingResponse(input: { - bookingId: string; - bookingCode: string; - bookingDate: Date; - createdAt: Date; - startTime: string; - endTime: string; - customerName: string; - customerPhone: string; - customerEmail: string; - price: number; - status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW'; - court: { - id: string; - name: string; - complexId: string; - complexName: string; - complexSlug: string; - sport: { - id: string; - name: string; - slug: string; - }; - }; -}) { - return { - id: input.bookingId, - bookingCode: input.bookingCode, - complexId: input.court.complexId, - complexName: input.court.complexName, - complexSlug: input.court.complexSlug, - courtId: input.court.id, - courtName: input.court.name, - sport: input.court.sport, - date: formatIsoDate(input.bookingDate), - startTime: input.startTime, - endTime: input.endTime, - customerName: input.customerName, - customerPhone: input.customerPhone, - customerEmail: input.customerEmail, - status: input.status, - price: input.price, - createdAt: input.createdAt.toISOString(), - }; -} - -export async function getPublicBookingConfirmation(complexSlug: string, bookingCode: string) { - const normalizedBookingCode = bookingCode.toUpperCase(); - const booking = await db.courtBooking.findFirst({ - where: { - bookingCode: normalizedBookingCode, - court: { - complex: { - complexSlug, - }, - }, - }, - select: { - bookingCode: true, - bookingDate: true, - startTime: true, - endTime: true, - customerEmail: true, - status: true, - createdAt: true, - court: { - select: { - name: true, - basePrice: true, - priceRules: { - where: { isActive: true }, - orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], - }, - sport: { - select: { - id: true, - name: true, - slug: true, - }, - }, - complex: { - select: { - complexName: true, - physicalAddress: true, - complexSlug: true, - }, - }, - }, - }, - }, - }); - - if (!booking) { - throw new PublicBookingServiceError('Reserva no encontrada.', 404); - } - - const date = formatIsoDate(booking.bookingDate); - const { dayOfWeek } = parseIsoDate(date); - const price = resolveSlotPrice(booking.court, dayOfWeek, { - startTime: booking.startTime, - endTime: booking.endTime, - }); - - return { - bookingCode: booking.bookingCode, - complexName: booking.court.complex.complexName, - complexAddress: booking.court.complex.physicalAddress ?? undefined, - complexSlug: booking.court.complex.complexSlug, - date, - startTime: booking.startTime, - endTime: booking.endTime, - price, - courtName: booking.court.name, - sport: { - id: booking.court.sport.id, - name: booking.court.sport.name, - slug: booking.court.sport.slug, - }, - status: booking.status, - customerEmail: booking.customerEmail, - createdAt: booking.createdAt.toISOString(), - }; -} - -async function hasVerifiedAdmin(complexId: string): Promise { - const verifiedAdmin = await db.complexUser.findFirst({ - where: { - complexId, - role: 'ADMIN', - user: { emailVerified: true }, - }, - }); - return verifiedAdmin !== null; -} - -export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) { - const { bookingDate, dayOfWeek } = parseIsoDate(query.date); - const complex = await getComplexWithBookingData(complexSlug); - - if (!(await hasVerifiedAdmin(complex.id))) { - return { - complexId: complex.id, - complexName: complex.complexName, - complexAddress: complex.physicalAddress ?? null, - complexSlug: complex.complexSlug, - date: query.date, - sportSelectionRequired: false, - sports: [], - courts: [], - }; - } - - const sports = resolveSports(complex); - const sportSelectionRequired = sports.length > 1; - - if (query.sportId && !sports.some((sport) => sport.id === query.sportId)) { - throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400); - } - - const selectedSportId = sportSelectionRequired ? query.sportId : (query.sportId ?? sports[0]?.id); - - const candidateCourts = complex.courts.filter((court) => { - if (!court.sport.isActive) { - return false; - } - - if (selectedSportId) { - return court.sportId === selectedSportId; - } - - return true; - }); - - const courtIds = candidateCourts.map((court) => court.id); - - const bookings = - courtIds.length > 0 - ? await db.courtBooking.findMany({ - where: { - bookingDate, - status: 'CONFIRMED', - courtId: { - in: courtIds, - }, - }, - select: { - courtId: true, - startTime: true, - endTime: true, - }, - }) - : []; - - const bookingsByCourt = new Map(); - - for (const booking of bookings) { - const current = bookingsByCourt.get(booking.courtId) ?? []; - current.push({ - startTime: booking.startTime, - endTime: booking.endTime, - }); - bookingsByCourt.set(booking.courtId, current); - } - - const courts = candidateCourts - .map((court) => { - const dayAvailability = court.availabilities.filter( - (availability) => availability.dayOfWeek === dayOfWeek - ); - - const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes); - const occupiedSlots = bookingsByCourt.get(court.id) ?? []; - - const availableSlots = allSlots.filter( - (slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied)) - ); - - return { - courtId: court.id, - courtName: court.name, - sport: { - id: court.sport.id, - name: court.sport.name, - slug: court.sport.slug, - }, - slotDurationMinutes: court.slotDurationMinutes, - availabilityDay: dayOfWeek, - availableSlots: availableSlots.map((slot) => ({ - ...slot, - price: resolveSlotPrice(court, dayOfWeek, slot), - })), - }; - }) - .filter((court) => court.availableSlots.length > 0); - - return { - complexId: complex.id, - complexName: complex.complexName, - complexAddress: complex.physicalAddress ?? null, - complexSlug: complex.complexSlug, - date: query.date, - sportSelectionRequired, - sports, - courts, - }; -} - -export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) { - const { bookingDate, dayOfWeek } = parseIsoDate(input.date); - const complex = await getComplexWithBookingData(complexSlug); - - if (!(await hasVerifiedAdmin(complex.id))) { - throw new PublicBookingServiceError( - 'El complejo no puede recibir reservas hasta que un administrador verifique su email.', - 403 - ); - } - - const sports = resolveSports(complex); - const { sportSelectionRequired } = validateSportSelection(sports, input.sportId); - - const selectedCourt = complex.courts.find( - (court) => court.id === input.courtId && court.sport.isActive - ); - - if (!selectedCourt) { - throw new PublicBookingServiceError('La cancha seleccionada no existe en el complejo.', 404); - } - - if (sportSelectionRequired && !input.sportId) { - throw new PublicBookingServiceError( - 'Debes seleccionar un deporte para reservar en este complejo.', - 400 - ); - } - - if (input.sportId && input.sportId !== selectedCourt.sportId) { - throw new PublicBookingServiceError( - 'La cancha seleccionada no corresponde al deporte indicado.', - 400 - ); - } - - const dayAvailability = selectedCourt.availabilities.filter( - (availability) => availability.dayOfWeek === dayOfWeek - ); - - const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes); - const selectedStartMinutes = toMinutes(input.startTime); - const selectedEndMinutes = selectedStartMinutes + selectedCourt.slotDurationMinutes; - - const selectedSlot = { - startTime: input.startTime, - endTime: minutesToTime(selectedEndMinutes), - }; - - const slotExists = validSlots.some( - (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime - ); - - if (!slotExists) { - throw new PublicBookingServiceError( - 'El horario seleccionado no esta disponible para esa cancha.', - 409 - ); - } - - if (isSlotInPast(bookingDate, input.startTime, selectedCourt.slotDurationMinutes)) { - throw new PublicBookingServiceError('No se pueden crear reservas en el pasado.', 400); - } - - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - const booking = await db.$transaction(async (tx) => { - if (complex.plan) { - const rules = parsePlanRules(complex.plan.rules); - const bookingsForDate = await tx.courtBooking.count({ - where: { - bookingDate, - status: 'CONFIRMED', - court: { - complexId: complex.id, - }, - }, - }); - - const violations = evaluatePlanUsage(rules, { - courtsCount: complex.courts.length, - bookingsToday: bookingsForDate, - }); - - const maxBookingsViolation = violations.find( - (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED' - ); - - if (maxBookingsViolation) { - throw new PublicBookingServiceError(maxBookingsViolation.message, 409); - } - } - - const overlappingBooking = await tx.courtBooking.findFirst({ - where: { - courtId: selectedCourt.id, - bookingDate, - status: 'CONFIRMED', - startTime: { - lt: selectedSlot.endTime, - }, - endTime: { - gt: selectedSlot.startTime, - }, - }, - }); - - if (overlappingBooking) { - throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409); - } - - return tx.courtBooking.create({ - data: { - id: uuidv7(), - bookingCode: generateBookingCode(), - courtId: selectedCourt.id, - bookingDate, - startTime: selectedSlot.startTime, - endTime: selectedSlot.endTime, - customerName: input.customerName.trim(), - customerPhone: input.customerPhone.trim(), - customerEmail: input.customerEmail.trim(), - status: 'CONFIRMED', - }, - select: { - id: true, - bookingCode: true, - bookingDate: true, - createdAt: true, - startTime: true, - endTime: true, - customerName: true, - customerPhone: true, - customerEmail: true, - status: true, - }, - }); - }); - - const price = resolveSlotPrice(selectedCourt, dayOfWeek, selectedSlot); - - return mapBookingResponse({ - bookingId: booking.id, - bookingCode: booking.bookingCode, - bookingDate: booking.bookingDate, - createdAt: booking.createdAt, - startTime: booking.startTime, - endTime: booking.endTime, - customerName: booking.customerName, - customerPhone: booking.customerPhone, - customerEmail: booking.customerEmail, - price, - status: booking.status, - court: { - id: selectedCourt.id, - name: selectedCourt.name, - complexId: complex.id, - complexName: complex.complexName, - complexSlug: complex.complexSlug, - sport: { - id: selectedCourt.sport.id, - name: selectedCourt.sport.name, - slug: selectedCourt.sport.slug, - }, - }, - }); - } catch (error) { - if (error instanceof PublicBookingServiceError) { - throw error; - } - - const prismaError = error as { - code?: string; - meta?: { - target?: string[] | string; - }; - }; - - if (prismaError.code === 'P2002') { - const targets = Array.isArray(prismaError.meta?.target) - ? prismaError.meta?.target - : [prismaError.meta?.target]; - const isBookingCodeCollision = targets.some((target) => - String(target).includes('booking_code') - ); - - if (isBookingCodeCollision) { - continue; - } - - throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409); - } - - throw error; - } - } - - throw new PublicBookingServiceError( - 'No se pudo generar un codigo de reserva unico. Intenta nuevamente.', - 409 - ); -} - -export async function cancelPublicBooking(complexSlug: string, input: CancelPublicBookingInput) { - const normalizedBookingCode = input.bookingCode.toUpperCase(); - const booking = await db.courtBooking.findFirst({ - where: { - bookingCode: normalizedBookingCode, - court: { - complex: { - complexSlug, - }, - }, - }, - include: { - court: { - select: { - id: true, - name: true, - basePrice: true, - priceRules: { - where: { isActive: true }, - orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], - }, - sport: { - select: { - id: true, - name: true, - slug: true, - }, - }, - complex: { - select: { - id: true, - complexName: true, - complexSlug: true, - }, - }, - }, - }, - }, - }); - - if (!booking) { - throw new PublicBookingServiceError('Reserva no encontrada.', 404); - } - - if (booking.status !== 'CONFIRMED') { - throw new PublicBookingServiceError( - 'Solo se pueden cancelar reservas en estado confirmada.', - 409 - ); - } - - if (booking.customerPhone !== input.customerPhone.trim()) { - throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 400); - } - - const date = formatIsoDate(booking.bookingDate); - const { dayOfWeek } = parseIsoDate(date); - const price = resolveSlotPrice(booking.court, dayOfWeek, { - startTime: booking.startTime, - endTime: booking.endTime, - }); - - await db.courtBookingLog.create({ - data: { - id: uuidv7(), - bookingCode: booking.bookingCode, - courtId: booking.courtId, - bookingDate: booking.bookingDate, - startTime: booking.startTime, - endTime: booking.endTime, - customerName: booking.customerName, - customerPhone: booking.customerPhone, - customerEmail: booking.customerEmail, - previousStatus: booking.status, - newStatus: 'CANCELLED', - }, - }); - - await db.courtBooking.delete({ - where: { id: booking.id }, - }); - - return mapBookingResponse({ - bookingId: booking.id, - bookingCode: booking.bookingCode, - bookingDate: booking.bookingDate, - createdAt: booking.createdAt, - startTime: booking.startTime, - endTime: booking.endTime, - customerName: booking.customerName, - customerPhone: booking.customerPhone, - customerEmail: booking.customerEmail, - price, - status: 'CANCELLED', - court: { - id: booking.court.id, - name: booking.court.name, - complexId: booking.court.complex.id, - complexName: booking.court.complex.complexName, - complexSlug: booking.court.complex.complexSlug, - sport: booking.court.sport, - }, - }); -} diff --git a/apps/backend/src/modules/public-booking/shared/errors.ts b/apps/backend/src/modules/public-booking/shared/errors.ts new file mode 100644 index 0000000..49f741a --- /dev/null +++ b/apps/backend/src/modules/public-booking/shared/errors.ts @@ -0,0 +1,6 @@ +export class PublicBookingServiceError extends Error { + constructor(message: string) { + super(message); + this.name = 'PublicBookingServiceError'; + } +} diff --git a/apps/backend/src/modules/public-booking/shared/helpers.ts b/apps/backend/src/modules/public-booking/shared/helpers.ts new file mode 100644 index 0000000..e777f65 --- /dev/null +++ b/apps/backend/src/modules/public-booking/shared/helpers.ts @@ -0,0 +1,305 @@ +import { randomInt } from 'node:crypto'; +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import { isSlotInPast } from '@/lib/slot-validator'; +import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; +import type { DayOfWeek } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; + +const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [ + 'SUNDAY', + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', +]; +const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const BOOKING_CODE_LENGTH = 6; + +export type Slot = { + startTime: string; + endTime: string; +}; + +export type PriceableCourt = { + basePrice: unknown; + priceRules: Array<{ + dayOfWeek: DayOfWeek | null; + startTime: string | null; + endTime: string | null; + price: unknown; + }>; +}; + +export type ComplexWithPublicBookingData = Awaited>; + +export function toMinutes(value: string): number { + const [hours, minutes] = value.split(':').map((part) => Number(part)); + return hours * 60 + minutes; +} + +export function minutesToTime(minutes: number): string { + const safeMinutes = Math.max(0, minutes); + const hours = Math.floor(safeMinutes / 60); + const mins = safeMinutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +export function parseIsoDate(date: string): Result<{ bookingDate: Date; dayOfWeek: DayOfWeek }> { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + + if (!match) { + return err(Errors.validation('La fecha debe tener formato YYYY-MM-DD.')); + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + + const bookingDate = new Date(Date.UTC(year, month - 1, day)); + + if ( + Number.isNaN(bookingDate.getTime()) || + bookingDate.getUTCFullYear() !== year || + bookingDate.getUTCMonth() + 1 !== month || + bookingDate.getUTCDate() !== day + ) { + return err(Errors.validation('La fecha enviada no es valida.')); + } + + const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()]; + + if (!dayOfWeek) { + return err(Errors.validation('No se pudo resolver el dia de la semana.')); + } + + return ok({ bookingDate, dayOfWeek }); +} + +export function formatIsoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export function generateBookingCode(): string { + let code = ''; + + for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { + code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; + } + + return code; +} + +export function hasOverlap(slot: Slot, existing: Slot): boolean { + return slot.startTime < existing.endTime && slot.endTime > existing.startTime; +} + +export function buildSlots( + availability: Array<{ + startTime: string; + endTime: string; + }>, + slotDurationMinutes: number +): Slot[] { + const slots: Slot[] = []; + + for (const range of availability) { + const start = toMinutes(range.startTime); + const end = toMinutes(range.endTime); + + for ( + let current = start; + current + slotDurationMinutes <= end; + current += slotDurationMinutes + ) { + slots.push({ + startTime: minutesToTime(current), + endTime: minutesToTime(current + slotDurationMinutes), + }); + } + } + + return slots; +} + +export function resolveSports(data: ComplexWithPublicBookingData) { + const map = new Map(); + + for (const court of data.courts) { + if (!court.sport.isActive) { + continue; + } + + if (!map.has(court.sport.id)) { + map.set(court.sport.id, { + id: court.sport.id, + name: court.sport.name, + slug: court.sport.slug, + }); + } + } + + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'es')); +} + +export function validateSportSelection( + availableSports: Array<{ id: string }>, + sportId: string | undefined +): Result<{ sportSelectionRequired: boolean; selectedSportId: string | undefined }> { + const sportSelectionRequired = availableSports.length > 1; + + if (sportSelectionRequired && !sportId) { + return err(Errors.validation('Debes seleccionar un deporte para consultar disponibilidad.')); + } + + if (sportId && !availableSports.some((sport) => sport.id === sportId)) { + return err(Errors.validation('El deporte seleccionado no pertenece al complejo.')); + } + + if (sportSelectionRequired) { + return ok({ sportSelectionRequired, selectedSportId: sportId }); + } + + return ok({ + sportSelectionRequired, + selectedSportId: sportId ?? availableSports[0]?.id, + }); +} + +export async function getComplexWithBookingData( + complexSlug: string +): Promise> { + const complex = await db.complex.findUnique({ + where: { complexSlug }, + select: { + id: true, + complexName: true, + complexSlug: true, + physicalAddress: true, + plan: { + select: { rules: true }, + }, + courts: { + include: { + sport: { + select: { + id: true, + name: true, + slug: true, + isActive: true, + }, + }, + availabilities: { + orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], + }, + priceRules: { + where: { isActive: true }, + orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], + }, + }, + orderBy: { createdAt: 'asc' }, + }, + }, + }); + + if (!complex) { + return err(Errors.notFound('Complejo no encontrado.')); + } + + if (complex.plan) { + const rules = parsePlanRules(complex.plan.rules); + + if (!rules.features.publicBookingPage) { + return err(Errors.forbidden('El complejo no tiene habilitada la reserva publica.')); + } + } + + return ok(complex); +} + +export function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number { + const slotStart = toMinutes(slot.startTime); + const slotEnd = toMinutes(slot.endTime); + const matchingRules = court.priceRules + .filter((rule) => { + if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) { + return false; + } + + if (!rule.startTime || !rule.endTime) { + return true; + } + + return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime); + }) + .sort((first, second) => { + const firstSpecificity = + (first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0); + const secondSpecificity = + (second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0); + + return secondSpecificity - firstSpecificity; + }); + + return Number(matchingRules[0]?.price ?? court.basePrice); +} + +export function mapBookingResponse(input: { + bookingId: string; + bookingCode: string; + bookingDate: Date; + createdAt: Date; + startTime: string; + endTime: string; + customerName: string; + customerPhone: string; + customerEmail: string; + price: number; + status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW'; + court: { + id: string; + name: string; + complexId: string; + complexName: string; + complexSlug: string; + sport: { + id: string; + name: string; + slug: string; + }; + }; +}) { + return { + id: input.bookingId, + bookingCode: input.bookingCode, + complexId: input.court.complexId, + complexName: input.court.complexName, + complexSlug: input.court.complexSlug, + courtId: input.court.id, + courtName: input.court.name, + sport: input.court.sport, + date: formatIsoDate(input.bookingDate), + startTime: input.startTime, + endTime: input.endTime, + customerName: input.customerName, + customerPhone: input.customerPhone, + customerEmail: input.customerEmail, + status: input.status, + price: input.price, + createdAt: input.createdAt.toISOString(), + }; +} + +export async function hasVerifiedAdmin(complexId: string): Promise { + const verifiedAdmin = await db.complexUser.findFirst({ + where: { + complexId, + role: 'ADMIN', + user: { emailVerified: true }, + }, + }); + return verifiedAdmin !== null; +} diff --git a/apps/backend/src/modules/sport/features/create-sport/create-sport.business.ts b/apps/backend/src/modules/sport/features/create-sport/create-sport.business.ts new file mode 100644 index 0000000..08b09b1 --- /dev/null +++ b/apps/backend/src/modules/sport/features/create-sport/create-sport.business.ts @@ -0,0 +1,34 @@ +import { Errors } from '@/lib/errors'; +import { db } from '@/lib/prisma'; +import { type Result, err, ok } from '@/lib/result'; +import { buildUniqueSlug } from '@/lib/slug'; +import type { CreateSportInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; + +export async function createSport(input: CreateSportInput) { + const slug = await buildUniqueSlug(input.name, (candidate) => + db.sport.findFirst({ + where: { slug: candidate }, + select: { id: true }, + }) + ); + + const existing = await db.sport.count({ + where: { slug }, + }); + + if (existing > 0) { + return err(Errors.conflict('Ya existe un deporte con ese nombre.')); + } + + const created = await db.sport.create({ + data: { + id: uuidv7(), + name: input.name.trim(), + slug, + isActive: true, + }, + }); + + return ok(created); +} diff --git a/apps/backend/src/modules/sport/features/create-sport/create-sport.handler.ts b/apps/backend/src/modules/sport/features/create-sport/create-sport.handler.ts new file mode 100644 index 0000000..7c01d55 --- /dev/null +++ b/apps/backend/src/modules/sport/features/create-sport/create-sport.handler.ts @@ -0,0 +1,17 @@ +import { handleResult } from '@/lib/http/handle-result'; +import type { AppContext } from '@/types/hono'; +import type { CreateSportInput } from '@repo/api-contract'; +import { createSport as createSportBusiness } from './create-sport.business'; + +type Deps = { + createSport: typeof createSportBusiness; +}; + +export function createCreateSportHandler(deps: Deps = { createSport: createSportBusiness }) { + return async function createSportHandler(c: AppContext) { + const payload = c.req.valid('json' as never) as CreateSportInput; + return handleResult(c, await deps.createSport(payload), 201); + }; +} + +export const createSportHandler = createCreateSportHandler(); diff --git a/apps/backend/src/modules/sport/features/list-sports/list-sports.business.ts b/apps/backend/src/modules/sport/features/list-sports/list-sports.business.ts new file mode 100644 index 0000000..5b63cb9 --- /dev/null +++ b/apps/backend/src/modules/sport/features/list-sports/list-sports.business.ts @@ -0,0 +1,10 @@ +import type { Sport } from '@/generated/prisma/client'; +import { db } from '@/lib/prisma'; +import { type Result, ok } from '@/lib/result'; + +export async function listSports(): Promise> { + const sports = await db.sport.findMany({ + orderBy: { name: 'asc' }, + }); + return ok(sports); +} diff --git a/apps/backend/src/modules/sport/handlers/list-sports.handler.ts b/apps/backend/src/modules/sport/features/list-sports/list-sports.handler.ts similarity index 54% rename from apps/backend/src/modules/sport/handlers/list-sports.handler.ts rename to apps/backend/src/modules/sport/features/list-sports/list-sports.handler.ts index c813cc8..94f1208 100644 --- a/apps/backend/src/modules/sport/handlers/list-sports.handler.ts +++ b/apps/backend/src/modules/sport/features/list-sports/list-sports.handler.ts @@ -1,8 +1,7 @@ import { handleResult } from '@/lib/http/handle-result'; -import { listSports } from '@/modules/sport/services/sport.service'; import type { AppContext } from '@/types/hono'; +import { listSports } from './list-sports.business'; export async function listSportsHandler(c: AppContext) { - const sports = await listSports(); - return handleResult(c, sports); + return handleResult(c, await listSports()); } diff --git a/apps/backend/src/modules/sport/features/update-sport/update-sport.business.ts b/apps/backend/src/modules/sport/features/update-sport/update-sport.business.ts new file mode 100644 index 0000000..39988bb --- /dev/null +++ b/apps/backend/src/modules/sport/features/update-sport/update-sport.business.ts @@ -0,0 +1,34 @@ +import type { Prisma } from '@/generated/prisma/client'; +import { db } from '@/lib/prisma'; +import { buildUniqueSlug } from '@/lib/slug'; +import type { UpdateSportInput } from '@repo/api-contract'; + +export async function getSportById(id: string) { + return db.sport.findUnique({ where: { id } }); +} + +export async function updateSport(id: string, input: UpdateSportInput) { + const data: Prisma.SportUncheckedUpdateInput = {}; + + if (input.name) { + data.name = input.name; + data.slug = await buildUniqueSlug(input.name, (candidate) => + db.sport.findFirst({ + where: { + slug: candidate, + id: { not: id }, + }, + select: { id: true }, + }) + ); + } + + if (input.isActive !== undefined) { + data.isActive = input.isActive; + } + + return db.sport.update({ + where: { id }, + data, + }); +} diff --git a/apps/backend/src/modules/sport/handlers/update-sport.handler.ts b/apps/backend/src/modules/sport/features/update-sport/update-sport.handler.ts similarity index 90% rename from apps/backend/src/modules/sport/handlers/update-sport.handler.ts rename to apps/backend/src/modules/sport/features/update-sport/update-sport.handler.ts index 467a0ff..0de4298 100644 --- a/apps/backend/src/modules/sport/handlers/update-sport.handler.ts +++ b/apps/backend/src/modules/sport/features/update-sport/update-sport.handler.ts @@ -1,7 +1,7 @@ import { Prisma } from '@/generated/prisma/client'; -import { getSportById, updateSport } from '@/modules/sport/services/sport.service'; import type { AppContext } from '@/types/hono'; import type { UpdateSportInput } from '@repo/api-contract'; +import { getSportById, updateSport } from './update-sport.business'; type SportIdParams = { id: string }; diff --git a/apps/backend/src/modules/sport/handlers/create-sport.handler.ts b/apps/backend/src/modules/sport/handlers/create-sport.handler.ts deleted file mode 100644 index 18deca1..0000000 --- a/apps/backend/src/modules/sport/handlers/create-sport.handler.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Prisma } from '@/generated/prisma/client'; -import { handleResult } from '@/lib/http/handle-result'; -import { createSport } from '@/modules/sport/services/sport.service'; -import type { AppContext } from '@/types/hono'; -import type { CreateSportInput } from '@repo/api-contract'; - -export async function createSportHandler(c: AppContext) { - const payload = c.req.valid('json' as never) as CreateSportInput; - return handleResult(c, await createSport(payload), 201); -} diff --git a/apps/backend/src/modules/sport/services/sport.service.ts b/apps/backend/src/modules/sport/services/sport.service.ts deleted file mode 100644 index f56ae65..0000000 --- a/apps/backend/src/modules/sport/services/sport.service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Prisma, Sport } from '@/generated/prisma/client'; -import { Errors } from '@/lib/errors'; -import { db } from '@/lib/prisma'; -import { Result, err, ok } from '@/lib/result'; -import { v7 as uuidv7 } from 'uuid'; - -export type CreateSportInput = { - name: string; -}; - -export type UpdateSportInput = { - name?: string; - isActive?: boolean; -}; - -function slugify(value: string): string { - return value - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .trim() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-'); -} - -async function buildUniqueSlug(source: string, excludeSportId?: string): Promise { - const base = slugify(source); - const fallback = base.length > 0 ? base : `sport-${uuidv7().slice(0, 8)}`; - - let candidate = fallback; - let index = 1; - - while (true) { - const existing = await db.sport.findFirst({ - where: { - slug: candidate, - ...(excludeSportId - ? { - id: { - not: excludeSportId, - }, - } - : {}), - }, - select: { id: true }, - }); - - if (!existing) return candidate; - - index += 1; - candidate = `${fallback}-${index}`; - } -} - -export async function listSports(): Promise> { - const sports = await db.sport.findMany({ - orderBy: { name: 'asc' }, - }); - return ok(sports); -} - -export async function createSport(input: CreateSportInput) { - const slug = await buildUniqueSlug(input.name); - - const existing = await db.sport.count({ - where: { slug }, - }); - - if (existing > 0) { - return err(Errors.conflict('Ya existe un deporte con ese nombre.')); - } - - const created = await db.sport.create({ - data: { - id: uuidv7(), - name: input.name.trim(), - slug, - isActive: true, - }, - }); - - return ok(created); -} - -export async function getSportById(id: string) { - return db.sport.findUnique({ where: { id } }); -} - -export async function updateSport(id: string, input: UpdateSportInput) { - const data: Prisma.SportUncheckedUpdateInput = {}; - - if (input.name) { - data.name = input.name; - data.slug = await buildUniqueSlug(input.name, id); - } - - if (input.isActive !== undefined) { - data.isActive = input.isActive; - } - - return db.sport.update({ - where: { id }, - data, - }); -} diff --git a/apps/backend/src/modules/sport/sport.routes.ts b/apps/backend/src/modules/sport/sport.routes.ts index ba1c332..1147c81 100644 --- a/apps/backend/src/modules/sport/sport.routes.ts +++ b/apps/backend/src/modules/sport/sport.routes.ts @@ -1,14 +1,14 @@ import { validate } from '@/lib/http/validate'; import { requireAuth } from '@/middlewares/require-auth.middleware'; import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware'; -import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler'; -import { listSportsHandler } from '@/modules/sport/handlers/list-sports.handler'; -import { updateSportHandler } from '@/modules/sport/handlers/update-sport.handler'; +import { createSportHandler } from '@/modules/sport/features/create-sport/create-sport.handler'; +import { listSportsHandler } from '@/modules/sport/features/list-sports/list-sports.handler'; +import { updateSportHandler } from '@/modules/sport/features/update-sport/update-sport.handler'; import type { AppEnv } from '@/types/hono'; -import { zValidator } from '@hono/zod-validator'; import { createSportSchema, updateSportSchema } from '@repo/api-contract'; import { Hono } from 'hono'; import { z } from 'zod'; + export const sportRoutes = new Hono(); const sportIdParamsSchema = z.object({ id: z.uuid() }); diff --git a/apps/backend/src/modules/user/services/user.service.ts b/apps/backend/src/modules/user/features/get-profile/get-profile.business.ts similarity index 100% rename from apps/backend/src/modules/user/services/user.service.ts rename to apps/backend/src/modules/user/features/get-profile/get-profile.business.ts diff --git a/apps/backend/src/modules/user/handlers/get-user-profile.handler.ts b/apps/backend/src/modules/user/features/get-profile/get-profile.handler.ts similarity index 58% rename from apps/backend/src/modules/user/handlers/get-user-profile.handler.ts rename to apps/backend/src/modules/user/features/get-profile/get-profile.handler.ts index cdf4e76..e1d4c9b 100644 --- a/apps/backend/src/modules/user/handlers/get-user-profile.handler.ts +++ b/apps/backend/src/modules/user/features/get-profile/get-profile.handler.ts @@ -1,12 +1,10 @@ -import { getUserProfile } from '@/modules/user/services/user.service'; import type { AppContext } from '@/types/hono'; import { getCookie } from 'hono/cookie'; - -const SELECTED_COMPLEX_COOKIE = 'selected-complex-id'; +import { getUserProfile } from './get-profile.business'; export async function getUserProfileHandler(c: AppContext) { const user = c.get('user'); - const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE); + const complexId = getCookie(c, 'selected-complex-id'); const profile = await getUserProfile(user, complexId); return c.json(profile); } diff --git a/apps/backend/src/modules/user/user.routes.ts b/apps/backend/src/modules/user/user.routes.ts index a1a5af2..31df53d 100644 --- a/apps/backend/src/modules/user/user.routes.ts +++ b/apps/backend/src/modules/user/user.routes.ts @@ -1,5 +1,5 @@ import { requireAuth } from '@/middlewares/require-auth.middleware'; -import { getUserProfileHandler } from '@/modules/user/handlers/get-user-profile.handler'; +import { getUserProfileHandler } from '@/modules/user/features/get-profile/get-profile.handler'; import type { AppEnv } from '@/types/hono'; import { Hono } from 'hono'; diff --git a/apps/backend/test/admin-booking/reschedule-admin-booking.handler.test.ts b/apps/backend/test/admin-booking/reschedule-admin-booking.handler.test.ts index eba2984..459e6ea 100644 --- a/apps/backend/test/admin-booking/reschedule-admin-booking.handler.test.ts +++ b/apps/backend/test/admin-booking/reschedule-admin-booking.handler.test.ts @@ -1,27 +1,22 @@ import { beforeEach, expect, mock, test } from 'bun:test'; -const rescheduleAdminBookingMock = mock( - async (_userId: string, _id: string, _input: unknown) => ({}) +import { Errors } from '@/lib/errors'; +import { err, ok } from '@/lib/result'; + +const rescheduleAdminBookingMock = mock(async (_userId: string, _id: string, _input: unknown) => + ok({}) ); -class MockAdminBookingServiceError extends Error { - status: 400 | 403 | 404 | 409; - - constructor(message: string, status: 400 | 403 | 404 | 409 = 400) { - super(message); - this.name = 'AdminBookingServiceError'; - this.status = status; - } -} - -mock.module('@/modules/admin-booking/services/admin-booking.service', () => ({ - __esModule: true, - AdminBookingServiceError: MockAdminBookingServiceError, - rescheduleAdminBooking: rescheduleAdminBookingMock, -})); +mock.module( + '@/modules/admin-booking/features/reschedule-admin-booking/reschedule-admin-booking.business', + () => ({ + __esModule: true, + rescheduleAdminBooking: rescheduleAdminBookingMock, + }) +); const { rescheduleAdminBookingHandler } = await import( - '@/modules/admin-booking/handlers/reschedule-admin-booking.handler' + '@/modules/admin-booking/features/reschedule-admin-booking/reschedule-admin-booking.handler' ); type HandlerContext = { @@ -82,7 +77,7 @@ test('returns 200 with the updated booking', async () => { updatedAt: '2026-07-14T00:00:00.000Z', } as const; - rescheduleAdminBookingMock.mockResolvedValue(updatedBooking); + rescheduleAdminBookingMock.mockResolvedValue(ok(updatedBooking)); const { c, json } = createContext({ userId: 'user-1', @@ -96,16 +91,13 @@ test('returns 200 with the updated booking', async () => { startTime: '14:00', }); expect(json.mock.calls[0]?.[0]).toEqual(updatedBooking); - expect(json.mock.calls[0]?.[1]).toBeUndefined(); + expect(json.mock.calls[0]?.[1]).toBe(200); expect(response).toBeDefined(); }); test('maps AdminBookingServiceError to an error response', async () => { - rescheduleAdminBookingMock.mockRejectedValue( - new MockAdminBookingServiceError( - 'Solo se pueden reprogramar reservas en estado confirmada.', - 409 - ) + rescheduleAdminBookingMock.mockResolvedValue( + err(Errors.conflict('Solo se pueden reprogramar reservas en estado confirmada.')) ); const { c, json } = createContext({ @@ -119,7 +111,10 @@ test('maps AdminBookingServiceError to an error response', async () => { startTime: '14:00', }); expect(json.mock.calls[0]?.[0]).toEqual({ - message: 'Solo se pueden reprogramar reservas en estado confirmada.', + type: 'https://api.myapp.dev/problems/conflict', + title: 'Conflict', + status: 409, + detail: 'Solo se pueden reprogramar reservas en estado confirmada.', }); expect(json.mock.calls[0]?.[1]).toBe(409); expect(response).toBeDefined(); diff --git a/apps/backend/test/admin-booking/reschedule-admin-booking.service.test.ts b/apps/backend/test/admin-booking/reschedule-admin-booking.service.test.ts index 4cafe67..0cc48db 100644 --- a/apps/backend/test/admin-booking/reschedule-admin-booking.service.test.ts +++ b/apps/backend/test/admin-booking/reschedule-admin-booking.service.test.ts @@ -2,8 +2,8 @@ import { beforeEach, expect, test } from 'bun:test'; import { prismaMock, transactionMock, uuidV7Mock } from '../support/prisma.mock'; -const { AdminBookingServiceError, rescheduleAdminBooking } = await import( - '@/modules/admin-booking/services/admin-booking.service' +const { rescheduleAdminBooking } = await import( + '@/modules/admin-booking/features/reschedule-admin-booking/reschedule-admin-booking.business' ); function makeBooking(overrides?: Record) { @@ -84,9 +84,11 @@ test('reschedules a booking changing both court and time', async () => { startTime: '14:00', }); - expect(result.courtId).toBe('court-2'); - expect(result.startTime).toBe('14:00'); - expect(result.courtName).toBe('Cancha 2'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.courtId).toBe('court-2'); + expect(result.value.startTime).toBe('14:00'); + expect(result.value.courtName).toBe('Cancha 2'); expect(prismaMock.courtBookingLog.create).toHaveBeenCalledTimes(1); expect(prismaMock.courtBooking.update).toHaveBeenCalledWith({ where: { id: 'booking-1' }, @@ -137,8 +139,10 @@ test('reschedules a booking changing only the time', async () => { startTime: '15:00', }); - expect(result.startTime).toBe('15:00'); - expect(result.courtId).toBe('court-1'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.startTime).toBe('15:00'); + expect(result.value.courtId).toBe('court-1'); expect(prismaMock.courtBookingLog.create).toHaveBeenCalledTimes(1); expect(prismaMock.courtBooking.update).toHaveBeenCalledWith({ where: { id: 'booking-1' }, @@ -153,15 +157,11 @@ test('reschedules a booking changing only the time', async () => { test('rejects when booking is not found', async () => { prismaMock.courtBooking.findFirst.mockResolvedValue(null as never); - let caught: unknown; - try { - await rescheduleAdminBooking('user-1', 'non-existent', { startTime: '14:00' }); - } catch (error) { - caught = error; - } + const result = await rescheduleAdminBooking('user-1', 'non-existent', { startTime: '14:00' }); - expect(caught).toBeInstanceOf(AdminBookingServiceError); - expect((caught as InstanceType).status).toBe(404); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.type).toBe('not_found'); expect(transactionMock).not.toHaveBeenCalled(); }); @@ -170,15 +170,11 @@ test('rejects when booking status is not CONFIRMED', async () => { makeBooking({ status: 'COMPLETED' }) as never ); - let caught: unknown; - try { - await rescheduleAdminBooking('user-1', 'booking-1', { startTime: '14:00' }); - } catch (error) { - caught = error; - } + const result = await rescheduleAdminBooking('user-1', 'booking-1', { startTime: '14:00' }); - expect(caught).toBeInstanceOf(AdminBookingServiceError); - expect((caught as InstanceType).status).toBe(409); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.type).toBe('conflict'); expect(transactionMock).not.toHaveBeenCalled(); }); @@ -194,18 +190,14 @@ test('rejects when slot is not available in target court', async () => { }) as never ); - let caught: unknown; - try { - await rescheduleAdminBooking('user-1', 'booking-1', { - courtId: 'court-2', - startTime: '14:00', - }); - } catch (error) { - caught = error; - } + const result = await rescheduleAdminBooking('user-1', 'booking-1', { + courtId: 'court-2', + startTime: '14:00', + }); - expect(caught).toBeInstanceOf(AdminBookingServiceError); - expect((caught as InstanceType).status).toBe(409); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.type).toBe('conflict'); }); test('rejects when target court has a different sport', async () => { @@ -216,18 +208,14 @@ test('rejects when target court has a different sport', async () => { }) as never ); - let caught: unknown; - try { - await rescheduleAdminBooking('user-1', 'booking-1', { - courtId: 'court-2', - startTime: '14:00', - }); - } catch (error) { - caught = error; - } + const result = await rescheduleAdminBooking('user-1', 'booking-1', { + courtId: 'court-2', + startTime: '14:00', + }); - expect(caught).toBeInstanceOf(AdminBookingServiceError); - expect((caught as InstanceType).status).toBe(409); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.type).toBe('conflict'); expect(transactionMock).not.toHaveBeenCalled(); }); @@ -239,18 +227,14 @@ test('rejects when target court is under maintenance', async () => { }) as never ); - let caught: unknown; - try { - await rescheduleAdminBooking('user-1', 'booking-1', { - courtId: 'court-2', - startTime: '14:00', - }); - } catch (error) { - caught = error; - } + const result = await rescheduleAdminBooking('user-1', 'booking-1', { + courtId: 'court-2', + startTime: '14:00', + }); - expect(caught).toBeInstanceOf(AdminBookingServiceError); - expect((caught as InstanceType).status).toBe(409); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.type).toBe('conflict'); }); test('rejects when there is an overlapping booking', async () => { @@ -266,33 +250,25 @@ test('rejects when there is an overlapping booking', async () => { }) as never ); - let caught: unknown; - try { - await rescheduleAdminBooking('user-1', 'booking-1', { - courtId: 'court-2', - startTime: '14:00', - }); - } catch (error) { - caught = error; - } + const result = await rescheduleAdminBooking('user-1', 'booking-1', { + courtId: 'court-2', + startTime: '14:00', + }); - expect(caught).toBeInstanceOf(AdminBookingServiceError); - expect((caught as InstanceType).status).toBe(409); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.type).toBe('conflict'); expect(transactionMock).not.toHaveBeenCalled(); }); test('rejects when neither courtId nor startTime changes', async () => { prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never); - let caught: unknown; - try { - await rescheduleAdminBooking('user-1', 'booking-1', {}); - } catch (error) { - caught = error; - } + const result = await rescheduleAdminBooking('user-1', 'booking-1', {}); - expect(caught).toBeInstanceOf(AdminBookingServiceError); - expect((caught as InstanceType).status).toBe(400); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.type).toBe('validation'); expect(transactionMock).not.toHaveBeenCalled(); }); @@ -301,17 +277,13 @@ test('rejects when target court does not belong to the same complex', async () = // Court belongs to a different complex prismaMock.court.findFirst.mockResolvedValue(null as never); - let caught: unknown; - try { - await rescheduleAdminBooking('user-1', 'booking-1', { - courtId: 'other-complex-court', - startTime: '14:00', - }); - } catch (error) { - caught = error; - } + const result = await rescheduleAdminBooking('user-1', 'booking-1', { + courtId: 'other-complex-court', + startTime: '14:00', + }); - expect(caught).toBeInstanceOf(AdminBookingServiceError); - expect((caught as InstanceType).status).toBe(404); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.type).toBe('not_found'); expect(transactionMock).not.toHaveBeenCalled(); }); diff --git a/apps/backend/test/complex/invite-complex-user.handler.test.ts b/apps/backend/test/complex/invite-complex-user.handler.test.ts index 5e7ee33..c7c5751 100644 --- a/apps/backend/test/complex/invite-complex-user.handler.test.ts +++ b/apps/backend/test/complex/invite-complex-user.handler.test.ts @@ -1,26 +1,17 @@ import { beforeEach, expect, mock, test } from 'bun:test'; +import { Errors } from '@/lib/errors'; +import { err, ok } from '@/lib/result'; import type { InviteComplexUserResponse } from '@repo/api-contract'; const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse); -class MockComplexMembersError extends Error { - status: 400 | 403 | 404 | 409; - - constructor(message: string, status: 400 | 403 | 404 | 409 = 400) { - super(message); - this.name = 'ComplexMembersError'; - this.status = status; - } -} - const { createInviteComplexUserHandler } = await import( - '@/modules/complex/handlers/invite-complex-user.handler' + '@/modules/complex/features/invite-complex-user/invite-complex-user.handler' ); const inviteComplexUserHandler = createInviteComplexUserHandler({ inviteComplexUser: inviteComplexUserMock, - ComplexMembersError: MockComplexMembersError, }); type HandlerContext = { @@ -78,7 +69,7 @@ test('returns 201 with the service result', async () => { }, } as const; - inviteComplexUserMock.mockResolvedValue(serviceResult); + inviteComplexUserMock.mockResolvedValue(ok(serviceResult)); const { c, json } = createContext({ userId: 'admin-1', @@ -99,8 +90,8 @@ test('returns 201 with the service result', async () => { }); test('maps ComplexMembersError to an error response', async () => { - inviteComplexUserMock.mockRejectedValue( - new MockComplexMembersError('Solo un ADMIN puede administrar usuarios.', 403) + inviteComplexUserMock.mockResolvedValue( + err(Errors.forbidden('Solo un ADMIN puede administrar usuarios.')) ); const { c, json } = createContext({ @@ -117,8 +108,11 @@ test('maps ComplexMembersError to an error response', async () => { { email: 'new.member@example.com' } ); expect(json.mock.calls[0]?.[0]).toEqual({ - message: 'Solo un ADMIN puede administrar usuarios.', + type: 'https://api.myapp.dev/problems/forbidden', + title: 'Forbidden', + status: 403, + detail: 'Solo un ADMIN puede administrar usuarios.', }); - expect(json.mock.calls[0]?.[1]).toEqual({ status: 403 }); + expect(json.mock.calls[0]?.[1]).toBe(403); expect(response).toBeDefined(); }); diff --git a/apps/backend/test/complex/invite-complex-user.test.ts b/apps/backend/test/complex/invite-complex-user.test.ts index 839db55..41f3e26 100644 --- a/apps/backend/test/complex/invite-complex-user.test.ts +++ b/apps/backend/test/complex/invite-complex-user.test.ts @@ -2,8 +2,8 @@ import { beforeEach, expect, test } from 'bun:test'; import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock'; -const { ComplexMembersError, inviteComplexUser } = await import( - '@/modules/complex/services/complex-members.service' +const { inviteComplexUser } = await import( + '@/modules/complex/features/invite-complex-user/invite-complex-user.business' ); beforeEach(() => { @@ -32,8 +32,10 @@ test('creates a pending invitation and sends the email', async () => { email: ' New.Member@example.com ', }); - expect(result.invitation.email).toBe('new.member@example.com'); - expect(result.invitation.status).toBe('PENDING'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.invitation.email).toBe('new.member@example.com'); + expect(result.value.invitation.status).toBe('PENDING'); expect(prismaMock.complexInvitation.create).toHaveBeenCalledTimes(1); const createArgs = prismaMock.complexInvitation.create.mock.calls[0]?.[0] as { @@ -69,18 +71,11 @@ test('rejects when the invite target already belongs to the complex', async () = prismaMock.complex.findUnique.mockResolvedValue({ complexName: 'Playzer Norte' } as never); prismaMock.user.findUnique.mockResolvedValue({ id: 'member-1' } as never); - let caught: unknown; + const result = await inviteComplexUser('admin-1', 'complex-1', { + email: 'member@example.com', + }); - try { - await inviteComplexUser('admin-1', 'complex-1', { - email: 'member@example.com', - }); - } catch (error) { - caught = error; - } - - expect(caught).toBeInstanceOf(ComplexMembersError); - expect((caught as InstanceType).status).toBe(409); + expect(result.ok).toBe(false); expect(transactionMock).not.toHaveBeenCalled(); expect(sendMailMock).not.toHaveBeenCalled(); }); diff --git a/apps/backend/test/sport/create-sport.handler.test.ts b/apps/backend/test/sport/create-sport.handler.test.ts index 5cff821..38fb54f 100644 --- a/apps/backend/test/sport/create-sport.handler.test.ts +++ b/apps/backend/test/sport/create-sport.handler.test.ts @@ -15,12 +15,11 @@ const createSportMock = mock(async (_input: CreateSportInput) => }) ); -mock.module('@/modules/sport/services/sport.service', () => ({ - __esModule: true, - createSport: createSportMock, -})); +const { createCreateSportHandler } = await import( + '@/modules/sport/features/create-sport/create-sport.handler' +); -const { createSportHandler } = await import('@/modules/sport/handlers/create-sport.handler'); +const createSportHandler = createCreateSportHandler({ createSport: createSportMock }); type HandlerContext = { get: (key: 'requestId') => string | undefined; diff --git a/apps/backend/test/sport/sport.service.test.ts b/apps/backend/test/sport/sport.service.test.ts index 5bf742d..8d90884 100644 --- a/apps/backend/test/sport/sport.service.test.ts +++ b/apps/backend/test/sport/sport.service.test.ts @@ -2,9 +2,11 @@ import { beforeEach, expect, test } from 'bun:test'; import { prismaMock, uuidV7Mock } from '../support/prisma.mock'; -const { createSport, getSportById, listSports, updateSport } = await import( - '@/modules/sport/services/sport.service' +const { createSport } = await import('@/modules/sport/features/create-sport/create-sport.business'); +const { getSportById, updateSport } = await import( + '@/modules/sport/features/update-sport/update-sport.business' ); +const { listSports } = await import('@/modules/sport/features/list-sports/list-sports.business'); beforeEach(() => { prismaMock._reset();