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:
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user