1601 lines
73 KiB
JavaScript
1601 lines
73 KiB
JavaScript
const DocumentTemplateService = require('../../services/template/DocumentTemplateService');
|
|
const ContractUploadService = require('../../services/contracts/ContractUploadService');
|
|
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
|
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
|
const PDFDocument = require('pdfkit');
|
|
const stream = require('stream');
|
|
const puppeteer = require('puppeteer');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const DEBUG_PDF_FILES = !!process.env.DEBUG_PDF_FILES;
|
|
const db = require('../../database/database');
|
|
const UnitOfWork = require('../../database/UnitOfWork');
|
|
const { logger } = require('../../middleware/logger');
|
|
const CompanyStampService = require('../../services/stamp/company/CompanyStampService');
|
|
|
|
// Ensure debug directory exists and helper to save files
|
|
function ensureDebugDir() {
|
|
const debugDir = path.join(__dirname, '../../debug-pdf');
|
|
if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
|
|
return debugDir;
|
|
}
|
|
function saveDebugFile(filename, data, encoding = 'utf8') {
|
|
if (!DEBUG_PDF_FILES) return;
|
|
const debugDir = ensureDebugDir();
|
|
const p = path.join(debugDir, filename);
|
|
try {
|
|
if (Buffer.isBuffer(data)) fs.writeFileSync(p, data);
|
|
else fs.writeFileSync(p, data, { encoding });
|
|
logger.debug(`[DEBUG] Wrote debug file: ${p}`);
|
|
} catch (e) {
|
|
logger.error('[DEBUG] Failed to write debug file', p, e);
|
|
}
|
|
}
|
|
|
|
// Helper to remove/empty placeholders except allow-list
|
|
// Updated: match any content inside {{ ... }} (not only \w+) so placeholders like {{company.name}} are sanitized.
|
|
// allowList contains exact placeholder names to preserve (e.g. 'companyStamp', 'profitplanetSignature').
|
|
function sanitizePlaceholders(html, allowList = ['currentDate','companyStamp','companyStampInline','companyStampSmall','profitplanetSignature']) {
|
|
if (!html || typeof html !== 'string') return html;
|
|
const allowSet = new Set((allowList || []).map(s => String(s).trim()).filter(Boolean));
|
|
|
|
let total = 0;
|
|
let preserved = 0;
|
|
const cleaned = html.replace(/{{\s*([^}]+?)\s*}}/g, (match, inner) => {
|
|
total++;
|
|
const name = String(inner || '').trim();
|
|
// preserve exact allow-list matches
|
|
if (allowSet.has(name)) {
|
|
preserved++;
|
|
return match;
|
|
}
|
|
// preserve allow-list items that are prefixes (e.g. allow 'companyStamp' to keep 'companyStampInline' variants)
|
|
for (const a of allowSet) {
|
|
if (name.startsWith(a)) {
|
|
preserved++;
|
|
return match;
|
|
}
|
|
}
|
|
// otherwise remove/empty
|
|
return '';
|
|
});
|
|
|
|
logger.debug('[sanitizePlaceholders] placeholders cleaned', { total, preserved, removed: total - preserved });
|
|
return cleaned;
|
|
}
|
|
|
|
// Helper to get signed URLs and raw HTML from S3
|
|
// NOTE: added optional serverBaseUrl parameter. When provided, previewUrl for contract templates will point
|
|
// to a backend preview endpoint (avoids direct S3 CORS issues).
|
|
async function enrichTemplate(template, s3, serverBaseUrl = null) {
|
|
let previewUrl = null;
|
|
let fileUrl = null;
|
|
let html = null;
|
|
try {
|
|
logger.debug(`[enrichTemplate] template.id=${template.id} storageKey=${template.storageKey} lang=${template.lang} version=${template.version}`);
|
|
const command = new GetObjectCommand({
|
|
Bucket: process.env.EXOSCALE_BUCKET,
|
|
Key: template.storageKey
|
|
});
|
|
// original signed URL (useful as fileUrl)
|
|
try {
|
|
fileUrl = await getSignedUrl(s3, command, { expiresIn: 600 }); // 10 min
|
|
} catch (e) {
|
|
logger.warn('[enrichTemplate] failed to create signed URL for original file', e && e.message);
|
|
fileUrl = null;
|
|
}
|
|
|
|
// Fetch raw HTML content
|
|
const fileObj = await s3.send(command);
|
|
if (fileObj.Body) {
|
|
html = await streamToString(fileObj.Body, template.id);
|
|
logger.debug(`[enrichTemplate] fetched html length=${html ? html.length : 0}`);
|
|
// Save full raw HTML for offline inspection when debug enabled
|
|
if (DEBUG_PDF_FILES && html) {
|
|
saveDebugFile(`template_${template.id}_html_full.html`, html);
|
|
}
|
|
} else {
|
|
logger.warn(`[enrichTemplate] S3 returned empty Body for key=${template.storageKey}`);
|
|
}
|
|
|
|
// NEW: Do NOT expose previews for active contract templates (remove preview for "aktive contracts")
|
|
if (template.type === 'contract' && template.state === 'active') {
|
|
previewUrl = null; // explicitly hide preview
|
|
logger.debug('[enrichTemplate] hiding previewUrl for active contract', { id: template.id });
|
|
} else if (template.type === 'contract' && html) {
|
|
// previous sanitized-preview behavior for non-active contracts (kept)
|
|
try {
|
|
const sanitized = sanitizePlaceholders(html);
|
|
if (DEBUG_PDF_FILES) saveDebugFile(`template_${template.id}_sanitized_preview.html`, sanitized);
|
|
|
|
// optional: upload sanitized preview to S3 (keeps earlier behavior for offline debugging)
|
|
try {
|
|
const langFolder = template.lang === 'en' ? 'english' : (template.lang === 'de' ? 'german' : 'other');
|
|
const previewKey = `DocumentTemplates/previews/${langFolder}/template_${template.id}_preview_${Date.now()}.html`;
|
|
const putCmd = new PutObjectCommand({
|
|
Bucket: process.env.EXOSCALE_BUCKET,
|
|
Key: previewKey,
|
|
Body: sanitized,
|
|
ContentType: 'text/html; charset=utf-8'
|
|
});
|
|
await s3.send(putCmd);
|
|
logger.debug('[enrichTemplate] uploaded sanitized preview to S3', { previewKey });
|
|
} catch (eUpload) {
|
|
logger.warn('[enrichTemplate] failed to upload sanitized preview to S3 (continue)', eUpload && eUpload.message);
|
|
}
|
|
|
|
if (serverBaseUrl) {
|
|
previewUrl = `${serverBaseUrl}/api/document-templates/${template.id}/preview`;
|
|
} else {
|
|
previewUrl = fileUrl;
|
|
}
|
|
logger.debug('[enrichTemplate] previewUrl assigned', { previewUrl: !!previewUrl });
|
|
} catch (e) {
|
|
logger.warn('[enrichTemplate] failed to produce sanitized preview', e && e.message);
|
|
previewUrl = fileUrl;
|
|
}
|
|
} else {
|
|
// non-contract or other cases: use original signed URL as preview
|
|
previewUrl = fileUrl;
|
|
}
|
|
} catch (e) {
|
|
logger.error(`[enrichTemplate] Error fetching S3 object for key=${template.storageKey}`, e && e.stack ? e.stack : e);
|
|
previewUrl = previewUrl || null;
|
|
fileUrl = fileUrl || null;
|
|
html = html || null;
|
|
}
|
|
return {
|
|
...template,
|
|
previewUrl,
|
|
fileUrl,
|
|
html,
|
|
version: template.version
|
|
};
|
|
}
|
|
|
|
// Helper to convert S3 stream to string (hardened + debug)
|
|
function streamToString(s3BodyStream, templateId) {
|
|
return new Promise(async (resolve, reject) => {
|
|
try {
|
|
const chunks = [];
|
|
let totalBytes = 0;
|
|
let chunkCount = 0;
|
|
|
|
// Support async iterator (some AWS SDK bodies are async iterable)
|
|
if (s3BodyStream[Symbol.asyncIterator]) {
|
|
for await (const chunk of s3BodyStream) {
|
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
chunks.push(buf);
|
|
totalBytes += buf.length;
|
|
chunkCount++;
|
|
}
|
|
} else if (typeof s3BodyStream.on === 'function') {
|
|
s3BodyStream.on('data', (chunk) => {
|
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
chunks.push(buf);
|
|
totalBytes += buf.length;
|
|
chunkCount++;
|
|
});
|
|
s3BodyStream.on('error', (err) => {
|
|
logger.error(`[streamToString] stream error for template=${templateId}`, err && err.stack ? err.stack : err);
|
|
reject(err);
|
|
});
|
|
s3BodyStream.on('end', () => {
|
|
const buffer = Buffer.concat(chunks);
|
|
logger.debug(`[streamToString] template=${templateId} chunks=${chunkCount} bytes=${totalBytes}`);
|
|
// Save raw buffer when DEBUG or tiny
|
|
if (DEBUG_PDF_FILES || totalBytes < 32) {
|
|
saveDebugFile(`template_${templateId}_html_raw.bin`, buffer);
|
|
}
|
|
resolve(buffer.toString('utf-8'));
|
|
});
|
|
return;
|
|
} else {
|
|
// Fallback: try to read as buffer
|
|
const data = await s3BodyStream.arrayBuffer?.();
|
|
const buffer = data ? Buffer.from(data) : Buffer.alloc(0);
|
|
logger.debug(`[streamToString] fallback read template=${templateId} bytes=${buffer.length}`);
|
|
if (DEBUG_PDF_FILES || buffer.length < 32) saveDebugFile(`template_${templateId}_html_raw.bin`, buffer);
|
|
return resolve(buffer.toString('utf-8'));
|
|
}
|
|
|
|
// If we reached here (async iterator path), finalize
|
|
const buffer = Buffer.concat(chunks);
|
|
logger.debug(`[streamToString] template=${templateId} chunks=${chunkCount} bytes=${totalBytes}`);
|
|
if (DEBUG_PDF_FILES || totalBytes < 32) saveDebugFile(`template_${templateId}_html_raw.bin`, buffer);
|
|
resolve(buffer.toString('utf-8'));
|
|
} catch (err) {
|
|
logger.error(`[streamToString] Error reading stream for template=${templateId}`, err && err.stack ? err.stack : err);
|
|
reject(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Ensure HTML is a valid document
|
|
function ensureHtmlDocument(html) {
|
|
// If it already looks like a full HTML doc, return as is
|
|
if (/<html[\s\S]*?>[\s\S]*<\/html>/i.test(html)) return html;
|
|
// Otherwise, wrap it
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Contract PDF</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 40px; }
|
|
h1, h2, h3 { color: #222; }
|
|
img { display: block; margin-top: 20px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${html}
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
|
|
exports.listTemplates = async (req, res) => {
|
|
const templates = await DocumentTemplateService.listTemplates();
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
|
|
const serverBaseUrl = `${req.protocol}://${req.get('host')}`;
|
|
|
|
// Generate signed preview/file URLs and fetch HTML for each template
|
|
const templatesWithPreview = await Promise.all(
|
|
templates.map(t => enrichTemplate(t, s3, serverBaseUrl))
|
|
);
|
|
|
|
res.json(templatesWithPreview);
|
|
};
|
|
|
|
// Public dashboard endpoint (admin only)
|
|
exports.listTemplatesPublic = async (req, res) => {
|
|
// Same as listTemplates, but for /api/document-templates-public
|
|
const templates = await DocumentTemplateService.listTemplates();
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
|
|
const serverBaseUrl = `${req.protocol}://${req.get('host')}`;
|
|
|
|
const templatesWithPreview = await Promise.all(
|
|
templates.map(t => enrichTemplate(t, s3, serverBaseUrl))
|
|
);
|
|
|
|
res.json(templatesWithPreview);
|
|
};
|
|
|
|
exports.uploadTemplate = async (req, res) => {
|
|
const { name, type, description, lang } = req.body;
|
|
const rawUserType = req.body.user_type || req.body.userType;
|
|
const allowed = ['personal','company','both'];
|
|
const user_type = allowed.includes(rawUserType) ? rawUserType : 'both';
|
|
const file = req.file;
|
|
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
|
if (!lang || !['en', 'de'].includes(lang)) return res.status(400).json({ error: 'Invalid or missing language' });
|
|
|
|
// Use "english" for en, "german" for de
|
|
const langFolder = lang === 'en' ? 'english' : 'german';
|
|
const key = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`;
|
|
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
await s3.send(new PutObjectCommand({
|
|
Bucket: process.env.EXOSCALE_BUCKET,
|
|
Key: key,
|
|
Body: file.buffer,
|
|
ContentType: file.mimetype
|
|
}));
|
|
|
|
const template = await DocumentTemplateService.uploadTemplate({ name, type, storageKey: key, description, lang, version: 1, user_type });
|
|
// Enrich with previewUrl, fileUrl, html
|
|
const enriched = await enrichTemplate(template, s3);
|
|
res.status(201).json(enriched);
|
|
};
|
|
|
|
exports.getTemplate = async (req, res) => {
|
|
const template = await DocumentTemplateService.getTemplate(req.params.id);
|
|
if (!template) return res.status(404).json({ error: 'Not found' });
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
const enriched = await enrichTemplate(template, s3);
|
|
res.json(enriched);
|
|
};
|
|
|
|
exports.deleteTemplate = async (req, res) => {
|
|
await DocumentTemplateService.deleteTemplate(req.params.id);
|
|
res.status(204).end();
|
|
};
|
|
|
|
exports.updateTemplate = async (req, res) => {
|
|
const id = req.params.id;
|
|
const { name, type, description, lang } = req.body;
|
|
const rawUserType = req.body.user_type || req.body.userType;
|
|
const allowed = ['personal','company','both'];
|
|
const user_type = allowed.includes(rawUserType) ? rawUserType : undefined;
|
|
let storageKey;
|
|
let file = req.file;
|
|
|
|
const current = await DocumentTemplateService.getTemplate(id);
|
|
if (!current) return res.status(404).json({ error: 'Template not found' });
|
|
|
|
// Use "english" for en, "german" for de
|
|
const langFolder = lang ? (lang === 'en' ? 'english' : 'german') : (current.lang === 'en' ? 'english' : 'german');
|
|
if (file) {
|
|
storageKey = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`;
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
await s3.send(new PutObjectCommand({
|
|
Bucket: process.env.EXOSCALE_BUCKET,
|
|
Key: storageKey,
|
|
Body: file.buffer,
|
|
ContentType: file.mimetype
|
|
}));
|
|
}
|
|
|
|
const updateData = {
|
|
name: name !== undefined ? name : current.name,
|
|
type: type !== undefined ? type : current.type,
|
|
description: description !== undefined ? description : current.description,
|
|
lang: lang !== undefined ? lang : current.lang,
|
|
storageKey: storageKey || current.storageKey,
|
|
...(user_type !== undefined ? { user_type } : {})
|
|
};
|
|
|
|
const updated = await DocumentTemplateService.updateTemplate(id, updateData);
|
|
// Enrich with previewUrl, fileUrl, html
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
const enriched = await enrichTemplate(updated, s3);
|
|
res.json(enriched);
|
|
};
|
|
|
|
exports.updateTemplateState = async (req, res) => {
|
|
const id = req.params.id;
|
|
const { state } = req.body;
|
|
if (!['active', 'inactive'].includes(state)) {
|
|
return res.status(400).json({ error: 'Invalid state value' });
|
|
}
|
|
const updated = await DocumentTemplateService.updateTemplateState(id, state);
|
|
if (!updated) return res.status(404).json({ error: 'Template not found' });
|
|
// Enrich with previewUrl, fileUrl, html
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
const enriched = await enrichTemplate(updated, s3);
|
|
res.json(enriched);
|
|
};
|
|
|
|
exports.listTemplatesFiltered = async (req, res) => {
|
|
const { state } = req.query;
|
|
const userTypeFilter = req.query.user_type || req.query.userType;
|
|
let templates = await DocumentTemplateService.listTemplates();
|
|
if (state) templates = templates.filter(t => t.state === state);
|
|
if (userTypeFilter) templates = templates.filter(t => t.user_type === userTypeFilter);
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
const enriched = await Promise.all(templates.map(t => enrichTemplate(t, s3)));
|
|
res.json(enriched);
|
|
};
|
|
|
|
// New: list only active templates relevant to authenticated user (suggest route: GET /api/document-templates/active/mine)
|
|
exports.listMyActiveTemplates = async (req, res) => {
|
|
const userType = req.user?.user_type || req.user?.userType;
|
|
if (!userType) return res.status(401).json({ error: 'Unauthorized: missing user type' });
|
|
const templateType = req.query.type || null; // optional ?type=contract
|
|
const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, templateType);
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
const enriched = await Promise.all(templates.map(t => enrichTemplate(t, s3)));
|
|
res.json(enriched);
|
|
};
|
|
|
|
// small helper: log and send PDF buffer safely
|
|
function sendPdfBuffer(res, pdfBuffer, filename, debugTag) {
|
|
try {
|
|
// Log small head/tail of buffer for debugging
|
|
const head = pdfBuffer.slice(0, 16).toString('hex');
|
|
const tail = pdfBuffer.slice(-16).toString('hex');
|
|
logger.debug(`[SEND-PDF][${debugTag}] filename=${filename} bytes=${pdfBuffer.length} head=${head} tail=${tail}`);
|
|
|
|
// Ensure binary-friendly headers
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
res.setHeader('Content-Transfer-Encoding', 'binary');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('Content-Length', pdfBuffer.length);
|
|
|
|
// Log headers that will be sent
|
|
logger.debug(`[SEND-PDF][${debugTag}] Response headers prepared: Content-Length=${res.getHeader('Content-Length')}`);
|
|
|
|
// Attach finish/close listeners
|
|
res.on('finish', () => {
|
|
logger.debug(`[SEND-PDF][${debugTag}] res.finish for ${filename} bytesSent=${pdfBuffer.length}`);
|
|
});
|
|
res.on('close', () => {
|
|
logger.debug(`[SEND-PDF][${debugTag}] res.close for ${filename}`);
|
|
});
|
|
|
|
// Send binary. use end with buffer to avoid any transformations.
|
|
res.end(pdfBuffer);
|
|
} catch (e) {
|
|
logger.error('[SEND-PDF] failed to send pdfBuffer', e && e.stack ? e.stack : e);
|
|
try { res.status(500).json({ error: 'Failed to send PDF' }); } catch (e2) {}
|
|
}
|
|
}
|
|
|
|
// Helper: format date -> dd.mm.yyyy HH:MM:SS
|
|
function formatDateTime(dateLike) {
|
|
const d = dateLike ? new Date(dateLike) : new Date();
|
|
const pad = n => String(n).padStart(2,'0');
|
|
return `${pad(d.getDate())}.${pad(d.getMonth()+1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
}
|
|
|
|
// Generate PDF from template HTML (styled)
|
|
exports.generatePdf = async (req, res) => {
|
|
const id = req.params.id;
|
|
logger.info(`[generatePdf] Start for templateId=${id}`);
|
|
const template = await DocumentTemplateService.getTemplate(id);
|
|
if (!template) {
|
|
logger.error(`[generatePdf] Template not found for id=${id}`);
|
|
return res.status(404).json({ error: 'Template not found' });
|
|
}
|
|
const userType = req.user?.user_type || req.user?.userType;
|
|
if (!userType) {
|
|
logger.warn('[generatePdf] unauthorized_missing_user', { id });
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
if (template.state !== 'active') {
|
|
logger.warn('[generatePdf] template_inactive', { id, state: template.state });
|
|
return res.status(403).json({ error: 'Template inactive' });
|
|
}
|
|
if (template.user_type !== 'both' && template.user_type !== userType) {
|
|
logger.warn('[generatePdf] template_user_type_mismatch', { id, templateUserType: template.user_type, userType });
|
|
return res.status(403).json({ error: 'Template not allowed for this user type' });
|
|
}
|
|
|
|
// Get HTML content from S3
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
let html = '';
|
|
try {
|
|
logger.debug(`[generatePdf] Fetching HTML from S3: key=${template.storageKey} id=${id} lang=${template.lang} version=${template.version}`);
|
|
const command = new GetObjectCommand({
|
|
Bucket: process.env.EXOSCALE_BUCKET,
|
|
Key: template.storageKey
|
|
});
|
|
const fileObj = await s3.send(command);
|
|
if (fileObj.Body) {
|
|
html = await streamToString(fileObj.Body, id);
|
|
logger.debug(`[generatePdf] HTML fetched, length=${html.length}`);
|
|
if (DEBUG_PDF_FILES) saveDebugFile(`template_${id}_generate_html.html`, ensureHtmlDocument(html));
|
|
} else {
|
|
logger.error(`[generatePdf] S3 fileObj.Body is empty for key=${template.storageKey}`);
|
|
}
|
|
} catch (e) {
|
|
logger.error(`[generatePdf] Error fetching template file from S3:`, e && e.stack ? e.stack : e);
|
|
return res.status(500).json({ error: 'Failed to fetch template file' });
|
|
}
|
|
|
|
// If client requested a preview/sanitized PDF, and this is a contract, sanitize placeholders (opt-in)
|
|
const previewFlag = req.query && (req.query.preview === '1' || req.query.sanitize === '1');
|
|
if (previewFlag && template.type === 'contract') {
|
|
logger.debug('[generatePdf] preview flag detected - sanitizing placeholders before rendering PDF', { id });
|
|
html = sanitizePlaceholders(html);
|
|
// Still replace currentDate into sanitized output so previews show a date
|
|
try {
|
|
html = html.replace(/{{\s*currentDate\s*}}/g, formatDateTime());
|
|
} catch (e) { /* ignore */ }
|
|
if (DEBUG_PDF_FILES) saveDebugFile(`template_${id}_generate_html_sanitized.html`, ensureHtmlDocument(html));
|
|
} else {
|
|
// NEW: replace {{currentDate}} also for unsigned PDFs (existing behavior)
|
|
html = html.replace(/{{\s*currentDate\s*}}/g, formatDateTime());
|
|
}
|
|
|
|
// NEW: apply company stamp & Profit Planet signature (stamp insertion still safe after sanitization)
|
|
html = await applyCompanyStampPlaceholders(html, req);
|
|
html = await applyProfitPlanetSignature(html);
|
|
|
|
// Use puppeteer to render HTML and generate PDF
|
|
try {
|
|
const browser = await puppeteer.launch({
|
|
headless: "new",
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
|
});
|
|
const page = await browser.newPage();
|
|
|
|
// Instrument page console and errors
|
|
page.on('console', msg => {
|
|
try {
|
|
logger.debug(`[PUPPETEER][console][template:${id}] ${msg.type()}: ${msg.text()}`);
|
|
} catch (e) {
|
|
logger.debug('[PUPPETEER][console] error reading message', e);
|
|
}
|
|
});
|
|
page.on('pageerror', err => logger.error(`[PUPPETEER][pageerror][template:${id}]`, err && err.stack ? err.stack : err));
|
|
page.on('requestfailed', req => logger.warn(`[PUPPETEER][requestfailed][template:${id}] url=${req.url()} failure=${req.failure()?.errorText}`));
|
|
|
|
const htmlDoc = ensureHtmlDocument(html);
|
|
logger.debug(`[generatePdf] Final HTML length=${htmlDoc.length} headSnippet=${htmlDoc.slice(0,200).replace(/\n/g,' ')}`);
|
|
await page.setContent(htmlDoc, { waitUntil: 'networkidle0', timeout: 60000 });
|
|
|
|
// Save a rendered screenshot for inspection
|
|
if (DEBUG_PDF_FILES) {
|
|
const screenshotPath = `template_${id}_render.png`;
|
|
await page.screenshot({ path: path.join(ensureDebugDir(), screenshotPath), fullPage: true });
|
|
logger.debug(`[generatePdf] Saved screenshot ${screenshotPath}`);
|
|
}
|
|
|
|
const pdfBuffer = await page.pdf({ format: 'A4' });
|
|
await browser.close();
|
|
|
|
logger.info(`[generatePdf] Generated PDF for template ${id} size=${pdfBuffer.length} bytes`);
|
|
if (DEBUG_PDF_FILES) saveDebugFile(`template_${id}.pdf`, pdfBuffer, null);
|
|
|
|
// Log incoming request headers (helpful to troubleshoot transport)
|
|
try {
|
|
logger.debug(`[generatePdf] request headers: Accept=${req.get('accept')} Referer=${req.get('referer')} Origin=${req.get('origin')}`);
|
|
} catch (e) {}
|
|
|
|
// Send using helper
|
|
return sendPdfBuffer(res, pdfBuffer, `template_${id}.pdf`, `template_${id}`);
|
|
} catch (err) {
|
|
logger.error(`[generatePdf] Error generating PDF:`, err && err.stack ? err.stack : err);
|
|
res.status(500).json({ error: 'Fehler beim Generieren des Vertrags.' });
|
|
}
|
|
};
|
|
|
|
// Generate PDF with signature inserted (styled HTML)
|
|
exports.generatePdfWithSignature = async (req, res) => {
|
|
const id = req.params.id;
|
|
logger.info(`[generatePdfWithSignature] Start for templateId=${id}`);
|
|
|
|
// Accept multiple incoming shapes from frontend:
|
|
// - req.body.userData (preferred)
|
|
// - req.body.user or req.body.context (some clients send 'user' + 'context')
|
|
// - authenticated user in req.user
|
|
const rawBody = req.body || {};
|
|
// signature image may be named signatureImage, signature or signatureData on the client
|
|
const signatureImage = rawBody.signatureImage || rawBody.signature || rawBody.signatureData || null;
|
|
|
|
// Merge/normalize candidate sources for variables
|
|
const rawUserData = rawBody.userData || rawBody.user || rawBody.context || {};
|
|
const authUser = req.user || {};
|
|
|
|
// small helper: try multiple possible keys and return the first defined/non-empty one
|
|
const pick = (src, keys) => {
|
|
if (!src) return undefined;
|
|
for (const k of keys) {
|
|
if (src[k] !== undefined && src[k] !== null && String(src[k]).trim() !== '') return src[k];
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// Format Date/ISO -> "dd.mm.yyyy HH:MM:SS" (24h)
|
|
const formatDateTime = (input) => {
|
|
const d = input ? new Date(input) : new Date();
|
|
if (!d || isNaN(d.getTime())) return '';
|
|
const pad = n => String(n).padStart(2, '0');
|
|
const day = pad(d.getDate());
|
|
const month = pad(d.getMonth() + 1);
|
|
const year = d.getFullYear();
|
|
const hours = pad(d.getHours());
|
|
const minutes = pad(d.getMinutes());
|
|
const seconds = pad(d.getSeconds());
|
|
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`;
|
|
};
|
|
|
|
// prefer explicit context/currentDate (ISO), otherwise use now formatted
|
|
const rawDate = pick(rawBody, ['currentDate']) || pick(rawBody.context || {}, ['currentDate']);
|
|
const formattedCurrentDate = rawDate ? formatDateTime(rawDate) : formatDateTime();
|
|
|
|
// normalized map used to replace template placeholders
|
|
const userData = {
|
|
companyName: pick(rawUserData, ['companyName','company_name','company','name']) ||
|
|
pick(authUser, ['companyName','company_name','company','companyName','company']) || '',
|
|
registrationNumber: pick(rawUserData, ['registrationNumber','registration_number','registrationNo','registration']) ||
|
|
pick(authUser, ['registrationNumber','registration_number','registrationNo']) || '',
|
|
contactPersonName: pick(rawUserData, ['contactPersonName','contact_person_name','contactPerson','contactName','contact']) ||
|
|
pick(authUser, ['contactPersonName','name','firstName','fullName']) || '',
|
|
companyAddress: pick(rawUserData, ['companyAddress','company_address','address','streetAddress']) ||
|
|
pick(authUser, ['companyAddress','address']) || '',
|
|
email: pick(rawUserData, ['email','userEmail']) || authUser.email || '',
|
|
// ensure these exist so replacement step can touch them even if empty
|
|
contactPersonPhone: '',
|
|
country: '',
|
|
// ADDED: ensure company placeholders exist
|
|
companyFullAddress: '',
|
|
companyEmail: '',
|
|
companyPhone: ''
|
|
};
|
|
|
|
// Add personal-user specific fallbacks: fullName and personalAddress and phone
|
|
const rawFull = pick(rawUserData, ['fullName','full_name','name']) ||
|
|
((pick(rawUserData, ['firstName','first_name']) && pick(rawUserData, ['lastName','last_name'])) ? `${pick(rawUserData, ['firstName','first_name'])} ${pick(rawUserData, ['lastName','last_name'])}` : undefined);
|
|
const authFull = pick(authUser, ['fullName','full_name','name']) ||
|
|
((pick(authUser, ['firstName','first_name']) && pick(authUser, ['lastName','last_name'])) ? `${pick(authUser, ['firstName','first_name'])} ${pick(authUser, ['lastName','last_name'])}` : undefined);
|
|
userData.fullName = rawFull || authFull || '';
|
|
|
|
// street/address part
|
|
userData.address = pick(rawUserData, ['address','streetAddress','street','personalAddress','personal_address','addr']) ||
|
|
pick(authUser, ['address','streetAddress','street','personalAddress','personal_address']) || '';
|
|
|
|
// postal code / zip
|
|
userData.zip_code = pick(rawUserData, ['zip','zip_code','postalCode','postal_code','postcode']) ||
|
|
pick(authUser, ['zip','zip_code','postalCode','postal_code','postcode']) || '';
|
|
|
|
// city/town
|
|
userData.city = pick(rawUserData, ['city','town','municipality']) ||
|
|
pick(authUser, ['city','town']) || '';
|
|
|
|
// legacy single-field address still supported as personalAddress
|
|
userData.personalAddress = pick(rawUserData, ['personalAddress','personal_address']) ||
|
|
pick(authUser, ['personalAddress','personal_address']) || '';
|
|
|
|
userData.phone = pick(rawUserData, ['phone','telephone','mobile','contactPhone','contact_phone']) ||
|
|
pick(authUser, ['phone','telephone','mobile','contactPhone','contact_person_phone']) || '';
|
|
|
|
// add formatted currentDate into normalized map
|
|
userData.currentDate = formattedCurrentDate;
|
|
|
|
// If frontend didn't send personalAddress/phone, try to fetch from personal_profiles in DB (authenticated user)
|
|
try {
|
|
// Build candidate identifiers: various authUser/rawUserData id/email shapes
|
|
const idCandidates = [
|
|
authUser && (authUser.userId || authUser.id || authUser.user_id),
|
|
rawUserData && (rawUserData.userId || rawUserData.id || rawUserData.user_id)
|
|
].flat().filter(Boolean);
|
|
const emailCandidates = [
|
|
authUser && (authUser.email || authUser.userEmail || authUser.username),
|
|
rawUserData && (rawUserData.email || rawUserData.userEmail)
|
|
].flat().filter(Boolean);
|
|
|
|
// helper to normalize db.execute result (works with mysql2/other adapters)
|
|
const readRows = (res) => {
|
|
if (!res) return [];
|
|
if (Array.isArray(res) && Array.isArray(res[0])) return res[0];
|
|
if (Array.isArray(res)) return res;
|
|
return res;
|
|
};
|
|
|
|
// --- personal_profiles lookup (existing behavior) ---
|
|
let prof = null;
|
|
for (const uid of idCandidates) {
|
|
try {
|
|
const result = await db.execute('SELECT * FROM personal_profiles WHERE user_id = ? LIMIT 1', [uid]);
|
|
const rows = readRows(result);
|
|
if (rows && rows[0]) { prof = rows[0]; logger.debug(`[generatePdfWithSignature] Found personal_profiles by user_id=${uid}`); break; }
|
|
} catch (ignored) { /* continue to next */ }
|
|
}
|
|
if (!prof) {
|
|
for (const em of emailCandidates) {
|
|
try {
|
|
const result = await db.execute('SELECT * FROM personal_profiles WHERE email = ? LIMIT 1', [em]);
|
|
const rows = readRows(result);
|
|
if (rows && rows[0]) { prof = rows[0]; logger.debug(`[generatePdfWithSignature] Found personal_profiles by email=${em}`); break; }
|
|
} catch (ignored) { /* continue */ }
|
|
}
|
|
}
|
|
if (prof) {
|
|
if (!userData.address) {
|
|
userData.address = pick(prof, ['address','street','street_address','streetAddress','address_line1','addressLine1','addr','personal_address']) || userData.address || '';
|
|
}
|
|
if (!userData.zip_code) {
|
|
userData.zip_code = pick(prof, ['zip','zip_code','postal_code','postalCode']) || userData.zip_code || '';
|
|
}
|
|
if (!userData.city) {
|
|
userData.city = pick(prof, ['city','town','municipality']) || userData.city || '';
|
|
}
|
|
if (!userData.personalAddress) {
|
|
userData.personalAddress = pick(prof, ['personal_address','personalAddress']) || userData.personalAddress || '';
|
|
}
|
|
if (!userData.phone) {
|
|
userData.phone = pick(prof, ['phone','telephone','mobile','contact_phone','contactPhone']) || userData.phone || '';
|
|
}
|
|
if (!userData.fullName) {
|
|
userData.fullName = pick(prof, ['full_name','fullName','name']) || userData.fullName || '';
|
|
if (!userData.fullName) {
|
|
const fn = pick(prof, ['first_name','firstName']);
|
|
const ln = pick(prof, ['last_name','lastName']);
|
|
if (fn || ln) userData.fullName = `${fn || ''} ${ln || ''}`.trim();
|
|
}
|
|
}
|
|
userData.fullAddress = pick(rawUserData, ['fullAddress','full_address']) ||
|
|
pick(authUser, ['fullAddress','full_address']) ||
|
|
pick(prof, ['full_address','fullAddress']) ||
|
|
(userData.address ? `${userData.address}${userData.zip_code || userData.city ? ', ' : ''}${userData.zip_code ? userData.zip_code + ' ' : ''}${userData.city || ''}`.trim() : (userData.personalAddress || '')) || '';
|
|
logger.debug(`[generatePdfWithSignature] Filled missing userData from personal_profiles for profile id=${prof.id || prof.user_id || 'unknown'}`);
|
|
}
|
|
|
|
// --- company_profiles lookup (UnitOfWork) for company-related placeholders if still missing ---
|
|
const companyFieldsMissing = (
|
|
!userData.companyName ||
|
|
!userData.registrationNumber ||
|
|
!userData.companyAddress ||
|
|
!userData.address || // also check generic address used in templates
|
|
!userData.zip_code ||
|
|
!userData.city ||
|
|
!userData.country ||
|
|
!userData.contactPersonName ||
|
|
!userData.contactPersonPhone
|
|
);
|
|
if (companyFieldsMissing && (idCandidates.length || emailCandidates.length)) {
|
|
let uow;
|
|
try {
|
|
uow = new UnitOfWork();
|
|
await uow.start();
|
|
let comp = null;
|
|
// Try by id candidates
|
|
for (const uid of idCandidates) {
|
|
try {
|
|
const [rows] = await uow.connection.execute('SELECT * FROM company_profiles WHERE user_id = ? OR company_id = ? LIMIT 1', [uid, uid]);
|
|
if (rows && rows[0]) { comp = rows[0]; logger.debug(`[generatePdfWithSignature] Found company_profiles by id=${uid}`); break; }
|
|
} catch (ignored) { /* continue */ }
|
|
}
|
|
// Try by email if not found
|
|
if (!comp) {
|
|
for (const em of emailCandidates) {
|
|
try {
|
|
// 1) Try join: company_profiles matched to users by users.email (best case)
|
|
try {
|
|
const [joinRows] = await uow.connection.execute(
|
|
'SELECT cp.* FROM company_profiles cp JOIN users u ON cp.user_id = u.id WHERE u.email = ? LIMIT 1',
|
|
[em]
|
|
);
|
|
if (joinRows && joinRows[0]) {
|
|
comp = joinRows[0];
|
|
logger.debug(`[generatePdfWithSignature] Found company_profiles by join users.email=${em}`);
|
|
break;
|
|
}
|
|
} catch (eJoin) {
|
|
// non-fatal, continue to next strategy
|
|
// console.debug(`[generatePdfWithSignature] join lookup failed for ${em}`, eJoin && eJoin.message);
|
|
}
|
|
|
|
// 2) Resolve users.id by email, then find company_profiles by user_id/company_id
|
|
try {
|
|
const [uRows] = await uow.connection.execute('SELECT id FROM users WHERE email = ? LIMIT 1', [em]);
|
|
const userRow = (uRows && uRows[0]) ? uRows[0] : null;
|
|
if (userRow && userRow.id) {
|
|
const [rows] = await uow.connection.execute(
|
|
'SELECT * FROM company_profiles WHERE user_id = ? OR company_id = ? LIMIT 1',
|
|
[userRow.id, userRow.id]
|
|
);
|
|
if (rows && rows[0]) {
|
|
comp = rows[0];
|
|
logger.debug(`[generatePdfWithSignature] Found company_profiles by users.id=${userRow.id} (email=${em})`);
|
|
break;
|
|
}
|
|
}
|
|
} catch (eResolve) {
|
|
// non-fatal
|
|
// console.warn('[generatePdfWithSignature] resolve user id failed', eResolve && eResolve.message);
|
|
}
|
|
|
|
// 3) Fallback: try common email columns on company_profiles (safe tries inside try/catch)
|
|
const fallbackCols = ['company_email','contact_email','email'];
|
|
for (const col of fallbackCols) {
|
|
try {
|
|
const q = `SELECT * FROM company_profiles WHERE ${col} = ? LIMIT 1`;
|
|
const [rows2] = await uow.connection.execute(q, [em]);
|
|
if (rows2 && rows2[0]) {
|
|
comp = rows2[0];
|
|
logger.debug(`[generatePdfWithSignature] Found company_profiles by ${col}=${em}`);
|
|
break;
|
|
}
|
|
} catch (ignored2) {
|
|
// column might not exist or other minor issue - continue to next fallback
|
|
}
|
|
}
|
|
if (comp) break;
|
|
} catch (ignored) {
|
|
// continue to next email candidate
|
|
}
|
|
}
|
|
}
|
|
if (comp) {
|
|
// Primary company fields
|
|
const compName = pick(comp, ['company_name','companyName','name','company']);
|
|
const compReg = pick(comp, ['registration_number','registrationNumber','registrationNo']);
|
|
const compAddr = pick(comp, ['address','company_address','street','streetAddress']);
|
|
const compZip = pick(comp, ['zip','zip_code','postal_code','postcode']);
|
|
const compCity = pick(comp, ['city','town']);
|
|
const compCountry = pick(comp, ['country','country_code','countryName']);
|
|
const compContactName = pick(comp, ['contact_person_name','contactPersonName','contactName','contact']);
|
|
const compContactPhone = pick(comp, ['contact_person_phone','contactPersonPhone','phone','telephone','mobile']);
|
|
const compEmail = pick(comp, ['email','company_email','contact_email']);
|
|
// ADDED: possible generic phone field
|
|
const compGenericPhone = pick(comp, ['phone','telephone','mobile']);
|
|
|
|
if (!userData.companyName && compName) userData.companyName = compName;
|
|
if (!userData.registrationNumber && compReg) userData.registrationNumber = compReg;
|
|
if (!userData.companyAddress && compAddr) userData.companyAddress = compAddr;
|
|
// Also populate the generic address key used in many templates
|
|
if ((!userData.address || String(userData.address).trim() === '') && compAddr) userData.address = compAddr;
|
|
if (!userData.zip_code && compZip) userData.zip_code = compZip;
|
|
if (!userData.city && compCity) userData.city = compCity;
|
|
// populate country & contact phone (generic)
|
|
if (!userData.country && compCountry) userData.country = compCountry;
|
|
if (!userData.contactPersonName && compContactName) userData.contactPersonName = compContactName;
|
|
if (!userData.contactPersonPhone && compContactPhone) userData.contactPersonPhone = compContactPhone;
|
|
// Mirror contact phone into generic phone too (helps templates using phone)
|
|
if ((!userData.phone || String(userData.phone).trim() === '') && compContactPhone) userData.phone = compContactPhone;
|
|
// Fill email if missing
|
|
if ((!userData.email || String(userData.email).trim() === '') && compEmail) userData.email = compEmail;
|
|
|
|
// ALSO populate company-prefixed keys so templates using companyXXX placeholders are satisfied
|
|
if (compName) userData.companyCompanyName = compName;
|
|
if (compReg) userData.companyRegistrationNumber = compReg;
|
|
if (compAddr) userData.companyAddress = compAddr; // already set but ensure presence
|
|
if (compZip) userData.companyZipCode = compZip;
|
|
if (compCity) userData.companyCity = compCity;
|
|
if (compCountry) userData.companyCountry = compCountry;
|
|
if (compContactName) userData.companyContactPersonName = compContactName;
|
|
if (compContactPhone) userData.companyContactPersonPhone = compContactPhone;
|
|
|
|
// ADDED: fill required placeholders
|
|
if (!userData.companyEmail && compEmail) userData.companyEmail = compEmail;
|
|
if (!userData.companyPhone && (compContactPhone || compGenericPhone))
|
|
userData.companyPhone = compContactPhone || compGenericPhone;
|
|
|
|
// ADDED: compute companyFullAddress if not provided
|
|
if (!userData.companyFullAddress) {
|
|
const addrParts = [];
|
|
if (compAddr) addrParts.push(compAddr);
|
|
const zipCity = [compZip, compCity].filter(Boolean).join(' ');
|
|
if (zipCity) addrParts.push(zipCity);
|
|
userData.companyFullAddress = addrParts.join(', ');
|
|
}
|
|
// Fallback: if still empty try any supplied raw/full address fields
|
|
if (!userData.companyFullAddress) {
|
|
userData.companyFullAddress =
|
|
pick(rawUserData, ['companyFullAddress','company_full_address','fullAddress','full_address']) ||
|
|
pick(authUser, ['companyFullAddress','company_full_address']) || '';
|
|
}
|
|
|
|
// Safe debug log of which company-derived keys we populated
|
|
const populated = [];
|
|
if (compName) populated.push('companyName');
|
|
if (compReg) populated.push('registrationNumber');
|
|
if (compAddr) populated.push('companyAddress/address');
|
|
if (compZip) populated.push('zip_code/companyZipCode');
|
|
if (compCity) populated.push('city/companyCity');
|
|
if (compCountry) populated.push('country/companyCountry');
|
|
if (compContactName) populated.push('contactPersonName/companyContactPersonName');
|
|
if (compContactPhone) populated.push('contactPersonPhone/companyContactPersonPhone');
|
|
if (compEmail) populated.push('email/companyEmail');
|
|
if (userData.companyFullAddress) populated.push('companyFullAddress'); // ADDED
|
|
if (userData.companyEmail) populated.push('companyEmail'); // ADDED
|
|
if (userData.companyPhone) populated.push('companyPhone'); // ADDED
|
|
logger.debug(`[generatePdfWithSignature] Filled missing company userData from company_profiles id=${comp.id || comp.company_id || 'unknown'} - populated: ${populated.join(',')}`);
|
|
}
|
|
await uow.commit();
|
|
} catch (errU) {
|
|
if (uow) {
|
|
try { await uow.rollback(errU); } catch (e) { logger.error('[generatePdfWithSignature] rollback error', e); }
|
|
}
|
|
logger.error('[generatePdfWithSignature] Error fetching company_profiles via UnitOfWork', errU && errU.stack ? errU.stack : errU);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logger.error('[generatePdfWithSignature] Error fetching personal_profiles/company_profiles from DB', err && err.stack ? err.stack : err);
|
|
}
|
|
|
|
logger.debug(`[generatePdfWithSignature] Resolved userData keys=${Object.keys(userData).join(',')} values=${JSON.stringify(userData)}`);
|
|
logger.debug(`[generatePdfWithSignature] Received signatureImage length=${signatureImage ? String(signatureImage).length : 0}`);
|
|
const template = await DocumentTemplateService.getTemplate(id);
|
|
if (!template) {
|
|
logger.error(`[generatePdfWithSignature] Template not found for id=${id}`);
|
|
return res.status(404).json({ error: 'Template not found' });
|
|
}
|
|
const userType = req.user?.user_type || req.user?.userType;
|
|
if (!userType) {
|
|
logger.warn('[generatePdfWithSignature] unauthorized_missing_user', { id });
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
if (template.state !== 'active') {
|
|
logger.warn('[generatePdfWithSignature] template_inactive', { id, state: template.state });
|
|
return res.status(403).json({ error: 'Template inactive' });
|
|
}
|
|
if (template.user_type !== 'both' && template.user_type !== userType) {
|
|
logger.warn('[generatePdfWithSignature] template_user_type_mismatch', { id, templateUserType: template.user_type, userType });
|
|
return res.status(403).json({ error: 'Template not allowed for this user type' });
|
|
}
|
|
|
|
// Get HTML content from S3
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
let html = '';
|
|
try {
|
|
logger.debug(`[generatePdfWithSignature] Fetching HTML from S3: key=${template.storageKey}`);
|
|
const command = new GetObjectCommand({
|
|
Bucket: process.env.EXOSCALE_BUCKET,
|
|
Key: template.storageKey
|
|
});
|
|
const fileObj = await s3.send(command);
|
|
if (fileObj.Body) {
|
|
html = await streamToString(fileObj.Body, id);
|
|
logger.debug(`[generatePdfWithSignature] HTML fetched, length=${html.length}`);
|
|
} else {
|
|
logger.error(`[generatePdfWithSignature] S3 fileObj.Body is empty`);
|
|
}
|
|
} catch (e) {
|
|
logger.error(`[generatePdfWithSignature] Error fetching template file from S3:`, e && e.stack ? e.stack : e);
|
|
return res.status(500).json({ error: 'Failed to fetch template file' });
|
|
}
|
|
|
|
// If client requested a preview/sanitized PDF, and this is a contract, sanitize placeholders (opt-in)
|
|
const previewFlagSigned = req.query && (req.query.preview === '1' || req.query.sanitize === '1');
|
|
if (previewFlagSigned && template.type === 'contract') {
|
|
logger.debug('[generatePdfWithSignature] preview flag detected - sanitizing placeholders before rendering signed PDF', { id });
|
|
html = sanitizePlaceholders(html);
|
|
// Still replace currentDate into sanitized output so previews show a date
|
|
try {
|
|
html = html.replace(/{{\s*currentDate\s*}}/g, formattedCurrentDate || formatDateTime());
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// Insert userData into HTML (simple replace, adjust as needed)
|
|
if (userData && typeof userData === 'object') {
|
|
Object.entries(userData).forEach(([key, value]) => {
|
|
const beforeCount = (html.match(new RegExp(`{{\\s*${key}\\s*}}`, 'g')) || []).length;
|
|
html = html.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), value);
|
|
const afterCount = (html.match(new RegExp(`{{\\s*${key}\\s*}}`, 'g')) || []).length;
|
|
if (beforeCount || afterCount) {
|
|
logger.debug(`[generatePdfWithSignature] replaced ${key}: before=${beforeCount} after=${afterCount}`);
|
|
}
|
|
});
|
|
logger.debug(`[generatePdfWithSignature] User data replaced in HTML`);
|
|
}
|
|
|
|
// Replace {{signatureImage}} with <img src="...">
|
|
if (signatureImage) {
|
|
let imgSrc = signatureImage;
|
|
if (!String(imgSrc).startsWith('data:image/')) {
|
|
// assume raw base64 -> wrap
|
|
imgSrc = `data:image/png;base64,${imgSrc}`;
|
|
}
|
|
logger.debug(`[generatePdfWithSignature] signature image validated length=${imgSrc.length}`);
|
|
html = html.replace(/{{\s*signatureImage\s*}}/g, `<img src="${imgSrc}" style="max-width:200px;max-height:100px;">`);
|
|
logger.debug(`[generatePdfWithSignature] Signature image injected into HTML`);
|
|
} else {
|
|
html = html.replace(/{{\s*signatureImage\s*}}/g, '');
|
|
logger.debug(`[generatePdfWithSignature] No signature image provided`);
|
|
}
|
|
|
|
// NEW: apply company stamp placeholders
|
|
html = await applyCompanyStampPlaceholders(html, req);
|
|
html = await applyProfitPlanetSignature(html); // NEW
|
|
|
|
if (DEBUG_PDF_FILES) {
|
|
saveDebugFile(`template_${id}_signed_html.html`, ensureHtmlDocument(html));
|
|
logger.debug(`[generatePdfWithSignature] Signed HTML saved for inspection`);
|
|
}
|
|
|
|
// Use puppeteer to render HTML and generate PDF
|
|
try {
|
|
const browser = await puppeteer.launch({
|
|
headless: "new",
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
|
});
|
|
const page = await browser.newPage();
|
|
|
|
// Instrument page
|
|
page.on('console', msg => {
|
|
try { logger.debug(`[PUPPETEER][console][signed:${id}] ${msg.type()}: ${msg.text()}`); } catch (e) {}
|
|
});
|
|
page.on('pageerror', err => logger.error(`[PUPPETEER][pageerror][signed:${id}]`, err && err.stack ? err.stack : err));
|
|
page.on('requestfailed', req => logger.warn(`[PUPPETEER][requestfailed][signed:${id}] url=${req.url()} failure=${req.failure()?.errorText}`));
|
|
|
|
const htmlDoc = ensureHtmlDocument(html);
|
|
logger.debug(`[generatePdfWithSignature] Final signed HTML length=${htmlDoc.length} headSnippet=${htmlDoc.slice(0,200).replace(/\n/g,' ')}`);
|
|
await page.setContent(htmlDoc, { waitUntil: 'networkidle0', timeout: 60000 });
|
|
|
|
if (DEBUG_PDF_FILES) {
|
|
await page.screenshot({ path: path.join(ensureDebugDir(), `template_${id}_signed_render.png`), fullPage: true });
|
|
logger.debug(`[generatePdfWithSignature] Saved signed render screenshot`);
|
|
}
|
|
|
|
const pdfBuffer = await page.pdf({ format: 'A4' });
|
|
await browser.close();
|
|
|
|
logger.info(`[generatePdfWithSignature] Generated signed PDF size=${pdfBuffer.length} bytes`);
|
|
if (DEBUG_PDF_FILES) saveDebugFile(`template_${id}_signed.pdf`, pdfBuffer, null);
|
|
|
|
// Log incoming request headers (helpful to troubleshoot transport)
|
|
try {
|
|
logger.debug(`[generatePdfWithSignature] request headers: Accept=${req.get('accept')} Referer=${req.get('referer')} Origin=${req.get('origin')}`);
|
|
} catch (e) {}
|
|
|
|
// Send using helper
|
|
return sendPdfBuffer(res, pdfBuffer, `template_${id}_signed.pdf`, `template_${id}_signed`);
|
|
} catch (err) {
|
|
logger.error(`[generatePdfWithSignature] Error generating PDF:`, err && err.stack ? err.stack : err);
|
|
res.status(500).json({ error: 'Fehler beim Generieren des Vertrags.' });
|
|
}
|
|
};
|
|
|
|
// UPDATED helper: now delegates to service (queries removed from controller) + deep logs
|
|
async function applyProfitPlanetSignature(html) {
|
|
if (!html) {
|
|
logger.debug('[applyProfitPlanetSignature] html empty/undefined');
|
|
return html;
|
|
}
|
|
const placeholderMatches = html.match(/{{\s*profitplanetSignature\s*}}/gi) || [];
|
|
if (!placeholderMatches.length) {
|
|
logger.debug('[applyProfitPlanetSignature] no placeholder found');
|
|
return html;
|
|
}
|
|
logger.debug('[applyProfitPlanetSignature] placeholder(s) detected', { count: placeholderMatches.length });
|
|
|
|
const companyIdEnv = parseInt(process.env.PROFITPLANET_COMPANY_ID || '0', 10);
|
|
const companyId = Number.isFinite(companyIdEnv) && companyIdEnv > 0 ? companyIdEnv : null;
|
|
|
|
try {
|
|
const svcStart = Date.now();
|
|
const { tag, reason } = await DocumentTemplateService.getProfitPlanetSignatureTag({
|
|
companyId,
|
|
maxW: 300,
|
|
maxH: 300
|
|
});
|
|
const svcMs = Date.now() - svcStart;
|
|
logger.debug('[applyProfitPlanetSignature] service response', {
|
|
reason,
|
|
companyId: companyId || 'n/a',
|
|
tagLen: tag ? tag.length : 0,
|
|
tagHead: tag ? tag.slice(0, 60) : ''
|
|
});
|
|
|
|
let newHtml;
|
|
if (!tag) {
|
|
newHtml = html.replace(/{{\s*profitplanetSignature\s*}}/gi, '');
|
|
const remaining = (newHtml.match(/{{\s*profitplanetSignature\s*}}/gi) || []).length;
|
|
logger.debug('[applyProfitPlanetSignature] removed placeholder(s)', { reason, remaining, service_ms: svcMs });
|
|
return newHtml;
|
|
}
|
|
|
|
newHtml = html.replace(/{{\s*profitplanetSignature\s*}}/gi, tag);
|
|
const remaining = (newHtml.match(/{{\s*profitplanetSignature\s*}}/gi) || []).length;
|
|
logger.debug('[applyProfitPlanetSignature] replaced placeholder(s)', { insertedLen: tag.length, remaining, service_ms: svcMs });
|
|
return newHtml;
|
|
} catch (e) {
|
|
logger.error('[applyProfitPlanetSignature] service error', { msg: e.message, stack: e.stack?.split('\n')[0] });
|
|
const cleaned = html.replace(/{{\s*profitplanetSignature\s*}}/gi, '');
|
|
const remaining = (cleaned.match(/{{\s*profitplanetSignature\s*}}/gi) || []).length;
|
|
logger.debug('[applyProfitPlanetSignature] fallback removal after error', { remaining });
|
|
return cleaned;
|
|
}
|
|
}
|
|
|
|
// NEW helper: apply company stamp placeholders (single definition)
|
|
async function applyCompanyStampPlaceholders(html, req) {
|
|
try {
|
|
if (!html) return html;
|
|
if (!/{{\s*companyStamp(?:Inline|Small)?\s*}}/i.test(html)) return html;
|
|
const user = req.user;
|
|
if (!user || user.user_type !== 'company') {
|
|
logger.debug('[applyCompanyStampPlaceholders] user not company or missing user -> removing');
|
|
return html.replace(/{{\s*companyStamp(?:Inline|Small)?\s*}}/gi, '');
|
|
}
|
|
const companyId = user.id || user.userId;
|
|
const stamp = await CompanyStampService.getActiveStampForCompany(companyId);
|
|
if (!stamp) {
|
|
logger.debug('[applyCompanyStampPlaceholders] no active stamp', { companyId });
|
|
return html.replace(/{{\s*companyStamp(?:Inline|Small)?\s*}}/gi, '');
|
|
}
|
|
logger.debug('[applyCompanyStampPlaceholders] stamp found', {
|
|
companyId,
|
|
mime: stamp.mime_type,
|
|
imgLen: stamp.image_base64 ? stamp.image_base64.length : 0
|
|
});
|
|
const dataUri = `data:${stamp.mime_type};base64,${stamp.image_base64}`;
|
|
const mainTag = `<img src="${dataUri}" style="max-width:300px;max-height:300px;">`;
|
|
const inlineTag = `<img src="${dataUri}" style="height:60px;display:inline-block;vertical-align:middle;">`;
|
|
const smallTag = `<img src="${dataUri}" style="height:120px;max-width:120px;">`;
|
|
const replaced = html
|
|
.replace(/{{\s*companyStamp\s*}}/gi, mainTag)
|
|
.replace(/{{\s*companyStampInline\s*}}/gi, inlineTag)
|
|
.replace(/{{\s*companyStampSmall\s*}}/gi, smallTag);
|
|
const remaining = (replaced.match(/{{\s*companyStamp(?:Inline|Small)?\s*}}/gi) || []).length;
|
|
logger.debug('[applyCompanyStampPlaceholders] replacement done', { remaining });
|
|
return replaced;
|
|
} catch (e) {
|
|
logger.error('[applyCompanyStampPlaceholders] error', { msg: e.message });
|
|
return html.replace(/{{\s*companyStamp(?:Inline|Small)?\s*}}/gi, '');
|
|
}
|
|
}
|
|
|
|
// NEW endpoint: serve a sanitized preview HTML from backend (avoids S3 CORS). Uses same sanitize logic and will
|
|
// inject allowed stamps/signature if available for the requesting user.
|
|
exports.previewTemplate = async (req, res) => {
|
|
const id = req.params.id;
|
|
try {
|
|
const template = await DocumentTemplateService.getTemplate(id);
|
|
if (!template) return res.status(404).send('Not found');
|
|
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
|
|
const command = new GetObjectCommand({
|
|
Bucket: process.env.EXOSCALE_BUCKET,
|
|
Key: template.storageKey
|
|
});
|
|
const fileObj = await s3.send(command);
|
|
let html = '';
|
|
if (fileObj.Body) {
|
|
html = await streamToString(fileObj.Body, id);
|
|
} else {
|
|
logger.warn('[previewTemplate] S3 returned empty body', { id, key: template.storageKey });
|
|
return res.status(404).send('Preview not available');
|
|
}
|
|
|
|
// sanitize for contract templates (opt-out: non-contract keep placeholders)
|
|
if (template.type === 'contract') {
|
|
html = sanitizePlaceholders(html);
|
|
// keep current date for preview UX
|
|
html = html.replace(/{{\s*currentDate\s*}}/g, formatDateTime());
|
|
}
|
|
|
|
// Apply company stamp & profitplanet signature for richer preview when possible
|
|
try { html = await applyCompanyStampPlaceholders(html, req); } catch (e) { logger.warn('[previewTemplate] applyCompanyStampPlaceholders failed', e && e.message); }
|
|
try { html = await applyProfitPlanetSignature(html); } catch (e) { logger.warn('[previewTemplate] applyProfitPlanetSignature failed', e && e.message); }
|
|
|
|
// Return sanitized HTML with correct content-type (CORS already handled by your server middleware)
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
return res.send(ensureHtmlDocument(html));
|
|
} catch (err) {
|
|
logger.error('[previewTemplate] error', { err: err && err.stack ? err.stack : err });
|
|
return res.status(500).send('Preview error');
|
|
}
|
|
};
|
|
|
|
// NEW endpoint: render sanitized preview as PDF and return binary
|
|
exports.previewPdf = async (req, res) => {
|
|
const id = req.params.id;
|
|
logger.info(`[previewPdf] Start for templateId=${id}`);
|
|
try {
|
|
const template = await DocumentTemplateService.getTemplate(id);
|
|
if (!template) return res.status(404).json({ error: 'Template not found' });
|
|
|
|
// Fetch HTML from S3
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
|
|
const command = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: template.storageKey });
|
|
const fileObj = await s3.send(command);
|
|
if (!fileObj.Body) {
|
|
logger.warn('[previewPdf] empty S3 body', { id, key: template.storageKey });
|
|
return res.status(404).json({ error: 'Preview not available' });
|
|
}
|
|
let html = await streamToString(fileObj.Body, id);
|
|
|
|
// Sanitize for contract previews (opt-in policy used for listing previews)
|
|
if (template.type === 'contract') {
|
|
html = sanitizePlaceholders(html);
|
|
html = html.replace(/{{\s*currentDate\s*}}/g, formatDateTime());
|
|
}
|
|
|
|
// Apply stamps/signature placeholders where appropriate (harmless for sanitized content)
|
|
try { html = await applyCompanyStampPlaceholders(html, req); } catch (e) { logger.warn('[previewPdf] applyCompanyStampPlaceholders failed', e && e.message); }
|
|
try { html = await applyProfitPlanetSignature(html); } catch (e) { logger.warn('[previewPdf] applyProfitPlanetSignature failed', e && e.message); }
|
|
|
|
if (DEBUG_PDF_FILES) saveDebugFile(`template_${id}_preview_html.html`, ensureHtmlDocument(html));
|
|
|
|
// Render PDF server-side
|
|
const browser = await puppeteer.launch({ headless: "new", args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
|
const page = await browser.newPage();
|
|
page.on('console', msg => { try { logger.debug(`[PUPPETEER][preview:${id}] ${msg.type()}: ${msg.text()}`); } catch (e) {} });
|
|
await page.setContent(ensureHtmlDocument(html), { waitUntil: 'networkidle0', timeout: 60000 });
|
|
const pdfBuffer = await page.pdf({ format: 'A4' });
|
|
await browser.close();
|
|
|
|
if (DEBUG_PDF_FILES) saveDebugFile(`template_${id}_preview.pdf`, pdfBuffer, null);
|
|
logger.info(`[previewPdf] Generated preview PDF for template ${id} size=${pdfBuffer.length} bytes`);
|
|
|
|
return sendPdfBuffer(res, pdfBuffer, `template_${id}_preview.pdf`, `template_${id}_preview`);
|
|
} catch (err) {
|
|
logger.error('[previewPdf] error', err && err.stack ? err.stack : err);
|
|
return res.status(500).json({ error: 'Failed to generate preview PDF' });
|
|
}
|
|
};
|
|
|
|
// NEW: Admin-only endpoint to preview latest active contract for a specific user with DB-filled placeholders
|
|
// GET /api/admin/contracts/:id/preview?userType=personal|company&type=contract
|
|
exports.previewLatestForUser = async (req, res) => {
|
|
const targetUserId = parseInt(req.params.id, 10);
|
|
const overrideUserType = (req.query.userType || req.query.user_type || '').toString().toLowerCase();
|
|
const templateType = (req.query.type || 'contract').toString();
|
|
|
|
if (!req.user || !['admin', 'super_admin'].includes(req.user.role)) {
|
|
return res.status(403).json({ error: 'Forbidden: Admins only' });
|
|
}
|
|
if (!Number.isFinite(targetUserId) || targetUserId <= 0) {
|
|
return res.status(400).json({ error: 'Invalid user id' });
|
|
}
|
|
|
|
try {
|
|
// Resolve target user and determine user_type if not overridden
|
|
let userRow = null;
|
|
try {
|
|
const [uRows] = await db.execute('SELECT id, email, user_type, role FROM users WHERE id = ? LIMIT 1', [targetUserId]);
|
|
userRow = (uRows && uRows[0]) ? uRows[0] : null;
|
|
} catch (e) {
|
|
logger.error('[previewLatestForUser] failed to load users row', e && e.message);
|
|
}
|
|
if (!userRow) {
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
|
|
const userType = (overrideUserType === 'personal' || overrideUserType === 'company') ? overrideUserType : userRow.user_type;
|
|
|
|
// Find the latest active template for this user type
|
|
const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, templateType);
|
|
if (!latest) {
|
|
return res.status(404).json({ error: 'No active template found for user type' });
|
|
}
|
|
|
|
// Fetch template HTML from S3
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
const command = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: latest.storageKey });
|
|
const fileObj = await s3.send(command);
|
|
if (!fileObj.Body) {
|
|
return res.status(404).json({ error: 'Template file not available' });
|
|
}
|
|
let html = await streamToString(fileObj.Body, latest.id);
|
|
|
|
// Build variable map from DB for target user
|
|
const vars = {};
|
|
// Always include email and currentDate
|
|
vars.email = userRow.email || '';
|
|
const now = new Date();
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
vars.currentDate = `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
|
|
// Personal profile fields
|
|
if (userType === 'personal') {
|
|
try {
|
|
const [pRows] = await db.execute('SELECT * FROM personal_profiles WHERE user_id = ? LIMIT 1', [targetUserId]);
|
|
const p = (pRows && pRows[0]) ? pRows[0] : null;
|
|
if (p) {
|
|
const first = p.first_name || '';
|
|
const last = p.last_name || '';
|
|
const fullName = `${first} ${last}`.trim();
|
|
vars.fullName = fullName;
|
|
vars.address = p.address || '';
|
|
vars.zip_code = p.zip_code || '';
|
|
vars.city = p.city || '';
|
|
vars.country = p.country || '';
|
|
vars.phone = p.phone || '';
|
|
const fullAddressParts = [];
|
|
if (vars.address) fullAddressParts.push(vars.address);
|
|
const zipCity = [vars.zip_code, vars.city].filter(Boolean).join(' ');
|
|
if (zipCity) fullAddressParts.push(zipCity);
|
|
vars.fullAddress = fullAddressParts.join(', ');
|
|
}
|
|
} catch (e) {
|
|
logger.warn('[previewLatestForUser] load personal_profiles failed', e && e.message);
|
|
}
|
|
}
|
|
|
|
// Company profile fields
|
|
if (userType === 'company') {
|
|
try {
|
|
const [cRows] = await db.execute('SELECT * FROM company_profiles WHERE user_id = ? LIMIT 1', [targetUserId]);
|
|
const c = (cRows && cRows[0]) ? cRows[0] : null;
|
|
if (c) {
|
|
vars.companyName = c.company_name || '';
|
|
vars.registrationNumber = c.registration_number || '';
|
|
vars.companyAddress = c.address || '';
|
|
// generic address keys used by some templates
|
|
vars.address = vars.companyAddress;
|
|
vars.zip_code = c.zip_code || '';
|
|
vars.city = c.city || '';
|
|
vars.country = c.country || '';
|
|
vars.contactPersonName = c.contact_person_name || '';
|
|
vars.contactPersonPhone = c.contact_person_phone || c.phone || '';
|
|
vars.companyEmail = c.email || c.company_email || c.contact_email || userRow.email || '';
|
|
vars.companyPhone = c.phone || c.contact_person_phone || '';
|
|
const addrParts = [];
|
|
if (vars.companyAddress) addrParts.push(vars.companyAddress);
|
|
const zipCity = [vars.zip_code, vars.city].filter(Boolean).join(' ');
|
|
if (zipCity) addrParts.push(zipCity);
|
|
vars.companyFullAddress = addrParts.join(', ');
|
|
|
|
// Ensure template-prefixed company placeholders are populated
|
|
vars.companyCompanyName = vars.companyName;
|
|
vars.companyRegistrationNumber = vars.registrationNumber;
|
|
vars.companyZipCode = vars.zip_code;
|
|
vars.companyCity = vars.city;
|
|
}
|
|
} catch (e) {
|
|
logger.warn('[previewLatestForUser] load company_profiles failed', e && e.message);
|
|
}
|
|
}
|
|
|
|
// Replace placeholders with resolved variables (simple global replace)
|
|
if (vars && typeof vars === 'object') {
|
|
Object.entries(vars).forEach(([key, value]) => {
|
|
const before = (html.match(new RegExp(`{{\\s*${key}\\s*}}`, 'g')) || []).length;
|
|
if (before) {
|
|
html = html.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), String(value ?? ''));
|
|
}
|
|
});
|
|
}
|
|
|
|
// Apply stamp/signature placeholders (harmless if placeholders are absent)
|
|
try { html = await applyCompanyStampPlaceholders(html, { ...req, user: { ...userRow, user_type: userType } }); } catch (e) { logger.warn('[previewLatestForUser] applyCompanyStampPlaceholders failed', e && e.message); }
|
|
try { html = await applyProfitPlanetSignature(html); } catch (e) { logger.warn('[previewLatestForUser] applyProfitPlanetSignature failed', e && e.message); }
|
|
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
return res.send(ensureHtmlDocument(html));
|
|
} catch (err) {
|
|
logger.error('[previewLatestForUser] error', err && err.stack ? err.stack : err);
|
|
return res.status(500).json({ error: 'Failed to render preview' });
|
|
}
|
|
};
|
|
|
|
// NEW: Authenticated user endpoint to preview their own latest active contract with DB-filled placeholders
|
|
// GET /api/contracts/preview/latest
|
|
exports.previewLatestForMe = async (req, res) => {
|
|
if (!req.user) return res.status(401).json({ error: 'Unauthorized' });
|
|
const targetUserId = req.user.id || req.user.userId;
|
|
const userType = req.user.user_type || req.user.userType;
|
|
if (!targetUserId || !userType) return res.status(400).json({ error: 'Invalid authenticated user' });
|
|
|
|
try {
|
|
// Find the latest active template for this user type
|
|
const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract');
|
|
if (!latest) {
|
|
return res.status(404).json({ error: 'No active template found for your user type' });
|
|
}
|
|
|
|
// Fetch template HTML from storage
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
const command = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: latest.storageKey });
|
|
const fileObj = await s3.send(command);
|
|
if (!fileObj.Body) return res.status(404).json({ error: 'Template file not available' });
|
|
let html = await streamToString(fileObj.Body, latest.id);
|
|
|
|
// Build variables from the authenticated user's DB data
|
|
const vars = {};
|
|
// base fields
|
|
vars.email = req.user.email || '';
|
|
const d = new Date();
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
vars.currentDate = `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
|
|
if (userType === 'personal') {
|
|
try {
|
|
const [rows] = await db.execute('SELECT * FROM personal_profiles WHERE user_id = ? LIMIT 1', [targetUserId]);
|
|
const p = rows && rows[0] ? rows[0] : null;
|
|
if (p) {
|
|
const fullName = `${p.first_name || ''} ${p.last_name || ''}`.trim();
|
|
vars.fullName = fullName;
|
|
vars.address = p.address || '';
|
|
vars.zip_code = p.zip_code || '';
|
|
vars.city = p.city || '';
|
|
vars.country = p.country || '';
|
|
vars.phone = p.phone || '';
|
|
const parts = [];
|
|
if (vars.address) parts.push(vars.address);
|
|
const zipCity = [vars.zip_code, vars.city].filter(Boolean).join(' ');
|
|
if (zipCity) parts.push(zipCity);
|
|
vars.fullAddress = parts.join(', ');
|
|
}
|
|
} catch (e) {
|
|
logger.warn('[previewLatestForMe] personal profile lookup failed', e && e.message);
|
|
}
|
|
} else if (userType === 'company') {
|
|
try {
|
|
const [rows] = await db.execute('SELECT * FROM company_profiles WHERE user_id = ? LIMIT 1', [targetUserId]);
|
|
const c = rows && rows[0] ? rows[0] : null;
|
|
if (c) {
|
|
vars.companyName = c.company_name || '';
|
|
vars.registrationNumber = c.registration_number || '';
|
|
vars.companyAddress = c.address || '';
|
|
vars.address = vars.companyAddress;
|
|
vars.zip_code = c.zip_code || '';
|
|
vars.city = c.city || '';
|
|
vars.country = c.country || '';
|
|
vars.contactPersonName = c.contact_person_name || '';
|
|
vars.contactPersonPhone = c.contact_person_phone || c.phone || '';
|
|
vars.companyEmail = c.email || c.company_email || c.contact_email || req.user.email || '';
|
|
vars.companyPhone = c.phone || c.contact_person_phone || '';
|
|
const parts = [];
|
|
if (vars.companyAddress) parts.push(vars.companyAddress);
|
|
const zipCity = [vars.zip_code, vars.city].filter(Boolean).join(' ');
|
|
if (zipCity) parts.push(zipCity);
|
|
vars.companyFullAddress = parts.join(', ');
|
|
|
|
// Ensure template-prefixed company placeholders are populated
|
|
vars.companyCompanyName = vars.companyName;
|
|
vars.companyRegistrationNumber = vars.registrationNumber;
|
|
vars.companyZipCode = vars.zip_code;
|
|
vars.companyCity = vars.city;
|
|
}
|
|
} catch (e) {
|
|
logger.warn('[previewLatestForMe] company profile lookup failed', e && e.message);
|
|
}
|
|
}
|
|
|
|
// Replace placeholders
|
|
Object.entries(vars).forEach(([k, v]) => {
|
|
html = html.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v ?? ''));
|
|
});
|
|
|
|
// Apply company stamp and signature where applicable
|
|
try { html = await applyCompanyStampPlaceholders(html, req); } catch (e) {}
|
|
try { html = await applyProfitPlanetSignature(html); } catch (e) {}
|
|
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
return res.send(ensureHtmlDocument(html));
|
|
} catch (err) {
|
|
logger.error('[previewLatestForMe] error', err && err.stack ? err.stack : err);
|
|
return res.status(500).json({ error: 'Failed to render preview' });
|
|
}
|
|
};
|
|
|
|
// NEW controller: download sanitized PDF (variables emptied)
|
|
// Keeps stamp/signature placeholders so company stamps or profitplanetSignature can still be applied if available.
|
|
exports.downloadPdf = async (req, res) => {
|
|
const id = req.params.id;
|
|
logger.info(`[downloadPdf] Start for templateId=${id}`);
|
|
try {
|
|
const template = await DocumentTemplateService.getTemplate(id);
|
|
if (!template) {
|
|
logger.warn(`[downloadPdf] Template not found id=${id}`);
|
|
return res.status(404).json({ error: 'Template not found' });
|
|
}
|
|
|
|
const userType = req.user?.user_type || req.user?.userType;
|
|
if (!userType) {
|
|
logger.warn('[downloadPdf] unauthorized_missing_user', { id });
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
if (template.state !== 'active') {
|
|
logger.warn('[downloadPdf] template_inactive', { id, state: template.state });
|
|
return res.status(403).json({ error: 'Template inactive' });
|
|
}
|
|
if (template.user_type !== 'both' && template.user_type !== userType) {
|
|
logger.warn('[downloadPdf] template_user_type_mismatch', { id, templateUserType: template.user_type, userType });
|
|
return res.status(403).json({ error: 'Template not allowed for this user type' });
|
|
}
|
|
|
|
// Fetch HTML from S3
|
|
const s3 = new S3Client({
|
|
region: process.env.EXOSCALE_REGION,
|
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
|
|
}
|
|
});
|
|
const command = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: template.storageKey });
|
|
const fileObj = await s3.send(command);
|
|
if (!fileObj.Body) {
|
|
logger.warn('[downloadPdf] empty S3 body', { id, key: template.storageKey });
|
|
return res.status(404).json({ error: 'Template file not available' });
|
|
}
|
|
let html = await streamToString(fileObj.Body, id);
|
|
|
|
// SANITIZE: remove variables for downloaded PDF.
|
|
// Allow only stamp/signature placeholders to remain so images can still be injected.
|
|
html = sanitizePlaceholders(html, ['companyStamp','companyStampInline','companyStampSmall','profitplanetSignature']);
|
|
// Do NOT keep currentDate for download (user requested variables emptied)
|
|
|
|
// Apply company stamp & profitplanet signature (these placeholders were preserved above)
|
|
try { html = await applyCompanyStampPlaceholders(html, req); } catch (e) { logger.warn('[downloadPdf] applyCompanyStampPlaceholders failed', e && e.message); }
|
|
try { html = await applyProfitPlanetSignature(html); } catch (e) { logger.warn('[downloadPdf] applyProfitPlanetSignature failed', e && e.message); }
|
|
|
|
if (DEBUG_PDF_FILES) saveDebugFile(`template_${id}_download_html.html`, ensureHtmlDocument(html));
|
|
|
|
// Render to PDF server-side
|
|
const browser = await puppeteer.launch({ headless: "new", args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
|
const page = await browser.newPage();
|
|
page.on('console', msg => { try { logger.debug(`[PUPPETEER][download:${id}] ${msg.type()}: ${msg.text()}`); } catch (e) {} });
|
|
await page.setContent(ensureHtmlDocument(html), { waitUntil: 'networkidle0', timeout: 60000 });
|
|
const pdfBuffer = await page.pdf({ format: 'A4' });
|
|
await browser.close();
|
|
|
|
if (DEBUG_PDF_FILES) saveDebugFile(`template_${id}_download.pdf`, pdfBuffer, null);
|
|
logger.info(`[downloadPdf] Generated download PDF for template ${id} size=${pdfBuffer.length} bytes`);
|
|
|
|
return sendPdfBuffer(res, pdfBuffer, `template_${id}_download.pdf`, `template_${id}_download`);
|
|
} catch (err) {
|
|
logger.error('[downloadPdf] error', err && err.stack ? err.stack : err);
|
|
return res.status(500).json({ error: 'Failed to generate download PDF' });
|
|
}
|
|
};
|