From 6e99f4f783174ff98781d7c74fc8c5356a2c12f8 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 10 Jul 2025 12:57:43 -0400 Subject: [PATCH] feat: Enhance receipt processing in PDF generation with overall receipt numbering and improved path extraction for MinIO --- server/api/expenses/generate-pdf.ts | 160 +++++++++++++++++++++------- 1 file changed, 124 insertions(+), 36 deletions(-) diff --git a/server/api/expenses/generate-pdf.ts b/server/api/expenses/generate-pdf.ts index 267f890..b7309ee 100644 --- a/server/api/expenses/generate-pdf.ts +++ b/server/api/expenses/generate-pdf.ts @@ -478,6 +478,7 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) { let totalReceiptImages = 0; let processedImages = 0; + let currentReceiptNumber = 0; // Track overall receipt number across all expenses // Count total receipt images for progress tracking expensesWithReceipts.forEach(expense => { @@ -495,9 +496,11 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) { // Process receipt images - each gets its own page if (expense.Receipt && Array.isArray(expense.Receipt)) { for (const [receiptIndex, receipt] of expense.Receipt.entries()) { - if (receipt.url || receipt.directus_files_id?.filename_download || receipt.filename_download) { + currentReceiptNumber++; // Increment overall receipt counter + + if (receipt.url || receipt.signedUrl || receipt.directus_files_id?.filename_download || receipt.filename_download) { try { - console.log(`[expenses/generate-pdf] Fetching receipt ${receiptIndex + 1}/${expense.Receipt.length} for expense ${expense.Id}`); + console.log(`[expenses/generate-pdf] Fetching receipt ${currentReceiptNumber}/${totalReceiptImages} (expense ${expense.Id}, receipt ${receiptIndex + 1}/${expense.Receipt.length})`); const imageBuffer = await fetchReceiptImage(receipt); if (imageBuffer) { @@ -517,10 +520,10 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) { doc.fillColor('#000000'); - // Receipt header content + // Receipt header content with overall numbering doc.fontSize(16) .font('Helvetica-Bold') - .text(`Receipt Image ${receiptIndex + 1}${expense.Receipt.length > 1 ? ` of ${expense.Receipt.length}` : ''}`, + .text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`, 70, 80, { align: 'left' }); doc.fontSize(14) @@ -584,14 +587,14 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) { } } else { - console.warn(`[expenses/generate-pdf] No image buffer received for receipt ${receiptIndex + 1} of expense ${expense.Id}`); + console.warn(`[expenses/generate-pdf] No image buffer received for receipt ${currentReceiptNumber} of expense ${expense.Id}`); // Add page with error message doc.addPage(); doc.fontSize(16) .font('Helvetica-Bold') - .text(`Receipt Image ${receiptIndex + 1}${expense.Receipt.length > 1 ? ` of ${expense.Receipt.length}` : ''}`, + .text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`, { align: 'center' }); doc.fontSize(14) @@ -609,14 +612,14 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) { } } catch (imageError: any) { - console.error(`[expenses/generate-pdf] Error processing receipt ${receiptIndex + 1} for expense ${expense.Id}:`, imageError); + console.error(`[expenses/generate-pdf] Error processing receipt ${currentReceiptNumber} for expense ${expense.Id}:`, imageError); // Add page with error information doc.addPage(); doc.fontSize(16) .font('Helvetica-Bold') - .text(`Receipt Image ${receiptIndex + 1}${expense.Receipt.length > 1 ? ` of ${expense.Receipt.length}` : ''}`, + .text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`, { align: 'center' }); doc.fontSize(14) @@ -637,7 +640,7 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) { doc.fillColor('#000000'); } } else { - console.log(`[expenses/generate-pdf] Skipping receipt ${receiptIndex + 1} for expense ${expense.Id} - no valid file path`); + console.log(`[expenses/generate-pdf] Skipping receipt ${currentReceiptNumber} for expense ${expense.Id} - no valid file path`); } } } @@ -655,48 +658,60 @@ async function fetchReceiptImage(receipt: any): Promise { const client = getMinioClient(); const bucketName = useRuntimeConfig().minio.bucketName; - // Determine the file path - try multiple possible locations - let filePath = null; + // Determine the file path - try multiple possible sources + let rawPath = null; - // Try different receipt data structures - if (receipt.url) { - filePath = receipt.url; + // Try different receipt data structures - prioritize signedUrl for S3 URLs + if (receipt.signedUrl) { + rawPath = receipt.signedUrl; + } else if (receipt.url) { + rawPath = receipt.url; } else if (receipt.directus_files_id?.filename_download) { - filePath = receipt.directus_files_id.filename_download; + rawPath = receipt.directus_files_id.filename_download; } else if (receipt.filename_download) { - filePath = receipt.filename_download; + rawPath = receipt.filename_download; } else if (receipt.id && receipt.filename_disk) { - filePath = receipt.filename_disk; + rawPath = receipt.filename_disk; } else if (typeof receipt === 'string') { - filePath = receipt; + rawPath = receipt; } - if (!filePath) { + if (!rawPath) { console.log('[expenses/generate-pdf] No file path found for receipt:', JSON.stringify(receipt, null, 2)); return null; } - console.log('[expenses/generate-pdf] Fetching receipt image from path:', filePath); + console.log('[expenses/generate-pdf] Raw path from receipt:', rawPath); - // Remove any URL prefixes if present - if (filePath.includes('/files/')) { - const parts = filePath.split('/files/'); - if (parts.length > 1) { - filePath = parts[parts.length - 1]; - } + // Extract MinIO path from S3 URL or use as-is if it's already a path + let minioPath = extractMinioPath(rawPath); + + if (!minioPath) { + console.log('[expenses/generate-pdf] Could not extract MinIO path from:', rawPath); + return null; } - // Ensure we're looking in the right place - sometimes files are in receipts/ folder + console.log('[expenses/generate-pdf] Extracted MinIO path:', minioPath); + + // Try multiple possible locations in MinIO const possiblePaths = [ - filePath, - `receipts/${filePath}`, - `expenses/${filePath}`, - filePath.startsWith('receipts/') ? filePath : `receipts/${filePath}` + minioPath, + `receipts/${minioPath}`, + `expenses/${minioPath}`, + // Try without any folder prefix + minioPath.split('/').pop() || minioPath, + // Try in receipts folder with just filename + `receipts/${minioPath.split('/').pop() || minioPath}`, + // Try in expenses folder with just filename + `expenses/${minioPath.split('/').pop() || minioPath}` ]; - for (const testPath of possiblePaths) { + // Remove duplicates + const uniquePaths = [...new Set(possiblePaths)]; + + for (const testPath of uniquePaths) { try { - console.log('[expenses/generate-pdf] Trying path:', testPath); + console.log('[expenses/generate-pdf] Trying MinIO path:', testPath); // Check if object exists first await client.statObject(bucketName, testPath); @@ -713,16 +728,16 @@ async function fetchReceiptImage(receipt: any): Promise { dataStream.on('error', reject); }); - console.log('[expenses/generate-pdf] Successfully fetched image from:', testPath, 'Size:', imageBuffer.length); + console.log('[expenses/generate-pdf] Successfully fetched image from MinIO path:', testPath, 'Size:', imageBuffer.length); return imageBuffer; } catch (pathError) { - console.log('[expenses/generate-pdf] Path not found:', testPath); + console.log('[expenses/generate-pdf] MinIO path not found:', testPath); continue; } } - console.log('[expenses/generate-pdf] Could not find image in any of the attempted paths'); + console.log('[expenses/generate-pdf] Could not find image in any of the attempted MinIO paths:', uniquePaths); return null; } catch (error) { @@ -731,6 +746,79 @@ async function fetchReceiptImage(receipt: any): Promise { } } +/** + * Extract the MinIO path from an S3 URL or return the path as-is + */ +function extractMinioPath(urlOrPath: string): string | null { + try { + // If it's already just a path, return it + if (!urlOrPath.startsWith('http')) { + return urlOrPath; + } + + // Parse the URL + const url = new URL(urlOrPath); + + // Extract the pathname (removes query parameters) + let pathname = decodeURIComponent(url.pathname); + + console.log('[expenses/generate-pdf] URL pathname:', pathname); + + // For S3 URLs, we need to extract the part after the bucket name + // Pattern: /database/nc/uploads/path/to/file.jpg + // We want: uploads/path/to/file.jpg + + // Remove leading slash + if (pathname.startsWith('/')) { + pathname = pathname.substring(1); + } + + // Look for common patterns + if (pathname.includes('/uploads/')) { + // Extract everything from 'uploads/' onwards + const uploadsIndex = pathname.indexOf('uploads/'); + const extractedPath = pathname.substring(uploadsIndex); + console.log('[expenses/generate-pdf] Extracted path from uploads pattern:', extractedPath); + return extractedPath; + } + + if (pathname.includes('/nc/uploads/')) { + // Extract everything from 'uploads/' onwards + const uploadsIndex = pathname.indexOf('uploads/'); + const extractedPath = pathname.substring(uploadsIndex); + console.log('[expenses/generate-pdf] Extracted path from nc/uploads pattern:', extractedPath); + return extractedPath; + } + + if (pathname.includes('/database/')) { + // Remove the database prefix + const databaseIndex = pathname.indexOf('database/'); + const withoutDatabase = pathname.substring(databaseIndex + 'database/'.length); + console.log('[expenses/generate-pdf] Extracted path after removing database prefix:', withoutDatabase); + return withoutDatabase; + } + + // If no specific pattern found, return the pathname as-is + console.log('[expenses/generate-pdf] Using pathname as-is:', pathname); + return pathname; + + } catch (error) { + console.error('[expenses/generate-pdf] Error parsing URL:', error); + // If URL parsing fails, try to extract manually + + // Remove query parameters manually + const withoutQuery = urlOrPath.split('?')[0]; + + // Look for uploads pattern + if (withoutQuery.includes('/uploads/')) { + const uploadsIndex = withoutQuery.indexOf('/uploads/'); + return withoutQuery.substring(uploadsIndex + 1); // +1 to remove the leading slash + } + + return null; + } +} + function addFooter(doc: PDFKit.PDFDocument) { doc.fontSize(10) .fillColor('#666666')