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"
|
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()
|
||||||
|
|
||||||
|
|||||||
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_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
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -619,6 +625,7 @@ export async function updateBookingRemark(input: {
|
|||||||
remark = ${input.remark},
|
remark = ${input.remark},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where id = ${input.bookingId}
|
where id = ${input.bookingId}
|
||||||
|
and deleted_at is null
|
||||||
and person_in_charge_id = ${input.personInChargeId}
|
and person_in_charge_id = ${input.personInChargeId}
|
||||||
returning *
|
returning *
|
||||||
)
|
)
|
||||||
@@ -635,6 +642,7 @@ export async function updateBookingRemark(input: {
|
|||||||
remark = ${input.remark},
|
remark = ${input.remark},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where id = ${input.bookingId}
|
where id = ${input.bookingId}
|
||||||
|
and deleted_at is null
|
||||||
returning *
|
returning *
|
||||||
)
|
)
|
||||||
select ${bookingSelectColumns(sql)}
|
select ${bookingSelectColumns(sql)}
|
||||||
@@ -663,6 +671,7 @@ export async function updateBookingPersonInCharge(input: {
|
|||||||
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 deleted_at is null
|
||||||
and person_in_charge_id = ${input.currentPersonInChargeId}
|
and person_in_charge_id = ${input.currentPersonInChargeId}
|
||||||
returning *
|
returning *
|
||||||
)
|
)
|
||||||
@@ -679,6 +688,7 @@ export async function updateBookingPersonInCharge(input: {
|
|||||||
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 deleted_at is null
|
||||||
returning *
|
returning *
|
||||||
)
|
)
|
||||||
select ${bookingSelectColumns(sql)}
|
select ${bookingSelectColumns(sql)}
|
||||||
@@ -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,
|
||||||
@@ -900,6 +1091,7 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
|
|||||||
confirmed_at = now(),
|
confirmed_at = now(),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where confirmation_token = ${confirmationToken}
|
where confirmation_token = ${confirmationToken}
|
||||||
|
and deleted_at is null
|
||||||
and status = 'pending'
|
and status = 'pending'
|
||||||
returning *
|
returning *
|
||||||
)
|
)
|
||||||
@@ -927,6 +1119,7 @@ export async function cancelBookingConfirmationByConfirmationToken(confirmationT
|
|||||||
confirmed_at = null,
|
confirmed_at = null,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where confirmation_token = ${confirmationToken}
|
where confirmation_token = ${confirmationToken}
|
||||||
|
and deleted_at is null
|
||||||
and status = 'confirmed'
|
and status = 'confirmed'
|
||||||
returning *
|
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: {
|
export function parseBookingRemarkInput(body: {
|
||||||
remark?: string | null
|
remark?: string | null
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user