Merge pull request 'Refactor to vertical slice' (#20) from refactor/vertical-slice into development
Reviewed-on: #20
This commit is contained in:
176
apps/backend/src/lib/booking-utils.ts
Normal file
176
apps/backend/src/lib/booking-utils.ts
Normal file
@@ -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<DayOfWeek, Array<{ start: number; end: number }>>();
|
||||
|
||||
for (const range of availability) {
|
||||
const start = toMinutes(range.startTime);
|
||||
const end = toMinutes(range.endTime);
|
||||
|
||||
if (start >= end) {
|
||||
throw new 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
apps/backend/src/lib/complex-access.ts
Normal file
41
apps/backend/src/lib/complex-access.ts
Normal file
@@ -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;
|
||||
}
|
||||
31
apps/backend/src/lib/slug.ts
Normal file
31
apps/backend/src/lib/slug.ts
Normal file
@@ -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<string> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Result<{ ok: boolean }>> {
|
||||
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 });
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<Result<AdminBooking>> {
|
||||
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.'));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Date> {
|
||||
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<DayOfWeek> {
|
||||
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<Result<RecurringBookingGroup>> {
|
||||
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.'));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Result<{ bookings: unknown[] }>> {
|
||||
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)) });
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(),
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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<Result<AdminBooking>> {
|
||||
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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Result<ReturnType<typeof mapBookingResponse>>> {
|
||||
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 }));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, number> = {
|
||||
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<Date> {
|
||||
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<Result<RecurringBookingGroup>> {
|
||||
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<string, string> = {};
|
||||
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(),
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<AdminBooking> {
|
||||
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);
|
||||
}
|
||||
@@ -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<string, number> = {
|
||||
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<Date> {
|
||||
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<RecurringBookingGroup> {
|
||||
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<RecurringBookingGroup[]> {
|
||||
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<RecurringBookingGroup> {
|
||||
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<string, string> = {};
|
||||
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(),
|
||||
};
|
||||
}
|
||||
6
apps/backend/src/modules/admin-booking/shared/errors.ts
Normal file
6
apps/backend/src/modules/admin-booking/shared/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class AdminBookingServiceError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AdminBookingServiceError';
|
||||
}
|
||||
}
|
||||
216
apps/backend/src/modules/admin-booking/shared/helpers.ts
Normal file
216
apps/backend/src/modules/admin-booking/shared/helpers.ts
Normal file
@@ -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<Date> {
|
||||
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<DayOfWeek> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
import { AdminServiceError } from '../../shared/errors';
|
||||
|
||||
export async function blockUser(userId: string, banReason?: string): Promise<void> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.' };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<AppEnv> = async (c) => {
|
||||
const stats = await getGeoStats();
|
||||
@@ -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<AdminUserSession[]> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -26,9 +26,7 @@ export async function getComplexStatsList(): Promise<ComplexStats[]> {
|
||||
plan: true,
|
||||
users: true,
|
||||
courts: {
|
||||
include: {
|
||||
bookings: true,
|
||||
},
|
||||
include: { bookings: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<AdminUser[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -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');
|
||||
@@ -0,0 +1,9 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
|
||||
export async function revokeAllUserSessions(userId: string): Promise<number> {
|
||||
const result = await db.session.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
|
||||
export async function unblockUser(userId: string): Promise<void> {
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
banned: false,
|
||||
bannedAt: null,
|
||||
banReason: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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.' });
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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<AdminUser[]> {
|
||||
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<AdminUserSession[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
banned: false,
|
||||
bannedAt: null,
|
||||
banReason: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeAllUserSessions(userId: string): Promise<number> {
|
||||
const result = await db.session.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
9
apps/backend/src/modules/admin/shared/errors.ts
Normal file
9
apps/backend/src/modules/admin/shared/errors.ts
Normal file
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Result<AcceptComplexInvitationsResponse>> {
|
||||
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 });
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<Result<CancelComplexInvitationResponse>> {
|
||||
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 });
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
|
||||
export async function getComplexById(id: string) {
|
||||
return db.complex.findUnique({ where: { id } });
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
|
||||
export async function getComplexBySlug(slug: string) {
|
||||
return db.complex.findUnique({ where: { complexSlug: slug } });
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<Result<InviteComplexUserResponse>> {
|
||||
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) });
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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<Result<InviteComplexUserResponse>> {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<Result<{ ok: boolean }>> {
|
||||
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 });
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<string, unknown> = {};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<h2>Te invitaron a <strong>${input.complexName}</strong></h2>
|
||||
<p>Hacé click en el siguiente enlace para crear tu cuenta o iniciar sesión y aceptar la invitación:</p>
|
||||
<p><a href="${inviteUrl.toString()}">${inviteUrl.toString()}</a></p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
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<InviteComplexUserResponse> {
|
||||
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<InviteComplexUserResponse> {
|
||||
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<AcceptComplexInvitationsResponse> {
|
||||
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<CancelComplexInvitationResponse> {
|
||||
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);
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string, unknown> = {};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
6
apps/backend/src/modules/complex/shared/errors.ts
Normal file
6
apps/backend/src/modules/complex/shared/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class ComplexMembersError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ComplexMembersError';
|
||||
}
|
||||
}
|
||||
158
apps/backend/src/modules/complex/shared/members-helpers.ts
Normal file
158
apps/backend/src/modules/complex/shared/members-helpers.ts
Normal file
@@ -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<Result<void>> {
|
||||
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<Result<{ role: string }>> {
|
||||
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: `
|
||||
<h2>Te invitaron a <strong>${input.complexName}</strong></h2>
|
||||
<p>Hacé click en el siguiente enlace para crear tu cuenta o iniciar sesión y aceptar la invitación:</p>
|
||||
<p><a href="${inviteUrl.toString()}">${inviteUrl.toString()}</a></p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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<Result<void>> {
|
||||
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<Result<ReturnType<typeof mapCourtResponse>>> {
|
||||
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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Result<ReturnType<typeof mapCourtResponse>[]>> {
|
||||
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 };
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<Result<ReturnType<typeof mapCourtResponse>>> {
|
||||
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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<DayOfWeek, Array<{ start: number; end: number }>>();
|
||||
|
||||
for (const range of availability) {
|
||||
const start = toMinutes(range.startTime);
|
||||
const end = toMinutes(range.endTime);
|
||||
|
||||
if (start >= end) {
|
||||
throw new CourtServiceError(`El rango ${range.startTime}-${range.endTime} es invalido.`, 400);
|
||||
}
|
||||
|
||||
const current = grouped.get(range.dayOfWeek) ?? [];
|
||||
current.push({ start, end });
|
||||
grouped.set(range.dayOfWeek, current);
|
||||
}
|
||||
|
||||
for (const ranges of grouped.values()) {
|
||||
ranges.sort((a, b) => a.start - b.start);
|
||||
|
||||
for (let index = 1; index < ranges.length; index += 1) {
|
||||
const previous = ranges[index - 1];
|
||||
const current = ranges[index];
|
||||
|
||||
if (previous.end > current.start) {
|
||||
throw new CourtServiceError('Hay rangos horarios superpuestos para el mismo dia.', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureComplexAccess(complexId: string, userId: string) {
|
||||
const complexUser = await db.complexUser.findUnique({
|
||||
where: {
|
||||
complexId_userId: {
|
||||
complexId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
complex: {
|
||||
select: {
|
||||
id: true,
|
||||
plan: {
|
||||
select: {
|
||||
rules: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!complexUser) {
|
||||
throw new CourtServiceError('No tienes permisos para administrar este complejo.', 403);
|
||||
}
|
||||
|
||||
return complexUser.complex;
|
||||
}
|
||||
|
||||
async function ensureActiveSport(sportId: string) {
|
||||
const sport = await db.sport.findFirst({
|
||||
where: {
|
||||
id: sportId,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!sport) {
|
||||
throw new CourtServiceError('El deporte seleccionado no existe o esta inactivo.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function enforcePlanCourtLimit(complexId: string) {
|
||||
const complex = await db.complex.findUnique({
|
||||
where: { id: complexId },
|
||||
select: {
|
||||
plan: {
|
||||
select: {
|
||||
rules: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!complex?.plan) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = parsePlanRules(complex.plan.rules);
|
||||
const courtsCount = await db.court.count({
|
||||
where: { complexId },
|
||||
});
|
||||
|
||||
const violations = evaluatePlanUsage(rules, {
|
||||
courtsCount,
|
||||
bookingsToday: 0,
|
||||
});
|
||||
|
||||
const maxCourtViolation = violations.find((violation) => violation.code === 'MAX_COURTS_REACHED');
|
||||
|
||||
if (maxCourtViolation) {
|
||||
throw new CourtServiceError(maxCourtViolation.message, 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function getCourtByIdForUser(courtId: string, userId: string) {
|
||||
return db.court.findFirst({
|
||||
where: {
|
||||
id: courtId,
|
||||
complex: {
|
||||
users: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
sport: true,
|
||||
availabilities: {
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
},
|
||||
priceRules: {
|
||||
where: { isActive: true },
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mapCourtResponse(court: CourtWithRelations) {
|
||||
return {
|
||||
id: court.id,
|
||||
complexId: court.complexId,
|
||||
name: court.name,
|
||||
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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user