import { requireAuth } from '@/server/utils/auth'; import { getExpenseById } from '@/server/utils/nocodb'; import { processExpenseWithCurrency } from '@/server/utils/currency'; import { uploadBuffer } from '@/server/utils/minio'; import { generate } from '@pdfme/generator'; import { Template } from '@pdfme/common'; import sharp from 'sharp'; import type { Expense } from '@/utils/types'; interface PDFOptions { documentName: string; subheader?: string; groupBy: 'none' | 'payer' | 'category' | 'date'; includeReceipts: boolean; includeSummary: boolean; includeDetails: boolean; pageFormat: 'A4' | 'Letter' | 'Legal'; includeProcessingFee?: boolean; } export default defineEventHandler(async (event) => { await requireAuth(event); const body = await readBody(event); const { expenseIds, options } = body; if (!expenseIds || !Array.isArray(expenseIds) || expenseIds.length === 0) { throw createError({ statusCode: 400, statusMessage: 'Expense IDs are required' }); } if (!options || !options.documentName) { throw createError({ statusCode: 400, statusMessage: 'PDF options with document name are required' }); } console.log('[expenses/generate-pdf] Generating PDF for expenses:', expenseIds); try { // Get user info for file naming const userInfo = event.context.user; const userName = userInfo?.preferred_username || userInfo?.email || 'user'; const userEmail = userInfo?.email; // Determine if we should use direct generation or email delivery const shouldEmailDelivery = expenseIds.length >= 20; if (shouldEmailDelivery && !userEmail) { throw createError({ statusCode: 400, statusMessage: 'Email address is required for large PDF generation' }); } if (shouldEmailDelivery) { // Queue for background processing setResponseStatus(event, 202); // Accepted // Start background processing (simplified for now) // In a real implementation, you'd use a proper queue system process.nextTick(async () => { try { await generatePDFBackground(expenseIds, options, userName, userEmail); } catch (error) { console.error('[expenses/generate-pdf] Background generation failed:', error); // TODO: Send error email to user } }); return { message: "Your PDF is being generated and will be emailed to you shortly.", estimatedTime: `${Math.ceil(expenseIds.length / 10)} minutes`, deliveryMethod: 'email' }; } // Direct generation for smaller requests const pdfBuffer = await generatePDFDirect(expenseIds, options, userName); // Generate filename with date range const dates = await getExpenseDates(expenseIds); const filename = generateFilename(userName, dates, options.documentName); // Store PDF in MinIO const year = new Date().getFullYear(); const month = String(new Date().getMonth() + 1).padStart(2, '0'); const storagePath = `expense-sheets/${year}/${month}/${filename}`; try { await uploadBuffer(pdfBuffer, storagePath, 'application/pdf'); console.log(`[expenses/generate-pdf] PDF stored at: ${storagePath}`); } catch (error) { console.error('[expenses/generate-pdf] Failed to store PDF in MinIO:', error); // Continue with direct download even if storage fails } // Return PDF for direct download setHeader(event, 'Content-Type', 'application/pdf'); setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`); setHeader(event, 'Content-Length', pdfBuffer.length.toString()); return pdfBuffer; } catch (error: any) { console.error('[expenses/generate-pdf] Error generating PDF:', error); throw createError({ statusCode: 500, statusMessage: error.message || 'Failed to generate PDF' }); } }); async function generatePDFDirect(expenseIds: number[], options: PDFOptions, userName: string): Promise { // Fetch all expenses const expenses: Expense[] = []; const failedExpenses: string[] = []; for (const id of expenseIds) { try { const expense = await getExpenseById(id.toString()); const processedExpense = await processExpenseWithCurrency(expense); expenses.push(processedExpense); } catch (error) { console.error(`[expenses/generate-pdf] Failed to fetch expense ${id}:`, error); failedExpenses.push(id.toString()); } } if (failedExpenses.length > 0) { throw new Error(`Failed to fetch expenses: ${failedExpenses.join(', ')}`); } if (expenses.length === 0) { throw new Error('No expenses found'); } // Validate receipt images if required if (options.includeReceipts) { await validateReceiptImages(expenses); } // Sort expenses by date expenses.sort((a, b) => new Date(a.Time).getTime() - new Date(b.Time).getTime()); // Group expenses if needed const groupedExpenses = groupExpenses(expenses, options.groupBy); // Calculate totals const { subtotalEUR, processingFee, totalWithFee } = calculateTotals(expenses); // Generate PDF const template = await createPDFTemplate(options, groupedExpenses, { subtotalEUR, processingFee, totalWithFee, userName, includeProcessingFee: options.includeProcessingFee ?? true }); const inputs = await createPDFInputs(groupedExpenses, options); const pdf = await generate({ template, inputs }); return Buffer.from(pdf); } async function generatePDFBackground(expenseIds: number[], options: PDFOptions, userName: string, userEmail: string) { try { console.log('[expenses/generate-pdf] Starting background PDF generation'); const pdfBuffer = await generatePDFDirect(expenseIds, options, userName); // Generate filename and store in MinIO const dates = await getExpenseDates(expenseIds); const filename = generateFilename(userName, dates, options.documentName); const year = new Date().getFullYear(); const month = String(new Date().getMonth() + 1).padStart(2, '0'); const storagePath = `expense-sheets/${year}/${month}/${filename}`; await uploadBuffer(pdfBuffer, storagePath, 'application/pdf'); // TODO: Send email with download link console.log(`[expenses/generate-pdf] Background PDF generated and stored at: ${storagePath}`); // For now, just log success - in a real implementation, you'd send an email // await sendPDFReadyEmail(userEmail, filename, storagePath); } catch (error) { console.error('[expenses/generate-pdf] Background generation failed:', error); throw error; } } async function validateReceiptImages(expenses: Expense[]) { const missingImages: string[] = []; for (const expense of expenses) { if (expense.Receipt && expense.Receipt.length > 0) { for (const [index, receipt] of expense.Receipt.entries()) { if (!receipt.signedUrl && !receipt.url) { missingImages.push(`Expense #${expense.Id} (${expense['Establishment Name']} on ${expense.Time}) - Receipt ${index + 1}`); } } } } if (missingImages.length > 0) { throw new Error(`Missing receipt images:\n${missingImages.join('\n')}`); } } function groupExpenses(expenses: Expense[], groupBy: PDFOptions['groupBy']) { if (groupBy === 'none') { return [{ title: 'All Expenses', expenses }]; } const groups: Record = {}; expenses.forEach(expense => { let key: string; switch (groupBy) { case 'payer': key = expense.Payer || 'Unknown'; break; case 'category': key = expense.Category || 'Other'; break; case 'date': key = new Date(expense.Time).toLocaleDateString(); break; default: key = 'All'; } if (!groups[key]) { groups[key] = []; } groups[key].push(expense); }); return Object.entries(groups).map(([title, expenses]) => ({ title, expenses })); } function calculateTotals(expenses: Expense[]) { const subtotalEUR = expenses.reduce((sum, expense) => { if (expense.currency === 'EUR') { return sum + (expense.PriceNumber || 0); } else { // Convert to EUR return sum + (expense.PriceNumber || 0) / (expense.ConversionRate || 1); } }, 0); const processingFee = subtotalEUR * 0.05; const totalWithFee = subtotalEUR + processingFee; return { subtotalEUR, processingFee, totalWithFee }; } async function createPDFTemplate( options: PDFOptions, groupedExpenses: Array<{ title: string; expenses: Expense[] }>, totals: any ): Promise