Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
const PDFDocument = require('pdfkit');
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function a4Doc(orientation = 'portrait') {
|
||||
return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' });
|
||||
}
|
||||
|
||||
// POST /api/reports/pdf
|
||||
// body: { title: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } }
|
||||
const generatePdf = async (req, res) => {
|
||||
try {
|
||||
const { title, kind, table, layered, orientation } = 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');
|
||||
}
|
||||
|
||||
doc.end();
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/reports/email
|
||||
// body: { title, kind, table?, layered?, subject?, body? }
|
||||
const emailPdf = async (req, res) => {
|
||||
try {
|
||||
const { title, kind, table, layered, subject, body, orientation } = 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, '_'));
|
||||
|
||||
// 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,
|
||||
to: user.email,
|
||||
subject: subject || (title ? `${title} PDF` : 'Report PDF'),
|
||||
text: body || 'Please find your report attached.',
|
||||
attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }]
|
||||
});
|
||||
|
||||
// Clean
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
|
||||
res.json({ message: `Report emailed to ${user.email}` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { generatePdf, emailPdf };
|
||||
Reference in New Issue
Block a user