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

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