Files
dticket.tootaio.com/server/utils/bookings.ts
xiaomai 8541c4a2d1 feat(bookings): implement booking system and confirmation flow
Add database tables and repository for managing bookings
Create API endpoints for booking submission and capacity management
Update landing page to persist bookings before WhatsApp redirection
2026-04-12 21:43:30 +08:00

91 lines
2.9 KiB
TypeScript

import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingModeLabel,
getTicketCatalogItem,
isBookingMode,
isTicketType
} from '~~/shared/booking'
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
import { assertBadRequest } from './http'
export function parseCreateBookingInput(body: {
customerName?: string
customerPhone?: string
bookingMode?: BookingMode
quantity?: number
ticketType?: TicketType
personInChargeId?: string
}) {
const customerName = normalizeFullName(body.customerName || '')
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
const bookingMode = body.bookingMode
const ticketType = body.ticketType
const quantity = Number(body.quantity)
const personInChargeId = (body.personInChargeId || '').trim()
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must contain 8 to 15 digits')
assertBadRequest(isBookingMode(bookingMode), 'Booking mode is invalid')
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid')
assertBadRequest(personInChargeId, 'Person in charge is required')
return {
customerName,
customerPhone,
bookingMode,
quantity,
ticketType,
personInChargeId
}
}
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
const ticket = getTicketCatalogItem(booking.ticketType)
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
return [
"I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.",
'',
`Name: ${booking.customerName}`,
`Phone Number: ${booking.customerPhone}`,
`Booking Mode: ${getBookingModeLabel(booking.bookingMode)}`,
`Quantity: ${booking.quantity}`,
`Ticket Category: ${ticketLabel}`,
`Seats Covered: ${booking.seatCount}`,
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
'',
'PIC confirmation link:',
confirmationUrl
].join('\n')
}
export function parseBookingCapacityInput(body: {
totalTables?: number | string | null
}): Pick<BookingCapacitySettings, 'totalTables'> {
const totalTables = parseOptionalInteger(body.totalTables)
assertBadRequest(totalTables === null || totalTables >= 0, 'Total tables must be 0 or greater')
return {
totalTables
}
}
function parseOptionalInteger(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') {
return null
}
const parsed = typeof value === 'number'
? value
: Number.parseInt(String(value), 10)
assertBadRequest(Number.isInteger(parsed), 'Capacity values must be whole numbers')
return parsed
}