feat: add reschedule functionality for admin bookings

- Implemented rescheduleAdminBooking service to allow users to change court and time for confirmed bookings.
- Added validation for court availability, maintenance status, and overlapping bookings.
- Created reschedule-admin-booking handler to process rescheduling requests and send confirmation emails.
- Updated booking email service to include rescheduling notifications.
- Enhanced frontend components to support booking rescheduling, including a new dialog for selecting new court and time.
- Added tests for rescheduling logic, covering various scenarios including validation errors and successful reschedules.
- Updated Prisma schema to log previous court and time for audit purposes.
This commit is contained in:
Jose Selesan
2026-06-24 08:38:10 -03:00
parent fd4d8b7abd
commit dce312d426
23 changed files with 1231 additions and 17 deletions

View File

@@ -125,18 +125,21 @@ model CourtBooking {
}
model CourtBookingLog {
id String @id @db.Uuid
bookingCode String @map("booking_code") @db.VarChar(8)
courtId String @map("court_id") @db.Uuid
bookingDate DateTime @map("booking_date") @db.Date
startTime String @map("start_time") @db.VarChar(5)
endTime String @map("end_time") @db.VarChar(5)
previousStatus CourtBookingStatus @map("previous_status")
newStatus CourtBookingStatus @map("new_status")
customerName String @map("customer_name") @db.VarChar(120)
customerPhone String @map("customer_phone") @db.VarChar(30)
customerEmail String @map("customer_email") @db.VarChar(254)
changedAt DateTime @default(now()) @map("changed_at")
id String @id @db.Uuid
bookingCode String @map("booking_code") @db.VarChar(8)
courtId String @map("court_id") @db.Uuid
bookingDate DateTime @map("booking_date") @db.Date
startTime String @map("start_time") @db.VarChar(5)
endTime String @map("end_time") @db.VarChar(5)
previousStatus CourtBookingStatus @map("previous_status")
newStatus CourtBookingStatus @map("new_status")
previousCourtId String? @map("previous_court_id") @db.Uuid
previousStartTime String? @map("previous_start_time") @db.VarChar(5)
previousEndTime String? @map("previous_end_time") @db.VarChar(5)
customerName String @map("customer_name") @db.VarChar(120)
customerPhone String @map("customer_phone") @db.VarChar(30)
customerEmail String @map("customer_email") @db.VarChar(254)
changedAt DateTime @default(now()) @map("changed_at")
@@map("court_booking_logs")
@@index([courtId, newStatus])

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "court_booking_logs" ADD COLUMN "previous_court_id" UUID;
ALTER TABLE "court_booking_logs" ADD COLUMN "previous_start_time" VARCHAR(5);
ALTER TABLE "court_booking_logs" ADD COLUMN "previous_end_time" VARCHAR(5);

View File

@@ -300,6 +300,172 @@ export function bookingCancelledHtml(data: BookingEmailData): string {
return wrapLayout(content);
}
type BookingRescheduledEmailData = BookingEmailData & {
previousCourtName: string;
previousStartTime: string;
previousEndTime: string;
};
export function bookingRescheduledHtml(data: BookingRescheduledEmailData): string {
const friendlyDate = formatFriendlyDate(data.date);
const content = `
<tr>
<td style="padding:24px 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td valign="top">
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#f4f4f5;border-radius:999px;padding:4px 12px;">
<tr>
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#71717a;text-transform:uppercase;line-height:1.25rem;">
Reserva reprogramada
</td>
</tr>
</table>
<h1 style="margin:12px 0 0;font-size:28px;font-weight:700;color:#111827;letter-spacing:-0.025em;">
${data.complexName}
</h1>
</td>
<td valign="top" align="right" style="white-space:nowrap;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td valign="middle" style="padding-right:8px;">
<img src="${APP_BASE_URL}/playzer-favicon-512-transparent.png" alt="Playzer" width="32" height="32" style="display:block;" />
</td>
<td valign="middle">
<span style="font-size:18px;font-weight:700;color:#475569;letter-spacing:-0.025em;">Playzer</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0fdf4;border-radius:24px;border:1px solid rgba(5,150,105,0.3);">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#15803d;">
Nuevo turno
</p>
<p style="margin:12px 0 0;font-size:28px;font-weight:900;color:#111827;line-height:1.1;letter-spacing:-0.025em;">
${friendlyDate}
</p>
<p style="margin:8px 0 0;font-size:24px;font-weight:900;color:#059669;line-height:1;letter-spacing:-0.025em;">
${data.startTime} &mdash; ${data.endTime}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
<tr>
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Cancha anterior
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.previousCourtName} &mdash; ${data.sportName}
</p>
</td>
<td width="50%" style="padding:16px;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Cancha nueva
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.courtName} &mdash; ${data.sportName}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
<tr>
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Horario anterior
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.previousStartTime} &mdash; ${data.previousEndTime}
</p>
</td>
<td width="50%" style="padding:16px;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Horario nuevo
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.startTime} &mdash; ${data.endTime}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
<tr>
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Cliente
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.customerName}
</p>
</td>
<td width="50%" style="padding:16px;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
C&oacute;digo de reserva
</p>
<p style="margin:8px 0 0;font-family:monospace;font-size:16px;font-weight:700;letter-spacing:0.18em;color:#111827;">
${data.bookingCode}
</p>
</td>
</tr>
</table>
</td>
</tr>
${
data.complexSlug
? `
<tr>
<td style="padding:0 20px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" style="background-color:#059669;border-radius:12px;">
<tr>
<td style="padding:14px 32px;font-size:15px;font-weight:600;">
<a href="${APP_BASE_URL}/${data.complexSlug}/booking/confirmed/${data.bookingCode}" style="color:#ffffff;text-decoration:none;display:inline-block;">
Ver reserva
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>`
: ''
}`;
return wrapLayout(content);
}
export function bookingNoShowHtml(data: BookingEmailData): string {
const friendlyDate = formatFriendlyDate(data.date);

File diff suppressed because one or more lines are too long

View File

@@ -1924,6 +1924,9 @@ export const CourtBookingLogScalarFieldEnum = {
endTime: 'endTime',
previousStatus: 'previousStatus',
newStatus: 'newStatus',
previousCourtId: 'previousCourtId',
previousStartTime: 'previousStartTime',
previousEndTime: 'previousEndTime',
customerName: 'customerName',
customerPhone: 'customerPhone',
customerEmail: 'customerEmail',

View File

@@ -293,6 +293,9 @@ export const CourtBookingLogScalarFieldEnum = {
endTime: 'endTime',
previousStatus: 'previousStatus',
newStatus: 'newStatus',
previousCourtId: 'previousCourtId',
previousStartTime: 'previousStartTime',
previousEndTime: 'previousEndTime',
customerName: 'customerName',
customerPhone: 'customerPhone',
customerEmail: 'customerEmail',

View File

@@ -4,6 +4,7 @@ import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/crea
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 type { AppEnv } from '@/types/hono';
@@ -12,6 +13,7 @@ import {
createAdminBookingSchema,
createRecurringBookingSchema,
listAdminBookingsQuerySchema,
rescheduleAdminBookingSchema,
updateAdminBookingStatusSchema,
updateRecurringGroupSchema,
} from '@repo/api-contract';
@@ -71,3 +73,10 @@ adminBookingRoutes.patch(
zValidator('json', updateAdminBookingStatusSchema),
updateAdminBookingStatusHandler
);
adminBookingRoutes.patch(
'/:id/reschedule',
zValidator('param', bookingIdParamsSchema),
zValidator('json', rescheduleAdminBookingSchema),
rescheduleAdminBookingHandler
);

View File

@@ -0,0 +1,60 @@
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';
type BookingIdParams = { id: string };
export async function rescheduleAdminBookingHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as BookingIdParams;
const payload = c.req.valid('json' as never) as RescheduleAdminBookingInput;
const user = c.get('user');
const previous = await db.courtBooking.findUnique({
where: { id },
select: {
court: { select: { name: true } },
startTime: true,
endTime: true,
customerEmail: true,
},
});
try {
const booking = await rescheduleAdminBooking(user.id, id, payload);
if (previous) {
void sendBookingRescheduled({
bookingCode: booking.bookingCode,
complexName: booking.complexName,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
courtName: booking.courtName,
sportName: booking.sport.name,
customerName: booking.customerName,
customerEmail: previous.customerEmail,
previousCourtName: previous.court.name,
previousStartTime: previous.startTime,
previousEndTime: previous.endTime,
});
}
sseManager.emit(
`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;
}
}

View File

@@ -8,6 +8,7 @@ import type {
AdminBooking,
CreateAdminBookingInput,
ListAdminBookingsQuery,
RescheduleAdminBookingInput,
UpdateAdminBookingStatusInput,
} from '@repo/api-contract';
import { v7 as uuidv7 } from 'uuid';
@@ -598,3 +599,196 @@ export async function updateAdminBookingStatus(
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);
}

View File

@@ -167,6 +167,7 @@ function mapCourtResponse(court: CourtWithRelations) {
id: court.id,
complexId: court.complexId,
name: court.name,
isUnderMaintenance: court.isUnderMaintenance,
sportId: court.sportId,
sport: {
id: court.sport.id,

View File

@@ -2,6 +2,7 @@ import {
bookingCancelledHtml,
bookingConfirmationHtml,
bookingNoShowHtml,
bookingRescheduledHtml,
} from '@/emails/booking-confirmation';
import { sendMail } from '@/lib/mailer';
@@ -86,3 +87,35 @@ export async function sendBookingNoShow(data: BookingEmailInput) {
text: `Reserva no concretada en ${data.complexName}. Codigo: ${data.bookingCode}. Fecha: ${data.date}. Horario: ${data.startTime} - ${data.endTime}.`,
});
}
type BookingRescheduledEmailInput = BookingEmailInput & {
previousCourtName: string;
previousStartTime: string;
previousEndTime: string;
};
export async function sendBookingRescheduled(data: BookingRescheduledEmailInput) {
if (!data.customerEmail) return;
const html = bookingRescheduledHtml({
bookingCode: data.bookingCode,
complexSlug: data.complexSlug,
complexName: data.complexName,
date: data.date,
startTime: data.startTime,
endTime: data.endTime,
courtName: data.courtName,
sportName: data.sportName,
customerName: data.customerName,
previousCourtName: data.previousCourtName,
previousStartTime: data.previousStartTime,
previousEndTime: data.previousEndTime,
});
await sendMail({
to: data.customerEmail,
subject: `Reserva reprogramada en ${data.complexName} | Playzer`,
html,
text: `Reserva reprogramada en ${data.complexName}. Codigo: ${data.bookingCode}. Nueva cancha: ${data.courtName}. Nuevo horario: ${data.startTime} - ${data.endTime}.`,
});
}

View File

@@ -0,0 +1,126 @@
import { beforeEach, expect, mock, test } from 'bun:test';
const rescheduleAdminBookingMock = mock(
async (_userId: string, _id: string, _input: unknown) => ({})
);
class MockAdminBookingServiceError extends Error {
status: 400 | 403 | 404 | 409;
constructor(message: string, status: 400 | 403 | 404 | 409 = 400) {
super(message);
this.name = 'AdminBookingServiceError';
this.status = status;
}
}
mock.module('@/modules/admin-booking/services/admin-booking.service', () => ({
__esModule: true,
AdminBookingServiceError: MockAdminBookingServiceError,
rescheduleAdminBooking: rescheduleAdminBookingMock,
}));
const { rescheduleAdminBookingHandler } = await import(
'@/modules/admin-booking/handlers/reschedule-admin-booking.handler'
);
type HandlerContext = {
get: (key: 'user' | 'requestId') => { id: string } | undefined;
req: {
valid: (type: string) => Record<string, unknown>;
};
json: ReturnType<typeof mock>;
};
function createContext(input: { userId: string; body: unknown }) {
const json = mock((payload: unknown, init?: unknown) => ({
payload,
init,
}));
const c = {
get: (key: 'user' | 'requestId') => {
if (key === 'user') return { id: input.userId };
return undefined;
},
req: {
valid: (type: string) => {
if (type === 'param') return { id: 'booking-1' };
if (type === 'json') return input.body as Record<string, unknown>;
return {};
},
},
json,
} as unknown as HandlerContext;
return { c, json };
}
beforeEach(() => {
rescheduleAdminBookingMock.mockReset();
});
test('returns 200 with the updated booking', async () => {
const updatedBooking = {
id: 'booking-1',
bookingCode: 'ABC123',
complexId: 'complex-1',
complexName: 'Complejo Test',
courtId: 'court-2',
courtName: 'Cancha 2',
sport: { id: 'sport-1', name: 'Tenis', slug: 'tenis' },
date: '2026-07-15',
startTime: '14:00',
endTime: '15:00',
customerName: 'Juan Perez',
customerPhone: '1144444444',
customerEmail: 'juan@example.com',
price: 15000,
status: 'CONFIRMED',
recurringGroupId: null,
createdAt: '2026-07-14T00:00:00.000Z',
updatedAt: '2026-07-14T00:00:00.000Z',
} as const;
rescheduleAdminBookingMock.mockResolvedValue(updatedBooking);
const { c, json } = createContext({
userId: 'user-1',
body: { courtId: 'court-2', startTime: '14:00' },
});
const response = await rescheduleAdminBookingHandler(c as never);
expect(rescheduleAdminBookingMock).toHaveBeenCalledWith('user-1', 'booking-1', {
courtId: 'court-2',
startTime: '14:00',
});
expect(json.mock.calls[0]?.[0]).toEqual(updatedBooking);
expect(json.mock.calls[0]?.[1]).toBeUndefined();
expect(response).toBeDefined();
});
test('maps AdminBookingServiceError to an error response', async () => {
rescheduleAdminBookingMock.mockRejectedValue(
new MockAdminBookingServiceError(
'Solo se pueden reprogramar reservas en estado confirmada.',
409
)
);
const { c, json } = createContext({
userId: 'user-1',
body: { startTime: '14:00' },
});
const response = await rescheduleAdminBookingHandler(c as never);
expect(rescheduleAdminBookingMock).toHaveBeenCalledWith('user-1', 'booking-1', {
startTime: '14:00',
});
expect(json.mock.calls[0]?.[0]).toEqual({
message: 'Solo se pueden reprogramar reservas en estado confirmada.',
});
expect(json.mock.calls[0]?.[1]).toBe(409);
expect(response).toBeDefined();
});

View File

@@ -0,0 +1,317 @@
import { beforeEach, expect, test } from 'bun:test';
import { prismaMock, transactionMock, uuidV7Mock } from '../support/prisma.mock';
const { AdminBookingServiceError, rescheduleAdminBooking } = await import(
'@/modules/admin-booking/services/admin-booking.service'
);
function makeBooking(overrides?: Record<string, unknown>) {
return {
id: 'booking-1',
bookingCode: 'ABC123',
courtId: 'court-1',
bookingDate: new Date('2026-07-15T00:00:00.000Z'),
startTime: '10:00',
endTime: '11:00',
customerName: 'Juan Perez',
customerPhone: '1144444444',
customerEmail: 'juan@example.com',
status: 'CONFIRMED',
recurringGroupId: null,
createdAt: new Date('2026-07-14T00:00:00.000Z'),
updatedAt: new Date('2026-07-14T00:00:00.000Z'),
court: {
id: 'court-1',
name: 'Cancha 1',
complexId: 'complex-1',
slotDurationMinutes: 60,
sport: { id: 'sport-1', name: 'Tenis', slug: 'tenis' },
complex: { id: 'complex-1', complexName: 'Complejo Test' },
},
...overrides,
};
}
// July 15, 2026 is a Wednesday
const WEDNESDAY = 'WEDNESDAY';
function makeCourt(overrides?: Record<string, unknown>) {
return {
id: 'court-2',
name: 'Cancha 2',
complexId: 'complex-1',
slotDurationMinutes: 60,
basePrice: 15000,
isUnderMaintenance: false,
availabilities: [{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '18:00' }],
priceRules: [],
sport: { id: 'sport-1', name: 'Tenis', slug: 'tenis' },
...overrides,
};
}
beforeEach(() => {
prismaMock._reset();
transactionMock.mockClear();
uuidV7Mock.mockReset();
uuidV7Mock.mockReturnValue('00000000-0000-4000-8000-000000000001');
});
test('reschedules a booking changing both court and time', async () => {
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
prismaMock.court.findFirst.mockResolvedValue(makeCourt() as never);
prismaMock.courtBooking.findFirst
.mockResolvedValueOnce(makeBooking() as never) // initial fetch
.mockResolvedValueOnce(null as never); // overlap check -> no overlap
transactionMock.mockImplementation(async <T>(fn: (tx: typeof prismaMock) => Promise<T>) =>
fn(prismaMock)
);
prismaMock.courtBookingLog.create.mockResolvedValue({} as never);
prismaMock.courtBooking.update.mockResolvedValue(
makeBooking({
courtId: 'court-2',
startTime: '14:00',
endTime: '15:00',
}) as never
);
const result = await rescheduleAdminBooking('user-1', 'booking-1', {
courtId: 'court-2',
startTime: '14:00',
});
expect(result.courtId).toBe('court-2');
expect(result.startTime).toBe('14:00');
expect(result.courtName).toBe('Cancha 2');
expect(prismaMock.courtBookingLog.create).toHaveBeenCalledTimes(1);
expect(prismaMock.courtBooking.update).toHaveBeenCalledWith({
where: { id: 'booking-1' },
data: {
courtId: 'court-2',
startTime: '14:00',
endTime: '15:00',
},
});
const logArgs = prismaMock.courtBookingLog.create.mock.calls[0]?.[0] as {
data: { previousCourtId: string; previousStartTime: string; previousEndTime: string };
};
expect(logArgs.data.previousCourtId).toBe('court-1');
expect(logArgs.data.previousStartTime).toBe('10:00');
expect(logArgs.data.previousEndTime).toBe('11:00');
});
test('reschedules a booking changing only the time', async () => {
prismaMock.courtBooking.findFirst
.mockResolvedValueOnce(makeBooking() as never) // initial fetch
.mockResolvedValueOnce(null as never); // overlap check
// Same court, different time
prismaMock.court.findFirst.mockResolvedValue(
makeCourt({
id: 'court-1',
name: 'Cancha 1',
availabilities: [
{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '18:00' },
],
}) as never
);
transactionMock.mockImplementation(async <T>(fn: (tx: typeof prismaMock) => Promise<T>) =>
fn(prismaMock)
);
prismaMock.courtBookingLog.create.mockResolvedValue({} as never);
prismaMock.courtBooking.update.mockResolvedValue(
makeBooking({
startTime: '15:00',
endTime: '16:00',
}) as never
);
const result = await rescheduleAdminBooking('user-1', 'booking-1', {
startTime: '15:00',
});
expect(result.startTime).toBe('15:00');
expect(result.courtId).toBe('court-1');
expect(prismaMock.courtBookingLog.create).toHaveBeenCalledTimes(1);
expect(prismaMock.courtBooking.update).toHaveBeenCalledWith({
where: { id: 'booking-1' },
data: {
courtId: 'court-1',
startTime: '15:00',
endTime: '16:00',
},
});
});
test('rejects when booking is not found', async () => {
prismaMock.courtBooking.findFirst.mockResolvedValue(null as never);
let caught: unknown;
try {
await rescheduleAdminBooking('user-1', 'non-existent', { startTime: '14:00' });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AdminBookingServiceError);
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(404);
expect(transactionMock).not.toHaveBeenCalled();
});
test('rejects when booking status is not CONFIRMED', async () => {
prismaMock.courtBooking.findFirst.mockResolvedValue(
makeBooking({ status: 'COMPLETED' }) as never
);
let caught: unknown;
try {
await rescheduleAdminBooking('user-1', 'booking-1', { startTime: '14:00' });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AdminBookingServiceError);
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
expect(transactionMock).not.toHaveBeenCalled();
});
test('rejects when slot is not available in target court', async () => {
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
// Court with no availabilities that match the requested time
prismaMock.court.findFirst.mockResolvedValue(
makeCourt({
id: 'court-2',
availabilities: [
{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '09:00' },
],
}) as never
);
let caught: unknown;
try {
await rescheduleAdminBooking('user-1', 'booking-1', {
courtId: 'court-2',
startTime: '14:00',
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AdminBookingServiceError);
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
});
test('rejects when target court has a different sport', async () => {
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
prismaMock.court.findFirst.mockResolvedValue(
makeCourt({
sport: { id: 'sport-2', name: 'Fútbol', slug: 'futbol' },
}) as never
);
let caught: unknown;
try {
await rescheduleAdminBooking('user-1', 'booking-1', {
courtId: 'court-2',
startTime: '14:00',
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AdminBookingServiceError);
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
expect(transactionMock).not.toHaveBeenCalled();
});
test('rejects when target court is under maintenance', async () => {
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
prismaMock.court.findFirst.mockResolvedValue(
makeCourt({
isUnderMaintenance: true,
}) as never
);
let caught: unknown;
try {
await rescheduleAdminBooking('user-1', 'booking-1', {
courtId: 'court-2',
startTime: '14:00',
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AdminBookingServiceError);
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
});
test('rejects when there is an overlapping booking', async () => {
prismaMock.courtBooking.findFirst
.mockResolvedValueOnce(makeBooking() as never) // initial fetch
.mockResolvedValueOnce({ id: 'overlap-1' } as never); // overlap found
prismaMock.court.findFirst.mockResolvedValue(
makeCourt({
availabilities: [
{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '18:00' },
],
}) as never
);
let caught: unknown;
try {
await rescheduleAdminBooking('user-1', 'booking-1', {
courtId: 'court-2',
startTime: '14:00',
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AdminBookingServiceError);
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
expect(transactionMock).not.toHaveBeenCalled();
});
test('rejects when neither courtId nor startTime changes', async () => {
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
let caught: unknown;
try {
await rescheduleAdminBooking('user-1', 'booking-1', {});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AdminBookingServiceError);
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(400);
expect(transactionMock).not.toHaveBeenCalled();
});
test('rejects when target court does not belong to the same complex', async () => {
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
// Court belongs to a different complex
prismaMock.court.findFirst.mockResolvedValue(null as never);
let caught: unknown;
try {
await rescheduleAdminBooking('user-1', 'booking-1', {
courtId: 'other-complex-court',
startTime: '14:00',
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AdminBookingServiceError);
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(404);
expect(transactionMock).not.toHaveBeenCalled();
});

View File

@@ -22,6 +22,11 @@ export const dbMock = {
user: prismaMock.user,
complexInvitation: prismaMock.complexInvitation,
sport: prismaMock.sport,
court: prismaMock.court,
courtBooking: prismaMock.courtBooking,
courtBookingLog: prismaMock.courtBookingLog,
courtAvailability: prismaMock.courtAvailability,
courtPriceRule: prismaMock.courtPriceRule,
$transaction: transactionMock,
};