Add receipt tokens and booking_seats table to track individual tickets Create receipt and seat view pages with QR code generation
600 lines
15 KiB
TypeScript
600 lines
15 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
|
|
import type {
|
|
BookingCapacitySettings,
|
|
BookingInventorySummary,
|
|
BookingMode,
|
|
BookingStatus,
|
|
PublicBooking,
|
|
PublicBookingSeat,
|
|
ReceiptBooking,
|
|
TicketType
|
|
} from '~~/shared/booking'
|
|
|
|
import { calculateBookingInventorySummary, 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: BookingMode
|
|
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: BookingMode
|
|
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
|
|
updated_at: Date | string
|
|
}
|
|
|
|
function parseInteger(value: number | string) {
|
|
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
|
}
|
|
|
|
function mapBooking(row: DbBookingRow): PublicBooking {
|
|
return {
|
|
id: row.id,
|
|
confirmationToken: row.confirmation_token,
|
|
receiptToken: row.receipt_token,
|
|
customerName: row.customer_name,
|
|
customerPhone: row.customer_phone,
|
|
bookingMode: row.booking_mode,
|
|
quantity: parseInteger(row.quantity),
|
|
seatCount: parseInteger(row.seat_count),
|
|
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 {
|
|
return {
|
|
id: row.id,
|
|
receiptToken: row.receipt_token,
|
|
customerName: row.customer_name,
|
|
customerPhone: row.customer_phone,
|
|
bookingMode: row.booking_mode,
|
|
quantity: parseInteger(row.quantity),
|
|
seatCount: parseInteger(row.seat_count),
|
|
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 {
|
|
totalTables: null,
|
|
updatedAt: null
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalTables: row.total_tables === null ? null : parseInteger(row.total_tables),
|
|
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,
|
|
updated_at
|
|
from booking_settings
|
|
where id = 'default'
|
|
limit 1
|
|
`
|
|
|
|
return mapBookingCapacitySettings(row)
|
|
}
|
|
|
|
export async function updateBookingCapacitySettings(input: {
|
|
totalTables: number | null
|
|
}): Promise<BookingCapacitySettings> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbBookingSettingsRow[]>`
|
|
update booking_settings
|
|
set
|
|
total_tables = ${input.totalTables},
|
|
updated_at = now()
|
|
where id = 'default'
|
|
returning
|
|
total_tables,
|
|
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)
|
|
}
|