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
This commit is contained in:
2026-04-12 21:43:30 +08:00
parent 07e5d42005
commit 8541c4a2d1
17 changed files with 1585 additions and 92 deletions

18
server/utils/app-url.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { H3Event } from 'h3'
import { getRequestURL } from 'h3'
export function getAppOrigin(event: H3Event) {
const config = useRuntimeConfig()
if (config.public.appUrl) {
return new URL(config.public.appUrl).origin
}
const url = getRequestURL(event)
return `${url.protocol}//${url.host}`
}
export function buildAppUrl(event: H3Event, path: string) {
return new URL(path, getAppOrigin(event)).toString()
}

View File

@@ -0,0 +1,322 @@
import { randomUUID } from 'node:crypto'
import type {
BookingCapacitySettings,
BookingInventorySummary,
BookingMode,
BookingStatus,
PublicBooking,
TicketType
} from '~~/shared/booking'
import { calculateBookingInventorySummary, isBookingStatus } from '~~/shared/booking'
import { randomToken, toIsoString } from './base64url'
import { ensureDatabaseReady } from './db-init'
import { getSqlClient } from './postgres'
type DbBookingRow = {
id: string
confirmation_token: string
customer_name: string
customer_phone: string
booking_mode: BookingMode
quantity: number | string
seat_count: number | string
ticket_type: TicketType
unit_price: number | string
total_price: number | string
person_in_charge_id: string
person_in_charge_name: string
person_in_charge_phone_number: string
status: BookingStatus | string
created_at: Date | string
confirmed_at: Date | string | null
}
type DbBookingSettingsRow = {
total_tables: number | string | null
updated_at: Date | string
}
function parseInteger(value: number | string) {
return typeof value === 'number' ? value : Number.parseInt(value, 10)
}
function mapBooking(row: DbBookingRow): PublicBooking {
return {
id: row.id,
confirmationToken: row.confirmation_token,
customerName: row.customer_name,
customerPhone: row.customer_phone,
bookingMode: row.booking_mode,
quantity: parseInteger(row.quantity),
seatCount: parseInteger(row.seat_count),
ticketType: row.ticket_type,
unitPrice: parseInteger(row.unit_price),
totalPrice: parseInteger(row.total_price),
personInChargeId: row.person_in_charge_id,
personInChargeName: row.person_in_charge_name,
personInChargePhoneNumber: row.person_in_charge_phone_number,
status: isBookingStatus(row.status) ? row.status : 'pending',
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
confirmedAt: toIsoString(row.confirmed_at)
}
}
function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings {
if (!row) {
return {
totalTables: null,
totalSeats: null,
updatedAt: null
}
}
return {
totalTables: row.total_tables === null ? null : parseInteger(row.total_tables),
updatedAt: toIsoString(row.updated_at)
}
}
export async function createBooking(input: {
customerName: string
customerPhone: string
bookingMode: BookingMode
quantity: number
seatCount: number
ticketType: TicketType
unitPrice: number
totalPrice: number
personInChargeId: string
personInChargeName: string
personInChargePhoneNumber: string
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
const confirmationToken = randomToken(24)
const [row] = await sql<DbBookingRow[]>`
insert into bookings (
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status
)
values (
${randomUUID()},
${confirmationToken},
${input.customerName},
${input.customerPhone},
${input.bookingMode},
${input.quantity},
${input.seatCount},
${input.ticketType},
${input.unitPrice},
${input.totalPrice},
${input.personInChargeId},
${input.personInChargeName},
${input.personInChargePhoneNumber},
'pending'
)
returning
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status,
created_at,
confirmed_at
`
return {
booking: mapBooking(row),
confirmationToken
}
}
export async function getBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingRow[]>`
select
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status,
created_at,
confirmed_at
from bookings
where confirmation_token = ${confirmationToken}
limit 1
`
return row ? mapBooking(row) : null
}
export async function listBookings(options?: {
personInChargeId?: string
}): Promise<PublicBooking[]> {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = options?.personInChargeId
? await sql<DbBookingRow[]>`
select
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status,
created_at,
confirmed_at
from bookings
where person_in_charge_id = ${options.personInChargeId}
order by created_at desc
`
: await sql<DbBookingRow[]>`
select
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status,
created_at,
confirmed_at
from bookings
order by created_at desc
`
return rows.map(mapBooking)
}
export async function getBookingCapacitySettings(): Promise<BookingCapacitySettings> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingSettingsRow[]>`
select
total_tables,
updated_at
from booking_settings
where id = 'default'
limit 1
`
return mapBookingCapacitySettings(row)
}
export async function updateBookingCapacitySettings(input: {
totalTables: number | null
}): Promise<BookingCapacitySettings> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingSettingsRow[]>`
update booking_settings
set
total_tables = ${input.totalTables},
updated_at = now()
where id = 'default'
returning
total_tables,
updated_at
`
return mapBookingCapacitySettings(row)
}
export async function getBookingInventorySummary(): Promise<BookingInventorySummary> {
const [bookings, settings] = await Promise.all([
listBookings(),
getBookingCapacitySettings()
])
return calculateBookingInventorySummary(bookings, settings)
}
export async function confirmBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingRow[]>`
update bookings
set
status = 'confirmed',
confirmed_at = now(),
updated_at = now()
where confirmation_token = ${confirmationToken}
and status = 'pending'
returning
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status,
created_at,
confirmed_at
`
if (row) {
return mapBooking(row)
}
return await getBookingByConfirmationToken(confirmationToken)
}

90
server/utils/bookings.ts Normal file
View File

@@ -0,0 +1,90 @@
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
}

View File

@@ -61,6 +61,43 @@ async function initializeDatabase() {
on user_passkeys (user_id)
`
await sql`
create table if not exists bookings (
id text primary key,
confirmation_token text not null unique,
customer_name text not null,
customer_phone text not null,
booking_mode text not null check (booking_mode in ('table', 'pax')),
quantity integer not null check (quantity >= 1),
seat_count integer not null check (seat_count >= 1),
ticket_type text not null check (ticket_type in ('vip', 'supporter')),
unit_price integer not null check (unit_price >= 0),
total_price integer not null check (total_price >= 0),
person_in_charge_id text not null references users(id) on delete restrict,
person_in_charge_name text not null,
person_in_charge_phone_number text not null,
status text not null check (status in ('pending', 'confirmed')) default 'pending',
confirmed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
)
`
await sql`
create table if not exists booking_settings (
id text primary key,
total_tables integer,
total_seats integer,
updated_at timestamptz not null default now()
)
`
await sql`
insert into booking_settings (id)
values ('default')
on conflict (id) do nothing
`
const [existingSuperAdmin] = await sql<{ id: string }[]>`
select id
from users

View File

@@ -256,6 +256,36 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
}))
}
export async function getPublicContactById(contactId: string): Promise<PublicContact | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
select
users.id,
users.full_name,
users.phone_number,
users.role
from users
where users.id = ${contactId}
and users.is_active = true
and users.phone_number is not null
and users.phone_number <> ''
limit 1
`
if (!row) {
return null
}
return {
id: row.id,
fullName: row.full_name,
phoneNumber: row.phone_number || '',
role: row.role
}
}
export async function createUser(input: {
username: string
fullName: string

View File

@@ -2,25 +2,13 @@ import type { H3Event } from 'h3'
import type { AuthenticatorTransportFuture, WebAuthnCredential } from '@simplewebauthn/server'
import { getRequestURL } from 'h3'
import { decodeBase64Url, randomToken } from './base64url'
import { getAppOrigin } from './app-url'
import { getRedisClient } from './redis'
import type { PasskeyRecord } from './user-repository'
const CHALLENGE_TTL_SECONDS = 60 * 5
function getAppOrigin(event: H3Event) {
const config = useRuntimeConfig()
if (config.public.appUrl) {
return new URL(config.public.appUrl).origin
}
const url = getRequestURL(event)
return `${url.protocol}//${url.host}`
}
export function getWebAuthnConfig(event: H3Event) {
const config = useRuntimeConfig()
const origin = getAppOrigin(event)