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:
2026-05-09 12:56:32 +08:00
parent 3710216346
commit b64a2b4c1c
14 changed files with 888 additions and 31 deletions

View File

@@ -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

View File

@@ -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',

View File

@@ -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}

View File

@@ -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:

View File

@@ -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',

View File

@@ -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
} }

View 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
}
})

View 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)
})

View 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
}
})

View File

@@ -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[]> {

View File

@@ -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
} }
} }

View File

@@ -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

View 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)
}

View File

@@ -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