|
| 1 | +// PDF export (S4-04). A general "render this payload as a branded PDF" endpoint. |
| 2 | +// The client sends what it already displays — table rows for timesheets, and |
| 3 | +// client-rendered chart images (ApexCharts dataURI) for the agile reports — so |
| 4 | +// the PDF always matches the on-screen data and we avoid server-side chart |
| 5 | +// rendering and date/unit guesswork. Uses pdfmake with the standard PDF |
| 6 | +// Helvetica font (no embedded font files / headless browser — light Docker image). |
| 7 | +const path = require('path'); |
| 8 | +const fs = require('fs'); |
| 9 | +const logger = require('../../Config/loggerConfig'); |
| 10 | + |
| 11 | +// pdfmake is loaded lazily so a missing install never breaks app startup — only |
| 12 | +// this endpoint errors (with a clear message) until `npm install` brings it in. |
| 13 | +let _printer = null; |
| 14 | +function getPrinter() { |
| 15 | + if (_printer) return _printer; |
| 16 | + const PdfPrinter = require('pdfmake'); |
| 17 | + _printer = new PdfPrinter({ |
| 18 | + Helvetica: { |
| 19 | + normal: 'Helvetica', |
| 20 | + bold: 'Helvetica-Bold', |
| 21 | + italics: 'Helvetica-Oblique', |
| 22 | + bolditalics: 'Helvetica-BoldOblique', |
| 23 | + }, |
| 24 | + }); |
| 25 | + return _printer; |
| 26 | +} |
| 27 | + |
| 28 | +const BRAND_COLOR = '#2F3990'; |
| 29 | + |
| 30 | +function productName() { |
| 31 | + try { |
| 32 | + const p = path.join(__dirname, '../../brandSettings.json'); |
| 33 | + if (fs.existsSync(p)) { |
| 34 | + const data = JSON.parse(fs.readFileSync(p, 'utf8')); |
| 35 | + return data.productName || 'AlianHub'; |
| 36 | + } |
| 37 | + } catch (e) { /* fall through to default */ } |
| 38 | + return 'AlianHub'; |
| 39 | +} |
| 40 | + |
| 41 | +function sanitizeFilename(name) { |
| 42 | + return String(name || 'export').replace(/[^a-z0-9-_]+/gi, '_').slice(0, 60) || 'export'; |
| 43 | +} |
| 44 | + |
| 45 | +const STYLES = { |
| 46 | + brand: { fontSize: 16, bold: true, color: BRAND_COLOR }, |
| 47 | + h1: { fontSize: 18, bold: true, margin: [0, 6, 0, 0] }, |
| 48 | + sub: { fontSize: 10, color: '#666666', margin: [0, 2, 0, 0] }, |
| 49 | + th: { bold: true, fontSize: 10, color: '#ffffff' }, |
| 50 | + meta: { fontSize: 10, color: '#444444', margin: [0, 1, 0, 1] }, |
| 51 | + total: { bold: true, fontSize: 10 }, |
| 52 | +}; |
| 53 | + |
| 54 | +function headerBlocks(title, subtitle) { |
| 55 | + const blocks = [ |
| 56 | + { text: productName(), style: 'brand' }, |
| 57 | + { text: title || 'Report', style: 'h1' }, |
| 58 | + ]; |
| 59 | + if (subtitle) blocks.push({ text: subtitle, style: 'sub' }); |
| 60 | + blocks.push({ canvas: [{ type: 'line', x1: 0, y1: 4, x2: 515, y2: 4, lineWidth: 1, lineColor: '#dddddd' }], margin: [0, 6, 0, 10] }); |
| 61 | + return blocks; |
| 62 | +} |
| 63 | + |
| 64 | +function tableBlock(tableHead, tableRows, totalRow) { |
| 65 | + const body = []; |
| 66 | + if (Array.isArray(tableHead) && tableHead.length) { |
| 67 | + body.push(tableHead.map((h) => ({ text: String(h), style: 'th', fillColor: BRAND_COLOR, margin: [0, 4, 0, 4] }))); |
| 68 | + } |
| 69 | + (tableRows || []).forEach((row) => { |
| 70 | + body.push((row || []).map((cell) => ({ text: cell == null ? '' : String(cell), fontSize: 9, margin: [0, 2, 0, 2] }))); |
| 71 | + }); |
| 72 | + if (Array.isArray(totalRow) && totalRow.length) { |
| 73 | + body.push(totalRow.map((cell) => ({ text: cell == null ? '' : String(cell), style: 'total', fillColor: '#f0f1f7', margin: [0, 4, 0, 4] }))); |
| 74 | + } |
| 75 | + if (!body.length) return { text: 'No data for this selection.', italics: true, color: '#888888' }; |
| 76 | + const colCount = (tableHead && tableHead.length) || (body[0] && body[0].length) || 1; |
| 77 | + return { |
| 78 | + table: { headerRows: tableHead && tableHead.length ? 1 : 0, widths: Array(colCount).fill('*'), body }, |
| 79 | + layout: { hLineColor: () => '#e6e6e6', vLineColor: () => '#e6e6e6' }, |
| 80 | + margin: [0, 4, 0, 12], |
| 81 | + }; |
| 82 | +} |
| 83 | + |
| 84 | +function streamPdf(res, docDefinition, filename) { |
| 85 | + const pdfDoc = getPrinter().createPdfKitDocument(docDefinition); |
| 86 | + res.setHeader('Content-Type', 'application/pdf'); |
| 87 | + res.setHeader('Content-Disposition', `attachment; filename="${sanitizeFilename(filename)}.pdf"`); |
| 88 | + pdfDoc.pipe(res); |
| 89 | + pdfDoc.end(); |
| 90 | +} |
| 91 | + |
| 92 | +/* POST /api/v1/export/pdf body: { type, params } (companyId from header) |
| 93 | + * params: { title, subtitle?, filename?, meta?: string[], image?|images?: base64 dataURI, |
| 94 | + * tableHead?: string[], tableRows?: any[][], totalRow?: any[] } */ |
| 95 | +exports.exportPdf = (req, res) => { |
| 96 | + try { |
| 97 | + const companyId = req.headers['companyid'] || ''; |
| 98 | + const { type, params = {} } = req.body || {}; |
| 99 | + if (!companyId || !type) { |
| 100 | + return res.status(400).send({ status: false, statusText: 'companyId and type are required.' }); |
| 101 | + } |
| 102 | + |
| 103 | + const content = headerBlocks(params.title, params.subtitle); |
| 104 | + |
| 105 | + if (Array.isArray(params.meta) && params.meta.length) { |
| 106 | + params.meta.forEach((line) => content.push({ text: String(line), style: 'meta' })); |
| 107 | + content.push({ text: ' ', margin: [0, 0, 0, 6] }); |
| 108 | + } |
| 109 | + |
| 110 | + const images = params.images || (params.image ? [params.image] : []); |
| 111 | + images.forEach((img) => { |
| 112 | + if (typeof img === 'string' && img.startsWith('data:image')) { |
| 113 | + content.push({ image: img, width: 515, margin: [0, 4, 0, 12] }); |
| 114 | + } |
| 115 | + }); |
| 116 | + |
| 117 | + if ((Array.isArray(params.tableHead) && params.tableHead.length) || (Array.isArray(params.tableRows) && params.tableRows.length)) { |
| 118 | + content.push(tableBlock(params.tableHead, params.tableRows, params.totalRow)); |
| 119 | + } |
| 120 | + |
| 121 | + const docDefinition = { |
| 122 | + pageSize: 'A4', |
| 123 | + pageMargins: [40, 40, 40, 50], |
| 124 | + defaultStyle: { font: 'Helvetica', fontSize: 10 }, |
| 125 | + styles: STYLES, |
| 126 | + content, |
| 127 | + footer: (currentPage, pageCount) => ({ |
| 128 | + columns: [ |
| 129 | + { text: `Generated ${new Date().toISOString().slice(0, 10)}`, fontSize: 8, color: '#999999', margin: [40, 0, 0, 0] }, |
| 130 | + { text: `${currentPage} / ${pageCount}`, alignment: 'right', fontSize: 8, color: '#999999', margin: [0, 0, 40, 0] }, |
| 131 | + ], |
| 132 | + }), |
| 133 | + }; |
| 134 | + |
| 135 | + streamPdf(res, docDefinition, params.filename || type); |
| 136 | + } catch (error) { |
| 137 | + logger.error(`ERROR in export pdf: ${error.message}`); |
| 138 | + if (!res.headersSent) res.status(500).send({ status: false, statusText: error.message }); |
| 139 | + } |
| 140 | +}; |
0 commit comments