Add API endpoint to revert confirmed bookings to pending status Add unconfirm buttons to the bookings list and confirmation page Update inventory summary when a confirmation is cancelled
889 lines
24 KiB
TypeScript
889 lines
24 KiB
TypeScript
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<DbBookingRow, 'id' | 'created_at'> & {
|
|
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<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,
|
|
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<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,
|
|
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<PublicBooking | null> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbBookingRow[]>`
|
|
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<PublicBooking | null> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbBookingRow[]>`
|
|
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<PublicBooking[]> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const rows = options?.personInChargeId
|
|
? await sql<DbBookingRow[]>`
|
|
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<DbBookingRow[]>`
|
|
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<PublicBooking | null> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const rows = input.personInChargeId
|
|
? await sql<DbBookingRow[]>`
|
|
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<DbBookingRow[]>`
|
|
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<PublicBookingSeat[]> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const rows = await sql<DbBookingSeatRow[]>`
|
|
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<DbBookingSeatWithBookingRow[]>`
|
|
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<PublicBookingSeat | null> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
const nextSeatToken = input.shared ? null : randomToken(24)
|
|
|
|
const [row] = await sql<DbBookingSeatRow[]>`
|
|
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<BookingCapacitySettings> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbBookingSettingsRow[]>`
|
|
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<BookingCapacitySettings> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbBookingSettingsRow[]>`
|
|
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<BookingInventorySummary> {
|
|
const [bookings, settings] = await Promise.all([
|
|
listBookings(),
|
|
getBookingCapacitySettings()
|
|
])
|
|
|
|
return calculateBookingInventorySummary(bookings, settings)
|
|
}
|
|
|
|
export async function confirmBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbBookingRow[]>`
|
|
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<PublicBooking | null> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbBookingRow[]>`
|
|
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)
|
|
}
|