feat: implement public booking cancellation feature

- Added cancelPublicBooking handler to manage booking cancellations.
- Updated public booking service to include cancellation logic.
- Created cancelPublicBooking schema for input validation.
- Modified public booking confirmation page to allow users to cancel their bookings.
- Enhanced email service to send cancellation confirmation emails.
- Made customerEmail field required in booking schemas and updated related components.
- Updated API client to support cancellation requests.
- Added migration to enforce non-nullable customer_email fields in the database.
This commit is contained in:
Jose Selesan
2026-06-05 11:24:02 -03:00
parent 1210854c22
commit 260d79fc99
24 changed files with 488 additions and 105 deletions

View File

@@ -2,6 +2,7 @@ import { randomInt } from 'node:crypto';
import { db } from '@/lib/prisma';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type {
CancelPublicBookingInput,
CreatePublicBookingInput,
DayOfWeek,
PublicAvailabilityQuery,
@@ -42,7 +43,6 @@ export class PublicBookingServiceError extends Error {
constructor(message: string, status: 400 | 403 | 404 | 409) {
super(message);
this.name = 'PublicBookingServiceError';
this.status = status;
}
}
@@ -276,7 +276,7 @@ function mapBookingResponse(input: {
endTime: string;
customerName: string;
customerPhone: string;
customerEmail: string | null;
customerEmail: string;
price: number;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
court: {
@@ -306,7 +306,7 @@ function mapBookingResponse(input: {
endTime: input.endTime,
customerName: input.customerName,
customerPhone: input.customerPhone,
customerEmail: input.customerEmail ?? undefined,
customerEmail: input.customerEmail,
status: input.status,
price: input.price,
createdAt: input.createdAt.toISOString(),
@@ -386,7 +386,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
slug: booking.court.sport.slug,
},
status: booking.status,
customerEmail: booking.customerEmail ?? undefined,
customerEmail: booking.customerEmail,
createdAt: booking.createdAt.toISOString(),
};
}
@@ -631,7 +631,7 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
endTime: selectedSlot.endTime,
customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(),
customerEmail: input.customerEmail?.trim() || null,
customerEmail: input.customerEmail.trim(),
status: 'CONFIRMED',
},
select: {
@@ -712,3 +712,108 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
409
);
}
export async function cancelPublicBooking(complexSlug: string, input: CancelPublicBookingInput) {
const normalizedBookingCode = input.bookingCode.toUpperCase();
const booking = await db.courtBooking.findFirst({
where: {
bookingCode: normalizedBookingCode,
court: {
complex: {
complexSlug,
},
},
},
include: {
court: {
select: {
id: true,
name: true,
basePrice: true,
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
sport: {
select: {
id: true,
name: true,
slug: true,
},
},
complex: {
select: {
id: true,
complexName: true,
complexSlug: true,
},
},
},
},
},
});
if (!booking) {
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
}
if (booking.status !== 'CONFIRMED') {
throw new PublicBookingServiceError(
'Solo se pueden cancelar reservas en estado confirmada.',
409
);
}
if (booking.customerPhone !== input.customerPhone.trim()) {
throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 403);
}
const date = formatIsoDate(booking.bookingDate);
const { dayOfWeek } = parseIsoDate(date);
const price = resolveSlotPrice(booking.court, dayOfWeek, {
startTime: booking.startTime,
endTime: booking.endTime,
});
await db.courtBookingLog.create({
data: {
id: uuidv7(),
bookingCode: booking.bookingCode,
courtId: booking.courtId,
bookingDate: booking.bookingDate,
startTime: booking.startTime,
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
previousStatus: booking.status,
newStatus: 'CANCELLED',
},
});
await db.courtBooking.delete({
where: { id: booking.id },
});
return mapBookingResponse({
bookingId: booking.id,
bookingCode: booking.bookingCode,
bookingDate: booking.bookingDate,
createdAt: booking.createdAt,
startTime: booking.startTime,
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
price,
status: 'CANCELLED',
court: {
id: booking.court.id,
name: booking.court.name,
complexId: booking.court.complex.id,
complexName: booking.court.complex.complexName,
complexSlug: booking.court.complex.complexSlug,
sport: booking.court.sport,
},
});
}