Files
dticket.tootaio.com/server/utils/bookings.ts
xiaomai b64a2b4c1c 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
2026-05-09 12:56:32 +08:00

187 lines
6.6 KiB
TypeScript

import type { BookingCapacitySettings, BookingMode, PaymentMethod, PublicBooking, TicketType } from '~~/shared/booking'
import {
formatBookingCurrency,
isPaymentMethod
} from '~~/shared/booking'
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
import { resolveLocale } from '~~/shared/i18n'
import { assertBadRequest } from './http'
export function parseCreateBookingInput(body: {
customerName?: string
customerPhone?: string
bookingMode?: BookingMode | string | null
quantity?: number
ticketType?: TicketType
personInChargeId?: string
locale?: string | null
}) {
const customerName = normalizeFullName(body.customerName || '')
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
const bookingMode = typeof body.bookingMode === 'string' ? body.bookingMode.trim().toLowerCase() : body.bookingMode
const ticketType = typeof body.ticketType === 'string' ? body.ticketType.trim().toLowerCase() : body.ticketType
const quantity = Number(body.quantity)
const personInChargeId = (body.personInChargeId || '').trim()
const locale = resolveLocale(body.locale)
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must include a country code, e.g. +60123456789')
assertBadRequest(typeof bookingMode === 'string' && bookingMode.length > 0, 'Booking mode is required')
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
assertBadRequest(typeof ticketType === 'string' && ticketType.trim().length > 0, 'Ticket category is required')
assertBadRequest(personInChargeId, 'Person in charge is required')
return {
customerName,
customerPhone,
bookingMode,
quantity,
ticketType,
personInChargeId,
locale
}
}
export function parseUpdateBookingDetailsInput(body: {
customerName?: string
customerPhone?: string
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() : ''
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must include a country code, e.g. +60123456789')
assertBadRequest(typeof bookingMode === 'string' && bookingMode.length > 0, 'Booking mode is required')
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
assertBadRequest(typeof ticketType === 'string' && ticketType.trim().length > 0, 'Ticket category is required')
assertBadRequest(isPaymentMethod(paymentMethod), 'Payment method must be Cash or Bank')
assertBadRequest(remark.length <= 1000, 'Remark must be 1,000 characters or fewer')
return {
customerName,
customerPhone,
bookingMode,
quantity,
ticketType,
paymentMethod,
remark: remark || null
}
}
export function parseBookingRemarkInput(body: {
remark?: string | null
}) {
const remark = typeof body.remark === 'string' ? body.remark.trim() : ''
assertBadRequest(remark.length <= 1000, 'Remark must be 1,000 characters or fewer')
return {
remark: remark || null
}
}
export function parseBookingPicTransferInput(body: {
personInChargeId?: string | null
}) {
const personInChargeId = typeof body.personInChargeId === 'string'
? body.personInChargeId.trim()
: ''
assertBadRequest(personInChargeId, 'Person in charge is required')
return {
personInChargeId
}
}
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
if (booking.locale === 'zh') {
return [
`我想预订 ${booking.event.title} 的票券。`,
'',
`姓名:${booking.customerName}`,
`联络号码:${booking.customerPhone}`,
`座位:${booking.seatCount}`,
`票券类别:${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
`总价:${formatBookingCurrency(booking.totalPrice, booking.locale)}`,
'',
'负责人确认链接:',
confirmationUrl
].join('\n')
}
return [
`I'd like to book tickets for the ${booking.event.title}.`,
'',
`Name: ${booking.customerName}`,
`Phone Number: ${booking.customerPhone}`,
`Seats: ${booking.seatCount}`,
`Ticket Category: ${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
`Total Price: ${formatBookingCurrency(booking.totalPrice, booking.locale)}`,
'',
'PIC confirmation link:',
confirmationUrl
].join('\n')
}
export function parseSeatShareInput(body: {
shared?: boolean
recipientName?: string | null
recipientPhone?: string | null
}) {
const shared = body.shared
const recipientName = normalizeFullName(body.recipientName || '')
const recipientPhone = normalizePhoneNumber(body.recipientPhone || '')
assertBadRequest(typeof shared === 'boolean', 'Shared flag is required')
if (shared) {
assertBadRequest(!recipientName || hasValidFullName(recipientName), 'Recipient name must be at least 2 characters')
assertBadRequest(!recipientPhone || isValidPhoneNumber(recipientPhone), 'Recipient phone number must include a country code, e.g. +60123456789')
}
return {
shared,
recipientName: recipientName || null,
recipientPhone: recipientPhone || null
}
}
export function parseBookingCapacityInput(body: {
totalSeats?: number | string | null
}): Pick<BookingCapacitySettings, 'totalSeats'> {
const totalSeats = parseOptionalInteger(body.totalSeats)
assertBadRequest(totalSeats === null || totalSeats >= 0, 'Total seats must be 0 or greater')
return {
totalSeats
}
}
function parseOptionalInteger(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') {
return null
}
const parsed = typeof value === 'number'
? value
: Number.parseInt(String(value), 10)
assertBadRequest(Number.isInteger(parsed), 'Capacity values must be whole numbers')
return parsed
}