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' }); } };