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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user