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) {
// CHANGED: route path must match getRoutes.js
previewUrl = `${serverBaseUrl}/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 (/[\s\S]*<\/html>/i.test(html)) return html;
// Otherwise, wrap it
return `
Contract PDF
${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
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, `
`);
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 = `
`;
const inlineTag = `
`;
const smallTag = `
`;
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 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' });
}
};