feat(booking): move event and ticket configuration to database

Replace hardcoded event details and ticket types with dynamic DB records
Add booking-config API endpoint to serve active event settings
This commit is contained in:
2026-05-04 10:09:08 +08:00
parent 06165f80db
commit 3f7025c8e4
13 changed files with 970 additions and 342 deletions

View File

@@ -213,7 +213,7 @@
<template #quantity-cell="{ row }">
<div class="space-y-0.5 py-0.5">
<div class="text-sm font-medium text-default">
{{ ticketLabel(row.original.ticketType) }}
{{ ticketLabel(row.original) }}
</div>
</div>
</template>
@@ -243,7 +243,7 @@
<template #status-cell="{ row }">
<div class="space-y-1 py-0.5">
<UBadge
:label="getBookingStatusLabel(row.original.status)"
:label="getBookingStatusLabel(row.original.status, row.original.statusLabel)"
:color="row.original.status === 'confirmed' ? 'success' : 'warning'"
variant="soft"
size="sm"
@@ -288,12 +288,11 @@
</template>
<script lang="ts" setup>
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking, TicketType } from '~~/shared/booking'
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingStatusLabel,
getTicketCatalogItem
getBookingStatusLabel
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
@@ -370,6 +369,7 @@ const filteredBookings = computed(() => {
booking.personInChargeName,
booking.personInChargePhoneNumber,
booking.ticketType,
booking.ticketLabel,
booking.status
].some((value) => value.toLowerCase().includes(keyword))
})
@@ -385,8 +385,8 @@ const confirmedCount = computed(() => {
await refreshBookings()
function ticketLabel(ticketType: TicketType) {
return getTicketCatalogItem(ticketType)?.label || ticketType.toUpperCase()
function ticketLabel(booking: PublicBooking) {
return booking.ticketLabel || booking.ticketType.toUpperCase()
}
function confirmationPath(booking: PublicBooking) {

View File

@@ -3,8 +3,7 @@ import type { ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingStatusLabel,
getTicketCatalogItem
getBookingStatusLabel
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
@@ -32,7 +31,7 @@ try {
const booking = ref(initialBooking)
const statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning')
const ticketLabel = computed(() => getTicketCatalogItem(booking.value.ticketType)?.label || booking.value.ticketType.toUpperCase())
const ticketLabel = computed(() => booking.value.ticketLabel || booking.value.ticketType.toUpperCase())
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice))
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
const detailRows = computed(() => {
@@ -139,7 +138,7 @@ async function confirmBooking() {
<p class="text-xs font-medium uppercase tracking-wide text-muted">
Booking status
</p>
<UBadge :label="getBookingStatusLabel(booking.status)" :color="statusColor" variant="soft" />
<UBadge :label="getBookingStatusLabel(booking.status, booking.statusLabel)" :color="statusColor" variant="soft" />
</div>
<div class="text-sm text-muted">

View File

@@ -7,17 +7,10 @@ import {
normalizePhoneNumber,
type PublicContact
} from '~~/shared/auth'
import type { CreateBookingResponse } from '~~/shared/booking'
import type { BookingModeOption, CreateBookingResponse, PublicBookingConfig, TicketCatalogItem } from '~~/shared/booking'
import {
BOOKING_MODE_OPTIONS,
BOOKING_TICKET_CATALOG,
DINNER_EVENT_DATE_LABEL,
DINNER_EVENT_TIME_LABEL,
DINNER_EVENT_TITLE,
DINNER_EVENT_VENUE,
formatBookingCurrency,
getSeatCount,
getTicketCatalogItem,
type BookingMode,
type TicketType
} from '~~/shared/booking'
@@ -28,25 +21,44 @@ import { getErrorMessage } from '../utils/errors'
const toast = useToast()
const apiClient = useApiClient()
const eventDetails = [
const [bookingConfig, contactsResponse] = await Promise.all([
apiClient<PublicBookingConfig>('/api/public/booking-config'),
apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
])
const eventDetails = computed(() => [
{
label: 'Date',
value: DINNER_EVENT_DATE_LABEL,
value: bookingConfig.event.dateLabel,
icon: 'lucide:calendar-days'
},
{
label: 'Time',
value: DINNER_EVENT_TIME_LABEL,
value: bookingConfig.event.timeLabel,
icon: 'lucide:clock-6'
},
{
label: 'Venue',
value: DINNER_EVENT_VENUE,
value: bookingConfig.event.venue,
icon: 'lucide:map-pin'
}
] as const
])
const bookingModeOptions = computed(() => {
return bookingConfig.bookingModes.map((mode) => ({
value: mode.value,
label: mode.label
}))
})
const ticketCatalogOptions = computed(() => {
return bookingConfig.ticketCatalog.map((ticket) => ({
value: ticket.value,
label: ticket.label,
description: ticket.description
}))
})
const contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
const personInCharge = computed(() => {
return contactsResponse.contacts.map((contact) => ({
label: contact.fullName,
@@ -57,9 +69,9 @@ const personInCharge = computed(() => {
const form = reactive({
name: '',
phone: DEFAULT_PHONE_COUNTRY_CODE,
bookingMode: 'table' as BookingMode,
bookingMode: (bookingConfig.bookingModes[0]?.value ?? '') as BookingMode,
quantity: 1,
ticketType: 'vip' as TicketType
ticketType: (bookingConfig.ticketCatalog[0]?.value ?? '') as TicketType
})
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
@@ -68,15 +80,22 @@ const selectedPersonInChargeRecord = computed(() => {
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
})
const selectedTicket = computed(() => getTicketCatalogItem(form.ticketType) ?? BOOKING_TICKET_CATALOG[0])
const selectedBookingMode = computed<BookingModeOption | null>(() => {
return bookingConfig.bookingModes.find((mode) => mode.value === form.bookingMode) ?? bookingConfig.bookingModes[0] ?? null
})
const selectedTicket = computed<TicketCatalogItem | null>(() => {
return bookingConfig.ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? bookingConfig.ticketCatalog[0] ?? null
})
const submittingBooking = ref(false)
const quantityLabel = computed(() => {
return form.bookingMode === 'table' ? 'Number of Tables' : 'Number of Seats'
return selectedBookingMode.value?.quantityLabel || 'Quantity'
})
const seatCount = computed(() => getSeatCount(form.bookingMode, form.quantity))
const totalPrice = computed(() => seatCount.value * selectedTicket.value.price)
const seatCount = computed(() => getSeatCount(selectedBookingMode.value, form.quantity))
const totalPrice = computed(() => seatCount.value * (selectedTicket.value?.price ?? 0))
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value))
@@ -97,6 +116,14 @@ function validateBooking(state: typeof form): FormError[] {
errors.push({ name: 'quantity', message: `${quantityLabel.value} must be at least 1.` })
}
if (!selectedBookingMode.value) {
errors.push({ name: 'bookingMode', message: 'Please select a booking mode.' })
}
if (!selectedTicket.value) {
errors.push({ name: 'ticketType', message: 'Please select a ticket category.' })
}
return errors
}
@@ -161,7 +188,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
<div class="mx-auto max-w-2xl">
<div class="mb-8 text-center">
<h1 class="text-3xl font-extrabold tracking-tight text-highlighted sm:text-4xl">
{{ DINNER_EVENT_TITLE }}
{{ bookingConfig.event.title }}
</h1>
</div>
@@ -192,7 +219,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
<UFormField label="Booking Mode" name="bookingMode">
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
:items="BOOKING_MODE_OPTIONS" :ui="{
:items="bookingModeOptions" :ui="{
fieldset: 'grid grid-cols-2 gap-3',
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}" />
@@ -207,7 +234,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
<UFormField label="Ticket Category" name="ticketType">
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
:items="BOOKING_TICKET_CATALOG" :ui="{
:items="ticketCatalogOptions" :ui="{
fieldset: 'grid grid-cols-2 gap-3',
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}" />
@@ -231,7 +258,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
</UFormField>
<UButton id="getTicketBtn" type="submit" label="Book Now" size="xl"
class="w-full justify-center" :disabled="!selectedPersonInCharge" :loading="submittingBooking" />
class="w-full justify-center" :disabled="!selectedPersonInCharge || !selectedBookingMode || !selectedTicket" :loading="submittingBooking" />
</UForm>
</UCard>
</div>

View File

@@ -2,14 +2,9 @@
import type { PublicBookingReceipt, PublicBookingSeatWithUrl } from '~~/shared/booking'
import {
DINNER_EVENT_DATE_LABEL,
DINNER_EVENT_TIME_LABEL,
DINNER_EVENT_TITLE,
DINNER_EVENT_VENUE,
formatBookingCurrency,
getBookingStatusLabel,
getSeatLabel,
getTicketCatalogItem
getSeatLabel
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
@@ -51,7 +46,8 @@ try {
const receipt = ref(initialReceipt)
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
const eventDetails = computed(() => receipt.value.booking.event)
const ticketLabel = computed(() => receipt.value.booking.ticketLabel || receipt.value.booking.ticketType.toUpperCase())
const statusColor = computed(() => receipt.value.booking.status === 'confirmed' ? 'success' : 'warning')
const sharedSeats = computed(() => receipt.value.seats.filter((seat) => Boolean(seat.sharedAt)))
const availableSeats = computed(() => receipt.value.seats.filter((seat) => !seat.sharedAt))
@@ -66,7 +62,7 @@ const statusRows = computed(() => {
return [
{
label: 'Status',
value: getBookingStatusLabel(receipt.value.booking.status),
value: getBookingStatusLabel(receipt.value.booking.status, receipt.value.booking.statusLabel),
isBadge: true
},
{
@@ -126,15 +122,15 @@ function buildSeatBundleText(
: null
return [
DINNER_EVENT_TITLE,
eventDetails.value.title,
`Guest: ${receipt.value.booking.customerName}`,
recipientLabel,
recipientPhoneLabel,
`Seats: ${seats.map((seat) => getSeatLabel(seat.seatNumber)).join(', ')}`,
`Category: ${ticketLabel.value}`,
`Date: ${DINNER_EVENT_DATE_LABEL}`,
`Time: ${DINNER_EVENT_TIME_LABEL}`,
`Venue: ${DINNER_EVENT_VENUE}`,
`Date: ${eventDetails.value.dateLabel}`,
`Time: ${eventDetails.value.timeLabel}`,
`Venue: ${eventDetails.value.venue}`,
'',
...seats.flatMap((seat) => [
`${getSeatLabel(seat.seatNumber)}:`,
@@ -222,7 +218,7 @@ async function shareSeats() {
recipientPhone: shareForm.recipientPhone
})
const shared = await shareLink({
title: `${DINNER_EVENT_TITLE} seats`,
title: `${eventDetails.value.title} seats`,
text: shareText,
clipboardText: shareText,
successTitle: 'Seats ready',
@@ -289,7 +285,7 @@ async function shareSeat(seat: PublicBookingSeatWithUrl) {
try {
const shared = await shareLink({
title: `${DINNER_EVENT_TITLE} ${getSeatLabel(seat.seatNumber)}`,
title: `${eventDetails.value.title} ${getSeatLabel(seat.seatNumber)}`,
text: buildSeatBundleText([seat]),
clipboardText: buildSeatBundleText([seat]),
successTitle: 'Seat ready',
@@ -365,7 +361,7 @@ async function openBatchShare() {
<div class="space-y-1 text-center">
<UBadge label="Ticket Receipt" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
{{ DINNER_EVENT_TITLE }}
{{ eventDetails.title }}
</h1>
</div>

View File

@@ -2,13 +2,8 @@
import type { PublicSeatReceipt } from '~~/shared/booking'
import {
DINNER_EVENT_DATE_LABEL,
DINNER_EVENT_TIME_LABEL,
DINNER_EVENT_TITLE,
DINNER_EVENT_VENUE,
formatBookingCurrency,
getSeatLabel,
getTicketCatalogItem
getSeatLabel
} from '~~/shared/booking'
import { formatDateTime } from '../../utils/formatters'
@@ -31,7 +26,8 @@ try {
const receipt = ref(initialReceipt)
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
const eventDetails = computed(() => receipt.value.booking.event)
const ticketLabel = computed(() => receipt.value.booking.ticketLabel || receipt.value.booking.ticketType.toUpperCase())
const totalFormatted = computed(() => formatBookingCurrency(receipt.value.booking.totalPrice))
</script>
@@ -44,7 +40,7 @@ const totalFormatted = computed(() => formatBookingCurrency(receipt.value.bookin
{{ getSeatLabel(receipt.seat.seatNumber) }}
</h1>
<p class="text-sm text-muted">
{{ DINNER_EVENT_TITLE }}
{{ eventDetails.title }}
</p>
</div>
@@ -124,15 +120,15 @@ const totalFormatted = computed(() => formatBookingCurrency(receipt.value.bookin
<div class="space-y-3 rounded-2xl border border-default bg-elevated p-4">
<div class="flex items-center gap-3 text-sm text-default">
<UIcon name="i-lucide-calendar-days" class="size-4 text-muted" />
<span>{{ DINNER_EVENT_DATE_LABEL }}</span>
<span>{{ eventDetails.dateLabel }}</span>
</div>
<div class="flex items-center gap-3 text-sm text-default">
<UIcon name="i-lucide-clock-6" class="size-4 text-muted" />
<span>{{ DINNER_EVENT_TIME_LABEL }}</span>
<span>{{ eventDetails.timeLabel }}</span>
</div>
<div class="flex items-center gap-3 text-sm text-default">
<UIcon name="i-lucide-map-pin" class="size-4 text-muted" />
<span>{{ DINNER_EVENT_VENUE }}</span>
<span>{{ eventDetails.venue }}</span>
</div>
</div>

View File

@@ -0,0 +1,5 @@
import { getPublicBookingConfig } from '../../utils/booking-repository'
export default defineEventHandler(async () => {
return await getPublicBookingConfig()
})

View File

@@ -1,9 +1,13 @@
import type { BookingMode, CreateBookingResponse, TicketType } from '~~/shared/booking'
import { getTicketCatalogItem, getSeatCount } from '~~/shared/booking'
import { getSeatCount } from '~~/shared/booking'
import { buildAppUrl } from '../../utils/app-url'
import { createBooking } from '../../utils/booking-repository'
import {
createBooking,
getActiveBookingModeOptionByCode,
getActiveTicketCatalogItemByCode
} from '../../utils/booking-repository'
import { buildBookingMessage, parseCreateBookingInput } from '../../utils/bookings'
import { assertBadRequest } from '../../utils/http'
import { getPublicContactById } from '../../utils/user-repository'
@@ -20,29 +24,33 @@ export default defineEventHandler(async (event): Promise<CreateBookingResponse>
}>(event)
const input = parseCreateBookingInput(body)
const personInCharge = await getPublicContactById(input.personInChargeId)
const [personInCharge, bookingMode, ticket] = await Promise.all([
getPublicContactById(input.personInChargeId),
getActiveBookingModeOptionByCode(input.bookingMode),
getActiveTicketCatalogItemByCode(input.ticketType)
])
assertBadRequest(personInCharge, 'Selected person in charge is not available')
const ticket = getTicketCatalogItem(input.ticketType)
assertBadRequest(bookingMode, 'Booking mode is invalid')
assertBadRequest(ticket, 'Ticket category is invalid')
assertBadRequest(bookingMode.eventId === ticket.eventId, 'Booking mode and ticket category must belong to the same event')
const seatCount = getSeatCount(input.bookingMode, input.quantity)
const seatCount = getSeatCount(bookingMode, input.quantity)
const totalPrice = seatCount * ticket.price
const { booking, confirmationToken } = await createBooking({
eventId: bookingMode.eventId,
customerName: input.customerName,
customerPhone: input.customerPhone,
bookingMode: input.bookingMode,
bookingModeId: bookingMode.id,
bookingMode: bookingMode.value,
quantity: input.quantity,
seatCount,
ticketType: input.ticketType,
ticketTypeId: ticket.id,
ticketType: ticket.value,
unitPrice: ticket.price,
totalPrice,
personInChargeId: personInCharge.id,
personInChargeName: personInCharge.fullName,
personInChargePhoneNumber: personInCharge.phoneNumber
personInChargeId: personInCharge.id
})
const confirmationUrl = buildAppUrl(event, `/confirmation/${confirmationToken}`)

View File

@@ -1,17 +1,21 @@
import { randomUUID } from 'node:crypto'
import type {
BookingModeOption,
BookingCapacitySettings,
BookingInventorySummary,
DinnerEvent,
BookingMode,
BookingStatus,
PublicBookingConfig,
PublicBooking,
PublicBookingSeat,
ReceiptBooking,
TicketCatalogItem,
TicketType
} from '~~/shared/booking'
import { calculateBookingInventorySummary, isBookingMode, isBookingStatus } from '~~/shared/booking'
import { calculateBookingInventorySummary, getBookingStatusLabel, isBookingStatus } from '~~/shared/booking'
import { randomToken, toIsoString } from './base64url'
import { ensureDatabaseReady } from './db-init'
@@ -21,18 +25,30 @@ type DbBookingRow = {
id: string
confirmation_token: string
receipt_token: string
event_id: string
event_title: string
event_date_label: string
event_time_label: string
event_venue: string
customer_name: string
customer_phone: string
booking_mode_id: string | null
booking_mode: string
booking_mode_label: string | null
booking_mode_seats_per_unit: number | string | null
quantity: number | string
seat_count: number | string
ticket_type: TicketType
ticket_type_id: string | null
ticket_type: string
ticket_label: string | null
ticket_description: string | null
unit_price: number | string
total_price: number | string
person_in_charge_id: string
person_in_charge_name: string
person_in_charge_phone_number: string
person_in_charge_name: string | null
person_in_charge_phone_number: string | null
status: BookingStatus | string
status_label: string | null
created_at: Date | string
confirmed_at: Date | string | null
}
@@ -48,56 +64,175 @@ type DbBookingSeatRow = {
updated_at: Date | string
}
type DbBookingSeatWithBookingRow = DbBookingSeatRow & {
type DbBookingSeatWithBookingRow = DbBookingSeatRow & Omit<DbBookingRow, 'id' | 'created_at'> & {
booking_id: string
confirmation_token: string
receipt_token: string
customer_name: string
customer_phone: string
booking_mode: string
quantity: number | string
seat_count: number | string
ticket_type: TicketType
unit_price: number | string
total_price: number | string
status: BookingStatus | string
booking_created_at: Date | string
confirmed_at: Date | string | null
}
type DbBookingSettingsRow = {
event_id: string
total_tables: number | string | null
total_seats: number | string | null
updated_at: Date | string
}
type DbDinnerEventRow = {
id: string
title: string
date_label: string
time_label: string
venue: string
}
type DbBookingModeOptionRow = {
id: string
event_id: string
code: string
label: string
quantity_label: string
seats_per_unit: number | string
sort_order: number | string
}
type DbTicketCatalogItemRow = {
id: string
event_id: string
code: string
label: string
description: string
price: number | string
sort_order: number | string
}
export interface BookingModeOptionRecord extends BookingModeOption {
eventId: string
}
export interface TicketCatalogItemRecord extends TicketCatalogItem {
eventId: string
}
function bookingSelectColumns(sql: any) {
return sql`
bookings.id,
bookings.confirmation_token,
bookings.receipt_token,
dinner_events.id as event_id,
dinner_events.title as event_title,
dinner_events.date_label as event_date_label,
dinner_events.time_label as event_time_label,
dinner_events.venue as event_venue,
bookings.customer_name,
bookings.customer_phone,
bookings.booking_mode_id,
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
booking_modes.label as booking_mode_label,
booking_modes.seats_per_unit as booking_mode_seats_per_unit,
bookings.quantity,
bookings.seat_count,
bookings.ticket_type_id,
coalesce(ticket_types.code, bookings.ticket_type) as ticket_type,
ticket_types.label as ticket_label,
ticket_types.description as ticket_description,
bookings.unit_price,
bookings.total_price,
bookings.person_in_charge_id,
coalesce(users.full_name, bookings.person_in_charge_name) as person_in_charge_name,
coalesce(users.phone_number, bookings.person_in_charge_phone_number) as person_in_charge_phone_number,
bookings.status,
booking_statuses.label as status_label,
bookings.created_at,
bookings.confirmed_at
`
}
function bookingJoins(sql: any) {
return sql`
inner join dinner_events on dinner_events.id = bookings.event_id
left join booking_modes on booking_modes.id = bookings.booking_mode_id
left join ticket_types on ticket_types.id = bookings.ticket_type_id
left join users on users.id = bookings.person_in_charge_id
left join booking_statuses on booking_statuses.code = bookings.status
`
}
function parseInteger(value: number | string) {
return typeof value === 'number' ? value : Number.parseInt(value, 10)
}
function normalizeBookingMode(value: string): BookingMode {
return isBookingMode(value) ? value : 'seat'
function mapDinnerEvent(row: DbDinnerEventRow): DinnerEvent {
return {
id: row.id,
title: row.title,
dateLabel: row.date_label,
timeLabel: row.time_label,
venue: row.venue
}
}
function mapDinnerEventFromBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): DinnerEvent {
return {
id: row.event_id,
title: row.event_title,
dateLabel: row.event_date_label,
timeLabel: row.event_time_label,
venue: row.event_venue
}
}
function mapBookingModeOption(row: DbBookingModeOptionRow): BookingModeOptionRecord {
return {
id: row.id,
eventId: row.event_id,
value: row.code,
label: row.label,
quantityLabel: row.quantity_label,
seatsPerUnit: parseInteger(row.seats_per_unit),
sortOrder: parseInteger(row.sort_order)
}
}
function mapTicketCatalogItem(row: DbTicketCatalogItemRow): TicketCatalogItemRecord {
return {
id: row.id,
eventId: row.event_id,
value: row.code,
label: row.label,
description: row.description,
price: parseInteger(row.price),
sortOrder: parseInteger(row.sort_order)
}
}
function mapBooking(row: DbBookingRow): PublicBooking {
const seatCount = parseInteger(row.seat_count)
const status = isBookingStatus(row.status) ? row.status : 'pending'
const ticketType = row.ticket_type
const bookingMode = row.booking_mode
return {
id: row.id,
confirmationToken: row.confirmation_token,
receiptToken: row.receipt_token,
event: mapDinnerEventFromBooking(row),
customerName: row.customer_name,
customerPhone: row.customer_phone,
bookingMode: normalizeBookingMode(row.booking_mode),
bookingModeId: row.booking_mode_id,
bookingMode,
bookingModeLabel: row.booking_mode_label || bookingMode,
quantity: parseInteger(row.quantity),
seatCount,
ticketType: row.ticket_type,
ticketTypeId: row.ticket_type_id,
ticketType,
ticketLabel: row.ticket_label || ticketType.toUpperCase(),
ticketDescription: row.ticket_description,
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',
personInChargeName: row.person_in_charge_name || '',
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
status,
statusLabel: row.status_label || getBookingStatusLabel(status),
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
confirmedAt: toIsoString(row.confirmed_at)
}
@@ -105,24 +240,59 @@ function mapBooking(row: DbBookingRow): PublicBooking {
function mapReceiptBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): ReceiptBooking {
const seatCount = parseInteger(row.seat_count)
const status = isBookingStatus(row.status) ? row.status : 'pending'
const ticketType = row.ticket_type
const bookingMode = row.booking_mode
return {
id: row.id,
receiptToken: row.receipt_token,
event: mapDinnerEventFromBooking(row),
customerName: row.customer_name,
customerPhone: row.customer_phone,
bookingMode: normalizeBookingMode(row.booking_mode),
bookingModeId: row.booking_mode_id,
bookingMode,
bookingModeLabel: row.booking_mode_label || bookingMode,
quantity: parseInteger(row.quantity),
seatCount,
ticketType: row.ticket_type,
ticketTypeId: row.ticket_type_id,
ticketType,
ticketLabel: row.ticket_label || ticketType.toUpperCase(),
ticketDescription: row.ticket_description,
unitPrice: parseInteger(row.unit_price),
totalPrice: parseInteger(row.total_price),
status: isBookingStatus(row.status) ? row.status : 'pending',
status,
statusLabel: row.status_label || getBookingStatusLabel(status),
createdAt: toIsoString('booking_created_at' in row ? row.booking_created_at : row.created_at) ?? new Date().toISOString(),
confirmedAt: toIsoString(row.confirmed_at)
}
}
function mapPublicBookingToReceiptBooking(booking: PublicBooking): ReceiptBooking {
return {
id: booking.id,
receiptToken: booking.receiptToken,
event: booking.event,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
bookingModeId: booking.bookingModeId,
bookingMode: booking.bookingMode,
bookingModeLabel: booking.bookingModeLabel,
quantity: booking.quantity,
seatCount: booking.seatCount,
ticketTypeId: booking.ticketTypeId,
ticketType: booking.ticketType,
ticketLabel: booking.ticketLabel,
ticketDescription: booking.ticketDescription,
unitPrice: booking.unitPrice,
totalPrice: booking.totalPrice,
status: booking.status,
statusLabel: booking.statusLabel,
createdAt: booking.createdAt,
confirmedAt: booking.confirmedAt
}
}
function mapBookingSeat(row: DbBookingSeatRow): PublicBookingSeat {
return {
id: row.id,
@@ -144,9 +314,7 @@ function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): Book
}
}
const totalSeats = row.total_seats === null
? (row.total_tables === null ? null : parseInteger(row.total_tables) * 10)
: parseInteger(row.total_seats)
const totalSeats = row.total_seats === null ? null : parseInteger(row.total_seats)
return {
totalSeats,
@@ -154,6 +322,115 @@ function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): Book
}
}
export async function getPublicBookingConfig(): Promise<PublicBookingConfig> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [event] = await sql<DbDinnerEventRow[]>`
select
id,
title,
date_label,
time_label,
venue
from dinner_events
where is_active = true
order by sort_order asc, created_at asc
limit 1
`
if (!event) {
throw new Error('No active dinner event is configured.')
}
const [bookingModes, ticketCatalog] = await Promise.all([
sql<DbBookingModeOptionRow[]>`
select
id,
event_id,
code,
label,
quantity_label,
seats_per_unit,
sort_order
from booking_modes
where event_id = ${event.id}
and is_active = true
order by sort_order asc, label asc
`,
sql<DbTicketCatalogItemRow[]>`
select
id,
event_id,
code,
label,
description,
price,
sort_order
from ticket_types
where event_id = ${event.id}
and is_active = true
order by sort_order asc, label asc
`
])
return {
event: mapDinnerEvent(event),
bookingModes: bookingModes.map(mapBookingModeOption),
ticketCatalog: ticketCatalog.map(mapTicketCatalogItem)
}
}
export async function getActiveBookingModeOptionByCode(code: string): Promise<BookingModeOptionRecord | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingModeOptionRow[]>`
select
booking_modes.id,
booking_modes.event_id,
booking_modes.code,
booking_modes.label,
booking_modes.quantity_label,
booking_modes.seats_per_unit,
booking_modes.sort_order
from booking_modes
inner join dinner_events on dinner_events.id = booking_modes.event_id
where dinner_events.is_active = true
and booking_modes.is_active = true
and booking_modes.code = ${code}
order by booking_modes.sort_order asc
limit 1
`
return row ? mapBookingModeOption(row) : null
}
export async function getActiveTicketCatalogItemByCode(code: string): Promise<TicketCatalogItemRecord | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbTicketCatalogItemRow[]>`
select
ticket_types.id,
ticket_types.event_id,
ticket_types.code,
ticket_types.label,
ticket_types.description,
ticket_types.price,
ticket_types.sort_order
from ticket_types
inner join dinner_events on dinner_events.id = ticket_types.event_id
where dinner_events.is_active = true
and ticket_types.is_active = true
and ticket_types.code = ${code}
order by ticket_types.sort_order asc
limit 1
`
return row ? mapTicketCatalogItem(row) : null
}
async function insertBookingSeats(
tx: ReturnType<typeof getSqlClient>,
bookingId: string,
@@ -178,17 +455,18 @@ async function insertBookingSeats(
}
export async function createBooking(input: {
eventId: string
customerName: string
customerPhone: string
bookingModeId: string
bookingMode: BookingMode
quantity: number
seatCount: number
ticketTypeId: string
ticketType: TicketType
unitPrice: number
totalPrice: number
personInChargeId: string
personInChargeName: string
personInChargePhoneNumber: string
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
@@ -198,58 +476,48 @@ export async function createBooking(input: {
const row = await sql.begin(async (tx) => {
const [createdBooking] = await tx<DbBookingRow[]>`
with inserted_booking as (
insert into bookings (
id,
confirmation_token,
receipt_token,
event_id,
customer_name,
customer_phone,
booking_mode_id,
booking_mode,
quantity,
seat_count,
ticket_type_id,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status
)
values (
${bookingId},
${confirmationToken},
${receiptToken},
${input.eventId},
${input.customerName},
${input.customerPhone},
${input.bookingModeId},
${input.bookingMode},
${input.quantity},
${input.seatCount},
${input.ticketTypeId},
${input.ticketType},
${input.unitPrice},
${input.totalPrice},
${input.personInChargeId},
${input.personInChargeName},
${input.personInChargePhoneNumber},
'pending'
)
returning
id,
confirmation_token,
receipt_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
returning *
)
select ${bookingSelectColumns(tx)}
from inserted_booking as bookings
${bookingJoins(tx)}
`
await insertBookingSeats(tx, bookingId, input.seatCount)
@@ -269,26 +537,10 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
const sql = getSqlClient()
const [row] = await sql<DbBookingRow[]>`
select
id,
confirmation_token,
receipt_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
select ${bookingSelectColumns(sql)}
from bookings
where confirmation_token = ${confirmationToken}
${bookingJoins(sql)}
where bookings.confirmation_token = ${confirmationToken}
limit 1
`
@@ -300,26 +552,10 @@ export async function getBookingByReceiptToken(receiptToken: string): Promise<Pu
const sql = getSqlClient()
const [row] = await sql<DbBookingRow[]>`
select
id,
confirmation_token,
receipt_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
select ${bookingSelectColumns(sql)}
from bookings
where receipt_token = ${receiptToken}
${bookingJoins(sql)}
where bookings.receipt_token = ${receiptToken}
limit 1
`
@@ -334,49 +570,19 @@ export async function listBookings(options?: {
const rows = options?.personInChargeId
? await sql<DbBookingRow[]>`
select
id,
confirmation_token,
receipt_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
select ${bookingSelectColumns(sql)}
from bookings
where person_in_charge_id = ${options.personInChargeId}
order by created_at desc
${bookingJoins(sql)}
where dinner_events.is_active = true
and bookings.person_in_charge_id = ${options.personInChargeId}
order by bookings.created_at desc
`
: await sql<DbBookingRow[]>`
select
id,
confirmation_token,
receipt_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
select ${bookingSelectColumns(sql)}
from bookings
order by created_at desc
${bookingJoins(sql)}
where dinner_events.is_active = true
order by bookings.created_at desc
`
return rows.map(mapBooking)
@@ -417,25 +623,7 @@ export async function getBookingReceiptByReceiptToken(receiptToken: string): Pro
const seats = await listBookingSeats(booking.id)
return {
booking: mapReceiptBooking({
id: booking.id,
confirmation_token: booking.confirmationToken,
receipt_token: booking.receiptToken,
customer_name: booking.customerName,
customer_phone: booking.customerPhone,
booking_mode: booking.bookingMode,
quantity: booking.quantity,
seat_count: booking.seatCount,
ticket_type: booking.ticketType,
unit_price: booking.unitPrice,
total_price: booking.totalPrice,
person_in_charge_id: booking.personInChargeId,
person_in_charge_name: booking.personInChargeName,
person_in_charge_phone_number: booking.personInChargePhoneNumber,
status: booking.status,
created_at: booking.createdAt,
confirmed_at: booking.confirmedAt
}),
booking: mapPublicBookingToReceiptBooking(booking),
seats
}
}
@@ -460,19 +648,35 @@ export async function getSeatReceiptBySeatToken(seatToken: string): Promise<{
bookings.id as booking_id,
bookings.confirmation_token,
bookings.receipt_token,
dinner_events.id as event_id,
dinner_events.title as event_title,
dinner_events.date_label as event_date_label,
dinner_events.time_label as event_time_label,
dinner_events.venue as event_venue,
bookings.customer_name,
bookings.customer_phone,
bookings.booking_mode,
bookings.booking_mode_id,
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
booking_modes.label as booking_mode_label,
booking_modes.seats_per_unit as booking_mode_seats_per_unit,
bookings.quantity,
bookings.seat_count,
bookings.ticket_type,
bookings.ticket_type_id,
coalesce(ticket_types.code, bookings.ticket_type) as ticket_type,
ticket_types.label as ticket_label,
ticket_types.description as ticket_description,
bookings.unit_price,
bookings.total_price,
bookings.person_in_charge_id,
coalesce(users.full_name, bookings.person_in_charge_name) as person_in_charge_name,
coalesce(users.phone_number, bookings.person_in_charge_phone_number) as person_in_charge_phone_number,
bookings.status,
booking_statuses.label as status_label,
bookings.created_at as booking_created_at,
bookings.confirmed_at
from booking_seats
inner join bookings on bookings.id = booking_seats.booking_id
${bookingJoins(sql)}
where booking_seats.seat_token = ${seatToken}
limit 1
`
@@ -533,11 +737,14 @@ export async function getBookingCapacitySettings(): Promise<BookingCapacitySetti
const [row] = await sql<DbBookingSettingsRow[]>`
select
booking_settings.event_id,
total_tables,
total_seats,
updated_at
booking_settings.updated_at
from booking_settings
where id = 'default'
inner join dinner_events on dinner_events.id = booking_settings.event_id
where dinner_events.is_active = true
order by dinner_events.sort_order asc
limit 1
`
@@ -555,11 +762,14 @@ export async function updateBookingCapacitySettings(input: {
set
total_seats = ${input.totalSeats},
updated_at = now()
where id = 'default'
from dinner_events
where booking_settings.event_id = dinner_events.id
and dinner_events.is_active = true
returning
booking_settings.event_id,
total_tables,
total_seats,
updated_at
booking_settings.updated_at
`
return mapBookingCapacitySettings(row)
@@ -579,31 +789,19 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
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,
receipt_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
with updated_booking as (
update bookings
set
status = 'confirmed',
confirmed_at = now(),
updated_at = now()
where confirmation_token = ${confirmationToken}
and status = 'pending'
returning *
)
select ${bookingSelectColumns(sql)}
from updated_booking as bookings
${bookingJoins(sql)}
`
if (row) {

View File

@@ -1,10 +1,7 @@
import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
import {
formatBookingCurrency,
getTicketCatalogItem,
isBookingMode,
isTicketType
formatBookingCurrency
} from '~~/shared/booking'
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
@@ -21,15 +18,15 @@ export function parseCreateBookingInput(body: {
const customerName = normalizeFullName(body.customerName || '')
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
const bookingMode = typeof body.bookingMode === 'string' ? body.bookingMode.trim().toLowerCase() : body.bookingMode
const ticketType = body.ticketType
const ticketType = typeof body.ticketType === 'string' ? body.ticketType.trim().toLowerCase() : 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 include a country code, e.g. +60123456789')
assertBadRequest(isBookingMode(bookingMode), 'Booking mode is invalid')
assertBadRequest(typeof bookingMode === 'string' && bookingMode.length > 0, 'Booking mode is required')
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid')
assertBadRequest(typeof ticketType === 'string' && ticketType.trim().length > 0, 'Ticket category is required')
assertBadRequest(personInChargeId, 'Person in charge is required')
return {
@@ -43,16 +40,13 @@ export function parseCreateBookingInput(body: {
}
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.",
`I'd like to book tickets for the ${booking.event.title}.`,
'',
`Name: ${booking.customerName}`,
`Phone Number: ${booking.customerPhone}`,
`Seats: ${booking.seatCount}`,
`Ticket Category: ${ticketLabel}`,
`Ticket Category: ${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
'',
'PIC confirmation link:',

View File

@@ -62,23 +62,205 @@ async function initializeDatabase() {
on user_passkeys (user_id)
`
await sql`
create table if not exists dinner_events (
id text primary key,
title text not null,
date_label text not null,
time_label text not null,
venue text not null,
is_active boolean not null default false,
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
)
`
await sql`
create unique index if not exists dinner_events_single_active_idx
on dinner_events (is_active)
where is_active = true
`
await sql`
insert into dinner_events (
id,
title,
date_label,
time_label,
venue,
is_active,
sort_order
)
values (
'dap-johor-60',
'DAP JOHOR 60th Anniversary Celebration',
'Saturday, 30 May 2026',
'6:30 PM',
'Yong Peng''s Chee Ann Kor',
true,
1
)
on conflict (id) do nothing
`
await sql`
update dinner_events
set
is_active = true,
updated_at = now()
where id = 'dap-johor-60'
and not exists (
select 1
from dinner_events
where is_active = true
)
`
await sql`
create table if not exists booking_modes (
id text primary key,
event_id text not null references dinner_events(id) on delete cascade,
code text not null,
label text not null,
quantity_label text not null,
seats_per_unit integer not null check (seats_per_unit >= 1),
is_active boolean not null default true,
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (event_id, code)
)
`
await sql`
insert into booking_modes (
id,
event_id,
code,
label,
quantity_label,
seats_per_unit,
is_active,
sort_order
)
values
(
'dap-johor-60-table',
'dap-johor-60',
'table',
'Table (10 seats)',
'Number of Tables',
10,
true,
1
),
(
'dap-johor-60-seat',
'dap-johor-60',
'seat',
'Seat',
'Number of Seats',
1,
true,
2
)
on conflict (event_id, code) do nothing
`
await sql`
create table if not exists ticket_types (
id text primary key,
event_id text not null references dinner_events(id) on delete cascade,
code text not null,
label text not null,
description text not null,
price integer not null check (price >= 0),
is_active boolean not null default true,
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (event_id, code)
)
`
await sql`
insert into ticket_types (
id,
event_id,
code,
label,
description,
price,
is_active,
sort_order
)
values
(
'dap-johor-60-vip',
'dap-johor-60',
'vip',
'VIP',
'RM150 / seat',
150,
true,
1
),
(
'dap-johor-60-supporter',
'dap-johor-60',
'supporter',
'Supporter',
'RM60 / seat',
60,
true,
2
)
on conflict (event_id, code) do nothing
`
await sql`
create table if not exists booking_statuses (
code text primary key,
label text not null,
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
)
`
await sql`
insert into booking_statuses (
code,
label,
sort_order
)
values
('pending', 'Pending PIC confirmation', 1),
('confirmed', 'Confirmed', 2)
on conflict (code) do nothing
`
await sql`
create table if not exists bookings (
id text primary key,
confirmation_token text not null unique,
receipt_token text not null unique,
event_id text references dinner_events(id) on delete restrict,
customer_name text not null,
customer_phone text not null,
booking_mode text not null check (booking_mode in ('table', 'seat')),
booking_mode_id text references booking_modes(id) on delete restrict,
booking_mode text not null,
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')),
ticket_type_id text references ticket_types(id) on delete restrict,
ticket_type text not null,
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',
status text not null default 'pending',
confirmed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
@@ -90,11 +272,41 @@ async function initializeDatabase() {
add column if not exists receipt_token text
`
await sql`
alter table bookings
add column if not exists event_id text
`
await sql`
alter table bookings
add column if not exists booking_mode_id text
`
await sql`
alter table bookings
add column if not exists ticket_type_id text
`
await sql`
create unique index if not exists bookings_receipt_token_idx
on bookings (receipt_token)
`
await sql`
create index if not exists bookings_event_id_idx
on bookings (event_id)
`
await sql`
create index if not exists bookings_booking_mode_id_idx
on bookings (booking_mode_id)
`
await sql`
create index if not exists bookings_ticket_type_id_idx
on bookings (ticket_type_id)
`
await sql`
create table if not exists booking_seats (
id text primary key,
@@ -118,21 +330,28 @@ async function initializeDatabase() {
await sql`
create table if not exists booking_settings (
id text primary key,
event_id text references dinner_events(id) on delete cascade,
total_tables integer,
total_seats integer,
updated_at timestamptz not null default now()
)
`
await sql`
alter table booking_settings
add column if not exists event_id text
`
await sql`
alter table booking_settings
add column if not exists total_seats integer
`
await sql`
insert into booking_settings (id)
values ('default')
on conflict (id) do nothing
insert into booking_settings (id, event_id)
values ('default', 'dap-johor-60')
on conflict (id) do update
set event_id = coalesce(booking_settings.event_id, excluded.event_id)
`
const bookingsMissingReceiptTokens = await sql<{ id: string }[]>`
@@ -156,12 +375,6 @@ async function initializeDatabase() {
drop constraint if exists bookings_booking_mode_check
`
await sql`
alter table bookings
add constraint bookings_booking_mode_check
check (booking_mode in ('table', 'pax', 'seat'))
`
await sql`
update bookings
set
@@ -177,14 +390,203 @@ async function initializeDatabase() {
await sql`
alter table bookings
add constraint bookings_booking_mode_check
check (booking_mode in ('table', 'seat'))
drop constraint if exists bookings_ticket_type_check
`
await sql`
alter table bookings
drop constraint if exists bookings_status_check
`
const [activeEvent] = await sql<{ id: string }[]>`
select id
from dinner_events
where is_active = true
order by sort_order asc, created_at asc
limit 1
`
if (activeEvent) {
await sql`
update booking_settings
set
event_id = ${activeEvent.id},
updated_at = now()
where event_id is null
`
await sql`
update bookings
set
event_id = ${activeEvent.id},
updated_at = now()
where event_id is null
`
await sql`
update bookings
set
booking_mode_id = booking_modes.id,
updated_at = now()
from booking_modes
where bookings.booking_mode_id is null
and booking_modes.event_id = bookings.event_id
and booking_modes.code = bookings.booking_mode
`
await sql`
update bookings
set
ticket_type_id = ticket_types.id,
updated_at = now()
from ticket_types
where bookings.ticket_type_id is null
and ticket_types.event_id = bookings.event_id
and ticket_types.code = bookings.ticket_type
`
const [fallbackBookingMode] = await sql<{ id: string, code: string }[]>`
select id, code
from booking_modes
where event_id = ${activeEvent.id}
and is_active = true
order by sort_order asc, created_at asc
limit 1
`
if (fallbackBookingMode) {
await sql`
update bookings
set
booking_mode_id = ${fallbackBookingMode.id},
booking_mode = ${fallbackBookingMode.code},
updated_at = now()
where booking_mode_id is null
`
}
const [fallbackTicketType] = await sql<{ id: string, code: string }[]>`
select id, code
from ticket_types
where event_id = ${activeEvent.id}
and is_active = true
order by sort_order asc, created_at asc
limit 1
`
if (fallbackTicketType) {
await sql`
update bookings
set
ticket_type_id = ${fallbackTicketType.id},
ticket_type = ${fallbackTicketType.code},
updated_at = now()
where ticket_type_id is null
`
}
}
await sql`
create unique index if not exists booking_settings_event_id_idx
on booking_settings (event_id)
`
await sql`
alter table bookings
alter column person_in_charge_name drop not null
`
await sql`
alter table bookings
alter column person_in_charge_phone_number drop not null
`
await sql`
alter table bookings
alter column event_id set not null
`
await sql`
alter table bookings
alter column booking_mode_id set not null
`
await sql`
alter table bookings
alter column ticket_type_id set not null
`
await sql`
alter table booking_settings
alter column event_id set not null
`
await sql`
alter table bookings
drop constraint if exists bookings_event_id_fkey
`
await sql`
alter table bookings
add constraint bookings_event_id_fkey
foreign key (event_id) references dinner_events(id) on delete restrict
`
await sql`
alter table bookings
drop constraint if exists bookings_booking_mode_id_fkey
`
await sql`
alter table bookings
add constraint bookings_booking_mode_id_fkey
foreign key (booking_mode_id) references booking_modes(id) on delete restrict
`
await sql`
alter table bookings
drop constraint if exists bookings_ticket_type_id_fkey
`
await sql`
alter table bookings
add constraint bookings_ticket_type_id_fkey
foreign key (ticket_type_id) references ticket_types(id) on delete restrict
`
await sql`
alter table bookings
drop constraint if exists bookings_status_fkey
`
await sql`
alter table bookings
add constraint bookings_status_fkey
foreign key (status) references booking_statuses(code) on delete restrict
`
await sql`
alter table booking_settings
drop constraint if exists booking_settings_event_id_fkey
`
await sql`
alter table booking_settings
add constraint booking_settings_event_id_fkey
foreign key (event_id) references dinner_events(id) on delete cascade
`
await sql`
update booking_settings
set
total_seats = total_tables * 10,
total_seats = total_tables * coalesce((
select booking_modes.seats_per_unit
from booking_modes
where booking_modes.event_id = booking_settings.event_id
and booking_modes.code = 'table'
order by booking_modes.sort_order asc
limit 1
), 1),
updated_at = now()
where total_seats is null
and total_tables is not null

View File

@@ -9,7 +9,8 @@ export function getSqlClient() {
sqlClient = postgres(
config.databaseUrl || 'postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system',
{
max: 10
max: 10,
onnotice: false
}
)
}

View File

@@ -3,12 +3,7 @@ import type { H3Event } from 'h3'
import type { PublicBooking, WhatsAppDeliveryResult } from '~~/shared/booking'
import {
DINNER_EVENT_DATE_LABEL,
DINNER_EVENT_TIME_LABEL,
DINNER_EVENT_TITLE,
DINNER_EVENT_VENUE,
formatBookingCurrency,
getTicketCatalogItem
formatBookingCurrency
} from '~~/shared/booking'
import { normalizePhoneNumber } from '~~/shared/auth'
@@ -29,22 +24,20 @@ export function buildWhatsAppDeepLink(phoneNumber: string, message: string) {
}
export function buildBookingTicketReceiptMessage(event: H3Event, booking: PublicBooking) {
const ticket = getTicketCatalogItem(booking.ticketType)
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
const receiptUrl = buildAppUrl(event, `/receipt/${booking.receiptToken}`)
return [
DINNER_EVENT_TITLE,
booking.event.title,
'',
`Hi ${booking.customerName}, your ticket receipt has been confirmed.`,
'',
`Receipt: ${receiptUrl}`,
`Seats: ${booking.seatCount}`,
`Ticket Category: ${ticketLabel}`,
`Ticket Category: ${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
`Date: ${DINNER_EVENT_DATE_LABEL}`,
`Time: ${DINNER_EVENT_TIME_LABEL}`,
`Venue: ${DINNER_EVENT_VENUE}`,
`Date: ${booking.event.dateLabel}`,
`Time: ${booking.event.timeLabel}`,
`Venue: ${booking.event.venue}`,
'',
'Please present the QR code from the receipt at the event.'
].join('\n')

View File

@@ -1,56 +1,62 @@
export type BookingMode = 'table' | 'seat'
export type TicketType = 'vip' | 'supporter'
export type BookingMode = string
export type TicketType = string
export type BookingStatus = 'pending' | 'confirmed'
export const DINNER_EVENT_TITLE = 'DAP JOHOR 60th Anniversary Celebration'
export const DINNER_EVENT_DATE_LABEL = 'Saturday, 30 May 2026'
export const DINNER_EVENT_TIME_LABEL = '6:30 PM'
export const DINNER_EVENT_VENUE = "Yong Peng's Chee Ann Kor"
export interface DinnerEvent {
id: string
title: string
dateLabel: string
timeLabel: string
venue: string
}
export const TABLE_SEAT_COUNT = 10
export interface BookingModeOption {
id: string
value: BookingMode
label: string
quantityLabel: string
seatsPerUnit: number
sortOrder: number
}
export const BOOKING_MODE_OPTIONS = [
{
value: 'table',
label: `Table (${TABLE_SEAT_COUNT} seats)`
},
{
value: 'seat',
label: 'Seat'
}
] satisfies Array<{ value: BookingMode, label: string }>
export interface TicketCatalogItem {
id: string
value: TicketType
label: string
description: string
price: number
sortOrder: number
}
export const BOOKING_TICKET_CATALOG = [
{
value: 'vip',
label: 'VIP',
description: 'RM150 / seat',
price: 150
},
{
value: 'supporter',
label: 'Supporter',
description: 'RM60 / seat',
price: 60
}
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
export interface PublicBookingConfig {
event: DinnerEvent
bookingModes: BookingModeOption[]
ticketCatalog: TicketCatalogItem[]
}
export interface PublicBooking {
id: string
confirmationToken: string
receiptToken: string
event: DinnerEvent
customerName: string
customerPhone: string
bookingModeId: string | null
bookingMode: BookingMode
bookingModeLabel: string
quantity: number
seatCount: number
ticketTypeId: string | null
ticketType: TicketType
ticketLabel: string
ticketDescription: string | null
unitPrice: number
totalPrice: number
personInChargeId: string
personInChargeName: string
personInChargePhoneNumber: string
status: BookingStatus
statusLabel: string
createdAt: string
confirmedAt: string | null
}
@@ -58,15 +64,22 @@ export interface PublicBooking {
export interface ReceiptBooking {
id: string
receiptToken: string
event: DinnerEvent
customerName: string
customerPhone: string
bookingModeId: string | null
bookingMode: BookingMode
bookingModeLabel: string
quantity: number
seatCount: number
ticketTypeId: string | null
ticketType: TicketType
ticketLabel: string
ticketDescription: string | null
unitPrice: number
totalPrice: number
status: BookingStatus
statusLabel: string
createdAt: string
confirmedAt: string | null
}
@@ -131,32 +144,28 @@ export interface ConfirmBookingResponse {
ticketReceiptWhatsApp: WhatsAppDeliveryResult
}
export function isBookingMode(value: string | null | undefined): value is BookingMode {
return value === 'table' || value === 'seat'
}
export function isTicketType(value: string | null | undefined): value is TicketType {
return value === 'vip' || value === 'supporter'
}
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
return value === 'pending' || value === 'confirmed'
}
export function getBookingModeLabel(value: BookingMode) {
return value === 'table' ? `Table (${TABLE_SEAT_COUNT} seats each)` : 'Per seat'
}
export function getBookingStatusLabel(value: BookingStatus | string, label?: string | null) {
if (label) {
return label
}
export function getBookingStatusLabel(value: BookingStatus) {
return value === 'confirmed' ? 'Confirmed' : 'Pending PIC confirmation'
}
export function getSeatCount(bookingMode: BookingMode, quantity: number) {
return bookingMode === 'table' ? quantity * TABLE_SEAT_COUNT : quantity
export function getSeatCount(bookingMode: Pick<BookingModeOption, 'seatsPerUnit'> | null | undefined, quantity: number) {
return quantity * (bookingMode?.seatsPerUnit ?? 1)
}
export function getTicketCatalogItem(ticketType: TicketType) {
return BOOKING_TICKET_CATALOG.find((ticket) => ticket.value === ticketType) ?? null
export function getTicketLabel(ticket: Pick<TicketCatalogItem, 'label'> | null | undefined, ticketType: TicketType) {
return ticket?.label || ticketType.toUpperCase()
}
export function getBookingTicketLabel(booking: Pick<PublicBooking | ReceiptBooking, 'ticketLabel' | 'ticketType'>) {
return booking.ticketLabel || booking.ticketType.toUpperCase()
}
export function formatBookingCurrency(value: number) {