const PDFDocument = require('pdfkit'); const ExcelJS = require('exceljs'); const fs = require('fs'); const path = require('path'); const { sendMail, emailWrapper } = require('../utils/email'); function a4Doc(orientation = 'portrait') { return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' }); } // Brand palette — mirrors the web reports' indigo theme and the dataviz-skill categorical // palette used by the on-screen HorizontalBarChart, so exported PDFs/Excel look like the // same report instead of a plain data dump. const BRAND = '#4f46e5'; const BRAND_DARK = '#3730a3'; const BRAND_LIGHT = '#eef2ff'; const TEXT_DARK = '#111827'; const TEXT_MUTED = '#6b7280'; const BORDER = '#e5e7eb'; const TONE_COLORS = { green: { bg: '#ecfdf5', accent: '#059669' }, blue: { bg: '#eff6ff', accent: '#2563eb' }, violet: { bg: '#f5f3ff', accent: '#7c3aed' }, amber: { bg: '#fffbeb', accent: '#d97706' }, rose: { bg: '#fff1f2', accent: '#e11d48' }, gray: { bg: '#f3f4f6', accent: '#4b5563' }, }; const CATEGORICAL_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#4a3aa7', '#e34948']; function isEmphasisRow(firstCell) { const s = String(firstCell ?? '').trim(); return /^total$/i.test(s) || /net profit/i.test(s) || /unassigned donations/i.test(s) || /revenue per ticket/i.test(s); } function drawHeader(doc, title, subtitle) { const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; const x = doc.page.margins.left; const y = doc.page.margins.top; const h = subtitle ? 46 : 32; doc.save(); doc.roundedRect(x, y, pageWidth, h, 6).fill(BRAND); doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(16).text(title || 'Report', x + 14, y + 9, { width: pageWidth - 28 }); if (subtitle) { doc.font('Helvetica').fontSize(9).fillColor('#e0e7ff').text(subtitle, x + 14, y + 30, { width: pageWidth - 28 }); } doc.restore(); doc.y = y + h + 14; } function drawStats(doc, stats) { if (!Array.isArray(stats) || stats.length === 0) return; const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; const perRow = Math.max(1, Math.min(5, Math.floor(pageWidth / 110))); const gap = 8; const boxW = (pageWidth - gap * (perRow - 1)) / perRow; const boxH = 34; let rowY = doc.y; stats.forEach((s, i) => { const col = i % perRow; if (col === 0 && i !== 0) rowY += boxH + gap; const bx = doc.page.margins.left + col * (boxW + gap); const tone = TONE_COLORS[s.tone] || TONE_COLORS.gray; doc.save(); doc.roundedRect(bx, rowY, boxW, boxH, 5).fill(tone.bg); doc.fillColor(TEXT_MUTED).font('Helvetica').fontSize(7.5).text(String(s.label || ''), bx + 8, rowY + 6, { width: boxW - 16 }); doc.fillColor(tone.accent).font('Helvetica-Bold').fontSize(11).text(String(s.value || ''), bx + 8, rowY + 17, { width: boxW - 16 }); doc.restore(); }); doc.y = rowY + boxH + 16; if (doc.y > doc.page.height - 60) doc.addPage(); } function drawChart(doc, chart) { if (!chart || !Array.isArray(chart.data) || chart.data.length === 0) return; const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; const x = doc.page.margins.left; if (chart.title) { doc.fillColor(TEXT_MUTED).font('Helvetica-Bold').fontSize(9).text(chart.title, x, doc.y); doc.moveDown(0.4); } const labelW = 110; const valueW = 80; const barAreaW = pageWidth - labelW - valueW - 16; const max = Math.max(1, ...chart.data.map(d => Math.abs(d.value || 0))); const rowH = 16; chart.data.forEach((d, i) => { const y = doc.y; doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(String(d.label || ''), x, y + 3, { width: labelW - 8 }); const trackX = x + labelW; doc.roundedRect(trackX, y + 2, barAreaW, 8, 4).fill('#f3f4f6'); const w = Math.max(4, (Math.abs(d.value || 0) / max) * barAreaW); doc.roundedRect(trackX, y + 2, w, 8, 4).fill(CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length]); doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(d.displayValue != null ? String(d.displayValue) : String(d.value), trackX + barAreaW + 8, y + 3, { width: valueW - 8, align: 'right' }); doc.y = y + rowH; if (doc.y > doc.page.height - 60) doc.addPage(); }); doc.moveDown(0.8); } function drawNote(doc, note) { if (!note) return; const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; const x = doc.page.margins.left; const y = doc.y; doc.font('Helvetica-Oblique').fontSize(8.5); const h = doc.heightOfString(note, { width: pageWidth - 20 }) + 14; doc.save(); doc.roundedRect(x, y, pageWidth, h, 5).fill('#f9fafb'); doc.fillColor(TEXT_MUTED).text(note, x + 10, y + 7, { width: pageWidth - 20 }); doc.restore(); doc.y = y + h + 12; if (doc.y > doc.page.height - 60) doc.addPage(); } // Draws a single branded table (header band, zebra rows, highlighted total rows) at the // document's current y — shared by the main table body and any extraTables sections below it. function drawTable(doc, table) { const columns = Array.isArray(table.columns) ? table.columns : []; const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1); const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; const baseWidth = Math.floor(pageWidth / Math.max(1, colCount)); const colWidths = new Array(colCount).fill(baseWidth); if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2); // Header band if (columns.length) { let x = doc.page.margins.left; const y = doc.y; doc.save(); doc.rect(x, y, pageWidth, 22).fill(BRAND_LIGHT); doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(10.5); columns.forEach((h, i) => { const w = colWidths[i] || baseWidth; doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 }); x += w; }); doc.restore(); doc.moveDown(1.6); } // Rows — zebra striped, with a highlighted tint+bold for total/summary rows const rows = table.rows; rows.forEach((row) => { const emphasis = isEmphasisRow(row[0]); const rowY = doc.y; const rowH = 18; const bg = emphasis ? BRAND_LIGHT : (rows.indexOf(row) % 2 === 0 ? '#ffffff' : '#f9fafb'); doc.save(); doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore(); let x = doc.page.margins.left; row.forEach((cell, i) => { const w = colWidths[i] || baseWidth; doc.fillColor(emphasis ? BRAND_DARK : TEXT_DARK).font(emphasis ? 'Helvetica-Bold' : 'Helvetica').fontSize(9.5).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 }); doc.strokeColor(BORDER).lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke(); x += w; }); doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke(); doc.moveDown(1.1); if (doc.y > doc.page.height - 40) { doc.addPage(); } }); doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke(); doc.moveDown(1); } // Shared drawing logic for all PDF-producing endpoints (download, email, WhatsApp) — draws a // branded header, optional stat tiles / bar chart / note, then a 'table' or 'layered' body // (plus any extraTables sections below it) onto whatever PDFDocument the caller gives it // (streamed straight to the HTTP response for download, or to a temp file for email/WhatsApp). function drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables }) { drawHeader(doc, title, subtitle); drawStats(doc, stats); drawChart(doc, chart); drawNote(doc, note); // Path-drawing ops (rect/moveTo/lineTo, used throughout drawTable's borders) leave PDFKit's // implicit text cursor (doc.x) at the last point drawn rather than the left margin, so every // text() call below passes an explicit x — relying on the implicit cursor after a table has // rendered puts the next label at the table's right edge instead of the margin. const leftX = doc.page.margins.left; if (kind === 'table' && table && Array.isArray(table.rows)) { drawTable(doc, table); } else if (kind === 'layered' && layered && Array.isArray(layered.sections)) { if (layered.header) { doc.font('Helvetica-Bold').fontSize(13).fillColor(TEXT_DARK).text(layered.header, leftX, doc.y); doc.moveDown(0.3); } doc.font('Helvetica').fontSize(11); for (const section of layered.sections) { doc.fillColor(BRAND_DARK).font('Helvetica-Bold').text(String(section.title || ''), leftX, doc.y, { continued: false }); doc.moveDown(0.15); doc.font('Helvetica').fontSize(10); if (Array.isArray(section.items) && section.items.length) { for (const item of section.items) { doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill(BRAND).stroke(); doc.fillColor(TEXT_DARK); doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 }); doc.moveDown(0.2); } } else { doc.fillColor(TEXT_MUTED).text('No items', leftX, doc.y); } doc.moveDown(0.5); if (doc.y > doc.page.height - 60) doc.addPage(); } } else { doc.fillColor(TEXT_MUTED).font('Helvetica').text('No content', leftX, doc.y); } if (Array.isArray(extraTables)) { extraTables.forEach(t => { if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return; if (doc.y > doc.page.height - 100) doc.addPage(); doc.moveDown(0.6); if (t.title) { doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(11).text(t.title, leftX, doc.y); doc.moveDown(0.4); } drawTable(doc, t); }); } } // Renders payload to a temp PDF file and returns its path — shared by emailPdf and whatsappPdf. async function renderReportPdfToFile(payload) { const tempDir = path.join(__dirname, '..', '..', 'temp'); if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); const filePath = path.join(tempDir, `${(payload.title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_')); await new Promise((resolve, reject) => { const doc = a4Doc(payload.orientation === 'landscape' ? 'landscape' : 'portrait'); const ws = fs.createWriteStream(filePath); doc.pipe(ws); drawReportPdf(doc, payload); doc.end(); ws.on('finish', resolve); ws.on('error', reject); }); return filePath; } // Builds a styled .xlsx workbook mirroring the same payload shape used for PDF — branded // title band, stat label/value rows, a chart rendered as a mini-table with a native Excel // data-bar conditional format (the closest free/no-extra-viz-dependency equivalent of the // web's bar chart), then the main table with a bold colored header row and highlighted // total rows. async function buildReportWorkbook(payload) { const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {}; const wb = new ExcelJS.Workbook(); const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'); wb.creator = `${orgName} Events`; wb.created = new Date(); const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report'; const ws = wb.addWorksheet(sheetName); const colCount = Math.max(4, (table?.columns?.length || 0)); let r = 1; ws.mergeCells(r, 1, r, colCount); const titleCell = ws.getCell(r, 1); titleCell.value = title || 'Report'; titleCell.font = { bold: true, size: 16, color: { argb: 'FFFFFFFF' } }; titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4F46E5' } }; titleCell.alignment = { vertical: 'middle' }; ws.getRow(r).height = 26; r++; if (subtitle) { ws.mergeCells(r, 1, r, colCount); const subCell = ws.getCell(r, 1); subCell.value = subtitle; subCell.font = { italic: true, size: 10, color: { argb: 'FF6B7280' } }; r++; } r++; if (Array.isArray(stats) && stats.length) { stats.forEach(s => { ws.getCell(r, 1).value = s.label; ws.getCell(r, 1).font = { color: { argb: 'FF6B7280' }, size: 10 }; ws.getCell(r, 2).value = s.value; ws.getCell(r, 2).font = { bold: true, size: 11 }; r++; }); r++; } if (chart && Array.isArray(chart.data) && chart.data.length) { if (chart.title) { ws.getCell(r, 1).value = chart.title; ws.getCell(r, 1).font = { bold: true, size: 10 }; r++; } const chartStartRow = r; chart.data.forEach(d => { ws.getCell(r, 1).value = d.label; ws.getCell(r, 2).value = typeof d.value === 'number' ? d.value : Number(d.value) || 0; r++; }); ws.addConditionalFormatting({ ref: `B${chartStartRow}:B${r - 1}`, rules: [{ type: 'dataBar', cfvo: [{ type: 'min' }, { type: 'max' }], color: { argb: 'FF2A78D6' } }] }); r++; } if (note) { ws.mergeCells(r, 1, r, colCount); ws.getCell(r, 1).value = note; ws.getCell(r, 1).font = { italic: true, size: 9, color: { argb: 'FF6B7280' } }; ws.getCell(r, 1).alignment = { wrapText: true }; r += 2; } if (kind === 'table' && table && Array.isArray(table.rows)) { const columns = table.columns || []; const { nextRow, dataStartRow } = writeTableRows(ws, r, columns, table.rows); ws.views = [{ state: 'frozen', ySplit: dataStartRow - 1 }]; r = nextRow; } else if (kind === 'layered' && layered && Array.isArray(layered.sections)) { if (layered.header) { ws.getCell(r, 1).value = layered.header; ws.getCell(r, 1).font = { bold: true, size: 12 }; r += 2; } layered.sections.forEach(section => { ws.getCell(r, 1).value = section.title; ws.getCell(r, 1).font = { bold: true, color: { argb: 'FF3730A3' } }; r++; (section.items || []).forEach(item => { ws.getCell(r, 1).value = `• ${item}`; r++; }); r++; }); ws.getColumn(1).width = 90; } if (Array.isArray(extraTables)) { extraTables.forEach(t => { if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return; r++; if (t.title) { ws.getCell(r, 1).value = t.title; ws.getCell(r, 1).font = { bold: true, size: 12, color: { argb: 'FF3730A3' } }; r++; } const { nextRow } = writeTableRows(ws, r, t.columns || [], t.rows); r = nextRow; }); } return wb; } // Writes a header row (bold, colored fill) + zebra/emphasis-highlighted data rows at the given // sheet row — shared by the main table and any extraTables sections. Returns the next free row // and the data's start row (for optional freeze-pane use by the caller). function writeTableRows(ws, startRow, columns, rows) { let r = startRow; const headerRow = ws.getRow(r); columns.forEach((c, i) => { const cell = headerRow.getCell(i + 1); cell.value = c; cell.font = { bold: true, color: { argb: 'FF3730A3' } }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } }; cell.border = { bottom: { style: 'thin', color: { argb: 'FFE5E7EB' } } }; }); headerRow.commit(); r++; const dataStartRow = r; rows.forEach((row, idx) => { const excelRow = ws.getRow(r); const emphasis = isEmphasisRow(row[0]); row.forEach((val, i) => { const cell = excelRow.getCell(i + 1); cell.value = val === '' ? null : val; if (emphasis) { cell.font = { bold: true, color: { argb: 'FF3730A3' } }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } }; } else if (idx % 2 === 1) { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } }; } }); excelRow.commit(); r++; }); columns.forEach((c, i) => { let maxLen = String(c || '').length; rows.forEach(row => { const v = row[i]; if (v != null && v !== '') maxLen = Math.max(maxLen, String(v).length); }); const col = ws.getColumn(i + 1); col.width = Math.max(col.width || 0, Math.min(40, Math.max(10, maxLen + 2))); }); return { nextRow: r, dataStartRow }; } // POST /api/reports/pdf // body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? } const generatePdf = async (req, res) => { try { const { title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables } = req.body || {}; res.setHeader('Content-Type', 'application/pdf'); const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`; res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait'); doc.pipe(res); drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables }); doc.end(); } catch (e) { res.status(400).json({ message: e.message }); } }; // POST /api/reports/excel // body: same payload shape as /pdf — used to produce a styled .xlsx mirroring the PDF/web report. const generateExcel = async (req, res) => { try { const payload = req.body || {}; const wb = await buildReportWorkbook(payload); const filename = `${(payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.xlsx`; res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); await wb.xlsx.write(res); res.end(); } catch (e) { res.status(400).json({ message: e.message }); } }; // POST /api/reports/email // body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, subject?, body? } const emailPdf = async (req, res) => { try { const { title, subtitle, kind, table, layered, subject, body, orientation, stats, chart, note, extraTables } = req.body || {}; const user = req.user; if (!user || !user.email) { res.status(400); throw new Error('User email not available'); } const filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables }); // Use the shared mail utility (DB-configured SMTP via Admin -> Site Settings, with env // fallback) instead of a one-off transporter — a bare `process.env.EMAIL_HOST` transporter // ignores that configuration entirely and fails wherever SMTP is only set up via the DB. const bodyText = body || 'Please find your report attached.'; await sendMail({ to: user.email, subject: subject || (title ? `${title} PDF` : 'Report PDF'), text: bodyText, html: emailWrapper( `

${bodyText}

` + `

Your report ${title || 'Report'} is attached as a PDF.

`, { preheader: title || 'Report PDF' } ), attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }] }); try { fs.unlinkSync(filePath); } catch {} res.json({ message: `Report emailed to ${user.email}` }); } catch (e) { res.status(400).json({ message: e.message }); } }; // POST /api/reports/whatsapp // body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, caption? } // Sends to the current user's own WhatsApp number (same self-service pattern as emailPdf). const whatsappPdf = async (req, res) => { let filePath = null; try { const { title, subtitle, kind, table, layered, caption, orientation, stats, chart, note, extraTables } = req.body || {}; const user = req.user; const { isValidZAPhone } = require('../utils/whatsapp'); if (!user || !isValidZAPhone(user.phoneNumber)) { res.status(400); throw new Error('No valid WhatsApp number on your account. Add one in your profile to use this.'); } filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables }); const { sendPdf } = require('../utils/whatsapp'); const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`; await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report'); res.json({ message: `Report sent to your WhatsApp` }); } catch (e) { res.status(400).json({ message: e.message }); } finally { if (filePath) { try { fs.unlinkSync(filePath); } catch {} } } }; module.exports = { generatePdf, generateExcel, emailPdf, whatsappPdf };