feat(bookings): add transaction document uploads for bank payments
Add payment method selection (Cash/Bank) to booking details Support uploading, downloading, and deleting transaction documents Update database schema and API endpoints to handle file storage
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system
|
NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system
|
||||||
NUXT_REDIS_URL=redis://127.0.0.1:6379
|
NUXT_REDIS_URL=redis://127.0.0.1:6379
|
||||||
NUXT_SESSION_COOKIE_NAME=dinner_ticket_session
|
NUXT_SESSION_COOKIE_NAME=dinner_ticket_session
|
||||||
|
NUXT_TRANSACTION_DOCUMENT_DIR=.data/transaction-documents
|
||||||
NUXT_WHATSAPP_ACCESS_TOKEN=
|
NUXT_WHATSAPP_ACCESS_TOKEN=
|
||||||
NUXT_WHATSAPP_PHONE_NUMBER_ID=
|
NUXT_WHATSAPP_PHONE_NUMBER_ID=
|
||||||
NUXT_WHATSAPP_API_VERSION=v23.0
|
NUXT_WHATSAPP_API_VERSION=v23.0
|
||||||
|
|||||||
@@ -197,7 +197,7 @@
|
|||||||
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
|
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
|
||||||
sticky="header"
|
sticky="header"
|
||||||
caption="Bookings"
|
caption="Bookings"
|
||||||
class="compact-table min-w-[1120px]"
|
class="compact-table min-w-[1280px]"
|
||||||
>
|
>
|
||||||
<template #customerName-cell="{ row }">
|
<template #customerName-cell="{ row }">
|
||||||
<div class="min-w-0 space-y-0.5 py-0.5">
|
<div class="min-w-0 space-y-0.5 py-0.5">
|
||||||
@@ -240,6 +240,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #paymentMethod-cell="{ row }">
|
||||||
|
<div class="space-y-1 py-0.5">
|
||||||
|
<UBadge
|
||||||
|
:label="getPaymentMethodLabel(row.original.paymentMethod)"
|
||||||
|
:color="row.original.paymentMethod === 'bank' ? 'info' : 'neutral'"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="row.original.transactionDocument"
|
||||||
|
:to="row.original.transactionDocument.url"
|
||||||
|
:label="row.original.transactionDocument.originalName"
|
||||||
|
color="neutral"
|
||||||
|
variant="link"
|
||||||
|
icon="i-lucide-file-down"
|
||||||
|
size="xs"
|
||||||
|
class="-ms-2 max-w-44 justify-start truncate"
|
||||||
|
/>
|
||||||
|
<div v-else-if="row.original.paymentMethod === 'bank'" class="text-xs text-muted">
|
||||||
|
No document
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #remark-cell="{ row }">
|
<template #remark-cell="{ row }">
|
||||||
<div class="max-w-64 space-y-1 py-0.5">
|
<div class="max-w-64 space-y-1 py-0.5">
|
||||||
<p
|
<p
|
||||||
@@ -331,9 +356,9 @@
|
|||||||
<UModal
|
<UModal
|
||||||
v-model:open="detailsModalOpen"
|
v-model:open="detailsModalOpen"
|
||||||
title="Edit Booking"
|
title="Edit Booking"
|
||||||
description="Update guest details, ticket selection, quantity, and internal remark."
|
description="Update guest details, payment, ticket selection, quantity, and handling note."
|
||||||
:dismissible="!savingDetails"
|
:dismissible="!savingDetails && !deletingTransactionDocument"
|
||||||
:close="!savingDetails"
|
:close="!savingDetails && !deletingTransactionDocument"
|
||||||
:content="{ class: 'sm:max-w-2xl' }"
|
:content="{ class: 'sm:max-w-2xl' }"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@@ -390,7 +415,7 @@
|
|||||||
v-model="detailsForm.quantity"
|
v-model="detailsForm.quantity"
|
||||||
:min="1"
|
:min="1"
|
||||||
:step="1"
|
:step="1"
|
||||||
:disabled="savingDetails"
|
:disabled="savingDetails || deletingTransactionDocument"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -412,6 +437,76 @@
|
|||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField name="paymentMethod" label="Payment Method" required>
|
||||||
|
<URadioGroup
|
||||||
|
v-model="detailsForm.paymentMethod"
|
||||||
|
orientation="horizontal"
|
||||||
|
variant="card"
|
||||||
|
indicator="hidden"
|
||||||
|
:items="paymentMethodItems"
|
||||||
|
:disabled="savingDetails"
|
||||||
|
:ui="{
|
||||||
|
fieldset: 'grid grid-cols-2 gap-3',
|
||||||
|
item: 'rounded-lg border border-default bg-default p-3 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div v-if="detailsForm.paymentMethod === 'bank'" class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-if="detailsBooking?.transactionDocument"
|
||||||
|
class="surface-panel flex flex-col gap-2 rounded-lg px-3 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-medium text-highlighted">
|
||||||
|
{{ detailsBooking.transactionDocument.originalName }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
{{ formatFileSize(detailsBooking.transactionDocument.size) }} - {{ formatDateTime(detailsBooking.transactionDocument.uploadedAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<UButton
|
||||||
|
:to="detailsBooking.transactionDocument.url"
|
||||||
|
label="Download"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-file-down"
|
||||||
|
size="sm"
|
||||||
|
class="justify-center"
|
||||||
|
:disabled="deletingTransactionDocument"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
label="Delete"
|
||||||
|
color="error"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
size="sm"
|
||||||
|
class="justify-center"
|
||||||
|
:loading="deletingTransactionDocument"
|
||||||
|
:disabled="savingDetails"
|
||||||
|
@click="deleteTransactionDocumentForDetails"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UFormField name="transactionDocument" label="Transaction Document">
|
||||||
|
<UInput
|
||||||
|
:key="transactionDocumentInputKey"
|
||||||
|
type="file"
|
||||||
|
:accept="transactionDocumentAccept"
|
||||||
|
:disabled="savingDetails || deletingTransactionDocument"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
@change="onTransactionDocumentChange"
|
||||||
|
/>
|
||||||
|
<template #help>
|
||||||
|
{{ selectedTransactionDocumentName || 'PDF, JPG, PNG, WEBP, HEIC, or HEIF - max 10MB.' }}
|
||||||
|
</template>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="surface-panel rounded-lg px-4 py-3">
|
<div class="surface-panel rounded-lg px-4 py-3">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<span class="text-sm font-medium text-muted">Updated total</span>
|
<span class="text-sm font-medium text-muted">Updated total</span>
|
||||||
@@ -433,7 +528,7 @@
|
|||||||
:maxlength="remarkLimit"
|
:maxlength="remarkLimit"
|
||||||
autoresize
|
autoresize
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placeholder="Internal handling note"
|
placeholder="Handling note"
|
||||||
/>
|
/>
|
||||||
<template #help>
|
<template #help>
|
||||||
{{ detailsForm.remark.length }}/{{ remarkLimit }}
|
{{ detailsForm.remark.length }}/{{ remarkLimit }}
|
||||||
@@ -449,7 +544,7 @@
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="justify-center"
|
class="justify-center"
|
||||||
:disabled="savingDetails"
|
:disabled="savingDetails || deletingTransactionDocument"
|
||||||
@click="closeBookingEditor"
|
@click="closeBookingEditor"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
@@ -459,6 +554,7 @@
|
|||||||
icon="i-lucide-save"
|
icon="i-lucide-save"
|
||||||
class="justify-center"
|
class="justify-center"
|
||||||
:loading="savingDetails"
|
:loading="savingDetails"
|
||||||
|
:disabled="deletingTransactionDocument"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -487,7 +583,7 @@
|
|||||||
:maxlength="remarkLimit"
|
:maxlength="remarkLimit"
|
||||||
autoresize
|
autoresize
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placeholder="Internal handling note"
|
placeholder="Handling note"
|
||||||
/>
|
/>
|
||||||
<template #help>
|
<template #help>
|
||||||
{{ remarkForm.remark.length }}/{{ remarkLimit }}
|
{{ remarkForm.remark.length }}/{{ remarkLimit }}
|
||||||
@@ -580,6 +676,7 @@ import type {
|
|||||||
BookingMode,
|
BookingMode,
|
||||||
CancelBookingConfirmationResponse,
|
CancelBookingConfirmationResponse,
|
||||||
DeleteBookingResponse,
|
DeleteBookingResponse,
|
||||||
|
PaymentMethod,
|
||||||
PublicBooking,
|
PublicBooking,
|
||||||
PublicBookingConfig,
|
PublicBookingConfig,
|
||||||
TicketCatalogItem,
|
TicketCatalogItem,
|
||||||
@@ -626,6 +723,7 @@ const savingCapacity = ref(false)
|
|||||||
const savingDetails = ref(false)
|
const savingDetails = ref(false)
|
||||||
const savingRemark = ref(false)
|
const savingRemark = ref(false)
|
||||||
const savingTransfer = ref(false)
|
const savingTransfer = ref(false)
|
||||||
|
const deletingTransactionDocument = ref(false)
|
||||||
const cancellingBookingId = ref<string | null>(null)
|
const cancellingBookingId = ref<string | null>(null)
|
||||||
const deletingBookingId = ref<string | null>(null)
|
const deletingBookingId = ref<string | null>(null)
|
||||||
const detailsModalOpen = ref(false)
|
const detailsModalOpen = ref(false)
|
||||||
@@ -654,8 +752,11 @@ const detailsForm = reactive({
|
|||||||
bookingMode: '' as BookingMode,
|
bookingMode: '' as BookingMode,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
ticketType: '' as TicketType,
|
ticketType: '' as TicketType,
|
||||||
|
paymentMethod: 'cash' as PaymentMethod,
|
||||||
remark: ''
|
remark: ''
|
||||||
})
|
})
|
||||||
|
const detailsTransactionDocumentFile = ref<File | null>(null)
|
||||||
|
const transactionDocumentInputKey = ref(0)
|
||||||
const remarkForm = reactive({
|
const remarkForm = reactive({
|
||||||
remark: ''
|
remark: ''
|
||||||
})
|
})
|
||||||
@@ -663,6 +764,19 @@ const transferForm = reactive({
|
|||||||
personInChargeId: ''
|
personInChargeId: ''
|
||||||
})
|
})
|
||||||
const remarkLimit = 1000
|
const remarkLimit = 1000
|
||||||
|
const transactionDocumentLimit = 10 * 1024 * 1024
|
||||||
|
const transactionDocumentAccept = '.pdf,.jpg,.jpeg,.png,.webp,.heic,.heif,application/pdf,image/jpeg,image/png,image/webp,image/heic,image/heif'
|
||||||
|
|
||||||
|
const paymentMethodItems: { label: string, value: PaymentMethod }[] = [
|
||||||
|
{
|
||||||
|
label: 'Cash',
|
||||||
|
value: 'cash'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Bank',
|
||||||
|
value: 'bank'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const bookingModeItems = computed(() => {
|
const bookingModeItems = computed(() => {
|
||||||
return bookingConfig.value?.bookingModes.map((mode: BookingModeOption) => ({
|
return bookingConfig.value?.bookingModes.map((mode: BookingModeOption) => ({
|
||||||
@@ -689,12 +803,14 @@ const selectedDetailsTicket = computed(() => {
|
|||||||
|
|
||||||
const detailsSeatCount = computed(() => getSeatCount(selectedDetailsBookingMode.value, detailsForm.quantity))
|
const detailsSeatCount = computed(() => getSeatCount(selectedDetailsBookingMode.value, detailsForm.quantity))
|
||||||
const detailsTotalFormatted = computed(() => formatBookingCurrency(detailsSeatCount.value * (selectedDetailsTicket.value?.price ?? 0)))
|
const detailsTotalFormatted = computed(() => formatBookingCurrency(detailsSeatCount.value * (selectedDetailsTicket.value?.price ?? 0)))
|
||||||
|
const selectedTransactionDocumentName = computed(() => detailsTransactionDocumentFile.value?.name || '')
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ accessorKey: 'customerName', header: 'Guest' },
|
{ accessorKey: 'customerName', header: 'Guest' },
|
||||||
{ accessorKey: 'quantity', header: 'Booking' },
|
{ accessorKey: 'quantity', header: 'Booking' },
|
||||||
{ accessorKey: 'seatCount', header: 'Seats / Total' },
|
{ accessorKey: 'seatCount', header: 'Seats / Total' },
|
||||||
{ accessorKey: 'personInChargeName', header: 'PIC' },
|
{ accessorKey: 'personInChargeName', header: 'PIC' },
|
||||||
|
{ accessorKey: 'paymentMethod', header: 'Payment' },
|
||||||
{ accessorKey: 'remark', header: 'Remark' },
|
{ accessorKey: 'remark', header: 'Remark' },
|
||||||
{ id: 'status', header: 'Status' },
|
{ id: 'status', header: 'Status' },
|
||||||
{ accessorKey: 'createdAt', header: 'Submitted' },
|
{ accessorKey: 'createdAt', header: 'Submitted' },
|
||||||
@@ -737,6 +853,8 @@ const filteredBookings = computed(() => {
|
|||||||
booking.personInChargePhoneNumber,
|
booking.personInChargePhoneNumber,
|
||||||
booking.ticketType,
|
booking.ticketType,
|
||||||
booking.ticketLabel,
|
booking.ticketLabel,
|
||||||
|
getPaymentMethodLabel(booking.paymentMethod),
|
||||||
|
booking.transactionDocument?.originalName || '',
|
||||||
booking.remark || '',
|
booking.remark || '',
|
||||||
booking.status
|
booking.status
|
||||||
].some((value) => value.toLowerCase().includes(keyword))
|
].some((value) => value.toLowerCase().includes(keyword))
|
||||||
@@ -772,6 +890,18 @@ function ticketLabel(booking: PublicBooking) {
|
|||||||
return booking.ticketLabel || booking.ticketType.toUpperCase()
|
return booking.ticketLabel || booking.ticketType.toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPaymentMethodLabel(paymentMethod: PaymentMethod) {
|
||||||
|
return paymentMethod === 'bank' ? 'Bank' : 'Cash'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(size: number) {
|
||||||
|
if (size >= 1024 * 1024) {
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.max(Math.round(size / 1024), 1)} KB`
|
||||||
|
}
|
||||||
|
|
||||||
function remarkPreview(remark: string) {
|
function remarkPreview(remark: string) {
|
||||||
const normalized = remark.trim()
|
const normalized = remark.trim()
|
||||||
return normalized.length > 120 ? `${normalized.slice(0, 120)}...` : normalized
|
return normalized.length > 120 ? `${normalized.slice(0, 120)}...` : normalized
|
||||||
@@ -845,12 +975,15 @@ function openBookingEditor(booking: PublicBooking) {
|
|||||||
detailsForm.bookingMode = booking.bookingMode
|
detailsForm.bookingMode = booking.bookingMode
|
||||||
detailsForm.quantity = booking.quantity
|
detailsForm.quantity = booking.quantity
|
||||||
detailsForm.ticketType = booking.ticketType
|
detailsForm.ticketType = booking.ticketType
|
||||||
|
detailsForm.paymentMethod = booking.paymentMethod
|
||||||
detailsForm.remark = booking.remark || ''
|
detailsForm.remark = booking.remark || ''
|
||||||
|
detailsTransactionDocumentFile.value = null
|
||||||
|
transactionDocumentInputKey.value += 1
|
||||||
detailsModalOpen.value = true
|
detailsModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBookingEditor() {
|
function closeBookingEditor() {
|
||||||
if (savingDetails.value) {
|
if (savingDetails.value || deletingTransactionDocument.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,7 +994,10 @@ function closeBookingEditor() {
|
|||||||
detailsForm.bookingMode = ''
|
detailsForm.bookingMode = ''
|
||||||
detailsForm.quantity = 1
|
detailsForm.quantity = 1
|
||||||
detailsForm.ticketType = ''
|
detailsForm.ticketType = ''
|
||||||
|
detailsForm.paymentMethod = 'cash'
|
||||||
detailsForm.remark = ''
|
detailsForm.remark = ''
|
||||||
|
detailsTransactionDocumentFile.value = null
|
||||||
|
transactionDocumentInputKey.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateBookingDetails(state: typeof detailsForm): FormError[] {
|
function validateBookingDetails(state: typeof detailsForm): FormError[] {
|
||||||
@@ -889,6 +1025,14 @@ function validateBookingDetails(state: typeof detailsForm): FormError[] {
|
|||||||
errors.push({ name: 'ticketType', message: 'Please select a ticket category.' })
|
errors.push({ name: 'ticketType', message: 'Please select a ticket category.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.paymentMethod !== 'cash' && state.paymentMethod !== 'bank') {
|
||||||
|
errors.push({ name: 'paymentMethod', message: 'Please select Cash or Bank.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailsTransactionDocumentFile.value && detailsTransactionDocumentFile.value.size > transactionDocumentLimit) {
|
||||||
|
errors.push({ name: 'transactionDocument', message: 'Transaction document must be 10MB or smaller.' })
|
||||||
|
}
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,12 +1102,6 @@ function removeBooking(bookingId: string) {
|
|||||||
bookings.value = bookings.value.filter((booking) => booking.id !== bookingId)
|
bookings.value = bookings.value.filter((booking) => booking.id !== bookingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCancelledConfirmationToSummary(booking: PublicBooking) {
|
|
||||||
summary.soldSeats = Math.max(summary.soldSeats - booking.seatCount, 0)
|
|
||||||
summary.pendingSeats += booking.seatCount
|
|
||||||
summary.leftSeats = summary.totalSeats === null ? null : Math.max(summary.totalSeats - summary.soldSeats, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openRemarkEditor(booking: PublicBooking) {
|
function openRemarkEditor(booking: PublicBooking) {
|
||||||
editingBooking.value = booking
|
editingBooking.value = booking
|
||||||
remarkForm.remark = booking.remark || ''
|
remarkForm.remark = booking.remark || ''
|
||||||
@@ -1002,6 +1140,53 @@ function closeTransferEditor() {
|
|||||||
transferForm.personInChargeId = ''
|
transferForm.personInChargeId = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onTransactionDocumentChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
detailsTransactionDocumentFile.value = input.files?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTransactionDocumentForDetails() {
|
||||||
|
const booking = detailsBooking.value
|
||||||
|
|
||||||
|
if (!booking?.transactionDocument || deletingTransactionDocument.value || savingDetails.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.client && !window.confirm(`Delete transaction document for ${booking.customerName}?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingTransactionDocument.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient<UpdateBookingDetailsResponse>(`/api/bookings/${booking.id}/transaction-document`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
|
||||||
|
replaceBooking(response.booking)
|
||||||
|
detailsBooking.value = response.booking
|
||||||
|
detailsTransactionDocumentFile.value = null
|
||||||
|
transactionDocumentInputKey.value += 1
|
||||||
|
await refreshBookings()
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: 'Document deleted',
|
||||||
|
description: 'The transaction document has been removed.',
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-trash-2'
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Delete failed',
|
||||||
|
description: getErrorMessage(error, 'Unable to delete the transaction document.'),
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-alert'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
deletingTransactionDocument.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshBookings() {
|
async function refreshBookings() {
|
||||||
if (loadingBookings.value) {
|
if (loadingBookings.value) {
|
||||||
return
|
return
|
||||||
@@ -1058,7 +1243,7 @@ async function saveBookingDetails(event: FormSubmitEvent<typeof detailsForm>) {
|
|||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (!booking || savingDetails.value) {
|
if (!booking || savingDetails.value || deletingTransactionDocument.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,14 +1258,31 @@ async function saveBookingDetails(event: FormSubmitEvent<typeof detailsForm>) {
|
|||||||
bookingMode: detailsForm.bookingMode,
|
bookingMode: detailsForm.bookingMode,
|
||||||
quantity: detailsForm.quantity,
|
quantity: detailsForm.quantity,
|
||||||
ticketType: detailsForm.ticketType,
|
ticketType: detailsForm.ticketType,
|
||||||
|
paymentMethod: detailsForm.paymentMethod,
|
||||||
remark: detailsForm.remark
|
remark: detailsForm.remark
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
replaceBooking(response.booking)
|
let updatedBooking = response.booking
|
||||||
|
|
||||||
|
if (detailsForm.paymentMethod === 'bank' && detailsTransactionDocumentFile.value) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('document', detailsTransactionDocumentFile.value)
|
||||||
|
|
||||||
|
const uploadResponse = await apiClient<UpdateBookingDetailsResponse>(`/api/bookings/${booking.id}/transaction-document`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
updatedBooking = uploadResponse.booking
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceBooking(updatedBooking)
|
||||||
await refreshBookings()
|
await refreshBookings()
|
||||||
detailsModalOpen.value = false
|
detailsModalOpen.value = false
|
||||||
detailsBooking.value = null
|
detailsBooking.value = null
|
||||||
|
detailsTransactionDocumentFile.value = null
|
||||||
|
transactionDocumentInputKey.value += 1
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Booking updated',
|
title: 'Booking updated',
|
||||||
@@ -1280,10 +1482,7 @@ async function cancelBookingConfirmation(booking: PublicBooking) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
replaceBooking(response.booking)
|
replaceBooking(response.booking)
|
||||||
|
await refreshBookings()
|
||||||
if (!response.alreadyPending) {
|
|
||||||
applyCancelledConfirmationToSummary(booking)
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',
|
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
NUXT_DATABASE_URL: ${NUXT_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/dinner_ticket_system}
|
NUXT_DATABASE_URL: ${NUXT_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/dinner_ticket_system}
|
||||||
NUXT_REDIS_URL: ${NUXT_REDIS_URL:-redis://redis:6379}
|
NUXT_REDIS_URL: ${NUXT_REDIS_URL:-redis://redis:6379}
|
||||||
NUXT_SESSION_COOKIE_NAME: ${NUXT_SESSION_COOKIE_NAME:-dinner_ticket_session}
|
NUXT_SESSION_COOKIE_NAME: ${NUXT_SESSION_COOKIE_NAME:-dinner_ticket_session}
|
||||||
|
NUXT_TRANSACTION_DOCUMENT_DIR: ${NUXT_TRANSACTION_DOCUMENT_DIR:-/app/.data/transaction-documents}
|
||||||
NUXT_WHATSAPP_ACCESS_TOKEN: ${NUXT_WHATSAPP_ACCESS_TOKEN:-}
|
NUXT_WHATSAPP_ACCESS_TOKEN: ${NUXT_WHATSAPP_ACCESS_TOKEN:-}
|
||||||
NUXT_WHATSAPP_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
|
NUXT_WHATSAPP_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
|
||||||
NUXT_WHATSAPP_API_VERSION: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
|
NUXT_WHATSAPP_API_VERSION: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ services:
|
|||||||
NUXT_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/dinner_ticket_system
|
NUXT_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/dinner_ticket_system
|
||||||
NUXT_REDIS_URL: redis://redis:6379
|
NUXT_REDIS_URL: redis://redis:6379
|
||||||
NUXT_SESSION_COOKIE_NAME: dinner_ticket_session
|
NUXT_SESSION_COOKIE_NAME: dinner_ticket_session
|
||||||
|
NUXT_TRANSACTION_DOCUMENT_DIR: /data/transaction-documents
|
||||||
NUXT_WHATSAPP_ACCESS_TOKEN: ${NUXT_WHATSAPP_ACCESS_TOKEN:-}
|
NUXT_WHATSAPP_ACCESS_TOKEN: ${NUXT_WHATSAPP_ACCESS_TOKEN:-}
|
||||||
NUXT_WHATSAPP_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
|
NUXT_WHATSAPP_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
|
||||||
NUXT_WHATSAPP_API_VERSION: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
|
NUXT_WHATSAPP_API_VERSION: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
|
||||||
@@ -55,6 +56,8 @@ services:
|
|||||||
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
|
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
|
||||||
ports:
|
ports:
|
||||||
- "20013:20013"
|
- "20013:20013"
|
||||||
|
volumes:
|
||||||
|
- transaction_documents:/data/transaction-documents
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:20013/api/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:20013/api/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -65,3 +68,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
transaction_documents:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default defineNuxtConfig({
|
|||||||
databaseUrl: '',
|
databaseUrl: '',
|
||||||
redisUrl: '',
|
redisUrl: '',
|
||||||
sessionCookieName: 'dinner_ticket_session',
|
sessionCookieName: 'dinner_ticket_session',
|
||||||
|
transactionDocumentDir: '.data/transaction-documents',
|
||||||
whatsappAccessToken: '',
|
whatsappAccessToken: '',
|
||||||
whatsappPhoneNumberId: '',
|
whatsappPhoneNumberId: '',
|
||||||
whatsappApiVersion: 'v23.0',
|
whatsappApiVersion: 'v23.0',
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
getBookingInventorySummary,
|
getBookingInventorySummary,
|
||||||
getActiveBookingModeOptionByCode,
|
getActiveBookingModeOptionByCode,
|
||||||
getActiveTicketCatalogItemByCode,
|
getActiveTicketCatalogItemByCode,
|
||||||
|
clearBookingTransactionDocument,
|
||||||
updateBookingDetails
|
updateBookingDetails
|
||||||
} from '../../utils/booking-repository'
|
} from '../../utils/booking-repository'
|
||||||
import { parseUpdateBookingDetailsInput } from '../../utils/bookings'
|
import { parseUpdateBookingDetailsInput } from '../../utils/bookings'
|
||||||
|
import { deleteTransactionDocument } from '../../utils/transaction-documents'
|
||||||
import { getRequiredRouteParam, httpError } from '../../utils/http'
|
import { getRequiredRouteParam, httpError } from '../../utils/http'
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
|
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
|
||||||
@@ -21,6 +23,7 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
|
|||||||
bookingMode?: string | null
|
bookingMode?: string | null
|
||||||
quantity?: number
|
quantity?: number
|
||||||
ticketType?: string
|
ticketType?: string
|
||||||
|
paymentMethod?: string | null
|
||||||
remark?: string | null
|
remark?: string | null
|
||||||
}>(event)
|
}>(event)
|
||||||
|
|
||||||
@@ -74,6 +77,7 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
|
|||||||
ticketType: ticket.value,
|
ticketType: ticket.value,
|
||||||
unitPrice: ticket.price,
|
unitPrice: ticket.price,
|
||||||
totalPrice,
|
totalPrice,
|
||||||
|
paymentMethod: input.paymentMethod,
|
||||||
remark: input.remark,
|
remark: input.remark,
|
||||||
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
|
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
|
||||||
})
|
})
|
||||||
@@ -82,6 +86,21 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
|
|||||||
httpError(404, 'Booking not found')
|
httpError(404, 'Booking not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.paymentMethod === 'cash') {
|
||||||
|
const cleared = await clearBookingTransactionDocument({
|
||||||
|
bookingId,
|
||||||
|
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cleared) {
|
||||||
|
await deleteTransactionDocument(cleared.previousStorageName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: cleared.booking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
booking
|
booking
|
||||||
}
|
}
|
||||||
|
|||||||
26
server/api/bookings/[id]/transaction-document.delete.ts
Normal file
26
server/api/bookings/[id]/transaction-document.delete.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
|
||||||
|
|
||||||
|
import { requireAuth } from '../../../utils/auth'
|
||||||
|
import { clearBookingTransactionDocument } from '../../../utils/booking-repository'
|
||||||
|
import { getRequiredRouteParam, httpError } from '../../../utils/http'
|
||||||
|
import { deleteTransactionDocument } from '../../../utils/transaction-documents'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
|
||||||
|
const auth = await requireAuth(event)
|
||||||
|
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
|
||||||
|
|
||||||
|
const result = await clearBookingTransactionDocument({
|
||||||
|
bookingId,
|
||||||
|
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
httpError(404, 'Booking not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteTransactionDocument(result.previousStorageName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: result.booking
|
||||||
|
}
|
||||||
|
})
|
||||||
35
server/api/bookings/[id]/transaction-document.get.ts
Normal file
35
server/api/bookings/[id]/transaction-document.get.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { sendStream, setHeader } from 'h3'
|
||||||
|
|
||||||
|
import { requireAuth } from '../../../utils/auth'
|
||||||
|
import { getBookingTransactionDocument } from '../../../utils/booking-repository'
|
||||||
|
import { getRequiredRouteParam, httpError } from '../../../utils/http'
|
||||||
|
import {
|
||||||
|
getSafeDownloadName,
|
||||||
|
getTransactionDocumentFile
|
||||||
|
} from '../../../utils/transaction-documents'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const auth = await requireAuth(event)
|
||||||
|
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
|
||||||
|
|
||||||
|
const document = await getBookingTransactionDocument({
|
||||||
|
bookingId,
|
||||||
|
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
httpError(404, 'Transaction document not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await getTransactionDocumentFile(document.storageName)
|
||||||
|
const downloadName = getSafeDownloadName(document.originalName)
|
||||||
|
|
||||||
|
setHeader(event, 'content-type', document.mimeType)
|
||||||
|
setHeader(event, 'content-length', String(file.size))
|
||||||
|
setHeader(event, 'content-disposition', `attachment; filename="${downloadName}"`)
|
||||||
|
setHeader(event, 'x-content-type-options', 'nosniff')
|
||||||
|
setHeader(event, 'cache-control', 'private, no-store')
|
||||||
|
setHeader(event, 'content-security-policy', 'sandbox')
|
||||||
|
|
||||||
|
return sendStream(event, file.stream)
|
||||||
|
})
|
||||||
86
server/api/bookings/[id]/transaction-document.post.ts
Normal file
86
server/api/bookings/[id]/transaction-document.post.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
|
||||||
|
|
||||||
|
import { getHeader, readMultipartFormData } from 'h3'
|
||||||
|
|
||||||
|
import { requireAuth } from '../../../utils/auth'
|
||||||
|
import {
|
||||||
|
getBookingById,
|
||||||
|
replaceBookingTransactionDocument
|
||||||
|
} from '../../../utils/booking-repository'
|
||||||
|
import { getRequiredRouteParam, httpError } from '../../../utils/http'
|
||||||
|
import {
|
||||||
|
deleteTransactionDocument,
|
||||||
|
saveTransactionDocument,
|
||||||
|
TRANSACTION_DOCUMENT_MAX_SIZE,
|
||||||
|
validateTransactionDocumentUpload
|
||||||
|
} from '../../../utils/transaction-documents'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
|
||||||
|
const auth = await requireAuth(event)
|
||||||
|
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
|
||||||
|
const accessScope = auth.user.role === 'super_admin'
|
||||||
|
? undefined
|
||||||
|
: { personInChargeId: auth.user.id }
|
||||||
|
|
||||||
|
const booking = await getBookingById(bookingId, accessScope)
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
httpError(404, 'Booking not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.paymentMethod !== 'bank') {
|
||||||
|
httpError(400, 'Transaction document can only be uploaded for Bank payments')
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = String(getHeader(event, 'content-type') || '').toLowerCase()
|
||||||
|
|
||||||
|
if (!contentType.startsWith('multipart/form-data;')) {
|
||||||
|
httpError(400, 'Transaction document upload must use multipart form data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = Number(getHeader(event, 'content-length') || 0)
|
||||||
|
|
||||||
|
if (contentLength > TRANSACTION_DOCUMENT_MAX_SIZE + 1024 * 1024) {
|
||||||
|
httpError(413, 'Transaction document must be 10MB or smaller')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await readMultipartFormData(event)
|
||||||
|
const filePart = formData?.find((part) => part.name === 'document' && part.filename)
|
||||||
|
|
||||||
|
if (!filePart) {
|
||||||
|
httpError(400, 'Transaction document is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = validateTransactionDocumentUpload({
|
||||||
|
data: filePart.data,
|
||||||
|
filename: filePart.filename,
|
||||||
|
contentType: filePart.type
|
||||||
|
})
|
||||||
|
|
||||||
|
const storageName = await saveTransactionDocument(filePart.data, upload.fileType)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await replaceBookingTransactionDocument({
|
||||||
|
bookingId,
|
||||||
|
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id,
|
||||||
|
originalName: upload.originalName,
|
||||||
|
storageName,
|
||||||
|
mimeType: upload.fileType.mimeType,
|
||||||
|
size: filePart.data.length
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
await deleteTransactionDocument(storageName)
|
||||||
|
httpError(404, 'Booking not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteTransactionDocument(result.previousStorageName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: result.booking
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await deleteTransactionDocument(storageName)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
BookingInventorySummary,
|
BookingInventorySummary,
|
||||||
DinnerEvent,
|
DinnerEvent,
|
||||||
BookingMode,
|
BookingMode,
|
||||||
|
BookingTransactionDocument,
|
||||||
|
PaymentMethod,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
PublicBookingConfig,
|
PublicBookingConfig,
|
||||||
PublicBooking,
|
PublicBooking,
|
||||||
@@ -16,7 +18,7 @@ import type {
|
|||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
import type { AppLocale } from '~~/shared/i18n'
|
import type { AppLocale } from '~~/shared/i18n'
|
||||||
|
|
||||||
import { calculateBookingInventorySummary, getBookingStatusLabel, isBookingStatus } from '~~/shared/booking'
|
import { calculateBookingInventorySummary, getBookingStatusLabel, isBookingStatus, isPaymentMethod } from '~~/shared/booking'
|
||||||
import { resolveLocale } from '~~/shared/i18n'
|
import { resolveLocale } from '~~/shared/i18n'
|
||||||
|
|
||||||
import { randomToken, toIsoString } from './base64url'
|
import { randomToken, toIsoString } from './base64url'
|
||||||
@@ -51,6 +53,12 @@ type DbBookingRow = {
|
|||||||
person_in_charge_id: string
|
person_in_charge_id: string
|
||||||
person_in_charge_name: string | null
|
person_in_charge_name: string | null
|
||||||
person_in_charge_phone_number: string | null
|
person_in_charge_phone_number: string | null
|
||||||
|
payment_method: PaymentMethod | string
|
||||||
|
transaction_document_original_name: string | null
|
||||||
|
transaction_document_storage_name: string | null
|
||||||
|
transaction_document_mime_type: string | null
|
||||||
|
transaction_document_size: number | string | null
|
||||||
|
transaction_document_uploaded_at: Date | string | null
|
||||||
remark?: string | null
|
remark?: string | null
|
||||||
status: BookingStatus | string
|
status: BookingStatus | string
|
||||||
status_label: string | null
|
status_label: string | null
|
||||||
@@ -74,6 +82,19 @@ type DbBookingSeatWithBookingRow = DbBookingSeatRow & Omit<DbBookingRow, 'id' |
|
|||||||
booking_created_at: Date | string
|
booking_created_at: Date | string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DbBookingTransactionDocumentRow = {
|
||||||
|
payment_method: PaymentMethod | string
|
||||||
|
transaction_document_original_name: string | null
|
||||||
|
transaction_document_storage_name: string | null
|
||||||
|
transaction_document_mime_type: string | null
|
||||||
|
transaction_document_size: number | string | null
|
||||||
|
transaction_document_uploaded_at: Date | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookingTransactionDocumentRecord = BookingTransactionDocument & {
|
||||||
|
storageName: string
|
||||||
|
}
|
||||||
|
|
||||||
type DbBookingSettingsRow = {
|
type DbBookingSettingsRow = {
|
||||||
event_id: string
|
event_id: string
|
||||||
total_tables: number | string | null
|
total_tables: number | string | null
|
||||||
@@ -146,6 +167,12 @@ function bookingSelectColumns(sql: any) {
|
|||||||
bookings.person_in_charge_id,
|
bookings.person_in_charge_id,
|
||||||
coalesce(users.full_name, bookings.person_in_charge_name) as person_in_charge_name,
|
coalesce(users.full_name, bookings.person_in_charge_name) as person_in_charge_name,
|
||||||
coalesce(users.phone_number, bookings.person_in_charge_phone_number) as person_in_charge_phone_number,
|
coalesce(users.phone_number, bookings.person_in_charge_phone_number) as person_in_charge_phone_number,
|
||||||
|
bookings.payment_method,
|
||||||
|
bookings.transaction_document_original_name,
|
||||||
|
bookings.transaction_document_storage_name,
|
||||||
|
bookings.transaction_document_mime_type,
|
||||||
|
bookings.transaction_document_size,
|
||||||
|
bookings.transaction_document_uploaded_at,
|
||||||
bookings.remark,
|
bookings.remark,
|
||||||
bookings.status,
|
bookings.status,
|
||||||
booking_statuses.label as status_label,
|
booking_statuses.label as status_label,
|
||||||
@@ -212,11 +239,36 @@ function mapTicketCatalogItem(row: DbTicketCatalogItemRow): TicketCatalogItemRec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapBooking(row: DbBookingRow): PublicBooking {
|
function mapBookingTransactionDocument(row: DbBookingTransactionDocumentRow, bookingId: string): BookingTransactionDocumentRecord | null {
|
||||||
|
if (
|
||||||
|
row.payment_method !== 'bank'
|
||||||
|
|| !row.transaction_document_original_name
|
||||||
|
|| !row.transaction_document_storage_name
|
||||||
|
|| !row.transaction_document_mime_type
|
||||||
|
|| row.transaction_document_size === null
|
||||||
|
|| !row.transaction_document_uploaded_at
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalName: row.transaction_document_original_name,
|
||||||
|
storageName: row.transaction_document_storage_name,
|
||||||
|
mimeType: row.transaction_document_mime_type,
|
||||||
|
size: parseInteger(row.transaction_document_size),
|
||||||
|
uploadedAt: toIsoString(row.transaction_document_uploaded_at) ?? new Date().toISOString(),
|
||||||
|
url: `/api/bookings/${bookingId}/transaction-document`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBooking(row: DbBookingRow, options?: {
|
||||||
|
includeTransactionDocument?: boolean
|
||||||
|
}): PublicBooking {
|
||||||
const seatCount = parseInteger(row.seat_count)
|
const seatCount = parseInteger(row.seat_count)
|
||||||
const status = isBookingStatus(row.status) ? row.status : 'pending'
|
const status = isBookingStatus(row.status) ? row.status : 'pending'
|
||||||
const ticketType = row.ticket_type
|
const ticketType = row.ticket_type
|
||||||
const bookingMode = row.booking_mode
|
const bookingMode = row.booking_mode
|
||||||
|
const paymentMethod = isPaymentMethod(row.payment_method) ? row.payment_method : 'cash'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -240,6 +292,8 @@ function mapBooking(row: DbBookingRow): PublicBooking {
|
|||||||
personInChargeId: row.person_in_charge_id,
|
personInChargeId: row.person_in_charge_id,
|
||||||
personInChargeName: row.person_in_charge_name || '',
|
personInChargeName: row.person_in_charge_name || '',
|
||||||
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
|
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
|
||||||
|
paymentMethod,
|
||||||
|
transactionDocument: options?.includeTransactionDocument ? mapBookingTransactionDocument(row, row.id) : null,
|
||||||
remark: row.remark || null,
|
remark: row.remark || null,
|
||||||
status,
|
status,
|
||||||
statusLabel: row.status_label || getBookingStatusLabel(status),
|
statusLabel: row.status_label || getBookingStatusLabel(status),
|
||||||
@@ -606,7 +660,7 @@ export async function listBookings(options?: {
|
|||||||
order by bookings.created_at desc
|
order by bookings.created_at desc
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows.map(mapBooking)
|
return rows.map((row) => mapBooking(row, { includeTransactionDocument: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBookingRemark(input: {
|
export async function updateBookingRemark(input: {
|
||||||
@@ -652,7 +706,7 @@ export async function updateBookingRemark(input: {
|
|||||||
limit 1
|
limit 1
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows[0] ? mapBooking(rows[0]) : null
|
return rows[0] ? mapBooking(rows[0], { includeTransactionDocument: true }) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBookingPersonInCharge(input: {
|
export async function updateBookingPersonInCharge(input: {
|
||||||
@@ -698,7 +752,7 @@ export async function updateBookingPersonInCharge(input: {
|
|||||||
limit 1
|
limit 1
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows[0] ? mapBooking(rows[0]) : null
|
return rows[0] ? mapBooking(rows[0], { includeTransactionDocument: true }) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBookingById(bookingId: string, options?: {
|
export async function getBookingById(bookingId: string, options?: {
|
||||||
@@ -726,7 +780,7 @@ export async function getBookingById(bookingId: string, options?: {
|
|||||||
limit 1
|
limit 1
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows[0] ? mapBooking(rows[0]) : null
|
return rows[0] ? mapBooking(rows[0], { includeTransactionDocument: true }) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncBookingSeats(tx: ReturnType<typeof getSqlClient>, bookingId: string, currentSeatCount: number, nextSeatCount: number) {
|
async function syncBookingSeats(tx: ReturnType<typeof getSqlClient>, bookingId: string, currentSeatCount: number, nextSeatCount: number) {
|
||||||
@@ -772,6 +826,7 @@ export async function updateBookingDetails(input: {
|
|||||||
ticketType: TicketType
|
ticketType: TicketType
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
totalPrice: number
|
totalPrice: number
|
||||||
|
paymentMethod: PaymentMethod
|
||||||
remark: string | null
|
remark: string | null
|
||||||
personInChargeId?: string
|
personInChargeId?: string
|
||||||
}) {
|
}) {
|
||||||
@@ -807,6 +862,7 @@ export async function updateBookingDetails(input: {
|
|||||||
ticket_type = ${input.ticketType},
|
ticket_type = ${input.ticketType},
|
||||||
unit_price = ${input.unitPrice},
|
unit_price = ${input.unitPrice},
|
||||||
total_price = ${input.totalPrice},
|
total_price = ${input.totalPrice},
|
||||||
|
payment_method = ${input.paymentMethod},
|
||||||
remark = ${input.remark},
|
remark = ${input.remark},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where id = ${input.bookingId}
|
where id = ${input.bookingId}
|
||||||
@@ -826,10 +882,152 @@ export async function updateBookingDetails(input: {
|
|||||||
|
|
||||||
await syncBookingSeats(tx, input.bookingId, currentSeatCount, input.seatCount)
|
await syncBookingSeats(tx, input.bookingId, currentSeatCount, input.seatCount)
|
||||||
|
|
||||||
return mapBooking(row)
|
return mapBooking(row, { includeTransactionDocument: true })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBookingTransactionDocument(input: {
|
||||||
|
bookingId: string
|
||||||
|
personInChargeId?: string
|
||||||
|
}): Promise<BookingTransactionDocumentRecord | null> {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
|
||||||
|
const [row] = input.personInChargeId
|
||||||
|
? await sql<(DbBookingTransactionDocumentRow & { id: string })[]>`
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
payment_method,
|
||||||
|
transaction_document_original_name,
|
||||||
|
transaction_document_storage_name,
|
||||||
|
transaction_document_mime_type,
|
||||||
|
transaction_document_size,
|
||||||
|
transaction_document_uploaded_at
|
||||||
|
from bookings
|
||||||
|
where id = ${input.bookingId}
|
||||||
|
and deleted_at is null
|
||||||
|
and person_in_charge_id = ${input.personInChargeId}
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
: await sql<(DbBookingTransactionDocumentRow & { id: string })[]>`
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
payment_method,
|
||||||
|
transaction_document_original_name,
|
||||||
|
transaction_document_storage_name,
|
||||||
|
transaction_document_mime_type,
|
||||||
|
transaction_document_size,
|
||||||
|
transaction_document_uploaded_at
|
||||||
|
from bookings
|
||||||
|
where id = ${input.bookingId}
|
||||||
|
and deleted_at is null
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
return row ? mapBookingTransactionDocument(row, row.id) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceBookingTransactionDocument(input: {
|
||||||
|
bookingId: string
|
||||||
|
personInChargeId?: string
|
||||||
|
originalName: string
|
||||||
|
storageName: string
|
||||||
|
mimeType: string
|
||||||
|
size: number
|
||||||
|
}): Promise<{ booking: PublicBooking, previousStorageName: string | null } | null> {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
|
||||||
|
const [row] = await sql<(DbBookingRow & { previous_storage_name: string | null })[]>`
|
||||||
|
with current_booking as (
|
||||||
|
select transaction_document_storage_name
|
||||||
|
from bookings
|
||||||
|
where id = ${input.bookingId}
|
||||||
|
and deleted_at is null
|
||||||
|
${input.personInChargeId ? sql`and person_in_charge_id = ${input.personInChargeId}` : sql``}
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
updated_booking as (
|
||||||
|
update bookings
|
||||||
|
set
|
||||||
|
payment_method = 'bank',
|
||||||
|
transaction_document_original_name = ${input.originalName},
|
||||||
|
transaction_document_storage_name = ${input.storageName},
|
||||||
|
transaction_document_mime_type = ${input.mimeType},
|
||||||
|
transaction_document_size = ${input.size},
|
||||||
|
transaction_document_uploaded_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
where id = ${input.bookingId}
|
||||||
|
and deleted_at is null
|
||||||
|
${input.personInChargeId ? sql`and person_in_charge_id = ${input.personInChargeId}` : sql``}
|
||||||
|
returning *
|
||||||
|
)
|
||||||
|
select
|
||||||
|
${bookingSelectColumns(sql)},
|
||||||
|
(select transaction_document_storage_name from current_booking) as previous_storage_name
|
||||||
|
from updated_booking as bookings
|
||||||
|
${bookingJoins(sql)}
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: mapBooking(row, { includeTransactionDocument: true }),
|
||||||
|
previousStorageName: row.previous_storage_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearBookingTransactionDocument(input: {
|
||||||
|
bookingId: string
|
||||||
|
personInChargeId?: string
|
||||||
|
}): Promise<{ booking: PublicBooking, previousStorageName: string | null } | null> {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
|
||||||
|
const [row] = await sql<(DbBookingRow & { previous_storage_name: string | null })[]>`
|
||||||
|
with current_booking as (
|
||||||
|
select transaction_document_storage_name
|
||||||
|
from bookings
|
||||||
|
where id = ${input.bookingId}
|
||||||
|
and deleted_at is null
|
||||||
|
${input.personInChargeId ? sql`and person_in_charge_id = ${input.personInChargeId}` : sql``}
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
updated_booking as (
|
||||||
|
update bookings
|
||||||
|
set
|
||||||
|
transaction_document_original_name = null,
|
||||||
|
transaction_document_storage_name = null,
|
||||||
|
transaction_document_mime_type = null,
|
||||||
|
transaction_document_size = null,
|
||||||
|
transaction_document_uploaded_at = null,
|
||||||
|
updated_at = now()
|
||||||
|
where id = ${input.bookingId}
|
||||||
|
and deleted_at is null
|
||||||
|
${input.personInChargeId ? sql`and person_in_charge_id = ${input.personInChargeId}` : sql``}
|
||||||
|
returning *
|
||||||
|
)
|
||||||
|
select
|
||||||
|
${bookingSelectColumns(sql)},
|
||||||
|
(select transaction_document_storage_name from current_booking) as previous_storage_name
|
||||||
|
from updated_booking as bookings
|
||||||
|
${bookingJoins(sql)}
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: mapBooking(row, { includeTransactionDocument: true }),
|
||||||
|
previousStorageName: row.previous_storage_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function softDeleteBooking(input: {
|
export async function softDeleteBooking(input: {
|
||||||
bookingId: string
|
bookingId: string
|
||||||
personInChargeId?: string
|
personInChargeId?: string
|
||||||
@@ -870,7 +1068,7 @@ export async function softDeleteBooking(input: {
|
|||||||
limit 1
|
limit 1
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows[0] ? mapBooking(rows[0]) : null
|
return rows[0] ? mapBooking(rows[0], { includeTransactionDocument: true }) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listBookingSeats(bookingId: string): Promise<PublicBookingSeat[]> {
|
export async function listBookingSeats(bookingId: string): Promise<PublicBookingSeat[]> {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
|
import type { BookingCapacitySettings, BookingMode, PaymentMethod, PublicBooking, TicketType } from '~~/shared/booking'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatBookingCurrency
|
formatBookingCurrency,
|
||||||
|
isPaymentMethod
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
|
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
|
||||||
import { resolveLocale } from '~~/shared/i18n'
|
import { resolveLocale } from '~~/shared/i18n'
|
||||||
@@ -49,12 +50,14 @@ export function parseUpdateBookingDetailsInput(body: {
|
|||||||
bookingMode?: BookingMode | string | null
|
bookingMode?: BookingMode | string | null
|
||||||
quantity?: number
|
quantity?: number
|
||||||
ticketType?: TicketType
|
ticketType?: TicketType
|
||||||
|
paymentMethod?: PaymentMethod | string | null
|
||||||
remark?: string | null
|
remark?: string | null
|
||||||
}) {
|
}) {
|
||||||
const customerName = normalizeFullName(body.customerName || '')
|
const customerName = normalizeFullName(body.customerName || '')
|
||||||
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
|
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
|
||||||
const bookingMode = typeof body.bookingMode === 'string' ? body.bookingMode.trim().toLowerCase() : body.bookingMode
|
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 ticketType = typeof body.ticketType === 'string' ? body.ticketType.trim().toLowerCase() : body.ticketType
|
||||||
|
const paymentMethod = typeof body.paymentMethod === 'string' ? body.paymentMethod.trim().toLowerCase() : body.paymentMethod
|
||||||
const quantity = Number(body.quantity)
|
const quantity = Number(body.quantity)
|
||||||
const remark = typeof body.remark === 'string' ? body.remark.trim() : ''
|
const remark = typeof body.remark === 'string' ? body.remark.trim() : ''
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ export function parseUpdateBookingDetailsInput(body: {
|
|||||||
assertBadRequest(typeof bookingMode === 'string' && bookingMode.length > 0, 'Booking mode is required')
|
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(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(typeof ticketType === 'string' && ticketType.trim().length > 0, 'Ticket category is required')
|
||||||
|
assertBadRequest(isPaymentMethod(paymentMethod), 'Payment method must be Cash or Bank')
|
||||||
assertBadRequest(remark.length <= 1000, 'Remark must be 1,000 characters or fewer')
|
assertBadRequest(remark.length <= 1000, 'Remark must be 1,000 characters or fewer')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -71,6 +75,7 @@ export function parseUpdateBookingDetailsInput(body: {
|
|||||||
bookingMode,
|
bookingMode,
|
||||||
quantity,
|
quantity,
|
||||||
ticketType,
|
ticketType,
|
||||||
|
paymentMethod,
|
||||||
remark: remark || null
|
remark: remark || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,12 @@ async function initializeDatabase() {
|
|||||||
person_in_charge_id text not null references users(id) on delete restrict,
|
person_in_charge_id text not null references users(id) on delete restrict,
|
||||||
person_in_charge_name text not null,
|
person_in_charge_name text not null,
|
||||||
person_in_charge_phone_number text not null,
|
person_in_charge_phone_number text not null,
|
||||||
|
payment_method text not null default 'cash',
|
||||||
|
transaction_document_original_name text,
|
||||||
|
transaction_document_storage_name text,
|
||||||
|
transaction_document_mime_type text,
|
||||||
|
transaction_document_size integer,
|
||||||
|
transaction_document_uploaded_at timestamptz,
|
||||||
remark text,
|
remark text,
|
||||||
status text not null default 'pending',
|
status text not null default 'pending',
|
||||||
confirmed_at timestamptz,
|
confirmed_at timestamptz,
|
||||||
@@ -328,6 +334,53 @@ async function initializeDatabase() {
|
|||||||
add column if not exists deleted_at timestamptz
|
add column if not exists deleted_at timestamptz
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists payment_method text not null default 'cash'
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists transaction_document_original_name text
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists transaction_document_storage_name text
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists transaction_document_mime_type text
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists transaction_document_size integer
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists transaction_document_uploaded_at timestamptz
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
update bookings
|
||||||
|
set payment_method = 'cash'
|
||||||
|
where payment_method is null
|
||||||
|
or payment_method not in ('cash', 'bank')
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
alter column payment_method set not null
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
alter column payment_method set default 'cash'
|
||||||
|
`
|
||||||
|
|
||||||
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)
|
||||||
@@ -353,6 +406,17 @@ async function initializeDatabase() {
|
|||||||
on bookings (deleted_at)
|
on bookings (deleted_at)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create index if not exists bookings_payment_method_idx
|
||||||
|
on bookings (payment_method)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create unique index if not exists bookings_transaction_document_storage_name_idx
|
||||||
|
on bookings (transaction_document_storage_name)
|
||||||
|
where transaction_document_storage_name is not null
|
||||||
|
`
|
||||||
|
|
||||||
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,
|
||||||
@@ -444,6 +508,17 @@ async function initializeDatabase() {
|
|||||||
drop constraint if exists bookings_status_check
|
drop constraint if exists bookings_status_check
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
drop constraint if exists bookings_payment_method_check
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add constraint bookings_payment_method_check
|
||||||
|
check (payment_method in ('cash', 'bank'))
|
||||||
|
`
|
||||||
|
|
||||||
const [activeEvent] = await sql<{ id: string }[]>`
|
const [activeEvent] = await sql<{ id: string }[]>`
|
||||||
select id
|
select id
|
||||||
from dinner_events
|
from dinner_events
|
||||||
|
|||||||
192
server/utils/transaction-documents.ts
Normal file
192
server/utils/transaction-documents.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { createReadStream } from 'node:fs'
|
||||||
|
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { httpError } from './http'
|
||||||
|
|
||||||
|
export const TRANSACTION_DOCUMENT_MAX_SIZE = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
export type TransactionDocumentFileType = {
|
||||||
|
mimeType: string
|
||||||
|
extension: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfFileType = { mimeType: 'application/pdf', extension: 'pdf' }
|
||||||
|
const jpegFileType = { mimeType: 'image/jpeg', extension: 'jpg' }
|
||||||
|
const pngFileType = { mimeType: 'image/png', extension: 'png' }
|
||||||
|
const webpFileType = { mimeType: 'image/webp', extension: 'webp' }
|
||||||
|
const heicFileType = { mimeType: 'image/heic', extension: 'heic' }
|
||||||
|
const heifFileType = { mimeType: 'image/heif', extension: 'heif' }
|
||||||
|
|
||||||
|
const allowedFileTypes: TransactionDocumentFileType[] = [
|
||||||
|
pdfFileType,
|
||||||
|
jpegFileType,
|
||||||
|
pngFileType,
|
||||||
|
webpFileType,
|
||||||
|
heicFileType,
|
||||||
|
heifFileType
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getTransactionDocumentDirectory() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const configured = String(config.transactionDocumentDir || '.data/transaction-documents').trim()
|
||||||
|
return path.resolve(process.cwd(), configured || '.data/transaction-documents')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTransactionDocument(data: Buffer, fileType: TransactionDocumentFileType) {
|
||||||
|
const directory = getTransactionDocumentDirectory()
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
|
||||||
|
const storageName = `${randomUUID()}.${fileType.extension}`
|
||||||
|
const filePath = getTransactionDocumentPath(storageName)
|
||||||
|
|
||||||
|
await writeFile(filePath, data, { flag: 'wx' })
|
||||||
|
|
||||||
|
return storageName
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTransactionDocumentPath(storageName: string) {
|
||||||
|
if (!/^[a-f0-9-]+\.(pdf|jpg|png|webp|heic|heif)$/.test(storageName)) {
|
||||||
|
httpError(404, 'Transaction document not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = getTransactionDocumentDirectory()
|
||||||
|
const filePath = path.resolve(directory, storageName)
|
||||||
|
const directoryPrefix = directory.endsWith(path.sep) ? directory : `${directory}${path.sep}`
|
||||||
|
|
||||||
|
if (!filePath.startsWith(directoryPrefix)) {
|
||||||
|
httpError(404, 'Transaction document not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTransactionDocumentFile(storageName: string) {
|
||||||
|
const filePath = getTransactionDocumentPath(storageName)
|
||||||
|
const fileStat = await stat(filePath).catch(() => null)
|
||||||
|
|
||||||
|
if (!fileStat?.isFile()) {
|
||||||
|
httpError(404, 'Transaction document not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
size: fileStat.size,
|
||||||
|
stream: createReadStream(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTransactionDocument(storageName: string | null | undefined) {
|
||||||
|
if (!storageName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unlink(getTransactionDocumentPath(storageName))
|
||||||
|
} catch {
|
||||||
|
// A missing or invalid old file should not block the booking update.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeTransactionDocumentName(filename: string | undefined) {
|
||||||
|
const baseName = path.basename(filename || 'transaction-document')
|
||||||
|
const normalized = baseName
|
||||||
|
.replace(/[\u0000-\u001f\u007f"\\/:*?<>|]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
return (normalized || 'transaction-document').slice(0, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSafeDownloadName(filename: string) {
|
||||||
|
const sanitized = sanitizeTransactionDocumentName(filename)
|
||||||
|
return sanitized.replace(/[^\x20-\x7e]+/g, '_').replace(/"/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTransactionDocumentUpload(input: {
|
||||||
|
data: Buffer
|
||||||
|
filename?: string
|
||||||
|
contentType?: string
|
||||||
|
}) {
|
||||||
|
if (!input.data.length) {
|
||||||
|
httpError(400, 'Transaction document is empty')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.data.length > TRANSACTION_DOCUMENT_MAX_SIZE) {
|
||||||
|
httpError(413, 'Transaction document must be 10MB or smaller')
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectedType = detectTransactionDocumentFileType(input.data)
|
||||||
|
|
||||||
|
if (!detectedType) {
|
||||||
|
httpError(400, 'Transaction document must be a PDF or supported image file')
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredType = String(input.contentType || '').toLowerCase()
|
||||||
|
|
||||||
|
if (declaredType && !isCompatibleDeclaredType(declaredType, detectedType)) {
|
||||||
|
httpError(400, 'Transaction document file type does not match the uploaded file')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalName: sanitizeTransactionDocumentName(input.filename),
|
||||||
|
fileType: detectedType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompatibleDeclaredType(declaredType: string, detectedType: TransactionDocumentFileType) {
|
||||||
|
if (declaredType === detectedType.mimeType) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return detectedType.mimeType === 'image/jpeg' && declaredType === 'image/jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectTransactionDocumentFileType(data: Buffer): TransactionDocumentFileType | null {
|
||||||
|
if (data.subarray(0, 5).toString('ascii') === '%PDF-') {
|
||||||
|
return pdfFileType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length >= 3 && data.readUInt8(0) === 0xff && data.readUInt8(1) === 0xd8 && data.readUInt8(2) === 0xff) {
|
||||||
|
return jpegFileType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.length >= 8
|
||||||
|
&& data.readUInt8(0) === 0x89
|
||||||
|
&& data.readUInt8(1) === 0x50
|
||||||
|
&& data.readUInt8(2) === 0x4e
|
||||||
|
&& data.readUInt8(3) === 0x47
|
||||||
|
&& data.readUInt8(4) === 0x0d
|
||||||
|
&& data.readUInt8(5) === 0x0a
|
||||||
|
&& data.readUInt8(6) === 0x1a
|
||||||
|
&& data.readUInt8(7) === 0x0a
|
||||||
|
) {
|
||||||
|
return pngFileType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.length >= 12
|
||||||
|
&& data.subarray(0, 4).toString('ascii') === 'RIFF'
|
||||||
|
&& data.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return webpFileType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHeifFamily(data)) {
|
||||||
|
const brand = data.subarray(8, 12).toString('ascii')
|
||||||
|
return brand === 'heif' || brand === 'mif1' || brand === 'msf1' ? heifFileType : heicFileType
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHeifFamily(data: Buffer) {
|
||||||
|
if (data.length < 12 || data.subarray(4, 8).toString('ascii') !== 'ftyp') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const brand = data.subarray(8, 12).toString('ascii')
|
||||||
|
return ['heic', 'heix', 'hevc', 'hevx', 'heif', 'mif1', 'msf1'].includes(brand)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { AppLocale } from './i18n'
|
|||||||
export type BookingMode = string
|
export type BookingMode = string
|
||||||
export type TicketType = string
|
export type TicketType = string
|
||||||
export type BookingStatus = 'pending' | 'confirmed'
|
export type BookingStatus = 'pending' | 'confirmed'
|
||||||
|
export type PaymentMethod = 'cash' | 'bank'
|
||||||
|
|
||||||
export interface DinnerEvent {
|
export interface DinnerEvent {
|
||||||
id: string
|
id: string
|
||||||
@@ -58,6 +59,8 @@ export interface PublicBooking {
|
|||||||
personInChargeId: string
|
personInChargeId: string
|
||||||
personInChargeName: string
|
personInChargeName: string
|
||||||
personInChargePhoneNumber: string
|
personInChargePhoneNumber: string
|
||||||
|
paymentMethod: PaymentMethod
|
||||||
|
transactionDocument: BookingTransactionDocument | null
|
||||||
remark: string | null
|
remark: string | null
|
||||||
status: BookingStatus
|
status: BookingStatus
|
||||||
statusLabel: string
|
statusLabel: string
|
||||||
@@ -65,6 +68,14 @@ export interface PublicBooking {
|
|||||||
confirmedAt: string | null
|
confirmedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookingTransactionDocument {
|
||||||
|
originalName: string
|
||||||
|
mimeType: string
|
||||||
|
size: number
|
||||||
|
uploadedAt: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReceiptBooking {
|
export interface ReceiptBooking {
|
||||||
id: string
|
id: string
|
||||||
receiptToken: string
|
receiptToken: string
|
||||||
@@ -170,6 +181,10 @@ export function isBookingStatus(value: string | null | undefined): value is Book
|
|||||||
return value === 'pending' || value === 'confirmed'
|
return value === 'pending' || value === 'confirmed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPaymentMethod(value: string | null | undefined): value is PaymentMethod {
|
||||||
|
return value === 'cash' || value === 'bank'
|
||||||
|
}
|
||||||
|
|
||||||
export function getBookingStatusLabel(value: BookingStatus | string, label?: string | null, locale: AppLocale = 'en') {
|
export function getBookingStatusLabel(value: BookingStatus | string, label?: string | null, locale: AppLocale = 'en') {
|
||||||
if (label && locale !== 'zh') {
|
if (label && locale !== 'zh') {
|
||||||
return label
|
return label
|
||||||
|
|||||||
Reference in New Issue
Block a user