feat: add recurring booking management functionality

- Implemented API endpoints for listing and updating recurring booking groups.
- Added handlers for listing and updating recurring groups in the backend.
- Created frontend components for displaying and editing recurring bookings.
- Enhanced booking provider to manage recurring groups and their states.
- Updated API client to include new methods for recurring bookings.
- Introduced new validation schemas for updating recurring groups.
- Added UI elements for managing recurring bookings in the booking interface.
This commit is contained in:
Jose Selesan
2026-06-16 15:16:38 -03:00
parent 1318e3bf57
commit 3b3def94ab
15 changed files with 936 additions and 5 deletions

View File

@@ -3,7 +3,9 @@ import { cancelRecurringGroupHandler } from '@/modules/admin-booking/handlers/ca
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 { updateAdminBookingStatusHandler } from '@/modules/admin-booking/handlers/update-admin-booking-status.handler';
import { updateRecurringGroupHandler } from '@/modules/admin-booking/handlers/update-recurring-group.handler';
import type { AppEnv } from '@/types/hono';
import { zValidator } from '@hono/zod-validator';
import {
@@ -11,6 +13,7 @@ import {
createRecurringBookingSchema,
listAdminBookingsQuerySchema,
updateAdminBookingStatusSchema,
updateRecurringGroupSchema,
} from '@repo/api-contract';
import { Hono } from 'hono';
import { z } from 'zod';
@@ -49,6 +52,19 @@ adminBookingRoutes.post(
cancelRecurringGroupHandler
);
adminBookingRoutes.get(
'/complex/:complexId/recurring',
zValidator('param', complexIdParamsSchema),
listRecurringGroupsHandler
);
adminBookingRoutes.patch(
'/recurring/:groupId',
zValidator('param', groupIdParamsSchema),
zValidator('json', updateRecurringGroupSchema),
updateRecurringGroupHandler
);
adminBookingRoutes.patch(
'/:id/status',
zValidator('param', bookingIdParamsSchema),

View File

@@ -0,0 +1,19 @@
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;
}
}

View File

@@ -0,0 +1,22 @@
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;
}
}

View File

@@ -7,7 +7,11 @@ import {
isFeatureEnabled,
parsePlanRules,
} from '@/modules/plan/services/plan-rules.service';
import type { CreateRecurringBookingInput, RecurringBookingGroup } from '@repo/api-contract';
import type {
CreateRecurringBookingInput,
RecurringBookingGroup,
UpdateRecurringGroupInput,
} from '@repo/api-contract';
import { v7 as uuidv7 } from 'uuid';
import { AdminBookingServiceError } from './admin-booking.service';
@@ -15,6 +19,16 @@ 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,
@@ -574,3 +588,306 @@ export async function cancelRecurringGroup(userId: string, groupId: string) {
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(),
};
}