Files
dticket.tootaio.com/server/utils/booking-repository.ts
xiaomai faa998c7e1 refactor(bookings): simplify capacity tracking to use seats
Replace 'pax' booking mode with 'seat'
Consolidate inventory summary to track only seat counts
Update database schema and UI for seat-centric capacity
2026-04-13 08:49:54 +08:00

615 lines
16 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import type {
BookingCapacitySettings,
BookingInventorySummary,
BookingMode,
BookingStatus,
PublicBooking,
PublicBookingSeat,
ReceiptBooking,
TicketType
} from '~~/shared/booking'
import { calculateBookingInventorySummary, isBookingMode, 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
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
person_in_charge_id: string
person_in_charge_name: string
person_in_charge_phone_number: string
status: BookingStatus | string
created_at: Date | string
confirmed_at: Date | string | null
}
type 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 & {
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 = {
total_tables: number | string | null
total_seats: number | string | null
updated_at: Date | string
}
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 mapBooking(row: DbBookingRow): PublicBooking {
const seatCount = parseInteger(row.seat_count)
return {
id: row.id,
confirmationToken: row.confirmation_token,
receiptToken: row.receipt_token,
customerName: row.customer_name,
customerPhone: row.customer_phone,
bookingMode: normalizeBookingMode(row.booking_mode),
quantity: parseInteger(row.quantity),
seatCount,
ticketType: row.ticket_type,
unitPrice: parseInteger(row.unit_price),
totalPrice: parseInteger(row.total_price),
personInChargeId: row.person_in_charge_id,
personInChargeName: row.person_in_charge_name,
personInChargePhoneNumber: row.person_in_charge_phone_number,
status: isBookingStatus(row.status) ? row.status : 'pending',
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
confirmedAt: toIsoString(row.confirmed_at)
}
}
function mapReceiptBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): ReceiptBooking {
const seatCount = parseInteger(row.seat_count)
return {
id: row.id,
receiptToken: row.receipt_token,
customerName: row.customer_name,
customerPhone: row.customer_phone,
bookingMode: normalizeBookingMode(row.booking_mode),
quantity: parseInteger(row.quantity),
seatCount,
ticketType: row.ticket_type,
unitPrice: parseInteger(row.unit_price),
totalPrice: parseInteger(row.total_price),
status: isBookingStatus(row.status) ? row.status : 'pending',
createdAt: toIsoString('booking_created_at' in row ? row.booking_created_at : row.created_at) ?? new Date().toISOString(),
confirmedAt: toIsoString(row.confirmed_at)
}
}
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
? (row.total_tables === null ? null : parseInteger(row.total_tables) * 10)
: parseInteger(row.total_seats)
return {
totalSeats,
updatedAt: toIsoString(row.updated_at)
}
}
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: {
customerName: string
customerPhone: string
bookingMode: BookingMode
quantity: number
seatCount: number
ticketType: TicketType
unitPrice: number
totalPrice: number
personInChargeId: string
personInChargeName: string
personInChargePhoneNumber: string
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
const bookingId = randomUUID()
const confirmationToken = randomToken(24)
const receiptToken = randomToken(24)
const row = await sql.begin(async (tx) => {
const [createdBooking] = await tx<DbBookingRow[]>`
insert into bookings (
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
)
values (
${bookingId},
${confirmationToken},
${receiptToken},
${input.customerName},
${input.customerPhone},
${input.bookingMode},
${input.quantity},
${input.seatCount},
${input.ticketType},
${input.unitPrice},
${input.totalPrice},
${input.personInChargeId},
${input.personInChargeName},
${input.personInChargePhoneNumber},
'pending'
)
returning
id,
confirmation_token,
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
`
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
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
from bookings
where 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
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
from bookings
where 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
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
from bookings
where person_in_charge_id = ${options.personInChargeId}
order by 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
from bookings
order by created_at desc
`
return rows.map(mapBooking)
}
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: 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
}),
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,
bookings.customer_name,
bookings.customer_phone,
bookings.booking_mode,
bookings.quantity,
bookings.seat_count,
bookings.ticket_type,
bookings.unit_price,
bookings.total_price,
bookings.status,
bookings.created_at as booking_created_at,
bookings.confirmed_at
from booking_seats
inner join bookings on bookings.id = booking_seats.booking_id
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
total_tables,
total_seats,
updated_at
from booking_settings
where id = 'default'
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()
where id = 'default'
returning
total_tables,
total_seats,
updated_at
`
return mapBookingCapacitySettings(row)
}
export async function getBookingInventorySummary(): Promise<BookingInventorySummary> {
const [bookings, settings] = await Promise.all([
listBookings(),
getBookingCapacitySettings()
])
return calculateBookingInventorySummary(bookings, settings)
}
export async function confirmBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingRow[]>`
update bookings
set
status = 'confirmed',
confirmed_at = now(),
updated_at = now()
where confirmation_token = ${confirmationToken}
and status = 'pending'
returning
id,
confirmation_token,
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
`
if (row) {
return mapBooking(row)
}
return await getBookingByConfirmationToken(confirmationToken)
}