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:
@@ -302,6 +302,15 @@
|
||||
icon="i-lucide-receipt"
|
||||
size="sm"
|
||||
/>
|
||||
<UButton
|
||||
label="Edit"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil-line"
|
||||
size="sm"
|
||||
:disabled="!bookingConfig"
|
||||
@click="openBookingEditor(row.original)"
|
||||
/>
|
||||
<UButton
|
||||
label="Transfer"
|
||||
color="neutral"
|
||||
@@ -322,12 +331,158 @@
|
||||
:disabled="cancellingBookingId !== null && cancellingBookingId !== row.original.id"
|
||||
@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>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</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
|
||||
v-model:open="remarkModalOpen"
|
||||
title="Booking Remark"
|
||||
@@ -434,12 +589,33 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
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 {
|
||||
formatBookingCurrency,
|
||||
getBookingStatusLabel
|
||||
getBookingStatusLabel,
|
||||
getSeatCount
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors'
|
||||
@@ -454,15 +630,21 @@ const apiClient = useApiClient()
|
||||
const auth = useAuth()
|
||||
|
||||
const bookings = ref<PublicBooking[]>([])
|
||||
const bookingConfig = ref<PublicBookingConfig | null>(null)
|
||||
const contacts = ref<PublicContact[]>([])
|
||||
const loadingBookings = ref(false)
|
||||
const loadingBookingConfig = ref(false)
|
||||
const loadingContacts = ref(false)
|
||||
const savingCapacity = ref(false)
|
||||
const savingDetails = ref(false)
|
||||
const savingRemark = ref(false)
|
||||
const savingTransfer = ref(false)
|
||||
const cancellingBookingId = ref<string | null>(null)
|
||||
const deletingBookingId = ref<string | null>(null)
|
||||
const detailsModalOpen = ref(false)
|
||||
const remarkModalOpen = ref(false)
|
||||
const transferModalOpen = ref(false)
|
||||
const detailsBooking = ref<PublicBooking | null>(null)
|
||||
const editingBooking = ref<PublicBooking | null>(null)
|
||||
const transferringBooking = ref<PublicBooking | null>(null)
|
||||
const searchQuery = ref('')
|
||||
@@ -479,6 +661,14 @@ const summary = reactive<BookingInventorySummary>({
|
||||
const capacityForm = reactive({
|
||||
totalSeats: ''
|
||||
})
|
||||
const detailsForm = reactive({
|
||||
customerName: '',
|
||||
customerPhone: DEFAULT_PHONE_COUNTRY_CODE,
|
||||
bookingMode: '' as BookingMode,
|
||||
quantity: 1,
|
||||
ticketType: '' as TicketType,
|
||||
remark: ''
|
||||
})
|
||||
const remarkForm = reactive({
|
||||
remark: ''
|
||||
})
|
||||
@@ -487,6 +677,32 @@ const transferForm = reactive({
|
||||
})
|
||||
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 = [
|
||||
{ accessorKey: 'customerName', header: 'Guest' },
|
||||
{ accessorKey: 'quantity', header: 'Booking' },
|
||||
@@ -560,6 +776,7 @@ const transferPersonInChargeItems = computed(() => {
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
refreshBookingConfig(),
|
||||
refreshBookings(),
|
||||
refreshContacts()
|
||||
])
|
||||
@@ -581,6 +798,82 @@ function receiptPath(booking: PublicBooking) {
|
||||
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) {
|
||||
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) {
|
||||
event.preventDefault()
|
||||
|
||||
|
||||
31
server/api/bookings/[id].delete.ts
Normal file
31
server/api/bookings/[id].delete.ts
Normal 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
|
||||
}
|
||||
})
|
||||
88
server/api/bookings/[id].patch.ts
Normal file
88
server/api/bookings/[id].patch.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -619,6 +625,7 @@ export async function updateBookingRemark(input: {
|
||||
remark = ${input.remark},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
and person_in_charge_id = ${input.personInChargeId}
|
||||
returning *
|
||||
)
|
||||
@@ -635,6 +642,7 @@ export async function updateBookingRemark(input: {
|
||||
remark = ${input.remark},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
@@ -663,6 +671,7 @@ export async function updateBookingPersonInCharge(input: {
|
||||
person_in_charge_id = ${input.nextPersonInChargeId},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
and person_in_charge_id = ${input.currentPersonInChargeId}
|
||||
returning *
|
||||
)
|
||||
@@ -679,6 +688,7 @@ export async function updateBookingPersonInCharge(input: {
|
||||
person_in_charge_id = ${input.nextPersonInChargeId},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
and deleted_at is null
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
@@ -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,
|
||||
@@ -900,6 +1091,7 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
|
||||
confirmed_at = now(),
|
||||
updated_at = now()
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and deleted_at is null
|
||||
and status = 'pending'
|
||||
returning *
|
||||
)
|
||||
@@ -927,6 +1119,7 @@ export async function cancelBookingConfirmationByConfirmationToken(confirmationT
|
||||
confirmed_at = null,
|
||||
updated_at = now()
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and deleted_at is null
|
||||
and status = 'confirmed'
|
||||
returning *
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -158,6 +158,14 @@ export interface TransferBookingPicResponse {
|
||||
booking: PublicBooking
|
||||
}
|
||||
|
||||
export interface UpdateBookingDetailsResponse {
|
||||
booking: PublicBooking
|
||||
}
|
||||
|
||||
export interface DeleteBookingResponse {
|
||||
booking: PublicBooking
|
||||
}
|
||||
|
||||
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
|
||||
return value === 'pending' || value === 'confirmed'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user