feat(bookings): implement ticket receipts and seat sharing system
Add receipt tokens and booking_seats table to track individual tickets Create receipt and seat view pages with QR code generation
This commit is contained in:
@@ -6,6 +6,8 @@ import type {
|
||||
BookingMode,
|
||||
BookingStatus,
|
||||
PublicBooking,
|
||||
PublicBookingSeat,
|
||||
ReceiptBooking,
|
||||
TicketType
|
||||
} from '~~/shared/booking'
|
||||
|
||||
@@ -18,6 +20,7 @@ import { getSqlClient } from './postgres'
|
||||
type DbBookingRow = {
|
||||
id: string
|
||||
confirmation_token: string
|
||||
receipt_token: string
|
||||
customer_name: string
|
||||
customer_phone: string
|
||||
booking_mode: BookingMode
|
||||
@@ -34,6 +37,34 @@ type DbBookingRow = {
|
||||
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
|
||||
@@ -47,6 +78,7 @@ 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,
|
||||
@@ -64,11 +96,41 @@ function mapBooking(row: DbBookingRow): PublicBooking {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
totalSeats: null,
|
||||
updatedAt: null
|
||||
}
|
||||
}
|
||||
@@ -79,6 +141,29 @@ function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): Book
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -94,63 +179,75 @@ export async function createBooking(input: {
|
||||
}) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
const bookingId = randomUUID()
|
||||
const confirmationToken = randomToken(24)
|
||||
const receiptToken = randomToken(24)
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
insert into bookings (
|
||||
id,
|
||||
confirmation_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 (
|
||||
${randomUUID()},
|
||||
${confirmationToken},
|
||||
${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,
|
||||
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
|
||||
`
|
||||
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
|
||||
confirmationToken,
|
||||
receiptToken
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +259,7 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
@@ -184,6 +282,37 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
|
||||
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[]> {
|
||||
@@ -195,6 +324,7 @@ export async function listBookings(options?: {
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
@@ -217,6 +347,7 @@ export async function listBookings(options?: {
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
@@ -238,6 +369,151 @@ export async function listBookings(options?: {
|
||||
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()
|
||||
@@ -298,6 +574,7 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
|
||||
returning
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
|
||||
@@ -63,6 +63,29 @@ export function buildBookingMessage(booking: PublicBooking, confirmationUrl: str
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function parseSeatShareInput(body: {
|
||||
shared?: boolean
|
||||
recipientName?: string | null
|
||||
recipientPhone?: string | null
|
||||
}) {
|
||||
const shared = body.shared
|
||||
const recipientName = normalizeFullName(body.recipientName || '')
|
||||
const recipientPhone = normalizePhoneNumber(body.recipientPhone || '')
|
||||
|
||||
assertBadRequest(typeof shared === 'boolean', 'Shared flag is required')
|
||||
|
||||
if (shared) {
|
||||
assertBadRequest(!recipientName || hasValidFullName(recipientName), 'Recipient name must be at least 2 characters')
|
||||
assertBadRequest(!recipientPhone || isValidPhoneNumber(recipientPhone), 'Recipient phone number must contain 8 to 15 digits')
|
||||
}
|
||||
|
||||
return {
|
||||
shared,
|
||||
recipientName: recipientName || null,
|
||||
recipientPhone: recipientPhone || null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBookingCapacityInput(body: {
|
||||
totalTables?: number | string | null
|
||||
}): Pick<BookingCapacitySettings, 'totalTables'> {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { DEFAULT_USER_PASSWORD } from '~~/shared/auth'
|
||||
|
||||
import { randomToken } from './base64url'
|
||||
import { hashPassword } from './password'
|
||||
import { getSqlClient } from './postgres'
|
||||
|
||||
@@ -65,6 +66,7 @@ async function initializeDatabase() {
|
||||
create table if not exists bookings (
|
||||
id text primary key,
|
||||
confirmation_token text not null unique,
|
||||
receipt_token text not null unique,
|
||||
customer_name text not null,
|
||||
customer_phone text not null,
|
||||
booking_mode text not null check (booking_mode in ('table', 'pax')),
|
||||
@@ -83,6 +85,36 @@ async function initializeDatabase() {
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add column if not exists receipt_token text
|
||||
`
|
||||
|
||||
await sql`
|
||||
create unique index if not exists bookings_receipt_token_idx
|
||||
on bookings (receipt_token)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists booking_seats (
|
||||
id text primary key,
|
||||
booking_id text not null references bookings(id) on delete cascade,
|
||||
seat_number integer not null check (seat_number >= 1),
|
||||
seat_token text not null unique,
|
||||
recipient_name text,
|
||||
recipient_phone text,
|
||||
shared_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (booking_id, seat_number)
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create index if not exists booking_seats_booking_id_idx
|
||||
on booking_seats (booking_id)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists booking_settings (
|
||||
id text primary key,
|
||||
@@ -98,6 +130,74 @@ async function initializeDatabase() {
|
||||
on conflict (id) do nothing
|
||||
`
|
||||
|
||||
const bookingsMissingReceiptTokens = await sql<{ id: string }[]>`
|
||||
select id
|
||||
from bookings
|
||||
where receipt_token is null or receipt_token = ''
|
||||
`
|
||||
|
||||
for (const booking of bookingsMissingReceiptTokens) {
|
||||
await sql`
|
||||
update bookings
|
||||
set
|
||||
receipt_token = ${randomToken(24)},
|
||||
updated_at = now()
|
||||
where id = ${booking.id}
|
||||
`
|
||||
}
|
||||
|
||||
const existingBookings = await sql<{ id: string, seat_count: number | string }[]>`
|
||||
select
|
||||
id,
|
||||
seat_count
|
||||
from bookings
|
||||
`
|
||||
|
||||
for (const booking of existingBookings) {
|
||||
const seatCount = typeof booking.seat_count === 'number'
|
||||
? booking.seat_count
|
||||
: Number.parseInt(booking.seat_count, 10)
|
||||
|
||||
const existingSeatRows = await sql<{ seat_number: number | string }[]>`
|
||||
select seat_number
|
||||
from booking_seats
|
||||
where booking_id = ${booking.id}
|
||||
`
|
||||
|
||||
const existingSeatNumbers = new Set(
|
||||
existingSeatRows.map((seat) => typeof seat.seat_number === 'number'
|
||||
? seat.seat_number
|
||||
: Number.parseInt(seat.seat_number, 10))
|
||||
)
|
||||
|
||||
for (let seatNumber = 1; seatNumber <= seatCount; seatNumber += 1) {
|
||||
if (existingSeatNumbers.has(seatNumber)) {
|
||||
continue
|
||||
}
|
||||
|
||||
await sql`
|
||||
insert into booking_seats (
|
||||
id,
|
||||
booking_id,
|
||||
seat_number,
|
||||
seat_token
|
||||
)
|
||||
values (
|
||||
${randomUUID()},
|
||||
${booking.id},
|
||||
${seatNumber},
|
||||
${randomToken(24)}
|
||||
)
|
||||
on conflict (booking_id, seat_number) do nothing
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
alter column receipt_token set not null
|
||||
`
|
||||
|
||||
const [existingSuperAdmin] = await sql<{ id: string }[]>`
|
||||
select id
|
||||
from users
|
||||
|
||||
Reference in New Issue
Block a user