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:
2026-05-08 15:57:32 +08:00
parent 1318e766d5
commit e05c238495
7 changed files with 754 additions and 14 deletions

View File

@@ -302,6 +302,15 @@
icon="i-lucide-receipt" icon="i-lucide-receipt"
size="sm" size="sm"
/> />
<UButton
label="Edit"
color="neutral"
variant="outline"
icon="i-lucide-pencil-line"
size="sm"
:disabled="!bookingConfig"
@click="openBookingEditor(row.original)"
/>
<UButton <UButton
label="Transfer" label="Transfer"
color="neutral" color="neutral"
@@ -322,12 +331,158 @@
:disabled="cancellingBookingId !== null && cancellingBookingId !== row.original.id" :disabled="cancellingBookingId !== null && cancellingBookingId !== row.original.id"
@click="cancelBookingConfirmation(row.original)" @click="cancelBookingConfirmation(row.original)"
/> />
<UButton
label="Delete"
color="error"
variant="outline"
icon="i-lucide-trash-2"
size="sm"
:loading="deletingBookingId === row.original.id"
:disabled="Boolean(deletingBookingId) && deletingBookingId !== row.original.id"
@click="deleteBooking(row.original)"
/>
</div> </div>
</template> </template>
</UTable> </UTable>
</div> </div>
</UCard> </UCard>
<UModal
v-model:open="detailsModalOpen"
title="Edit Booking"
description="Update guest details, ticket selection, quantity, and internal remark."
:dismissible="!savingDetails"
:close="!savingDetails"
:content="{ class: 'sm:max-w-2xl' }"
>
<template #body>
<UForm
id="bookingDetailsForm"
:state="detailsForm"
:validate="validateBookingDetails"
class="space-y-4"
@submit="saveBookingDetails"
>
<div v-if="detailsBooking" class="rounded-lg border border-default bg-muted/20 px-3 py-2">
<p class="text-sm font-medium text-highlighted">
{{ detailsBooking.customerName }}
</p>
<p class="text-xs text-muted">
Current: {{ ticketLabel(detailsBooking) }} - {{ detailsBooking.seatCount }} seats
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<UFormField name="customerName" label="Guest / Organizer" required>
<UInput
v-model="detailsForm.customerName"
size="lg"
class="w-full"
placeholder="e.g. John Doe"
/>
</UFormField>
<UFormField name="customerPhone" label="Phone Number" required>
<UInput
v-model="detailsForm.customerPhone"
size="lg"
type="tel"
class="w-full"
placeholder="e.g. +60123456789"
/>
</UFormField>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<UFormField name="bookingMode" label="Booking Mode" required>
<USelect
v-model="detailsForm.bookingMode"
:items="bookingModeItems"
:disabled="savingDetails || !bookingModeItems.length"
size="lg"
class="w-full"
/>
</UFormField>
<UFormField name="quantity" :label="selectedDetailsBookingMode?.quantityLabel || 'Quantity'" required>
<UInputNumber
v-model="detailsForm.quantity"
:min="1"
:step="1"
:disabled="savingDetails"
size="lg"
class="w-full"
/>
</UFormField>
</div>
<UFormField name="ticketType" label="Ticket Category" required>
<URadioGroup
v-model="detailsForm.ticketType"
orientation="horizontal"
variant="card"
indicator="hidden"
:items="ticketCatalogItems"
:disabled="savingDetails || !ticketCatalogItems.length"
:ui="{
fieldset: 'grid grid-cols-2 gap-3',
item: 'rounded-lg border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}"
/>
</UFormField>
<div class="rounded-lg border border-default bg-muted/30 px-4 py-3">
<div class="flex items-center justify-between gap-4">
<span class="text-sm font-medium text-muted">Updated total</span>
<div class="text-right">
<div class="text-lg font-semibold text-highlighted">
{{ detailsTotalFormatted }}
</div>
<div class="text-xs text-muted">
{{ detailsSeatCount }} seats
</div>
</div>
</div>
</div>
<UFormField name="remark" label="Remark">
<UTextarea
v-model="detailsForm.remark"
:rows="4"
:maxlength="remarkLimit"
autoresize
class="w-full"
placeholder="Internal handling note"
/>
<template #help>
{{ detailsForm.remark.length }}/{{ remarkLimit }}
</template>
</UFormField>
</UForm>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
label="Cancel"
color="neutral"
variant="ghost"
class="justify-center"
:disabled="savingDetails"
@click="closeBookingEditor"
/>
<UButton
type="submit"
form="bookingDetailsForm"
label="Save Booking"
icon="i-lucide-save"
class="justify-center"
:loading="savingDetails"
/>
</div>
</template>
</UModal>
<UModal <UModal
v-model:open="remarkModalOpen" v-model:open="remarkModalOpen"
title="Booking Remark" title="Booking Remark"
@@ -434,12 +589,33 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import type { PublicContact } from '~~/shared/auth' import type { PublicContact } from '~~/shared/auth'
import type { BookingCapacitySettings, BookingInventorySummary, CancelBookingConfirmationResponse, PublicBooking, TransferBookingPicResponse } from '~~/shared/booking' import type {
BookingCapacitySettings,
BookingInventorySummary,
BookingModeOption,
BookingMode,
CancelBookingConfirmationResponse,
DeleteBookingResponse,
PublicBooking,
PublicBookingConfig,
TicketCatalogItem,
TicketType,
TransferBookingPicResponse,
UpdateBookingDetailsResponse
} from '~~/shared/booking'
import {
DEFAULT_PHONE_COUNTRY_CODE,
isValidPhoneNumber,
normalizePhoneNumber
} from '~~/shared/auth'
import { import {
formatBookingCurrency, formatBookingCurrency,
getBookingStatusLabel getBookingStatusLabel,
getSeatCount
} from '~~/shared/booking' } from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors' import { getErrorMessage } from '../../utils/errors'
@@ -454,15 +630,21 @@ const apiClient = useApiClient()
const auth = useAuth() const auth = useAuth()
const bookings = ref<PublicBooking[]>([]) const bookings = ref<PublicBooking[]>([])
const bookingConfig = ref<PublicBookingConfig | null>(null)
const contacts = ref<PublicContact[]>([]) const contacts = ref<PublicContact[]>([])
const loadingBookings = ref(false) const loadingBookings = ref(false)
const loadingBookingConfig = ref(false)
const loadingContacts = ref(false) const loadingContacts = ref(false)
const savingCapacity = ref(false) const savingCapacity = ref(false)
const savingDetails = ref(false)
const savingRemark = ref(false) const savingRemark = ref(false)
const savingTransfer = ref(false) const savingTransfer = ref(false)
const cancellingBookingId = ref<string | null>(null) const cancellingBookingId = ref<string | null>(null)
const deletingBookingId = ref<string | null>(null)
const detailsModalOpen = ref(false)
const remarkModalOpen = ref(false) const remarkModalOpen = ref(false)
const transferModalOpen = ref(false) const transferModalOpen = ref(false)
const detailsBooking = ref<PublicBooking | null>(null)
const editingBooking = ref<PublicBooking | null>(null) const editingBooking = ref<PublicBooking | null>(null)
const transferringBooking = ref<PublicBooking | null>(null) const transferringBooking = ref<PublicBooking | null>(null)
const searchQuery = ref('') const searchQuery = ref('')
@@ -479,6 +661,14 @@ const summary = reactive<BookingInventorySummary>({
const capacityForm = reactive({ const capacityForm = reactive({
totalSeats: '' totalSeats: ''
}) })
const detailsForm = reactive({
customerName: '',
customerPhone: DEFAULT_PHONE_COUNTRY_CODE,
bookingMode: '' as BookingMode,
quantity: 1,
ticketType: '' as TicketType,
remark: ''
})
const remarkForm = reactive({ const remarkForm = reactive({
remark: '' remark: ''
}) })
@@ -487,6 +677,32 @@ const transferForm = reactive({
}) })
const remarkLimit = 1000 const remarkLimit = 1000
const bookingModeItems = computed(() => {
return bookingConfig.value?.bookingModes.map((mode: BookingModeOption) => ({
label: mode.label,
value: mode.value
})) || []
})
const ticketCatalogItems = computed(() => {
return bookingConfig.value?.ticketCatalog.map((ticket: TicketCatalogItem) => ({
label: ticket.label,
value: ticket.value,
description: ticket.description
})) || []
})
const selectedDetailsBookingMode = computed(() => {
return bookingConfig.value?.bookingModes.find((mode) => mode.value === detailsForm.bookingMode) ?? null
})
const selectedDetailsTicket = computed(() => {
return bookingConfig.value?.ticketCatalog.find((ticket) => ticket.value === detailsForm.ticketType) ?? null
})
const detailsSeatCount = computed(() => getSeatCount(selectedDetailsBookingMode.value, detailsForm.quantity))
const detailsTotalFormatted = computed(() => formatBookingCurrency(detailsSeatCount.value * (selectedDetailsTicket.value?.price ?? 0)))
const columns = [ const columns = [
{ accessorKey: 'customerName', header: 'Guest' }, { accessorKey: 'customerName', header: 'Guest' },
{ accessorKey: 'quantity', header: 'Booking' }, { accessorKey: 'quantity', header: 'Booking' },
@@ -560,6 +776,7 @@ const transferPersonInChargeItems = computed(() => {
}) })
await Promise.all([ await Promise.all([
refreshBookingConfig(),
refreshBookings(), refreshBookings(),
refreshContacts() refreshContacts()
]) ])
@@ -581,6 +798,82 @@ function receiptPath(booking: PublicBooking) {
return `/receipt/${booking.receiptToken}` return `/receipt/${booking.receiptToken}`
} }
function openBookingEditor(booking: PublicBooking) {
detailsBooking.value = booking
detailsForm.customerName = booking.customerName
detailsForm.customerPhone = booking.customerPhone
detailsForm.bookingMode = booking.bookingMode
detailsForm.quantity = booking.quantity
detailsForm.ticketType = booking.ticketType
detailsForm.remark = booking.remark || ''
detailsModalOpen.value = true
}
function closeBookingEditor() {
if (savingDetails.value) {
return
}
detailsModalOpen.value = false
detailsBooking.value = null
detailsForm.customerName = ''
detailsForm.customerPhone = DEFAULT_PHONE_COUNTRY_CODE
detailsForm.bookingMode = ''
detailsForm.quantity = 1
detailsForm.ticketType = ''
detailsForm.remark = ''
}
function validateBookingDetails(state: typeof detailsForm): FormError[] {
const errors: FormError[] = []
if (!state.customerName.trim()) {
errors.push({ name: 'customerName', message: 'Please enter the guest or organizer name.' })
}
if (!state.customerPhone.trim()) {
errors.push({ name: 'customerPhone', message: 'Please enter a contact number.' })
} else if (!isValidPhoneNumber(state.customerPhone.trim())) {
errors.push({ name: 'customerPhone', message: 'Use a valid phone number with country code, e.g. +60123456789.' })
}
if (state.quantity < 1) {
errors.push({ name: 'quantity', message: 'Quantity must be at least 1.' })
}
if (!selectedDetailsBookingMode.value) {
errors.push({ name: 'bookingMode', message: 'Please select a booking mode.' })
}
if (!selectedDetailsTicket.value) {
errors.push({ name: 'ticketType', message: 'Please select a ticket category.' })
}
return errors
}
async function refreshBookingConfig() {
if (loadingBookingConfig.value) {
return
}
loadingBookingConfig.value = true
try {
const response = await apiClient<PublicBookingConfig>('/api/public/booking-config')
bookingConfig.value = response
} catch (error: any) {
toast.add({
title: 'Unable to load booking settings',
description: getErrorMessage(error, 'The booking editor could not load the active event settings.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
loadingBookingConfig.value = false
}
}
function formatInventoryNumber(value: number | null) { function formatInventoryNumber(value: number | null) {
return value === null ? 'Not set' : String(value) return value === null ? 'Not set' : String(value)
} }
@@ -720,6 +1013,90 @@ async function refreshContacts() {
} }
} }
async function saveBookingDetails(event: FormSubmitEvent<typeof detailsForm>) {
const booking = detailsBooking.value
event.preventDefault()
if (!booking || savingDetails.value) {
return
}
savingDetails.value = true
try {
const response = await apiClient<UpdateBookingDetailsResponse>(`/api/bookings/${booking.id}`, {
method: 'PATCH',
body: {
customerName: detailsForm.customerName.trim(),
customerPhone: normalizePhoneNumber(detailsForm.customerPhone),
bookingMode: detailsForm.bookingMode,
quantity: detailsForm.quantity,
ticketType: detailsForm.ticketType,
remark: detailsForm.remark
}
})
replaceBooking(response.booking)
await refreshBookings()
detailsModalOpen.value = false
detailsBooking.value = null
toast.add({
title: 'Booking updated',
description: 'The booking details have been saved.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
toast.add({
title: 'Booking update failed',
description: getErrorMessage(error, 'Unable to save the booking details.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
savingDetails.value = false
}
}
async function deleteBooking(booking: PublicBooking) {
if (deletingBookingId.value) {
return
}
if (import.meta.client && !window.confirm(`Delete booking for ${booking.customerName}? It will be archived and removed from the active list.`)) {
return
}
deletingBookingId.value = booking.id
try {
const response = await apiClient<DeleteBookingResponse>(`/api/bookings/${booking.id}`, {
method: 'DELETE'
})
removeBooking(response.booking.id)
await refreshBookings()
toast.add({
title: 'Booking deleted',
description: 'The booking has been moved to archived state.',
color: 'success',
icon: 'i-lucide-trash-2'
})
} catch (error: any) {
toast.add({
title: 'Delete failed',
description: getErrorMessage(error, 'Unable to delete the booking.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
deletingBookingId.value = null
}
}
async function saveCapacity(event: Event) { async function saveCapacity(event: Event) {
event.preventDefault() event.preventDefault()

View File

@@ -0,0 +1,31 @@
import type { DeleteBookingResponse } from '~~/shared/booking'
import { requireAuth } from '../../utils/auth'
import { getBookingById, softDeleteBooking } from '../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../utils/http'
export default defineEventHandler(async (event): Promise<DeleteBookingResponse> => {
const auth = await requireAuth(event)
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
const existingBooking = await getBookingById(bookingId, auth.user.role === 'super_admin'
? undefined
: { personInChargeId: auth.user.id })
if (!existingBooking) {
httpError(404, 'Booking not found')
}
const booking = await softDeleteBooking({
bookingId,
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
})
if (!booking) {
httpError(404, 'Booking not found')
}
return {
booking
}
})

View File

@@ -0,0 +1,88 @@
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
import { getSeatCount } from '~~/shared/booking'
import { requireAuth } from '../../utils/auth'
import {
getBookingById,
getBookingInventorySummary,
getActiveBookingModeOptionByCode,
getActiveTicketCatalogItemByCode,
updateBookingDetails
} from '../../utils/booking-repository'
import { parseUpdateBookingDetailsInput } from '../../utils/bookings'
import { getRequiredRouteParam, httpError } from '../../utils/http'
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
const auth = await requireAuth(event)
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
const body = await readBody<{
customerName?: string
customerPhone?: string
bookingMode?: string | null
quantity?: number
ticketType?: string
remark?: string | null
}>(event)
const existingBooking = await getBookingById(bookingId, auth.user.role === 'super_admin'
? undefined
: { personInChargeId: auth.user.id })
if (!existingBooking) {
httpError(404, 'Booking not found')
}
const input = parseUpdateBookingDetailsInput(body)
const [bookingMode, ticket] = await Promise.all([
getActiveBookingModeOptionByCode(input.bookingMode),
getActiveTicketCatalogItemByCode(input.ticketType)
])
if (!bookingMode) {
httpError(400, 'Booking mode is invalid')
}
if (!ticket) {
httpError(400, 'Ticket category is invalid')
}
if (bookingMode.eventId !== ticket.eventId || bookingMode.eventId !== existingBooking.event.id) {
httpError(400, 'Booking mode and ticket category must belong to the same event')
}
const seatCount = getSeatCount(bookingMode, input.quantity)
const totalPrice = seatCount * ticket.price
const seatIncrease = Math.max(seatCount - existingBooking.seatCount, 0)
if (existingBooking.status === 'confirmed' && seatIncrease > 0) {
const summary = await getBookingInventorySummary()
if (summary.leftSeats !== null && seatIncrease > summary.leftSeats) {
httpError(409, `Total seats cannot exceed the remaining capacity by ${seatIncrease - summary.leftSeats} seats`)
}
}
const booking = await updateBookingDetails({
bookingId,
customerName: input.customerName,
customerPhone: input.customerPhone,
bookingModeId: bookingMode.id,
bookingMode: bookingMode.value,
quantity: input.quantity,
seatCount,
ticketTypeId: ticket.id,
ticketType: ticket.value,
unitPrice: ticket.price,
totalPrice,
remark: input.remark,
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
})
if (!booking) {
httpError(404, 'Booking not found')
}
return {
booking
}
})

View File

@@ -35,6 +35,7 @@ type DbBookingRow = {
customer_name: string customer_name: string
customer_phone: string customer_phone: string
locale: AppLocale | string | null locale: AppLocale | string | null
deleted_at: Date | string | null
booking_mode_id: string | null booking_mode_id: string | null
booking_mode: string booking_mode: string
booking_mode_label: string | null booking_mode_label: string | null
@@ -129,6 +130,7 @@ function bookingSelectColumns(sql: any) {
bookings.customer_name, bookings.customer_name,
bookings.customer_phone, bookings.customer_phone,
bookings.locale, bookings.locale,
bookings.deleted_at,
bookings.booking_mode_id, bookings.booking_mode_id,
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode, coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
booking_modes.label as booking_mode_label, booking_modes.label as booking_mode_label,
@@ -556,6 +558,7 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
from bookings from bookings
${bookingJoins(sql)} ${bookingJoins(sql)}
where bookings.confirmation_token = ${confirmationToken} where bookings.confirmation_token = ${confirmationToken}
and bookings.deleted_at is null
limit 1 limit 1
` `
@@ -571,6 +574,7 @@ export async function getBookingByReceiptToken(receiptToken: string): Promise<Pu
from bookings from bookings
${bookingJoins(sql)} ${bookingJoins(sql)}
where bookings.receipt_token = ${receiptToken} where bookings.receipt_token = ${receiptToken}
and bookings.deleted_at is null
limit 1 limit 1
` `
@@ -589,6 +593,7 @@ export async function listBookings(options?: {
from bookings from bookings
${bookingJoins(sql)} ${bookingJoins(sql)}
where dinner_events.is_active = true where dinner_events.is_active = true
and bookings.deleted_at is null
and bookings.person_in_charge_id = ${options.personInChargeId} and bookings.person_in_charge_id = ${options.personInChargeId}
order by bookings.created_at desc order by bookings.created_at desc
` `
@@ -597,6 +602,7 @@ export async function listBookings(options?: {
from bookings from bookings
${bookingJoins(sql)} ${bookingJoins(sql)}
where dinner_events.is_active = true where dinner_events.is_active = true
and bookings.deleted_at is null
order by bookings.created_at desc order by bookings.created_at desc
` `
@@ -618,8 +624,9 @@ export async function updateBookingRemark(input: {
set set
remark = ${input.remark}, remark = ${input.remark},
updated_at = now() updated_at = now()
where id = ${input.bookingId} where id = ${input.bookingId}
and person_in_charge_id = ${input.personInChargeId} and deleted_at is null
and person_in_charge_id = ${input.personInChargeId}
returning * returning *
) )
select ${bookingSelectColumns(sql)} select ${bookingSelectColumns(sql)}
@@ -634,8 +641,9 @@ export async function updateBookingRemark(input: {
set set
remark = ${input.remark}, remark = ${input.remark},
updated_at = now() updated_at = now()
where id = ${input.bookingId} where id = ${input.bookingId}
returning * and deleted_at is null
returning *
) )
select ${bookingSelectColumns(sql)} select ${bookingSelectColumns(sql)}
from updated_booking as bookings from updated_booking as bookings
@@ -662,8 +670,9 @@ export async function updateBookingPersonInCharge(input: {
set set
person_in_charge_id = ${input.nextPersonInChargeId}, person_in_charge_id = ${input.nextPersonInChargeId},
updated_at = now() updated_at = now()
where id = ${input.bookingId} where id = ${input.bookingId}
and person_in_charge_id = ${input.currentPersonInChargeId} and deleted_at is null
and person_in_charge_id = ${input.currentPersonInChargeId}
returning * returning *
) )
select ${bookingSelectColumns(sql)} select ${bookingSelectColumns(sql)}
@@ -678,8 +687,9 @@ export async function updateBookingPersonInCharge(input: {
set set
person_in_charge_id = ${input.nextPersonInChargeId}, person_in_charge_id = ${input.nextPersonInChargeId},
updated_at = now() updated_at = now()
where id = ${input.bookingId} where id = ${input.bookingId}
returning * and deleted_at is null
returning *
) )
select ${bookingSelectColumns(sql)} select ${bookingSelectColumns(sql)}
from updated_booking as bookings from updated_booking as bookings
@@ -691,6 +701,178 @@ export async function updateBookingPersonInCharge(input: {
return rows[0] ? mapBooking(rows[0]) : null 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[]> { export async function listBookingSeats(bookingId: string): Promise<PublicBookingSeat[]> {
await ensureDatabaseReady() await ensureDatabaseReady()
const sql = getSqlClient() const sql = getSqlClient()
@@ -707,6 +889,12 @@ export async function listBookingSeats(bookingId: string): Promise<PublicBooking
updated_at updated_at
from booking_seats from booking_seats
where booking_id = ${bookingId} 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 order by seat_number asc
` `
@@ -759,6 +947,7 @@ export async function getSeatReceiptBySeatToken(seatToken: string): Promise<{
bookings.customer_name, bookings.customer_name,
bookings.customer_phone, bookings.customer_phone,
bookings.locale, bookings.locale,
bookings.deleted_at,
bookings.booking_mode_id, bookings.booking_mode_id,
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode, coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
booking_modes.label as booking_mode_label, 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 inner join bookings on bookings.id = booking_seats.booking_id
${bookingJoins(sql)} ${bookingJoins(sql)}
where booking_seats.seat_token = ${seatToken} where booking_seats.seat_token = ${seatToken}
and bookings.deleted_at is null
limit 1 limit 1
` `
@@ -820,6 +1010,7 @@ export async function updateBookingSeatShareByReceiptToken(input: {
from bookings from bookings
where booking_seats.booking_id = bookings.id where booking_seats.booking_id = bookings.id
and bookings.receipt_token = ${input.receiptToken} and bookings.receipt_token = ${input.receiptToken}
and bookings.deleted_at is null
and booking_seats.id = ${input.seatId} and booking_seats.id = ${input.seatId}
returning returning
booking_seats.id, booking_seats.id,
@@ -899,8 +1090,9 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
status = 'confirmed', status = 'confirmed',
confirmed_at = now(), confirmed_at = now(),
updated_at = now() updated_at = now()
where confirmation_token = ${confirmationToken} where confirmation_token = ${confirmationToken}
and status = 'pending' and deleted_at is null
and status = 'pending'
returning * returning *
) )
select ${bookingSelectColumns(sql)} select ${bookingSelectColumns(sql)}
@@ -926,8 +1118,9 @@ export async function cancelBookingConfirmationByConfirmationToken(confirmationT
status = 'pending', status = 'pending',
confirmed_at = null, confirmed_at = null,
updated_at = now() updated_at = now()
where confirmation_token = ${confirmationToken} where confirmation_token = ${confirmationToken}
and status = 'confirmed' and deleted_at is null
and status = 'confirmed'
returning * returning *
) )
select ${bookingSelectColumns(sql)} select ${bookingSelectColumns(sql)}

View File

@@ -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: { export function parseBookingRemarkInput(body: {
remark?: string | null remark?: string | null
}) { }) {

View File

@@ -287,6 +287,7 @@ async function initializeDatabase() {
remark text, remark text,
status text not null default 'pending', status text not null default 'pending',
confirmed_at timestamptz, confirmed_at timestamptz,
deleted_at timestamptz,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
updated_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' 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` await sql`
create unique index if not exists bookings_receipt_token_idx create unique index if not exists bookings_receipt_token_idx
on bookings (receipt_token) on bookings (receipt_token)
@@ -342,6 +348,11 @@ async function initializeDatabase() {
on bookings (ticket_type_id) on bookings (ticket_type_id)
` `
await sql`
create index if not exists bookings_deleted_at_idx
on bookings (deleted_at)
`
await sql` await sql`
create table if not exists booking_seats ( create table if not exists booking_seats (
id text primary key, id text primary key,

View File

@@ -158,6 +158,14 @@ export interface TransferBookingPicResponse {
booking: PublicBooking booking: PublicBooking
} }
export interface UpdateBookingDetailsResponse {
booking: PublicBooking
}
export interface DeleteBookingResponse {
booking: PublicBooking
}
export function isBookingStatus(value: string | null | undefined): value is BookingStatus { export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
return value === 'pending' || value === 'confirmed' return value === 'pending' || value === 'confirmed'
} }