import { randomUUID } from 'node:crypto' import type { BookingModeOption, BookingCapacitySettings, BookingInventorySummary, DinnerEvent, BookingMode, BookingStatus, PublicBookingConfig, PublicBooking, PublicBookingSeat, ReceiptBooking, TicketCatalogItem, TicketType } from '~~/shared/booking' import { calculateBookingInventorySummary, getBookingStatusLabel, 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 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_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 | null person_in_charge_phone_number: string | null remark?: string | null status: BookingStatus | string status_label: string | null created_at: Date | string confirmed_at: Date | string | null } type DbBookingSeatRow = { id: string seat_number: number | string seat_token: string recipient_name: string | null recipient_phone: string | null shared_at: Date | string | null created_at: Date | string updated_at: Date | string } type DbBookingSeatWithBookingRow = DbBookingSeatRow & Omit & { booking_id: string booking_created_at: Date | string } 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.remark, 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 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, bookingModeId: row.booking_mode_id, bookingMode, bookingModeLabel: row.booking_mode_label || bookingMode, quantity: parseInteger(row.quantity), seatCount, 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 || '', remark: row.remark || null, status, statusLabel: row.status_label || getBookingStatusLabel(status), createdAt: toIsoString(row.created_at) ?? new Date().toISOString(), confirmedAt: toIsoString(row.confirmed_at) } } 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, bookingModeId: row.booking_mode_id, bookingMode, bookingModeLabel: row.booking_mode_label || bookingMode, quantity: parseInteger(row.quantity), seatCount, 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, 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, seatNumber: parseInteger(row.seat_number), seatToken: row.seat_token, recipientName: row.recipient_name, recipientPhone: row.recipient_phone, sharedAt: toIsoString(row.shared_at), createdAt: toIsoString(row.created_at) ?? new Date().toISOString(), updatedAt: toIsoString(row.updated_at) ?? new Date().toISOString() } } function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings { if (!row) { return { totalSeats: null, updatedAt: null } } const totalSeats = row.total_seats === null ? null : parseInteger(row.total_seats) return { totalSeats, updatedAt: toIsoString(row.updated_at) } } export async function getPublicBookingConfig(): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [event] = await sql` 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` 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` 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 { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` 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 { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` 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, bookingId: string, seatCount: number ) { for (let seatNumber = 1; seatNumber <= seatCount; seatNumber += 1) { await tx` insert into booking_seats ( id, booking_id, seat_number, seat_token ) values ( ${randomUUID()}, ${bookingId}, ${seatNumber}, ${randomToken(24)} ) ` } } 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 }) { await ensureDatabaseReady() const sql = getSqlClient() const bookingId = randomUUID() const confirmationToken = randomToken(24) const receiptToken = randomToken(24) const row = await sql.begin(async (tx) => { const [createdBooking] = await tx` 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, remark, 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}, null, 'pending' ) returning * ) select ${bookingSelectColumns(tx)} from inserted_booking as bookings ${bookingJoins(tx)} ` await insertBookingSeats(tx, bookingId, input.seatCount) return createdBooking }) return { booking: mapBooking(row), confirmationToken, receiptToken } } export async function getBookingByConfirmationToken(confirmationToken: string): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` select ${bookingSelectColumns(sql)} from bookings ${bookingJoins(sql)} where bookings.confirmation_token = ${confirmationToken} limit 1 ` return row ? mapBooking(row) : null } export async function getBookingByReceiptToken(receiptToken: string): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` select ${bookingSelectColumns(sql)} from bookings ${bookingJoins(sql)} where bookings.receipt_token = ${receiptToken} limit 1 ` return row ? mapBooking(row) : null } export async function listBookings(options?: { personInChargeId?: string }): Promise { await ensureDatabaseReady() const sql = getSqlClient() const rows = options?.personInChargeId ? await sql` select ${bookingSelectColumns(sql)} from bookings ${bookingJoins(sql)} where dinner_events.is_active = true and bookings.person_in_charge_id = ${options.personInChargeId} order by bookings.created_at desc ` : await sql` select ${bookingSelectColumns(sql)} from bookings ${bookingJoins(sql)} where dinner_events.is_active = true order by bookings.created_at desc ` return rows.map(mapBooking) } export async function updateBookingRemark(input: { bookingId: string personInChargeId?: string remark: string | null }): Promise { await ensureDatabaseReady() const sql = getSqlClient() const rows = input.personInChargeId ? await sql` with updated_booking as ( update bookings set remark = ${input.remark}, updated_at = now() where id = ${input.bookingId} and person_in_charge_id = ${input.personInChargeId} returning * ) select ${bookingSelectColumns(sql)} from updated_booking as bookings ${bookingJoins(sql)} where dinner_events.is_active = true limit 1 ` : await sql` with updated_booking as ( update bookings set remark = ${input.remark}, updated_at = now() where id = ${input.bookingId} returning * ) select ${bookingSelectColumns(sql)} from updated_booking as bookings ${bookingJoins(sql)} where dinner_events.is_active = true limit 1 ` return rows[0] ? mapBooking(rows[0]) : null } export async function listBookingSeats(bookingId: string): Promise { await ensureDatabaseReady() const sql = getSqlClient() const rows = await sql` select id, seat_number, seat_token, recipient_name, recipient_phone, shared_at, created_at, updated_at from booking_seats where booking_id = ${bookingId} order by seat_number asc ` return rows.map(mapBookingSeat) } export async function getBookingReceiptByReceiptToken(receiptToken: string): Promise<{ booking: ReceiptBooking seats: PublicBookingSeat[] } | null> { const booking = await getBookingByReceiptToken(receiptToken) if (!booking) { return null } const seats = await listBookingSeats(booking.id) return { booking: mapPublicBookingToReceiptBooking(booking), seats } } export async function getSeatReceiptBySeatToken(seatToken: string): Promise<{ booking: ReceiptBooking seat: PublicBookingSeat } | null> { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` select booking_seats.id, booking_seats.seat_number, booking_seats.seat_token, booking_seats.recipient_name, booking_seats.recipient_phone, booking_seats.shared_at, booking_seats.created_at, booking_seats.updated_at, 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_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 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 ` if (!row) { return null } return { booking: mapReceiptBooking({ ...row, id: row.booking_id }), seat: mapBookingSeat(row) } } export async function updateBookingSeatShareByReceiptToken(input: { receiptToken: string seatId: string shared: boolean recipientName: string | null recipientPhone: string | null }): Promise { await ensureDatabaseReady() const sql = getSqlClient() const nextSeatToken = input.shared ? null : randomToken(24) const [row] = await sql` update booking_seats set recipient_name = ${input.shared ? input.recipientName : null}, recipient_phone = ${input.shared ? input.recipientPhone : null}, shared_at = ${input.shared ? new Date() : null}, seat_token = coalesce(${nextSeatToken}, seat_token), updated_at = now() from bookings where booking_seats.booking_id = bookings.id and bookings.receipt_token = ${input.receiptToken} and booking_seats.id = ${input.seatId} returning booking_seats.id, booking_seats.seat_number, booking_seats.seat_token, booking_seats.recipient_name, booking_seats.recipient_phone, booking_seats.shared_at, booking_seats.created_at, booking_seats.updated_at ` return row ? mapBookingSeat(row) : null } export async function getBookingCapacitySettings(): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` select booking_settings.event_id, total_tables, total_seats, booking_settings.updated_at from booking_settings 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 ` return mapBookingCapacitySettings(row) } export async function updateBookingCapacitySettings(input: { totalSeats: number | null }): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` update booking_settings set total_seats = ${input.totalSeats}, updated_at = now() 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, booking_settings.updated_at ` return mapBookingCapacitySettings(row) } export async function getBookingInventorySummary(): Promise { const [bookings, settings] = await Promise.all([ listBookings(), getBookingCapacitySettings() ]) return calculateBookingInventorySummary(bookings, settings) } export async function confirmBookingByConfirmationToken(confirmationToken: string): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` 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) { return mapBooking(row) } return await getBookingByConfirmationToken(confirmationToken) } export async function cancelBookingConfirmationByConfirmationToken(confirmationToken: string): Promise { await ensureDatabaseReady() const sql = getSqlClient() const [row] = await sql` with updated_booking as ( update bookings set status = 'pending', confirmed_at = null, updated_at = now() where confirmation_token = ${confirmationToken} and status = 'confirmed' returning * ) select ${bookingSelectColumns(sql)} from updated_booking as bookings ${bookingJoins(sql)} ` if (row) { return mapBooking(row) } return await getBookingByConfirmationToken(confirmationToken) }