safeer-pdf-generator
Version:
Framework-agnostic PDF generation library with chunking, merging, S3 upload, and email delivery
1,038 lines (943 loc) • 32 kB
JavaScript
/**
* Advanced Express.js PDF Generation API using @safeersoft/@safeersoft/pdf-reporter
*
* Features:
* - RESTful PDF generation endpoints
* - Template management
* - Performance monitoring
* - Error handling middleware
* - Request validation
* - Async job processing
* - File caching
* - Health checks
* - Metrics collection
*/
const express = require('express');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const { v4: uuidv4 } = require('uuid');
const { promisify } = require('util');
const fs = require('fs');
const path = require('path');
const {
generatePdf,
generateOptimizedPdf,
mergePdfs,
splitPdf,
extractPages,
getPdfInfo,
analyzePdfs,
validatePdfs,
registerTemplate,
consoleLogger,
estimatePdfGeneration,
} = require('@safeersoft/@safeersoft/pdf-reporter');
const app = express();
const port = process.env.PORT || 3000;
// Middleware setup
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: {
error: 'Too many requests, please try again later.',
retryAfter: '15 minutes',
},
});
app.use('/api/', limiter);
// File storage setup
const UPLOAD_DIR = path.join(__dirname, 'uploads');
const OUTPUT_DIR = path.join(__dirname, 'output');
const CACHE_DIR = path.join(__dirname, 'cache');
[UPLOAD_DIR, OUTPUT_DIR, CACHE_DIR].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
// In-memory job store (use Redis in production)
const jobs = new Map();
const jobResults = new Map();
// Metrics collection
const metrics = {
requests: 0,
successful: 0,
failed: 0,
totalProcessingTime: 0,
averageFileSize: 0,
templates: new Set(),
};
// Advanced sample data generator
const generateSampleData = (count = 100, type = 'customers') => {
const data = [];
if (type === 'customers') {
for (let i = 1; i <= count; i++) {
data.push({
id: i,
name: `Customer ${String(i).padStart(4, '0')}`,
email: `customer${i}@example.com`,
amount: Math.round(Math.random() * 10000 * 100) / 100,
date: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0],
status: ['Active', 'Inactive', 'Pending', 'Suspended'][Math.floor(Math.random() * 4)],
region: ['North America', 'Europe', 'Asia Pacific', 'Latin America'][
Math.floor(Math.random() * 4)
],
tier: ['Bronze', 'Silver', 'Gold', 'Platinum'][Math.floor(Math.random() * 4)],
lastActivity: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0],
});
}
} else if (type === 'sales') {
for (let i = 1; i <= count; i++) {
data.push({
id: i,
product: `Product ${String(i).padStart(3, '0')}`,
category: ['Electronics', 'Clothing', 'Books', 'Home & Garden'][
Math.floor(Math.random() * 4)
],
price: Math.round(Math.random() * 500 * 100) / 100,
quantity: Math.floor(Math.random() * 100) + 1,
revenue: 0, // Will be calculated
salesperson: `Sales Rep ${Math.floor(Math.random() * 20) + 1}`,
date: new Date(Date.now() - Math.random() * 90 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0],
});
data[i - 1].revenue = Math.round(data[i - 1].price * data[i - 1].quantity * 100) / 100;
}
}
return data;
};
// Column definitions for different report types
const columnDefinitions = {
customers: [
{ key: 'id', title: 'ID', dataIndex: 'id', flex: 1 },
{ key: 'name', title: 'Customer Name', dataIndex: 'name', flex: 3 },
{ key: 'email', title: 'Email', dataIndex: 'email', flex: 3 },
{ key: 'amount', title: 'Amount ($)', dataIndex: 'amount', flex: 2, type: 'currency' },
{ key: 'date', title: 'Registration Date', dataIndex: 'date', flex: 2 },
{ key: 'status', title: 'Status', dataIndex: 'status', flex: 2 },
{ key: 'region', title: 'Region', dataIndex: 'region', flex: 2 },
{ key: 'tier', title: 'Tier', dataIndex: 'tier', flex: 1 },
],
sales: [
{ key: 'id', title: 'Sale ID', dataIndex: 'id', flex: 1 },
{ key: 'product', title: 'Product', dataIndex: 'product', flex: 3 },
{ key: 'category', title: 'Category', dataIndex: 'category', flex: 2 },
{ key: 'price', title: 'Unit Price', dataIndex: 'price', flex: 2, type: 'currency' },
{ key: 'quantity', title: 'Qty', dataIndex: 'quantity', flex: 1 },
{ key: 'revenue', title: 'Revenue', dataIndex: 'revenue', flex: 2, type: 'currency' },
{ key: 'salesperson', title: 'Sales Rep', dataIndex: 'salesperson', flex: 2 },
{ key: 'date', title: 'Sale Date', dataIndex: 'date', flex: 2 },
],
};
// Register advanced templates
registerTemplate('modern-business', params => {
const { title, data, columns, userInfo, translationFn: t = k => k } = params;
const formatValue = (value, type) => {
if (type === 'currency' && typeof value === 'number') {
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
}
return value;
};
const getStatusColor = status => {
const colors = {
Active: '#28a745',
Inactive: '#6c757d',
Pending: '#ffc107',
Suspended: '#dc3545',
};
return colors[status] || '#007bff';
};
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${title}</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0; padding: 20px; color: #333; line-height: 1.6;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
max-width: 1200px; margin: 0 auto; background: white;
border-radius: 16px; overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.1);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; padding: 40px; text-align: center;
position: relative; overflow: hidden;
}
.header::before {
content: ''; position: absolute; top: -50%; left: -50%;
width: 200%; height: 200%; background:
radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
background-size: 30px 30px; animation: float 20s infinite linear;
}
@keyframes float { 0% { transform: translate(0, 0); } 100% { transform: translate(-30px, -30px); } }
.header h1 { margin: 0; font-size: 2.5em; font-weight: 700; position: relative; z-index: 1; }
.header p { margin: 10px 0 0; font-size: 1.1em; opacity: 0.9; position: relative; z-index: 1; }
.content { padding: 40px; }
.metrics {
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px; margin-bottom: 40px;
}
.metric {
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
padding: 25px; border-radius: 12px; text-align: center;
border: 1px solid #dee2e6; transition: transform 0.3s ease;
}
.metric:hover { transform: translateY(-5px); }
.metric-value {
font-size: 2.2em; font-weight: 700; margin-bottom: 8px;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.metric-label { color: #666; font-weight: 500; }
table {
width: 100%; border-collapse: collapse; background: white;
border-radius: 12px; overflow: hidden; margin: 20px 0;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
}
th {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white; padding: 18px 12px; text-align: left;
font-weight: 600; font-size: 0.9em; text-transform: uppercase;
letter-spacing: 0.5px;
}
td { padding: 15px 12px; border-bottom: 1px solid #f1f3f4; }
tr:hover { background-color: #f8f9fa; }
tr:last-child td { border-bottom: none; }
.status-badge {
padding: 4px 12px; border-radius: 20px; font-size: 0.85em;
font-weight: 600; color: white; text-align: center;
}
.summary {
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
padding: 30px; border-radius: 12px; margin-top: 40px;
border: 1px solid #dee2e6;
}
.summary h3 {
margin: 0 0 20px; color: #495057; font-weight: 600;
display: flex; align-items: center;
}
.summary h3::before {
content: '📈'; margin-right: 10px; font-size: 1.3em;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>${t(title)}</h1>
<p>Generated on ${new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}</p>
</div>
<div class="content">
${
userInfo?.companyName
? `
<div style="text-align: center; margin-bottom: 40px;">
<h2 style="color: #667eea; margin: 0;">${userInfo.companyName}</h2>
${userInfo.name ? `<p style="margin: 5px 0; color: #666;">Prepared by: ${userInfo.name}</p>` : ''}
</div>
`
: ''
}
<div class="metrics">
<div class="metric">
<div class="metric-value">${data.length.toLocaleString()}</div>
<div class="metric-label">Total Records</div>
</div>
<div class="metric">
<div class="metric-value">${data.filter(item => item.status === 'Active').length}</div>
<div class="metric-label">Active Items</div>
</div>
<div class="metric">
<div class="metric-value">${
data.length > 0 && data[0].amount
? formatValue(
data.reduce((sum, item) => sum + (item.amount || item.revenue || 0), 0) /
data.length,
'currency'
)
: 'N/A'
}</div>
<div class="metric-label">Average Value</div>
</div>
<div class="metric">
<div class="metric-value">${new Set(data.map(item => item.region || item.category)).size}</div>
<div class="metric-label">Categories</div>
</div>
</div>
<table>
<thead>
<tr>
${columns.map(col => `<th>${col.title}</th>`).join('')}
</tr>
</thead>
<tbody>
${data
.slice(0, 50)
.map(
(item, index) => `
<tr>
${columns
.map(col => {
const value = item[col.dataIndex];
if (col.dataIndex === 'status') {
return `<td><span class="status-badge" style="background-color: ${getStatusColor(value)}">${value}</span></td>`;
}
return `<td>${formatValue(value, col.type)}</td>`;
})
.join('')}
</tr>
`
)
.join('')}
</tbody>
</table>
${
data.length > 50
? `
<p style="text-align: center; color: #666; font-style: italic;">
Showing first 50 of ${data.length.toLocaleString()} records
</p>
`
: ''
}
<div class="summary">
<h3>Report Summary</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px;">
<div>
<strong>Dataset Size:</strong> ${data.length.toLocaleString()} records<br>
<strong>Generated:</strong> ${new Date().toLocaleString()}<br>
<strong>Format:</strong> PDF Report
</div>
<div>
<strong>Processing:</strong> Optimized rendering<br>
<strong>Quality:</strong> High resolution<br>
<strong>Status:</strong> ✅ Complete
</div>
</div>
</div>
</div>
</div>
</body>
</html>
`;
return {
html,
header: `<div style="text-align: center; padding: 10px; background: #667eea; color: white; font-weight: 600;">${title} - Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
footer: `<div style="text-align: center; padding: 8px; font-size: 10px; color: #666; border-top: 1px solid #eee;">Generated by Safeersoft PDF Reporter | ${new Date().toLocaleDateString()}</div>`,
};
});
// Middleware for error handling
const asyncHandler = fn => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Middleware for request validation
const validatePdfRequest = (req, res, next) => {
const { title, data, columns } = req.body;
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return res.status(400).json({
error: 'Invalid title: must be a non-empty string',
code: 'VALIDATION_ERROR',
});
}
if (!Array.isArray(data)) {
return res.status(400).json({
error: 'Invalid data: must be an array',
code: 'VALIDATION_ERROR',
});
}
if (!Array.isArray(columns) || columns.length === 0) {
return res.status(400).json({
error: 'Invalid columns: must be a non-empty array',
code: 'VALIDATION_ERROR',
});
}
next();
};
// Routes
// Health check
app.get('/', (req, res) => {
res.json({
service: 'Safeersoft PDF Reporter API',
version: '1.0.1',
status: 'healthy',
uptime: process.uptime(),
memory: process.memoryUsage(),
endpoints: {
'GET /': 'API information',
'GET /api/health': 'Detailed health check',
'GET /api/metrics': 'Service metrics',
'POST /api/generate': 'Generate PDF from data',
'POST /api/generate/async': 'Generate PDF asynchronously',
'POST /api/estimate': 'Estimate PDF generation performance',
'POST /api/merge': 'Merge multiple PDFs',
'POST /api/split': 'Split PDF into pages',
'POST /api/extract': 'Extract specific pages',
'POST /api/analyze': 'Analyze PDF properties',
'GET /api/samples/:type': 'Get sample data',
'GET /api/job/:id': 'Get async job status',
},
documentation: 'https://github.com/HassanHamdanDev/@safeersoft/@safeersoft/pdf-reporter#api',
});
});
// Detailed health check
app.get(
'/api/health',
asyncHandler(async (req, res) => {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
environment: process.env.NODE_ENV || 'development',
version: process.version,
metrics: {
requests: metrics.requests,
successRate:
metrics.requests > 0
? ((metrics.successful / metrics.requests) * 100).toFixed(2) + '%'
: '0%',
averageProcessingTime:
metrics.requests > 0
? (metrics.totalProcessingTime / metrics.requests).toFixed(0) + 'ms'
: '0ms',
},
storage: {
uploadDir: fs.existsSync(UPLOAD_DIR),
outputDir: fs.existsSync(OUTPUT_DIR),
cacheDir: fs.existsSync(CACHE_DIR),
},
};
res.json(health);
})
);
// Service metrics
app.get('/api/metrics', (req, res) => {
res.json({
requests: metrics.requests,
successful: metrics.successful,
failed: metrics.failed,
successRate:
metrics.requests > 0
? ((metrics.successful / metrics.requests) * 100).toFixed(2) + '%'
: '0%',
averageProcessingTime:
metrics.requests > 0
? (metrics.totalProcessingTime / metrics.requests).toFixed(0) + 'ms'
: '0ms',
averageFileSize:
metrics.averageFileSize > 0 ? (metrics.averageFileSize / 1024).toFixed(2) + 'KB' : '0KB',
templatesUsed: Array.from(metrics.templates),
uptime: process.uptime(),
memory: process.memoryUsage(),
activeJobs: jobs.size,
completedJobs: jobResults.size,
});
});
// Get sample data
app.get('/api/samples/:type?', (req, res) => {
const { type = 'customers' } = req.params;
const { count = 100 } = req.query;
const parsedCount = Math.min(parseInt(count, 10) || 100, 1000); // Limit to 1000 records
try {
const data = generateSampleData(parsedCount, type);
const columns = columnDefinitions[type] || columnDefinitions.customers;
res.json({
type,
count: parsedCount,
data,
columns,
totalRecords: data.length,
sampleQuery: {
url: `/api/generate`,
method: 'POST',
body: {
title: `${type.charAt(0).toUpperCase() + type.slice(1)} Report`,
data,
columns,
options: {
template: 'modern-business',
format: 'A4',
orientation: 'portrait',
},
},
},
});
} catch (error) {
res.status(500).json({
error: 'Failed to generate sample data',
message: error.message,
code: 'SAMPLE_GENERATION_ERROR',
});
}
});
// Generate PDF
app.post(
'/api/generate',
validatePdfRequest,
asyncHandler(async (req, res) => {
const startTime = Date.now();
metrics.requests++;
try {
const {
title,
data,
columns,
options = {},
userInfo = {},
template = 'modern-business',
} = req.body;
// Track template usage
metrics.templates.add(template);
// Generate PDF
const pdfBuffer = await generatePdf({
title,
data,
columns,
options: {
template,
format: options.format || 'A4',
orientation: options.orientation || 'portrait',
quality: options.quality || 'high',
...options,
},
userInfo,
logger: consoleLogger,
});
const processingTime = Date.now() - startTime;
metrics.totalProcessingTime += processingTime;
metrics.successful++;
// Update average file size
if (metrics.averageFileSize === 0) {
metrics.averageFileSize = pdfBuffer.length;
} else {
metrics.averageFileSize = (metrics.averageFileSize + pdfBuffer.length) / 2;
}
// Set headers for PDF download
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${title.replace(/[^a-zA-Z0-9]/g, '_')}.pdf"`
);
res.setHeader('Content-Length', pdfBuffer.length);
res.setHeader('X-Processing-Time', `${processingTime}ms`);
res.setHeader('X-Records-Count', data.length);
res.setHeader('X-Template-Used', template);
res.send(pdfBuffer);
} catch (error) {
metrics.failed++;
consoleLogger.error('PDF generation failed:', error);
res.status(500).json({
error: 'PDF generation failed',
message: error.message,
code: 'PDF_GENERATION_ERROR',
processingTime: Date.now() - startTime,
});
}
})
);
// Generate PDF asynchronously
app.post(
'/api/generate/async',
validatePdfRequest,
asyncHandler(async (req, res) => {
const jobId = uuidv4();
const startTime = Date.now();
// Store job info
jobs.set(jobId, {
id: jobId,
status: 'pending',
createdAt: new Date(),
estimatedCompletion: null,
progress: 0,
});
// Start async processing
(async () => {
try {
const {
title,
data,
columns,
options = {},
userInfo = {},
template = 'modern-business',
} = req.body;
// Update job status
jobs.set(jobId, { ...jobs.get(jobId), status: 'processing', progress: 25 });
// Estimate processing time
const estimation = await estimatePdfGeneration({ data, columns });
jobs.set(jobId, {
...jobs.get(jobId),
estimatedCompletion: new Date(Date.now() + estimation.estimatedTime),
progress: 50,
});
// Generate PDF
const pdfBuffer = await generatePdf({
title,
data,
columns,
options: {
template,
format: options.format || 'A4',
orientation: options.orientation || 'portrait',
quality: options.quality || 'high',
...options,
},
userInfo,
logger: consoleLogger,
});
// Store result
const processingTime = Date.now() - startTime;
jobResults.set(jobId, {
jobId,
pdf: pdfBuffer,
metadata: {
title,
recordCount: data.length,
template,
processingTime,
fileSize: pdfBuffer.length,
completedAt: new Date(),
},
});
// Update job status
jobs.set(jobId, {
...jobs.get(jobId),
status: 'completed',
progress: 100,
completedAt: new Date(),
});
} catch (error) {
jobs.set(jobId, {
...jobs.get(jobId),
status: 'failed',
error: error.message,
failedAt: new Date(),
});
}
})();
res.json({
jobId,
status: 'accepted',
message: 'PDF generation started',
checkStatusUrl: `/api/job/${jobId}`,
estimatedTime: '30-60 seconds',
});
})
);
// Get async job status
app.get('/api/job/:id', (req, res) => {
const { id } = req.params;
const job = jobs.get(id);
if (!job) {
return res.status(404).json({
error: 'Job not found',
code: 'JOB_NOT_FOUND',
});
}
const response = { ...job };
if (job.status === 'completed') {
const result = jobResults.get(id);
if (result) {
response.downloadUrl = `/api/job/${id}/download`;
response.metadata = result.metadata;
}
}
res.json(response);
});
// Download completed job result
app.get('/api/job/:id/download', (req, res) => {
const { id } = req.params;
const job = jobs.get(id);
const result = jobResults.get(id);
if (!job || !result) {
return res.status(404).json({
error: 'Job or result not found',
code: 'RESULT_NOT_FOUND',
});
}
if (job.status !== 'completed') {
return res.status(400).json({
error: 'Job not completed yet',
status: job.status,
code: 'JOB_NOT_COMPLETED',
});
}
// Set headers for PDF download
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${result.metadata.title.replace(/[^a-zA-Z0-9]/g, '_')}.pdf"`
);
res.setHeader('Content-Length', result.pdf.length);
res.setHeader('X-Job-Id', id);
res.setHeader('X-Processing-Time', `${result.metadata.processingTime}ms`);
res.send(result.pdf);
});
// Estimate PDF generation performance
app.post(
'/api/estimate',
asyncHandler(async (req, res) => {
try {
const { data, columns, options = {} } = req.body;
if (!Array.isArray(data) || !Array.isArray(columns)) {
return res.status(400).json({
error: 'Invalid input: data and columns must be arrays',
code: 'VALIDATION_ERROR',
});
}
const estimation = await estimatePdfGeneration({
data,
columns,
options,
});
res.json({
...estimation,
recommendation:
estimation.estimatedTime > 30000
? 'Consider using async generation for better user experience'
: 'Synchronous generation recommended',
asyncEndpoint: '/api/generate/async',
});
} catch (error) {
res.status(500).json({
error: 'Estimation failed',
message: error.message,
code: 'ESTIMATION_ERROR',
});
}
})
);
// Merge PDFs
app.post(
'/api/merge',
asyncHandler(async (req, res) => {
try {
const { pdfs, options = {} } = req.body;
if (!Array.isArray(pdfs) || pdfs.length < 2) {
return res.status(400).json({
error: 'At least 2 PDFs required for merging',
code: 'VALIDATION_ERROR',
});
}
// Convert base64 strings to buffers if needed
const pdfBuffers = pdfs.map(pdf => {
if (typeof pdf === 'string') {
return Buffer.from(pdf, 'base64');
}
return Buffer.from(pdf);
});
const mergedPdf = await mergePdfs(pdfBuffers, {
logger: consoleLogger,
...options,
});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="merged-document.pdf"');
res.setHeader('Content-Length', mergedPdf.length);
res.setHeader('X-Source-Count', pdfs.length);
res.send(mergedPdf);
} catch (error) {
res.status(500).json({
error: 'PDF merge failed',
message: error.message,
code: 'MERGE_ERROR',
});
}
})
);
// Split PDF
app.post(
'/api/split',
asyncHandler(async (req, res) => {
try {
const { pdf, options = {} } = req.body;
if (!pdf) {
return res.status(400).json({
error: 'PDF data required',
code: 'VALIDATION_ERROR',
});
}
const pdfBuffer = typeof pdf === 'string' ? Buffer.from(pdf, 'base64') : Buffer.from(pdf);
const pages = await splitPdf(pdfBuffer, {
logger: consoleLogger,
...options,
});
res.json({
totalPages: pages.length,
pages: pages.map((page, index) => ({
pageNumber: index + 1,
size: page.length,
data: page.toString('base64'),
})),
message: `PDF split into ${pages.length} pages`,
});
} catch (error) {
res.status(500).json({
error: 'PDF split failed',
message: error.message,
code: 'SPLIT_ERROR',
});
}
})
);
// Extract specific pages
app.post(
'/api/extract',
asyncHandler(async (req, res) => {
try {
const { pdf, pages, options = {} } = req.body;
if (!pdf || !Array.isArray(pages)) {
return res.status(400).json({
error: 'PDF data and pages array required',
code: 'VALIDATION_ERROR',
});
}
const pdfBuffer = typeof pdf === 'string' ? Buffer.from(pdf, 'base64') : Buffer.from(pdf);
const extractedPdf = await extractPages(pdfBuffer, pages, {
logger: consoleLogger,
...options,
});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="extracted-pages-${pages.join('-')}.pdf"`
);
res.setHeader('Content-Length', extractedPdf.length);
res.setHeader('X-Extracted-Pages', pages.join(','));
res.send(extractedPdf);
} catch (error) {
res.status(500).json({
error: 'Page extraction failed',
message: error.message,
code: 'EXTRACTION_ERROR',
});
}
})
);
// Analyze PDF
app.post(
'/api/analyze',
asyncHandler(async (req, res) => {
try {
const { pdfs } = req.body;
if (!Array.isArray(pdfs) || pdfs.length === 0) {
return res.status(400).json({
error: 'PDFs array required',
code: 'VALIDATION_ERROR',
});
}
const pdfBuffers = pdfs.map(pdf =>
typeof pdf === 'string' ? Buffer.from(pdf, 'base64') : Buffer.from(pdf)
);
const analysis = await analyzePdfs(pdfBuffers, {
logger: consoleLogger,
});
res.json({
analysis,
summary: {
totalPdfs: pdfs.length,
totalPages: analysis.reduce((sum, pdf) => sum + pdf.pageCount, 0),
totalSize: analysis.reduce((sum, pdf) => sum + pdf.fileSize, 0),
averageFileSize: analysis.reduce((sum, pdf) => sum + pdf.fileSize, 0) / pdfs.length,
},
});
} catch (error) {
res.status(500).json({
error: 'PDF analysis failed',
message: error.message,
code: 'ANALYSIS_ERROR',
});
}
})
);
// Error handling middleware
app.use((error, req, res, next) => {
console.error('Unhandled error:', error);
res.status(500).json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong',
code: 'INTERNAL_ERROR',
timestamp: new Date().toISOString(),
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Endpoint not found',
message: `${req.method} ${req.path} is not a valid endpoint`,
code: 'NOT_FOUND',
availableEndpoints: [
'GET /',
'GET /api/health',
'GET /api/metrics',
'GET /api/samples/:type',
'POST /api/generate',
'POST /api/generate/async',
'POST /api/estimate',
'POST /api/merge',
'POST /api/split',
'POST /api/extract',
'POST /api/analyze',
'GET /api/job/:id',
'GET /api/job/:id/download',
],
});
});
// Cleanup old jobs periodically
setInterval(
() => {
const now = Date.now();
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
for (const [jobId, job] of jobs.entries()) {
const age = now - job.createdAt.getTime();
if (age > maxAge) {
jobs.delete(jobId);
jobResults.delete(jobId);
}
}
},
60 * 60 * 1000
); // Run every hour
// Start server
app.listen(port, () => {
console.log(`🚀 Safeersoft PDF Reporter API running on http://localhost:${port}`);
console.log(`📖 API Documentation: http://localhost:${port}/`);
console.log(`🏥 Health Check: http://localhost:${port}/api/health`);
console.log(`📊 Metrics: http://localhost:${port}/api/metrics`);
console.log(`📁 Sample Data: http://localhost:${port}/api/samples/customers`);
console.log('');
console.log('Available endpoints:');
console.log(' POST /api/generate - Generate PDF from data');
console.log(' POST /api/generate/async - Generate PDF asynchronously');
console.log(' POST /api/estimate - Estimate generation performance');
console.log(' POST /api/merge - Merge multiple PDFs');
console.log(' POST /api/split - Split PDF into pages');
console.log(' POST /api/extract - Extract specific pages');
console.log(' POST /api/analyze - Analyze PDF properties');
console.log(' GET /api/samples/:type - Get sample data (customers/sales)');
console.log('');
console.log('Example usage:');
console.log(` curl -X GET http://localhost:${port}/api/samples/customers`);
console.log(` curl -X POST http://localhost:${port}/api/generate \\`);
console.log(' -H "Content-Type: application/json" \\');
console.log(' -d \'{"title":"Test Report","data":[...],"columns":[...]}\'');
});