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_REDIS_URL=redis://127.0.0.1:6379
|
||||
NUXT_SESSION_COOKIE_NAME=dinner_ticket_session
|
||||
NUXT_TRANSACTION_DOCUMENT_DIR=.data/transaction-documents
|
||||
NUXT_WHATSAPP_ACCESS_TOKEN=
|
||||
NUXT_WHATSAPP_PHONE_NUMBER_ID=
|
||||
NUXT_WHATSAPP_API_VERSION=v23.0
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
|
||||
sticky="header"
|
||||
caption="Bookings"
|
||||
class="compact-table min-w-[1120px]"
|
||||
class="compact-table min-w-[1280px]"
|
||||
>
|
||||
<template #customerName-cell="{ row }">
|
||||
<div class="min-w-0 space-y-0.5 py-0.5">
|
||||
@@ -240,6 +240,31 @@
|
||||
</div>
|
||||
</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 }">
|
||||
<div class="max-w-64 space-y-1 py-0.5">
|
||||
<p
|
||||
@@ -331,9 +356,9 @@
|
||||
<UModal
|
||||
v-model:open="detailsModalOpen"
|
||||
title="Edit Booking"
|
||||
description="Update guest details, ticket selection, quantity, and internal remark."
|
||||
:dismissible="!savingDetails"
|
||||
:close="!savingDetails"
|
||||
description="Update guest details, payment, ticket selection, quantity, and handling note."
|
||||
:dismissible="!savingDetails && !deletingTransactionDocument"
|
||||
:close="!savingDetails && !deletingTransactionDocument"
|
||||
:content="{ class: 'sm:max-w-2xl' }"
|
||||
>
|
||||
<template #body>
|
||||
@@ -390,7 +415,7 @@
|
||||
v-model="detailsForm.quantity"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:disabled="savingDetails"
|
||||
:disabled="savingDetails || deletingTransactionDocument"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
/>
|
||||
@@ -412,6 +437,76 @@
|
||||
/>
|
||||
</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="flex items-center justify-between gap-4">
|
||||
<span class="text-sm font-medium text-muted">Updated total</span>
|
||||
@@ -433,7 +528,7 @@
|
||||
:maxlength="remarkLimit"
|
||||
autoresize
|
||||
class="w-full"
|
||||
placeholder="Internal handling note"
|
||||
placeholder="Handling note"
|
||||
/>
|
||||
<template #help>
|
||||
{{ detailsForm.remark.length }}/{{ remarkLimit }}
|
||||
@@ -449,7 +544,7 @@
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="justify-center"
|
||||
:disabled="savingDetails"
|
||||
:disabled="savingDetails || deletingTransactionDocument"
|
||||
@click="closeBookingEditor"
|
||||
/>
|
||||
<UButton
|
||||
@@ -459,6 +554,7 @@
|
||||
icon="i-lucide-save"
|
||||
class="justify-center"
|
||||
:loading="savingDetails"
|
||||
:disabled="deletingTransactionDocument"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -487,7 +583,7 @@
|
||||
:maxlength="remarkLimit"
|
||||
autoresize
|
||||
class="w-full"
|
||||
placeholder="Internal handling note"
|
||||
placeholder="Handling note"
|
||||
/>
|
||||
<template #help>
|
||||
{{ remarkForm.remark.length }}/{{ remarkLimit }}
|
||||
@@ -580,6 +676,7 @@ import type {
|
||||
BookingMode,
|
||||
CancelBookingConfirmationResponse,
|
||||
DeleteBookingResponse,
|
||||
PaymentMethod,
|
||||
PublicBooking,
|
||||
PublicBookingConfig,
|
||||
TicketCatalogItem,
|
||||
@@ -626,6 +723,7 @@ const savingCapacity = ref(false)
|
||||
const savingDetails = ref(false)
|
||||
const savingRemark = ref(false)
|
||||
const savingTransfer = ref(false)
|
||||
const deletingTransactionDocument = ref(false)
|
||||
const cancellingBookingId = ref<string | null>(null)
|
||||
const deletingBookingId = ref<string | null>(null)
|
||||
const detailsModalOpen = ref(false)
|
||||
@@ -654,8 +752,11 @@ const detailsForm = reactive({
|
||||
bookingMode: '' as BookingMode,
|
||||
quantity: 1,
|
||||
ticketType: '' as TicketType,
|
||||
paymentMethod: 'cash' as PaymentMethod,
|
||||
remark: ''
|
||||
})
|
||||
const detailsTransactionDocumentFile = ref<File | null>(null)
|
||||
const transactionDocumentInputKey = ref(0)
|
||||
const remarkForm = reactive({
|
||||
remark: ''
|
||||
})
|
||||
@@ -663,6 +764,19 @@ const transferForm = reactive({
|
||||
personInChargeId: ''
|
||||
})
|
||||
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(() => {
|
||||
return bookingConfig.value?.bookingModes.map((mode: BookingModeOption) => ({
|
||||
@@ -689,12 +803,14 @@ const selectedDetailsTicket = computed(() => {
|
||||
|
||||
const detailsSeatCount = computed(() => getSeatCount(selectedDetailsBookingMode.value, detailsForm.quantity))
|
||||
const detailsTotalFormatted = computed(() => formatBookingCurrency(detailsSeatCount.value * (selectedDetailsTicket.value?.price ?? 0)))
|
||||
const selectedTransactionDocumentName = computed(() => detailsTransactionDocumentFile.value?.name || '')
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: 'customerName', header: 'Guest' },
|
||||
{ accessorKey: 'quantity', header: 'Booking' },
|
||||
{ accessorKey: 'seatCount', header: 'Seats / Total' },
|
||||
{ accessorKey: 'personInChargeName', header: 'PIC' },
|
||||
{ accessorKey: 'paymentMethod', header: 'Payment' },
|
||||
{ accessorKey: 'remark', header: 'Remark' },
|
||||
{ id: 'status', header: 'Status' },
|
||||
{ accessorKey: 'createdAt', header: 'Submitted' },
|
||||
@@ -737,6 +853,8 @@ const filteredBookings = computed(() => {
|
||||
booking.personInChargePhoneNumber,
|
||||
booking.ticketType,
|
||||
booking.ticketLabel,
|
||||
getPaymentMethodLabel(booking.paymentMethod),
|
||||
booking.transactionDocument?.originalName || '',
|
||||
booking.remark || '',
|
||||
booking.status
|
||||
].some((value) => value.toLowerCase().includes(keyword))
|
||||
@@ -772,6 +890,18 @@ function ticketLabel(booking: PublicBooking) {
|
||||
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) {
|
||||
const normalized = remark.trim()
|
||||
return normalized.length > 120 ? `${normalized.slice(0, 120)}...` : normalized
|
||||
@@ -845,12 +975,15 @@ function openBookingEditor(booking: PublicBooking) {
|
||||
detailsForm.bookingMode = booking.bookingMode
|
||||
detailsForm.quantity = booking.quantity
|
||||
detailsForm.ticketType = booking.ticketType
|
||||
detailsForm.paymentMethod = booking.paymentMethod
|
||||
detailsForm.remark = booking.remark || ''
|
||||
detailsTransactionDocumentFile.value = null
|
||||
transactionDocumentInputKey.value += 1
|
||||
detailsModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeBookingEditor() {
|
||||
if (savingDetails.value) {
|
||||
if (savingDetails.value || deletingTransactionDocument.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -861,7 +994,10 @@ function closeBookingEditor() {
|
||||
detailsForm.bookingMode = ''
|
||||
detailsForm.quantity = 1
|
||||
detailsForm.ticketType = ''
|
||||
detailsForm.paymentMethod = 'cash'
|
||||
detailsForm.remark = ''
|
||||
detailsTransactionDocumentFile.value = null
|
||||
transactionDocumentInputKey.value += 1
|
||||
}
|
||||
|
||||
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.' })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -958,12 +1102,6 @@ function removeBooking(bookingId: string) {
|
||||
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) {
|
||||
editingBooking.value = booking
|
||||
remarkForm.remark = booking.remark || ''
|
||||
@@ -1002,6 +1140,53 @@ function closeTransferEditor() {
|
||||
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() {
|
||||
if (loadingBookings.value) {
|
||||
return
|
||||
@@ -1058,7 +1243,7 @@ async function saveBookingDetails(event: FormSubmitEvent<typeof detailsForm>) {
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (!booking || savingDetails.value) {
|
||||
if (!booking || savingDetails.value || deletingTransactionDocument.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1073,14 +1258,31 @@ async function saveBookingDetails(event: FormSubmitEvent<typeof detailsForm>) {
|
||||
bookingMode: detailsForm.bookingMode,
|
||||
quantity: detailsForm.quantity,
|
||||
ticketType: detailsForm.ticketType,
|
||||
paymentMethod: detailsForm.paymentMethod,
|
||||
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()
|
||||
detailsModalOpen.value = false
|
||||
detailsBooking.value = null
|
||||
detailsTransactionDocumentFile.value = null
|
||||
transactionDocumentInputKey.value += 1
|
||||
|
||||
toast.add({
|
||||
title: 'Booking updated',
|
||||
@@ -1280,10 +1482,7 @@ async function cancelBookingConfirmation(booking: PublicBooking) {
|
||||
})
|
||||
|
||||
replaceBooking(response.booking)
|
||||
|
||||
if (!response.alreadyPending) {
|
||||
applyCancelledConfirmationToSummary(booking)
|
||||
}
|
||||
await refreshBookings()
|
||||
|
||||
toast.add({
|
||||
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_REDIS_URL: ${NUXT_REDIS_URL:-redis://redis:6379}
|
||||
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_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
|
||||
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_REDIS_URL: redis://redis:6379
|
||||
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: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
|
||||
@@ -55,6 +56,8 @@ services:
|
||||
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
|
||||
ports:
|
||||
- "20013:20013"
|
||||
volumes:
|
||||
- transaction_documents:/data/transaction-documents
|
||||
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))"]
|
||||
interval: 10s
|
||||
@@ -65,3 +68,4 @@ services:
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
transaction_documents:
|
||||
|
||||
@@ -25,6 +25,7 @@ export default defineNuxtConfig({
|
||||
databaseUrl: '',
|
||||
redisUrl: '',
|
||||
sessionCookieName: 'dinner_ticket_session',
|
||||
transactionDocumentDir: '.data/transaction-documents',
|
||||
whatsappAccessToken: '',
|
||||
whatsappPhoneNumberId: '',
|
||||
whatsappApiVersion: 'v23.0',
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
getBookingInventorySummary,
|
||||
getActiveBookingModeOptionByCode,
|
||||
getActiveTicketCatalogItemByCode,
|
||||
clearBookingTransactionDocument,
|
||||
updateBookingDetails
|
||||
} from '../../utils/booking-repository'
|
||||
import { parseUpdateBookingDetailsInput } from '../../utils/bookings'
|
||||
import { deleteTransactionDocument } from '../../utils/transaction-documents'
|
||||
import { getRequiredRouteParam, httpError } from '../../utils/http'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
|
||||
@@ -21,6 +23,7 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
|
||||
bookingMode?: string | null
|
||||
quantity?: number
|
||||
ticketType?: string
|
||||
paymentMethod?: string | null
|
||||
remark?: string | null
|
||||
}>(event)
|
||||
|
||||
@@ -74,6 +77,7 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
|
||||
ticketType: ticket.value,
|
||||
unitPrice: ticket.price,
|
||||
totalPrice,
|
||||
paymentMethod: input.paymentMethod,
|
||||
remark: input.remark,
|
||||
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')
|
||||
}
|
||||
|
||||
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 {
|
||||
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,
|
||||
DinnerEvent,
|
||||
BookingMode,
|
||||
BookingTransactionDocument,
|
||||
PaymentMethod,
|
||||
BookingStatus,
|
||||
PublicBookingConfig,
|
||||
PublicBooking,
|
||||
@@ -16,7 +18,7 @@ import type {
|
||||
} from '~~/shared/booking'
|
||||
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 { randomToken, toIsoString } from './base64url'
|
||||
@@ -51,6 +53,12 @@ type DbBookingRow = {
|
||||
person_in_charge_id: string
|
||||
person_in_charge_name: 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
|
||||
status: BookingStatus | string
|
||||
status_label: string | null
|
||||
@@ -74,6 +82,19 @@ type DbBookingSeatWithBookingRow = DbBookingSeatRow & Omit<DbBookingRow, 'id' |
|
||||
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 = {
|
||||
event_id: string
|
||||
total_tables: number | string | null
|
||||
@@ -146,6 +167,12 @@ function bookingSelectColumns(sql: any) {
|
||||
bookings.person_in_charge_id,
|
||||
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,
|
||||
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.status,
|
||||
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 status = isBookingStatus(row.status) ? row.status : 'pending'
|
||||
const ticketType = row.ticket_type
|
||||
const bookingMode = row.booking_mode
|
||||
const paymentMethod = isPaymentMethod(row.payment_method) ? row.payment_method : 'cash'
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -240,6 +292,8 @@ function mapBooking(row: DbBookingRow): PublicBooking {
|
||||
personInChargeId: row.person_in_charge_id,
|
||||
personInChargeName: row.person_in_charge_name || '',
|
||||
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
|
||||
paymentMethod,
|
||||
transactionDocument: options?.includeTransactionDocument ? mapBookingTransactionDocument(row, row.id) : null,
|
||||
remark: row.remark || null,
|
||||
status,
|
||||
statusLabel: row.status_label || getBookingStatusLabel(status),
|
||||
@@ -606,7 +660,7 @@ export async function listBookings(options?: {
|
||||
order by bookings.created_at desc
|
||||
`
|
||||
|
||||
return rows.map(mapBooking)
|
||||
return rows.map((row) => mapBooking(row, { includeTransactionDocument: true }))
|
||||
}
|
||||
|
||||
export async function updateBookingRemark(input: {
|
||||
@@ -652,7 +706,7 @@ export async function updateBookingRemark(input: {
|
||||
limit 1
|
||||
`
|
||||
|
||||
return rows[0] ? mapBooking(rows[0]) : null
|
||||
return rows[0] ? mapBooking(rows[0], { includeTransactionDocument: true }) : null
|
||||
}
|
||||
|
||||
export async function updateBookingPersonInCharge(input: {
|
||||
@@ -698,7 +752,7 @@ export async function updateBookingPersonInCharge(input: {
|
||||
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?: {
|
||||
@@ -726,7 +780,7 @@ export async function getBookingById(bookingId: string, options?: {
|
||||
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) {
|
||||
@@ -772,6 +826,7 @@ export async function updateBookingDetails(input: {
|
||||
ticketType: TicketType
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
paymentMethod: PaymentMethod
|
||||
remark: string | null
|
||||
personInChargeId?: string
|
||||
}) {
|
||||
@@ -807,6 +862,7 @@ export async function updateBookingDetails(input: {
|
||||
ticket_type = ${input.ticketType},
|
||||
unit_price = ${input.unitPrice},
|
||||
total_price = ${input.totalPrice},
|
||||
payment_method = ${input.paymentMethod},
|
||||
remark = ${input.remark},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
@@ -826,10 +882,152 @@ export async function updateBookingDetails(input: {
|
||||
|
||||
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: {
|
||||
bookingId: string
|
||||
personInChargeId?: string
|
||||
@@ -870,7 +1068,7 @@ export async function softDeleteBooking(input: {
|
||||
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[]> {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
|
||||
import type { BookingCapacitySettings, BookingMode, PaymentMethod, PublicBooking, TicketType } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency
|
||||
formatBookingCurrency,
|
||||
isPaymentMethod
|
||||
} from '~~/shared/booking'
|
||||
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
|
||||
import { resolveLocale } from '~~/shared/i18n'
|
||||
@@ -49,12 +50,14 @@ export function parseUpdateBookingDetailsInput(body: {
|
||||
bookingMode?: BookingMode | string | null
|
||||
quantity?: number
|
||||
ticketType?: TicketType
|
||||
paymentMethod?: PaymentMethod | string | null
|
||||
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 paymentMethod = typeof body.paymentMethod === 'string' ? body.paymentMethod.trim().toLowerCase() : body.paymentMethod
|
||||
const quantity = Number(body.quantity)
|
||||
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(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(isPaymentMethod(paymentMethod), 'Payment method must be Cash or Bank')
|
||||
assertBadRequest(remark.length <= 1000, 'Remark must be 1,000 characters or fewer')
|
||||
|
||||
return {
|
||||
@@ -71,6 +75,7 @@ export function parseUpdateBookingDetailsInput(body: {
|
||||
bookingMode,
|
||||
quantity,
|
||||
ticketType,
|
||||
paymentMethod,
|
||||
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_name 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,
|
||||
status text not null default 'pending',
|
||||
confirmed_at timestamptz,
|
||||
@@ -328,6 +334,53 @@ async function initializeDatabase() {
|
||||
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`
|
||||
create unique index if not exists bookings_receipt_token_idx
|
||||
on bookings (receipt_token)
|
||||
@@ -353,6 +406,17 @@ async function initializeDatabase() {
|
||||
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`
|
||||
create table if not exists booking_seats (
|
||||
id text primary key,
|
||||
@@ -444,6 +508,17 @@ async function initializeDatabase() {
|
||||
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 }[]>`
|
||||
select id
|
||||
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 TicketType = string
|
||||
export type BookingStatus = 'pending' | 'confirmed'
|
||||
export type PaymentMethod = 'cash' | 'bank'
|
||||
|
||||
export interface DinnerEvent {
|
||||
id: string
|
||||
@@ -58,6 +59,8 @@ export interface PublicBooking {
|
||||
personInChargeId: string
|
||||
personInChargeName: string
|
||||
personInChargePhoneNumber: string
|
||||
paymentMethod: PaymentMethod
|
||||
transactionDocument: BookingTransactionDocument | null
|
||||
remark: string | null
|
||||
status: BookingStatus
|
||||
statusLabel: string
|
||||
@@ -65,6 +68,14 @@ export interface PublicBooking {
|
||||
confirmedAt: string | null
|
||||
}
|
||||
|
||||
export interface BookingTransactionDocument {
|
||||
originalName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ReceiptBooking {
|
||||
id: string
|
||||
receiptToken: string
|
||||
@@ -170,6 +181,10 @@ export function isBookingStatus(value: string | null | undefined): value is Book
|
||||
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') {
|
||||
if (label && locale !== 'zh') {
|
||||
return label
|
||||
|
||||
Reference in New Issue
Block a user