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:
@@ -0,0 +1,55 @@
|
||||
import { sseManager } from '@/lib/sse';
|
||||
import {
|
||||
PublicBookingServiceError,
|
||||
cancelPublicBooking,
|
||||
} from '@/modules/public-booking/services/public-booking.service';
|
||||
import { sendBookingCancelled } from '@/services/booking-email.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { CancelPublicBookingInput } from '@repo/api-contract';
|
||||
|
||||
type ComplexSlugParams = { complexSlug: string };
|
||||
|
||||
export async function cancelPublicBookingHandler(c: AppContext) {
|
||||
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams;
|
||||
const payload = c.req.valid('json' as never) as CancelPublicBookingInput;
|
||||
|
||||
try {
|
||||
const booking = await cancelPublicBooking(complexSlug, payload);
|
||||
|
||||
const channel = `complex-${booking.complexId}`;
|
||||
sseManager.emit(
|
||||
channel,
|
||||
JSON.stringify({
|
||||
type: 'booking_cancelled',
|
||||
booking: {
|
||||
bookingCode: booking.bookingCode,
|
||||
courtId: booking.courtId,
|
||||
date: booking.date,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
void sendBookingCancelled({
|
||||
bookingCode: booking.bookingCode,
|
||||
complexSlug: booking.complexSlug,
|
||||
complexName: booking.complexName,
|
||||
date: booking.date,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
courtName: booking.courtName,
|
||||
sportName: booking.sport.name,
|
||||
customerName: booking.customerName,
|
||||
customerEmail: booking.customerEmail,
|
||||
});
|
||||
|
||||
return c.json(booking);
|
||||
} catch (error) {
|
||||
if (error instanceof PublicBookingServiceError) {
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export async function createPublicBookingHandler(c: AppContext) {
|
||||
|
||||
void sendBookingConfirmation({
|
||||
bookingCode: booking.bookingCode,
|
||||
complexSlug: booking.complexSlug,
|
||||
complexName: booking.complexName,
|
||||
date: booking.date,
|
||||
startTime: booking.startTime,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { cancelPublicBookingHandler } from '@/modules/public-booking/handlers/cancel-public-booking.handler';
|
||||
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler';
|
||||
import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler';
|
||||
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { createPublicBookingSchema, publicAvailabilityQuerySchema } from '@repo/api-contract';
|
||||
import {
|
||||
cancelPublicBookingSchema,
|
||||
createPublicBookingSchema,
|
||||
publicAvailabilityQuerySchema,
|
||||
} from '@repo/api-contract';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -33,3 +38,10 @@ publicBookingRoutes.get(
|
||||
zValidator('param', confirmationParamsSchema),
|
||||
getPublicBookingConfirmationHandler
|
||||
);
|
||||
|
||||
publicBookingRoutes.post(
|
||||
'/complex/:complexSlug/cancel',
|
||||
zValidator('param', complexSlugParamsSchema),
|
||||
zValidator('json', cancelPublicBookingSchema),
|
||||
cancelPublicBookingHandler
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user