Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page

Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
  creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
  or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
  report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.

Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
  event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
  read-only "Report" tab that opens automatically once an event is closed.

Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
  toggles), searchable/categorized report grid, and a popup viewer with
  Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
  the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.

Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
  a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
  disconnected session, instead of a bare axios status-code message.

Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:52:54 +02:00
co-authored by Claude Sonnet 5
parent 56f2a9f7fc
commit 0de3f4be7d
42 changed files with 4297 additions and 1586 deletions
+453 -229
View File
@@ -1,269 +1,464 @@
const PDFDocument = require('pdfkit');
const ExcelJS = require('exceljs');
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
// Utility: draw a table
function drawTable(doc, startX, startY, colWidths, rows, header) {
let y = startY;
doc.font('Helvetica-Bold');
if (header && header.length) {
let x = startX;
header.forEach((h, i) => {
const w = colWidths[i] || 80;
doc.rect(x, y, w, 20).stroke();
doc.text(String(h || ''), x + 4, y + 6, { width: w - 8 });
x += w;
});
y += 20;
}
doc.font('Helvetica');
rows.forEach((row) => {
let x = startX;
row.forEach((cell, i) => {
const w = colWidths[i] || 80;
const h = 18;
doc.rect(x, y, w, h).stroke();
doc.text(String(cell ?? ''), x + 4, y + 4, { width: w - 8 });
x += w;
});
y += 18;
// New page if overflow
if (y > doc.page.height - 40) {
doc.addPage();
y = 20;
}
});
}
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();
wb.creator = 'Hope Family Church 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: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } }
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
const generatePdf = async (req, res) => {
try {
const { title, kind, table, layered, orientation } = req.body || {};
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);
// Title
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report', { align: 'left' });
doc.moveDown(0.5);
if (kind === 'table' && table && Array.isArray(table.rows)) {
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;
// Slightly wider first column to mimic site tables
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);
// Draw header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
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);
}
// Zebra rows
const rows = table.rows;
rows.forEach((row, idx) => {
const rowY = doc.y;
const rowH = 18;
const bg = idx % 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;
// Cell text
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
// Vertical separators similar to table borders
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
// Right border
doc.strokeColor('#e5e7eb').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();
}
});
// Bottom border
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { 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) {
// Bullet dot
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
doc.fillColor('#111827');
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.text('No items');
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.font('Helvetica').text('No content');
}
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, kind, table?, layered?, subject?, body? }
// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, subject?, body? }
const emailPdf = async (req, res) => {
try {
const { title, kind, table, layered, subject, body, orientation } = req.body || {};
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');
}
// Ensure temp dir
const tempDir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filePath = path.join(tempDir, `${(title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
const filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
// Build PDF to file
await new Promise((resolve, reject) => {
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
const ws = fs.createWriteStream(filePath);
doc.pipe(ws);
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report');
doc.moveDown(0.5);
if (kind === 'table' && table && Array.isArray(table.rows)) {
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;
// Slightly wider first column
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('#f3f4f6');
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
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
const rows = table.rows;
rows.forEach((row, idx) => {
const rowY = doc.y;
const rowH = 18;
const bg = idx % 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('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
doc.strokeColor('#e5e7eb').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('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { 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('#374151').stroke();
doc.fillColor('#111827');
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.text('No items');
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.font('Helvetica').text('No content');
}
doc.end();
ws.on('finish', resolve);
ws.on('error', reject);
});
// Send email using nodemailer (same config as tickets)
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
secure: process.env.EMAIL_PORT === '465',
auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS }
});
await transporter.sendMail({
from: process.env.EMAIL_FROM,
// 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: body || 'Please find your report attached.',
text: bodyText,
html: emailWrapper(
`<p style="margin:0 0 16px 0;color:#374151">${bodyText}</p>` +
`<p style="margin:0;color:#374151">Your report <strong>${title || 'Report'}</strong> is attached as a PDF.</p>`,
{ preheader: title || 'Report PDF' }
),
attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }]
});
// Clean
try { fs.unlinkSync(filePath); } catch {}
res.json({ message: `Report emailed to ${user.email}` });
@@ -272,4 +467,33 @@ const emailPdf = async (req, res) => {
}
};
module.exports = { generatePdf, emailPdf };
// 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 };