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

@@ -85,7 +85,7 @@ model CourtBooking {
endTime String @map("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)
customerEmail String @map("customer_email") @db.VarChar(254)
status CourtBookingStatus @default(CONFIRMED)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -108,7 +108,7 @@ model CourtBookingLog {
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)
customerEmail String @map("customer_email") @db.VarChar(254)
changedAt DateTime @default(now()) @map("changed_at")
@@map("court_booking_logs")

View File

@@ -0,0 +1,16 @@
/*
Warnings:
- Made the column `customer_email` on table `court_booking_logs` required. This step will fail if there are existing NULL values in that column.
- Made the column `customer_email` on table `court_bookings` required. This step will fail if there are existing NULL values in that column.
*/
-- Backfill existing NULL values with placeholder
UPDATE "court_booking_logs" SET "customer_email" = 'missing@playzer.app' WHERE "customer_email" IS NULL;
UPDATE "court_bookings" SET "customer_email" = 'missing@playzer.app' WHERE "customer_email" IS NULL;
-- AlterTable
ALTER TABLE "court_booking_logs" ALTER COLUMN "customer_email" SET NOT NULL;
-- AlterTable
ALTER TABLE "court_bookings" ALTER COLUMN "customer_email" SET NOT NULL;

View File

@@ -1,5 +1,6 @@
type BookingEmailData = {
bookingCode: string;
complexSlug?: string;
complexName: string;
date: string;
startTime: string;
@@ -171,15 +172,30 @@ export function bookingConfirmationHtml(data: BookingEmailData): string {
</td>
</tr>
${
data.complexSlug
? `
<tr>
<td style="padding:0 20px 24px;">
<p style="margin:0;font-size:13px;color:#6b7280;text-align:center;line-height:1.5;">
Present&aacute; el c&oacute;digo de reserva al llegar al complejo.
<br />
Si necesit&aacute;s cancelar o modificar, contactate directamente con el complejo.
</p>
<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;">
Cancelar reserva
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>`;
</tr>`
: ''
}`;
return wrapLayout(content);
}

File diff suppressed because one or more lines are too long

View File

@@ -198,7 +198,7 @@ export type CourtBookingGroupByOutputType = {
endTime: string
customerName: string
customerPhone: string
customerEmail: string | null
customerEmail: string
status: $Enums.CourtBookingStatus
createdAt: Date
updatedAt: Date
@@ -234,7 +234,7 @@ export type CourtBookingWhereInput = {
endTime?: Prisma.StringFilter<"CourtBooking"> | string
customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
@@ -250,7 +250,7 @@ export type CourtBookingOrderByWithRelationInput = {
endTime?: Prisma.SortOrder
customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder
customerEmail?: Prisma.SortOrderInput | Prisma.SortOrder
customerEmail?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -270,7 +270,7 @@ export type CourtBookingWhereUniqueInput = Prisma.AtLeast<{
endTime?: Prisma.StringFilter<"CourtBooking"> | string
customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
@@ -286,7 +286,7 @@ export type CourtBookingOrderByWithAggregationInput = {
endTime?: Prisma.SortOrder
customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder
customerEmail?: Prisma.SortOrderInput | Prisma.SortOrder
customerEmail?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -307,7 +307,7 @@ export type CourtBookingScalarWhereWithAggregatesInput = {
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringNullableWithAggregatesFilter<"CourtBooking"> | string | null
customerEmail?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
@@ -321,7 +321,7 @@ export type CourtBookingCreateInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -337,7 +337,7 @@ export type CourtBookingUncheckedCreateInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -351,7 +351,7 @@ export type CourtBookingUpdateInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -367,7 +367,7 @@ export type CourtBookingUncheckedUpdateInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -382,7 +382,7 @@ export type CourtBookingCreateManyInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -396,7 +396,7 @@ export type CourtBookingUpdateManyMutationInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -411,7 +411,7 @@ export type CourtBookingUncheckedUpdateManyInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -532,7 +532,7 @@ export type CourtBookingCreateWithoutCourtInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -546,7 +546,7 @@ export type CourtBookingUncheckedCreateWithoutCourtInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -590,7 +590,7 @@ export type CourtBookingScalarWhereInput = {
endTime?: Prisma.StringFilter<"CourtBooking"> | string
customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
@@ -604,7 +604,7 @@ export type CourtBookingCreateManyCourtInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -618,7 +618,7 @@ export type CourtBookingUpdateWithoutCourtInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -632,7 +632,7 @@ export type CourtBookingUncheckedUpdateWithoutCourtInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -646,7 +646,7 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -742,7 +742,7 @@ export type $CourtBookingPayload<ExtArgs extends runtime.Types.Extensions.Intern
endTime: string
customerName: string
customerPhone: string
customerEmail: string | null
customerEmail: string
status: $Enums.CourtBookingStatus
createdAt: Date
updatedAt: Date

View File

@@ -1,3 +1,4 @@
import { db } from '@/lib/prisma';
import {
AdminBookingServiceError,
createAdminBooking,
@@ -16,8 +17,14 @@ export async function createAdminBookingHandler(c: AppContext) {
try {
const booking = await createAdminBooking(user.id, complexId, payload);
const complex = await db.complex.findUnique({
where: { id: complexId },
select: { complexSlug: true },
});
void sendBookingConfirmation({
bookingCode: booking.bookingCode,
complexSlug: complex?.complexSlug ?? '',
complexName: booking.complexName,
date: booking.date,
startTime: booking.startTime,

View File

@@ -199,7 +199,7 @@ function mapBookingResponse(booking: {
endTime: string;
customerName: string;
customerPhone: string;
customerEmail: string | null;
customerEmail: string;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
createdAt: Date;
updatedAt: Date;
@@ -235,7 +235,7 @@ function mapBookingResponse(booking: {
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail ?? undefined,
customerEmail: booking.customerEmail,
price: booking.price ?? 0,
status: booking.status,
createdAt: booking.createdAt.toISOString(),
@@ -424,7 +424,7 @@ export async function createAdminBooking(
endTime: selectedSlot.endTime,
customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(),
customerEmail: input.customerEmail?.trim() || null,
customerEmail: input.customerEmail.trim(),
status: 'CONFIRMED',
},
include: {

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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
);

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,
},
});
}

View File

@@ -7,6 +7,7 @@ import { sendMail } from '@/lib/mailer';
type BookingEmailInput = {
bookingCode: string;
complexSlug?: string;
complexName: string;
date: string;
startTime: string;
@@ -14,7 +15,7 @@ type BookingEmailInput = {
courtName: string;
sportName: string;
customerName: string;
customerEmail?: string;
customerEmail: string;
price?: number;
};
@@ -23,6 +24,7 @@ export async function sendBookingConfirmation(data: BookingEmailInput) {
const html = bookingConfirmationHtml({
bookingCode: data.bookingCode,
complexSlug: data.complexSlug,
complexName: data.complexName,
date: data.date,
startTime: data.startTime,