feat(bookings): allow editing and soft-deleting bookings
Add edit modal to update guest details, ticket selection, and quantity Implement soft delete functionality to archive bookings
This commit is contained in:
@@ -35,6 +35,7 @@ type DbBookingRow = {
|
||||
customer_name: string
|
||||
customer_phone: string
|
||||
locale: AppLocale | string | null
|
||||
deleted_at: Date | string | null
|
||||
booking_mode_id: string | null
|
||||
booking_mode: string
|
||||
booking_mode_label: string | null
|
||||
@@ -129,6 +130,7 @@ function bookingSelectColumns(sql: any) {
|
||||
bookings.customer_name,
|
||||
bookings.customer_phone,
|
||||
bookings.locale,
|
||||
bookings.deleted_at,
|
||||
bookings.booking_mode_id,
|
||||
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
|
||||
booking_modes.label as booking_mode_label,
|
||||
@@ -556,6 +558,7 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
|
||||
from bookings
|
||||
${bookingJoins(sql)}
|
||||
where bookings.confirmation_token = ${confirmationToken}
|
||||
and bookings.deleted_at is null
|
||||
limit 1
|
||||
`
|
||||
|
||||
@@ -571,6 +574,7 @@ export async function getBookingByReceiptToken(receiptToken: string): Promise<Pu
|
||||
from bookings
|
||||
${bookingJoins(sql)}
|
||||
where bookings.receipt_token = ${receiptToken}
|
||||
and bookings.deleted_at is null
|
||||
limit 1
|
||||
`
|
||||
|
||||
@@ -589,6 +593,7 @@ export async function listBookings(options?: {
|
||||
from bookings
|
||||
${bookingJoins(sql)}
|
||||
where dinner_events.is_active = true
|
||||
and bookings.deleted_at is null
|
||||
and bookings.person_in_charge_id = ${options.personInChargeId}
|
||||
order by bookings.created_at desc
|
||||
`
|
||||
@@ -597,6 +602,7 @@ export async function listBookings(options?: {
|
||||
from bookings
|
||||
${bookingJoins(sql)}
|
||||
where dinner_events.is_active = true
|
||||
and bookings.deleted_at is null
|
||||
order by bookings.created_at desc
|
||||
`
|
||||
|
||||
@@ -618,8 +624,9 @@ export async function updateBookingRemark(input: {
|
||||
set
|
||||
remark = ${input.remark},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
and person_in_charge_id = ${input.personInChargeId}
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
and person_in_charge_id = ${input.personInChargeId}
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
@@ -634,8 +641,9 @@ export async function updateBookingRemark(input: {
|
||||
set
|
||||
remark = ${input.remark},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
returning *
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from updated_booking as bookings
|
||||
@@ -662,8 +670,9 @@ export async function updateBookingPersonInCharge(input: {
|
||||
set
|
||||
person_in_charge_id = ${input.nextPersonInChargeId},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
and person_in_charge_id = ${input.currentPersonInChargeId}
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
and person_in_charge_id = ${input.currentPersonInChargeId}
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
@@ -678,8 +687,9 @@ export async function updateBookingPersonInCharge(input: {
|
||||
set
|
||||
person_in_charge_id = ${input.nextPersonInChargeId},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
returning *
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from updated_booking as bookings
|
||||
@@ -691,6 +701,178 @@ export async function updateBookingPersonInCharge(input: {
|
||||
return rows[0] ? mapBooking(rows[0]) : null
|
||||
}
|
||||
|
||||
export async function getBookingById(bookingId: string, options?: {
|
||||
personInChargeId?: string
|
||||
}): Promise<PublicBooking | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = options?.personInChargeId
|
||||
? await sql<DbBookingRow[]>`
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from bookings
|
||||
${bookingJoins(sql)}
|
||||
where bookings.id = ${bookingId}
|
||||
and bookings.deleted_at is null
|
||||
and bookings.person_in_charge_id = ${options.personInChargeId}
|
||||
limit 1
|
||||
`
|
||||
: await sql<DbBookingRow[]>`
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from bookings
|
||||
${bookingJoins(sql)}
|
||||
where bookings.id = ${bookingId}
|
||||
and bookings.deleted_at is null
|
||||
limit 1
|
||||
`
|
||||
|
||||
return rows[0] ? mapBooking(rows[0]) : null
|
||||
}
|
||||
|
||||
async function syncBookingSeats(tx: ReturnType<typeof getSqlClient>, bookingId: string, currentSeatCount: number, nextSeatCount: number) {
|
||||
if (nextSeatCount > currentSeatCount) {
|
||||
for (let seatNumber = currentSeatCount + 1; seatNumber <= nextSeatCount; seatNumber += 1) {
|
||||
await tx`
|
||||
insert into booking_seats (
|
||||
id,
|
||||
booking_id,
|
||||
seat_number,
|
||||
seat_token
|
||||
)
|
||||
values (
|
||||
${randomUUID()},
|
||||
${bookingId},
|
||||
${seatNumber},
|
||||
${randomToken(24)}
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (nextSeatCount < currentSeatCount) {
|
||||
await tx`
|
||||
delete from booking_seats
|
||||
where booking_id = ${bookingId}
|
||||
and seat_number > ${nextSeatCount}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBookingDetails(input: {
|
||||
bookingId: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
bookingModeId: string
|
||||
bookingMode: BookingMode
|
||||
quantity: number
|
||||
seatCount: number
|
||||
ticketTypeId: string
|
||||
ticketType: TicketType
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
remark: string | null
|
||||
personInChargeId?: string
|
||||
}) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
return await sql.begin(async (tx) => {
|
||||
const [currentBooking] = await tx<{ seat_count: number | string }[]>`
|
||||
select seat_count
|
||||
from bookings
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
limit 1
|
||||
`
|
||||
|
||||
if (!currentBooking) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentSeatCount = parseInteger(currentBooking.seat_count)
|
||||
|
||||
const [row] = await tx<DbBookingRow[]>`
|
||||
with updated_booking as (
|
||||
update bookings
|
||||
set
|
||||
customer_name = ${input.customerName},
|
||||
customer_phone = ${input.customerPhone},
|
||||
booking_mode_id = ${input.bookingModeId},
|
||||
booking_mode = ${input.bookingMode},
|
||||
quantity = ${input.quantity},
|
||||
seat_count = ${input.seatCount},
|
||||
ticket_type_id = ${input.ticketTypeId},
|
||||
ticket_type = ${input.ticketType},
|
||||
unit_price = ${input.unitPrice},
|
||||
total_price = ${input.totalPrice},
|
||||
remark = ${input.remark},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
${input.personInChargeId ? tx`and person_in_charge_id = ${input.personInChargeId}` : tx``}
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(tx)}
|
||||
from updated_booking as bookings
|
||||
${bookingJoins(tx)}
|
||||
limit 1
|
||||
`
|
||||
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
await syncBookingSeats(tx, input.bookingId, currentSeatCount, input.seatCount)
|
||||
|
||||
return mapBooking(row)
|
||||
})
|
||||
}
|
||||
|
||||
export async function softDeleteBooking(input: {
|
||||
bookingId: string
|
||||
personInChargeId?: string
|
||||
}) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = input.personInChargeId
|
||||
? await sql<DbBookingRow[]>`
|
||||
with updated_booking as (
|
||||
update bookings
|
||||
set
|
||||
deleted_at = now(),
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
and person_in_charge_id = ${input.personInChargeId}
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from updated_booking as bookings
|
||||
${bookingJoins(sql)}
|
||||
limit 1
|
||||
`
|
||||
: await sql<DbBookingRow[]>`
|
||||
with updated_booking as (
|
||||
update bookings
|
||||
set
|
||||
deleted_at = now(),
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from updated_booking as bookings
|
||||
${bookingJoins(sql)}
|
||||
limit 1
|
||||
`
|
||||
|
||||
return rows[0] ? mapBooking(rows[0]) : null
|
||||
}
|
||||
|
||||
export async function listBookingSeats(bookingId: string): Promise<PublicBookingSeat[]> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
@@ -707,6 +889,12 @@ export async function listBookingSeats(bookingId: string): Promise<PublicBooking
|
||||
updated_at
|
||||
from booking_seats
|
||||
where booking_id = ${bookingId}
|
||||
and exists (
|
||||
select 1
|
||||
from bookings
|
||||
where bookings.id = booking_seats.booking_id
|
||||
and bookings.deleted_at is null
|
||||
)
|
||||
order by seat_number asc
|
||||
`
|
||||
|
||||
@@ -759,6 +947,7 @@ export async function getSeatReceiptBySeatToken(seatToken: string): Promise<{
|
||||
bookings.customer_name,
|
||||
bookings.customer_phone,
|
||||
bookings.locale,
|
||||
bookings.deleted_at,
|
||||
bookings.booking_mode_id,
|
||||
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
|
||||
booking_modes.label as booking_mode_label,
|
||||
@@ -782,6 +971,7 @@ export async function getSeatReceiptBySeatToken(seatToken: string): Promise<{
|
||||
inner join bookings on bookings.id = booking_seats.booking_id
|
||||
${bookingJoins(sql)}
|
||||
where booking_seats.seat_token = ${seatToken}
|
||||
and bookings.deleted_at is null
|
||||
limit 1
|
||||
`
|
||||
|
||||
@@ -820,6 +1010,7 @@ export async function updateBookingSeatShareByReceiptToken(input: {
|
||||
from bookings
|
||||
where booking_seats.booking_id = bookings.id
|
||||
and bookings.receipt_token = ${input.receiptToken}
|
||||
and bookings.deleted_at is null
|
||||
and booking_seats.id = ${input.seatId}
|
||||
returning
|
||||
booking_seats.id,
|
||||
@@ -899,8 +1090,9 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
|
||||
status = 'confirmed',
|
||||
confirmed_at = now(),
|
||||
updated_at = now()
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and status = 'pending'
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and deleted_at is null
|
||||
and status = 'pending'
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
@@ -926,8 +1118,9 @@ export async function cancelBookingConfirmationByConfirmationToken(confirmationT
|
||||
status = 'pending',
|
||||
confirmed_at = null,
|
||||
updated_at = now()
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and status = 'confirmed'
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and deleted_at is null
|
||||
and status = 'confirmed'
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
|
||||
@@ -43,6 +43,38 @@ export function parseCreateBookingInput(body: {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUpdateBookingDetailsInput(body: {
|
||||
customerName?: string
|
||||
customerPhone?: string
|
||||
bookingMode?: BookingMode | string | null
|
||||
quantity?: number
|
||||
ticketType?: TicketType
|
||||
remark?: string | null
|
||||
}) {
|
||||
const customerName = normalizeFullName(body.customerName || '')
|
||||
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
|
||||
const bookingMode = typeof body.bookingMode === 'string' ? body.bookingMode.trim().toLowerCase() : body.bookingMode
|
||||
const ticketType = typeof body.ticketType === 'string' ? body.ticketType.trim().toLowerCase() : body.ticketType
|
||||
const quantity = Number(body.quantity)
|
||||
const remark = typeof body.remark === 'string' ? body.remark.trim() : ''
|
||||
|
||||
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
|
||||
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must include a country code, e.g. +60123456789')
|
||||
assertBadRequest(typeof bookingMode === 'string' && bookingMode.length > 0, 'Booking mode is required')
|
||||
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
|
||||
assertBadRequest(typeof ticketType === 'string' && ticketType.trim().length > 0, 'Ticket category is required')
|
||||
assertBadRequest(remark.length <= 1000, 'Remark must be 1,000 characters or fewer')
|
||||
|
||||
return {
|
||||
customerName,
|
||||
customerPhone,
|
||||
bookingMode,
|
||||
quantity,
|
||||
ticketType,
|
||||
remark: remark || null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBookingRemarkInput(body: {
|
||||
remark?: string | null
|
||||
}) {
|
||||
|
||||
@@ -287,6 +287,7 @@ async function initializeDatabase() {
|
||||
remark text,
|
||||
status text not null default 'pending',
|
||||
confirmed_at timestamptz,
|
||||
deleted_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
)
|
||||
@@ -322,6 +323,11 @@ async function initializeDatabase() {
|
||||
add column if not exists locale text not null default 'en'
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add column if not exists deleted_at timestamptz
|
||||
`
|
||||
|
||||
await sql`
|
||||
create unique index if not exists bookings_receipt_token_idx
|
||||
on bookings (receipt_token)
|
||||
@@ -342,6 +348,11 @@ async function initializeDatabase() {
|
||||
on bookings (ticket_type_id)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create index if not exists bookings_deleted_at_idx
|
||||
on bookings (deleted_at)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists booking_seats (
|
||||
id text primary key,
|
||||
|
||||
Reference in New Issue
Block a user