feat: add customer email functionality for booking confirmations

- Implemented email confirmation for admin and public bookings.
- Added customerEmail field to booking schemas and services.
- Created email templates for booking confirmation, cancellation, and no-show notifications.
- Updated booking handlers to send emails upon booking creation and status updates.
- Enhanced frontend forms to capture customer email during booking creation.
This commit is contained in:
Jose Selesan
2026-06-02 19:21:04 -03:00
parent 50fa4ed9a5
commit 85f234b05e
22 changed files with 610 additions and 1536 deletions

View File

@@ -2,6 +2,7 @@ import {
AdminBookingServiceError,
createAdminBooking,
} from '@/modules/admin-booking/services/admin-booking.service';
import { sendBookingConfirmation } from '@/services/booking-email.service';
import type { AppContext } from '@/types/hono';
import type { CreateAdminBookingInput } from '@repo/api-contract';
@@ -14,6 +15,20 @@ export async function createAdminBookingHandler(c: AppContext) {
try {
const booking = await createAdminBooking(user.id, complexId, payload);
void sendBookingConfirmation({
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: booking.customerEmail,
price: booking.price,
});
return c.json(booking, 201);
} catch (error) {
if (error instanceof AdminBookingServiceError) {

View File

@@ -2,6 +2,7 @@ import {
AdminBookingServiceError,
updateAdminBookingStatus,
} from '@/modules/admin-booking/services/admin-booking.service';
import { sendBookingCancelled, sendBookingNoShow } from '@/services/booking-email.service';
import type { AppContext } from '@/types/hono';
import type { UpdateAdminBookingStatusInput } from '@repo/api-contract';
@@ -14,6 +15,33 @@ export async function updateAdminBookingStatusHandler(c: AppContext) {
try {
const booking = await updateAdminBookingStatus(user.id, id, payload);
if (payload.status === 'CANCELLED') {
void sendBookingCancelled({
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: booking.customerEmail,
});
} else if (payload.status === 'NOSHOW') {
void sendBookingNoShow({
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: booking.customerEmail,
});
}
return c.json(booking);
} catch (error) {
if (error instanceof AdminBookingServiceError) {

View File

@@ -2,6 +2,7 @@ import { randomInt } from 'node:crypto';
import { CourtBookingStatus } from '@/generated/prisma/enums';
import { db } from '@/lib/prisma';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { DayOfWeek } from '@repo/api-contract';
import type {
AdminBooking,
CreateAdminBookingInput,
@@ -156,6 +157,40 @@ async function ensureComplexAccess(complexId: string, userId: string) {
return complexUser.complex;
}
function resolvePrice(
court: {
basePrice: unknown;
priceRules: Array<{
dayOfWeek: string | null;
startTime: string | null;
endTime: string | null;
price: unknown;
}>;
},
dayOfWeek: string,
startTime: string,
endTime: string
): number {
const slotStart = toMinutes(startTime);
const slotEnd = toMinutes(endTime);
const matchingRules = court.priceRules
.filter((rule) => {
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) return false;
if (!rule.startTime || !rule.endTime) return true;
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
})
.sort((first, second) => {
const firstSpecificity =
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
const secondSpecificity =
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
return secondSpecificity - firstSpecificity;
});
return Number(matchingRules[0]?.price ?? court.basePrice);
}
function mapBookingResponse(booking: {
id: string;
bookingCode: string;
@@ -164,6 +199,7 @@ function mapBookingResponse(booking: {
endTime: string;
customerName: string;
customerPhone: string;
customerEmail: string | null;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
createdAt: Date;
updatedAt: Date;
@@ -180,6 +216,7 @@ function mapBookingResponse(booking: {
slug: string;
};
};
price?: number;
}): AdminBooking {
return {
id: booking.id,
@@ -198,6 +235,8 @@ function mapBookingResponse(booking: {
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail ?? undefined,
price: booking.price ?? 0,
status: booking.status,
createdAt: booking.createdAt.toISOString(),
updatedAt: booking.updatedAt.toISOString(),
@@ -292,6 +331,10 @@ export async function createAdminBooking(
slug: true,
},
},
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
},
});
@@ -381,6 +424,7 @@ export async function createAdminBooking(
endTime: selectedSlot.endTime,
customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(),
customerEmail: input.customerEmail?.trim() || null,
status: 'CONFIRMED',
},
include: {
@@ -407,7 +451,9 @@ export async function createAdminBooking(
});
});
return mapBookingResponse(booking);
const price = resolvePrice(court, dayOfWeek, selectedSlot.startTime, selectedSlot.endTime);
return mapBookingResponse({ ...booking, price });
} catch (error) {
if (error instanceof AdminBookingServiceError) {
throw error;
@@ -521,6 +567,7 @@ export async function updateAdminBookingStatus(
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
previousStatus: booking.status,
newStatus: input.status,
changedAt: new Date(),

View File

@@ -3,6 +3,7 @@ import {
PublicBookingServiceError,
createPublicBooking,
} from '@/modules/public-booking/services/public-booking.service';
import { sendBookingConfirmation } from '@/services/booking-email.service';
import type { AppContext } from '@/types/hono';
import type { CreatePublicBookingInput } from '@repo/api-contract';
@@ -30,6 +31,19 @@ export async function createPublicBookingHandler(c: AppContext) {
})
);
void sendBookingConfirmation({
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: booking.customerEmail,
price: booking.price,
});
return c.json(booking, 201);
} catch (error) {
if (error instanceof PublicBookingServiceError) {

View File

@@ -276,6 +276,8 @@ function mapBookingResponse(input: {
endTime: string;
customerName: string;
customerPhone: string;
customerEmail: string | null;
price: number;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
court: {
id: string;
@@ -304,7 +306,9 @@ function mapBookingResponse(input: {
endTime: input.endTime,
customerName: input.customerName,
customerPhone: input.customerPhone,
customerEmail: input.customerEmail ?? undefined,
status: input.status,
price: input.price,
createdAt: input.createdAt.toISOString(),
};
}
@@ -325,6 +329,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
bookingDate: true,
startTime: true,
endTime: true,
customerEmail: true,
status: true,
createdAt: true,
court: {
@@ -381,6 +386,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
slug: booking.court.sport.slug,
},
status: booking.status,
customerEmail: booking.customerEmail ?? undefined,
createdAt: booking.createdAt.toISOString(),
};
}
@@ -625,6 +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,
status: 'CONFIRMED',
},
select: {
@@ -636,11 +643,14 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
endTime: true,
customerName: true,
customerPhone: true,
customerEmail: true,
status: true,
},
});
});
const price = resolveSlotPrice(selectedCourt, dayOfWeek, selectedSlot);
return mapBookingResponse({
bookingId: booking.id,
bookingCode: booking.bookingCode,
@@ -650,6 +660,8 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
price,
status: booking.status,
court: {
id: selectedCourt.id,