const DocumentTemplateService = require('../../services/template/DocumentTemplateService'); const ContractUploadService = require('../../services/contracts/ContractUploadService'); const { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, CopyObjectCommand, DeleteObjectCommand } = 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'); const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader'); // 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 { let folderStructureWarning = null; 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 }; } // Hardened reader for S3 bodies (Node stream, async iterable, web stream, or Buffer) async function s3BodyToBuffer(body) { if (!body) return null; if (typeof body.transformToByteArray === 'function') { const arr = await body.transformToByteArray(); return Buffer.from(arr); } if (typeof body.getReader === 'function') { const reader = body.getReader(); const chunks = []; // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(Buffer.from(value)); } return Buffer.concat(chunks); } if (body[Symbol.asyncIterator]) { const chunks = []; for await (const chunk of body) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return Buffer.concat(chunks); } if (typeof body.on === 'function') { return new Promise((resolve, reject) => { const chunks = []; body.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); body.on('end', () => resolve(Buffer.concat(chunks))); body.on('error', reject); }); } if (Buffer.isBuffer(body)) return body; if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength); if (body instanceof ArrayBuffer) return Buffer.from(body); throw new Error('Unsupported S3 Body type'); } // 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); } }); } async function listAllKeys(s3Client, prefix) { const keys = []; let token = undefined; do { const res = await s3Client.send(new ListObjectsV2Command({ Bucket: process.env.EXOSCALE_BUCKET, Prefix: prefix, ContinuationToken: token })); const batch = (res && res.Contents ? res.Contents : []) .map(item => item && item.Key) .filter(Boolean); keys.push(...batch); token = res && res.IsTruncated ? res.NextContinuationToken : undefined; } while (token); return keys; } function getTimestamp() { const d = new Date(); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } function appendTimestampToKey(key) { const ext = path.posix.extname(key); const base = ext ? key.slice(0, -ext.length) : key; return `${base}-${getTimestamp()}${ext}`; } async function objectExists(s3Client, key) { try { const { HeadObjectCommand } = require('@aws-sdk/client-s3'); await s3Client.send(new HeadObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key })); return true; } catch (e) { const status = e && e.$metadata && e.$metadata.httpStatusCode; if (status === 404) return false; throw e; } } function formatS3Error(err) { if (!err) return null; if (typeof err === 'string') return { message: err }; return { name: err.name, message: err.message, code: err.code, stack: err.stack, metadata: err.$metadata, cause: err.cause && (err.cause.message || err.cause) }; } // 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 rawContractType = req.body.contract_type || req.body.contractType; 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' }); const allowedContractTypes = ['contract', 'gdpr']; const contract_type = (type === 'contract' && allowedContractTypes.includes(rawContractType)) ? rawContractType : (type === 'contract' ? 'contract' : null); // 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, contract_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 rawContractType = req.body.contract_type || req.body.contractType; 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' }); const nextType = type !== undefined ? type : current.type; const allowedContractTypes = ['contract', 'gdpr']; let contract_type = null; if (nextType === 'contract') { const candidate = rawContractType !== undefined ? rawContractType : current.contract_type; if (candidate && allowedContractTypes.includes(candidate)) { contract_type = candidate; } else { contract_type = 'contract'; } } // 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: nextType, contract_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); }; // NEW: revise a template by creating a NEW template record (version bump) and deactivating the previous one. // Route suggestion: POST /document-templates/:id/revise (multipart/form-data with 'file') exports.reviseTemplate = async (req, res) => { const previousId = req.params.id; const { name, type, description, lang } = req.body; const rawUserType = req.body.user_type || req.body.userType; const rawContractType = req.body.contract_type || req.body.contractType; const requestedState = req.body.state; // optional: 'active' | 'inactive' const file = req.file; if (!file) return res.status(400).json({ error: 'No file uploaded' }); const previous = await DocumentTemplateService.getTemplate(previousId); if (!previous) return res.status(404).json({ error: 'Template not found' }); const nextType = type !== undefined ? type : previous.type; const nextLang = lang !== undefined ? lang : previous.lang; if (!nextLang || !['en', 'de'].includes(nextLang)) return res.status(400).json({ error: 'Invalid or missing language' }); const allowedUserTypes = ['personal', 'company', 'both']; const user_type = allowedUserTypes.includes(rawUserType) ? rawUserType : (previous.user_type || 'both'); const allowedContractTypes = ['contract', 'gdpr']; let contract_type = null; if (nextType === 'contract') { const candidate = rawContractType !== undefined ? rawContractType : previous.contract_type; contract_type = allowedContractTypes.includes(candidate) ? candidate : 'contract'; } // Use "english" for en, "german" for de const langFolder = nextLang === 'en' ? 'english' : 'german'; const 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 payload = { name: name !== undefined ? name : previous.name, type: nextType, contract_type, storageKey, description: description !== undefined ? description : previous.description, lang: nextLang, user_type, ...(requestedState !== undefined ? { state: requestedState } : {}) }; const created = await DocumentTemplateService.reviseTemplate(previousId, payload); if (!created) return res.status(404).json({ error: 'Template not found' }); const enriched = await enrichTemplate(created, s3); res.status(201).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: '' }; // Map company-prefixed placeholders from raw request early (helps when no company_profile row exists) const rawCompanyName = pick(rawUserData, ['companyCompanyName','companyName','company_name','company']); const rawCompanyReg = pick(rawUserData, ['companyRegistrationNumber','registrationNumber','registration_number','registrationNo']); const rawCompanyAddr = pick(rawUserData, ['companyAddress','company_address','address','streetAddress']); const rawCompanyZip = pick(rawUserData, ['companyZipCode','company_zip','zip','zip_code','postalCode','postal_code']); const rawCompanyCity = pick(rawUserData, ['companyCity','city','town']); const rawCompanyEmail = pick(rawUserData, ['companyEmail','company_email','contact_email','email']); const rawCompanyPhone = pick(rawUserData, ['companyPhone','company_phone','contactPhone','contact_phone','phone','telephone','mobile']); const rawCompanyContactName = pick(rawUserData, ['companyContactPersonName','contactPersonName','contact_person_name','contactPerson','contactName','contact']); const rawCompanyContactPhone = pick(rawUserData, ['companyContactPersonPhone','contactPersonPhone','contact_person_phone','contactPhone','contact_phone','phone','telephone','mobile']); userData.companyCompanyName = userData.companyCompanyName || rawCompanyName || userData.companyName || ''; userData.companyRegistrationNumber = userData.companyRegistrationNumber || rawCompanyReg || userData.registrationNumber || ''; userData.companyAddress = userData.companyAddress || rawCompanyAddr || userData.address || ''; userData.companyZipCode = userData.companyZipCode || rawCompanyZip || userData.zip_code || ''; userData.companyCity = userData.companyCity || rawCompanyCity || userData.city || ''; userData.companyEmail = userData.companyEmail || rawCompanyEmail || userData.email || ''; userData.companyPhone = userData.companyPhone || rawCompanyPhone || userData.contactPersonPhone || userData.phone || ''; userData.companyContactPersonName = userData.companyContactPersonName || rawCompanyContactName || userData.contactPersonName || ''; userData.companyContactPersonPhone = userData.companyContactPersonPhone || rawCompanyContactPhone || userData.contactPersonPhone || ''; // 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; // Derive fullName from company contact if still missing (helps company contracts) if (!userData.fullName) { const contactName = rawCompanyContactName || rawCompanyName || userData.contactPersonName || userData.companyContactPersonName; if (contactName) userData.fullName = contactName; } // 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) { // Use DB values as canonical when present userData.address = pick(prof, ['address','street','street_address','streetAddress','address_line1','addressLine1','addr','personal_address']) || userData.address || ''; userData.zip_code = pick(prof, ['zip','zip_code','postal_code','postalCode']) || userData.zip_code || ''; userData.city = pick(prof, ['city','town','municipality']) || userData.city || ''; userData.personalAddress = pick(prof, ['personal_address','personalAddress']) || userData.personalAddress || ''; userData.phone = pick(prof, ['phone','telephone','mobile','contact_phone','contactPhone','phone_secondary']) || userData.phone || ''; const fn = pick(prof, ['full_name','fullName','name','first_name','firstName']); const ln = pick(prof, ['last_name','lastName']); if (fn || ln) userData.fullName = `${fn || ''} ${ln || ''}`.trim(); if (!userData.fullName) userData.fullName = pick(prof, ['full_name','fullName','name']) || userData.fullName || ''; // Compose full address primarily from DB columns const addrParts = []; if (userData.address) addrParts.push(userData.address); const zipCity = [userData.zip_code, userData.city].filter(Boolean).join(' '); if (zipCity) addrParts.push(zipCity); userData.fullAddress = addrParts.join(', ') || userData.fullAddress || userData.personalAddress || ''; logger.debug(`[generatePdfWithSignature] Applied personal_profiles data 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) { // Prefer DB values directly const compName = pick(comp, ['company_name']); const compReg = pick(comp, ['registration_number']); const compAddr = pick(comp, ['address']); const compZip = pick(comp, ['zip_code']); const compCity = pick(comp, ['city']); const compCountry = pick(comp, ['country']); const compContactName = pick(comp, ['contact_person_name']); const compContactPhone = pick(comp, ['contact_person_phone']); const compPhone = pick(comp, ['phone']); if (compName) { userData.companyName = compName; userData.companyCompanyName = compName; } if (compReg) { userData.registrationNumber = compReg; userData.companyRegistrationNumber = compReg; } if (compAddr) { userData.companyAddress = compAddr; userData.address = userData.address || compAddr; } if (compZip) { userData.companyZipCode = compZip; userData.zip_code = userData.zip_code || compZip; } if (compCity) { userData.companyCity = compCity; userData.city = userData.city || compCity; } if (compCountry) { userData.companyCountry = compCountry; userData.country = userData.country || compCountry; } if (compContactName) { userData.companyContactPersonName = compContactName; userData.contactPersonName = userData.contactPersonName || compContactName; } if (compContactPhone) { userData.companyContactPersonPhone = compContactPhone; userData.contactPersonPhone = userData.contactPersonPhone || compContactPhone; } if (compPhone) { userData.companyPhone = compPhone; userData.phone = userData.phone || compPhone; } // Compose company full address from DB fields const addrParts = []; if (compAddr) addrParts.push(compAddr); const zipCity = [compZip, compCity].filter(Boolean).join(' '); if (zipCity) addrParts.push(zipCity); const composed = addrParts.join(', '); if (composed) userData.companyFullAddress = composed; // Keep email fallback behavior (no email column in company_profiles) logger.debug(`[generatePdfWithSignature] Applied company_profiles data id=${comp.id || comp.company_id || 'unknown'}`); } 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 */ } } // Final fallbacks for composed address fields (still prefer DB values set above) if (!userData.fullAddress) { const addrParts = []; if (userData.address) addrParts.push(userData.address); const zipCity = [userData.zip_code, userData.city].filter(Boolean).join(' '); if (zipCity) addrParts.push(zipCity); userData.fullAddress = addrParts.join(', '); } if (!userData.companyFullAddress) { const addrParts = []; if (userData.companyAddress) addrParts.push(userData.companyAddress); const zipCity = [userData.companyZipCode, userData.companyCity].filter(Boolean).join(' '); if (zipCity) addrParts.push(zipCity); userData.companyFullAddress = addrParts.join(', '); } // 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}`); } }); const remainingPlaceholders = html.match(/{{\s*([^}\s]+)\s*}}/g) || []; if (remainingPlaceholders.length) { logger.debug('[generatePdfWithSignature] Unreplaced placeholders detected', { count: remainingPlaceholders.length, samples: remainingPlaceholders.slice(0, 5) }); } 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 the user's uploaded/signed contract (contract or gdpr) from object storage only // GET /api/admin/contracts/:id/preview?userType=personal|company&contract_type=contract|gdpr exports.previewLatestForUser = async (req, res) => { const targetUserId = parseInt(req.params.id, 10); const contractTypeParam = (req.query.contract_type || req.query.contractType || '').toString().toLowerCase(); const allowedContractTypes = ['contract', 'gdpr']; const contractType = allowedContractTypes.includes(contractTypeParam) ? contractTypeParam : 'contract'; const documentId = parseInt((req.query.documentId || req.query.document_id || '').toString(), 10); const objectKeyParam = (req.query.objectKey || req.query.object_key || '').toString(); let folderStructureWarning = null; logger.info('[previewLatestForUser] start', { targetUserId, contractType, requestId: req.id }); 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 (only for logging and email fallback if needed) 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); } const userTypeParam = (req.query.userType || req.query.user_type || '').toString().toLowerCase(); const userType = ['personal', 'company'].includes(userTypeParam) ? userTypeParam : ((userRow && userRow.user_type) ? String(userRow.user_type).toLowerCase() : 'personal'); const contractCategory = userType === 'company' ? 'company' : 'personal'; // Check if there are files in the user's Exoscale contracts folder but no contract/gdpr subfolders. try { const s3ForList = sharedExoscaleClient || new S3Client({ region: process.env.EXOSCALE_REGION, endpoint: process.env.EXOSCALE_ENDPOINT, forcePathStyle: true, credentials: { accessKeyId: process.env.EXOSCALE_ACCESS_KEY, secretAccessKey: process.env.EXOSCALE_SECRET_KEY } }); const basePrefix = `contracts/${contractCategory}/${targetUserId}/`; const listResp = await s3ForList.send(new ListObjectsV2Command({ Bucket: process.env.EXOSCALE_BUCKET, Prefix: basePrefix, MaxKeys: 200 })); const keys = (listResp && listResp.Contents ? listResp.Contents : []) .map(item => item && item.Key) .filter(Boolean); if (keys.length > 0) { const hasContractFolder = keys.some(k => k.startsWith(`${basePrefix}contract/`)); const hasGdprFolder = keys.some(k => k.startsWith(`${basePrefix}gdpr/`)); if (!hasContractFolder && !hasGdprFolder) { folderStructureWarning = 'Admin user has to clean up and move the files in exoscale folder'; logger.error('[previewLatestForUser] contract folder structure invalid: Admin user has to clean up and move the files in exoscale folder', { userId: targetUserId, contractCategory, prefix: basePrefix, sampleKeys: keys.slice(0, 10) }); } } } catch (e) { logger.warn('[previewLatestForUser] contract folder structure check failed', e && (e.stack || e.message)); } const jsonWithWarning = (payload) => { if (!folderStructureWarning) return payload; return { ...payload, warning: folderStructureWarning }; }; // Fetch a specific document by id (if provided), by objectKey, otherwise latest by contractType let doc = null; let resolvedContractType = contractType; if (Number.isFinite(documentId) && documentId > 0) { try { const [rows] = await db.execute( `SELECT id, object_storage_id, contract_type FROM user_documents WHERE id = ? AND user_id = ? LIMIT 1`, [documentId, targetUserId] ); const arr = Array.isArray(rows) ? rows : (rows ? [rows] : []); doc = arr[0] || null; if (doc && doc.contract_type && allowedContractTypes.includes(String(doc.contract_type).toLowerCase())) { resolvedContractType = String(doc.contract_type).toLowerCase(); } else if (doc && doc.object_storage_id && /\/gdpr\//i.test(doc.object_storage_id)) { resolvedContractType = 'gdpr'; } logger.info('[previewLatestForUser] user_documents lookup by documentId', { targetUserId, documentId, resolvedContractType, found: !!doc }); } catch (e) { logger.warn('[previewLatestForUser] user_documents lookup by documentId failed', e && (e.stack || e.message)); } } else if (objectKeyParam) { const key = objectKeyParam; const basePrefix = `contracts/${contractCategory}/${targetUserId}/`; if (!key.startsWith(basePrefix)) { return res.status(400).json({ error: 'Invalid object key' }); } if (key.includes('/gdpr/')) resolvedContractType = 'gdpr'; else if (key.includes('/contract/')) resolvedContractType = 'contract'; doc = { object_storage_id: key }; logger.info('[previewLatestForUser] using objectKey directly', { targetUserId, resolvedContractType, key }); } else { // Choose document_type set based on contractType (aligned with ContractUploadService paths) // uploadContract stores under contracts/// with document_type 'contract' // so use contract_type column to disambiguate between contract vs gdpr const docTypesMap = { contract: ['contract', 'signed_contract', 'contract_pdf', 'signed_contract_pdf'], gdpr: ['contract', 'signed_contract', 'contract_pdf', 'signed_contract_pdf'] }; const docTypes = docTypesMap[contractType] || docTypesMap.contract; const placeholders = docTypes.map(() => '?').join(','); try { const [rows] = await db.execute( `SELECT object_storage_id FROM user_documents WHERE user_id = ? AND document_type IN (${placeholders}) AND object_storage_id IS NOT NULL AND (contract_type = ? OR (contract_type IS NULL AND ? = 'contract')) ORDER BY upload_at DESC, id DESC LIMIT 1`, [targetUserId, ...docTypes, contractType, contractType] ); const arr = Array.isArray(rows) ? rows : (rows ? [rows] : []); doc = arr[0] || null; logger.info('[previewLatestForUser] user_documents lookup', { targetUserId, contractType, count: arr.length }); } catch (e) { logger.warn('[previewLatestForUser] user_documents lookup failed', e && (e.stack || e.message)); } } const activeContractType = resolvedContractType || contractType; if (!doc || !doc.object_storage_id) { if (folderStructureWarning) res.setHeader('X-Contract-Preview-Warning', folderStructureWarning); return res.status(404).json(jsonWithWarning({ message: `No uploaded ${activeContractType.toUpperCase()} file found for this user` })); } try { const createClient = () => sharedExoscaleClient || new S3Client({ region: process.env.EXOSCALE_REGION, endpoint: process.env.EXOSCALE_ENDPOINT, forcePathStyle: true, credentials: { accessKeyId: process.env.EXOSCALE_ACCESS_KEY, secretAccessKey: process.env.EXOSCALE_SECRET_KEY } }); let s3File = createClient(); logger.info('[previewLatestForUser] attempting S3 fetch', { bucket: process.env.EXOSCALE_BUCKET, key: doc.object_storage_id, userId: targetUserId, contractType: activeContractType }); const cmd = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: doc.object_storage_id }); let fileObj; try { fileObj = await s3File.send(cmd); } catch (firstErr) { logger.warn('[previewLatestForUser] S3 fetch failed (shared client), retrying with fresh client', { key: doc.object_storage_id, userId: targetUserId, contractType, error: formatS3Error(firstErr) }); s3File = new S3Client({ region: process.env.EXOSCALE_REGION, endpoint: process.env.EXOSCALE_ENDPOINT, forcePathStyle: true, credentials: { accessKeyId: process.env.EXOSCALE_ACCESS_KEY, secretAccessKey: process.env.EXOSCALE_SECRET_KEY } }); fileObj = await s3File.send(cmd); } const pdfBuffer = await s3BodyToBuffer(fileObj.Body); if (!pdfBuffer || !pdfBuffer.length) { logger.warn('[previewLatestForUser] S3 returned empty Body', { key: doc.object_storage_id, userId: targetUserId, contractType: activeContractType }); if (folderStructureWarning) res.setHeader('X-Contract-Preview-Warning', folderStructureWarning); return res.status(404).json(jsonWithWarning({ message: `${activeContractType.toUpperCase()} file not available` })); } const b64 = pdfBuffer.toString('base64'); const html = ensureHtmlDocument(` ${resolvedContractType.toUpperCase()} Preview `); res.setHeader('Content-Type', 'text/html; charset=utf-8'); if (folderStructureWarning) res.setHeader('X-Contract-Preview-Warning', folderStructureWarning); return res.send(html); } catch (e) { if (e && (e.name === 'NoSuchKey' || (e.$metadata && e.$metadata.httpStatusCode === 404))) { logger.warn('[previewLatestForUser] object missing in storage', { key: doc.object_storage_id, userId: targetUserId, contractType: activeContractType }); if (folderStructureWarning) res.setHeader('X-Contract-Preview-Warning', folderStructureWarning); return res.status(404).json(jsonWithWarning({ message: `${activeContractType.toUpperCase()} file not available` })); } logger.error('[previewLatestForUser] S3 fetch failed', { key: doc.object_storage_id, userId: targetUserId, contractType: activeContractType, error: formatS3Error(e) }); if (folderStructureWarning) res.setHeader('X-Contract-Preview-Warning', folderStructureWarning); return res.status(500).json(jsonWithWarning({ message: 'Failed to load user document' })); } } catch (err) { logger.error('[previewLatestForUser] error', err && err.stack ? err.stack : err); return res.status(500).json({ error: 'Failed to render preview' }); } }; // NEW: Admin-only endpoint to list contract files from object storage for a user // GET /api/admin/contracts/:id/files?userType=personal|company exports.listUserContractFiles = async (req, res) => { const targetUserId = parseInt(req.params.id, 10); 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 { const [uRows] = await db.execute('SELECT id, user_type FROM users WHERE id = ? LIMIT 1', [targetUserId]); const userRow = (uRows && uRows[0]) ? uRows[0] : null; const userType = (userRow && userRow.user_type) ? String(userRow.user_type).toLowerCase() : null; const contractCategory = userType === 'company' ? 'company' : 'personal'; const s3Client = sharedExoscaleClient || new S3Client({ region: process.env.EXOSCALE_REGION, endpoint: process.env.EXOSCALE_ENDPOINT, forcePathStyle: true, credentials: { accessKeyId: process.env.EXOSCALE_ACCESS_KEY, secretAccessKey: process.env.EXOSCALE_SECRET_KEY } }); const basePrefix = `contracts/${contractCategory}/${targetUserId}/`; const contractPrefix = `${basePrefix}contract/`; const gdprPrefix = `${basePrefix}gdpr/`; const [contractKeysRaw, gdprKeysRaw, docRows] = await Promise.all([ listAllKeys(s3Client, contractPrefix), listAllKeys(s3Client, gdprPrefix), db.execute( `SELECT id, object_storage_id, contract_type FROM user_documents WHERE user_id = ? AND document_type = 'contract'`, [targetUserId] ) ]); const normalizeKeys = (keys) => (Array.isArray(keys) ? keys : []) .filter(k => !!k && !k.endsWith('/')) .filter(k => /\.pdf$/i.test(String(k))); const contractKeys = normalizeKeys(contractKeysRaw); const gdprKeys = normalizeKeys(gdprKeysRaw); const rows = Array.isArray(docRows && docRows[0]) ? docRows[0] : []; const idMap = new Map(rows.map(r => [String(r.object_storage_id), r])); const toItem = (key) => { const doc = idMap.get(String(key)); return { key, filename: path.posix.basename(key), documentId: doc ? doc.id : null, contract_type: doc ? doc.contract_type : null }; }; return res.json({ userType, contract: contractKeys.map(toItem), gdpr: gdprKeys.map(toItem) }); } catch (err) { logger.error('[listUserContractFiles] error', err && err.stack ? err.stack : err); return res.status(500).json({ error: 'Failed to list user contract files' }); } }; // NEW: Admin-only endpoint to list contract documents for a user // GET /api/admin/contracts/:id/documents?userType=personal|company exports.listUserContractDocuments = async (req, res) => { const targetUserId = parseInt(req.params.id, 10); 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 { let userRow = null; try { const [uRows] = await db.execute('SELECT id, email, user_type FROM users WHERE id = ? LIMIT 1', [targetUserId]); userRow = (uRows && uRows[0]) ? uRows[0] : null; } catch (e) { logger.warn('[listUserContractDocuments] failed to load users row', e && e.message); } const [rows] = await db.execute( `SELECT id, user_id, document_type, contract_type, object_storage_id, original_filename, file_size, mime_type, upload_at FROM user_documents WHERE user_id = ? AND document_type = 'contract' ORDER BY upload_at DESC, id DESC`, [targetUserId] ); const docs = (Array.isArray(rows) ? rows : []).map((d) => { const key = d.object_storage_id || ''; let folderType = 'loose'; if (/\/gdpr\//i.test(key)) folderType = 'gdpr'; else if (/\/contract\//i.test(key)) folderType = 'contract'; return { ...d, folderType }; }); return res.json({ userType: userRow ? userRow.user_type : null, documents: docs }); } catch (err) { logger.error('[listUserContractDocuments] error', err && err.stack ? err.stack : err); return res.status(500).json({ error: 'Failed to list user contract documents' }); } }; // NEW: Admin-only endpoint to move a contract document between /contract and /gdpr // POST /api/admin/contracts/:id/move { documentId, targetType } exports.moveUserContractDocument = async (req, res) => { const targetUserId = parseInt(req.params.id, 10); const { documentId, targetType, objectKey } = req.body || {}; const allowedTypes = ['contract', 'gdpr']; const target = (targetType || '').toString().toLowerCase(); 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' }); } if ((!Number.isFinite(documentId) || documentId <= 0) && !objectKey) { return res.status(400).json({ error: 'Invalid document id' }); } if (!allowedTypes.includes(target)) { return res.status(400).json({ error: 'Invalid target type' }); } try { const [uRows] = await db.execute('SELECT id, user_type FROM users WHERE id = ? LIMIT 1', [targetUserId]); const userRow = (uRows && uRows[0]) ? uRows[0] : null; const userType = (userRow && userRow.user_type) ? String(userRow.user_type).toLowerCase() : null; const contractCategory = userType === 'company' ? 'company' : 'personal'; let currentKey = null; if (objectKey) { const key = String(objectKey); const basePrefix = `contracts/${contractCategory}/${targetUserId}/`; if (!key.startsWith(basePrefix) || (!key.includes('/contract/') && !key.includes('/gdpr/'))) { return res.status(400).json({ error: 'Invalid object key' }); } currentKey = key; } else { const [rows] = await db.execute( `SELECT id, object_storage_id, contract_type FROM user_documents WHERE id = ? AND user_id = ? LIMIT 1`, [documentId, targetUserId] ); const doc = (rows && rows[0]) ? rows[0] : null; if (!doc || !doc.object_storage_id) { return res.status(404).json({ error: 'Document not found' }); } currentKey = String(doc.object_storage_id); } const fileName = path.posix.basename(currentKey); const basePrefix = `contracts/${contractCategory}/${targetUserId}/`; let destKey = `${basePrefix}${target}/${fileName}`; const s3Client = sharedExoscaleClient || new S3Client({ region: process.env.EXOSCALE_REGION, endpoint: process.env.EXOSCALE_ENDPOINT, forcePathStyle: true, credentials: { accessKeyId: process.env.EXOSCALE_ACCESS_KEY, secretAccessKey: process.env.EXOSCALE_SECRET_KEY } }); if (currentKey !== destKey) { try { if (await objectExists(s3Client, destKey)) { destKey = appendTimestampToKey(destKey); } } catch (e) { logger.warn('[moveUserContractDocument] objectExists failed', { destKey, error: formatS3Error(e) }); } const copySource = `${process.env.EXOSCALE_BUCKET}/${encodeURIComponent(currentKey)}`; await s3Client.send(new CopyObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, CopySource: copySource, Key: destKey })); await s3Client.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: currentKey })); } await db.execute( `UPDATE user_documents SET object_storage_id = ?, contract_type = ? WHERE object_storage_id = ?`, [destKey, target, currentKey] ); return res.json({ success: true, documentId, from: currentKey, to: destKey, contract_type: target }); } catch (err) { logger.error('[moveUserContractDocument] error', err && err.stack ? err.stack : err); return res.status(500).json({ error: 'Failed to move document' }); } }; // 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 || '').toString().toLowerCase(); if (!targetUserId || !userType) return res.status(400).json({ error: 'Invalid authenticated user' }); const contractTypeParam = (req.query.contract_type || req.query.contractType || '').toString().toLowerCase(); const allowedContractTypes = ['contract', 'gdpr']; const contractType = allowedContractTypes.includes(contractTypeParam) ? contractTypeParam : 'contract'; try { // Find the latest active template for this user type const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', contractType); if (!latest) { logger.info('[previewLatestForMe] no active template', { userId: targetUserId, userType, contractType }); // Return 200 with empty body so clients can show a friendly empty state. return res.status(200).send(''); } // 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 using the same logic as contract upload const uow = new UnitOfWork(); await uow.start(); const vars = await ContractUploadService.buildTemplateVars({ userId: targetUserId, user_type: userType, contractData: {}, unitOfWork: uow }); await uow.commit(); // Replace placeholders Object.entries(vars).forEach(([k, v]) => { html = html.replace(new RegExp(`{{\s*${k}\s*}}`, 'g'), String(v ?? '')); }); // Show a friendly placeholder for signature in preview (not signed yet) html = html.replace(/{{\s*signatureImage\s*}}/g, 'Your signature will appear here'); // Remove any remaining placeholders except stamp/signature markers html = sanitizePlaceholders(html, ['companyStamp','companyStampInline','companyStampSmall','profitplanetSignature']); // Log any remaining placeholders to aid debugging const remaining = html.match(/{{\s*([^}\s]+)\s*}}/g) || []; if (remaining.length) { logger.debug('[previewLatestForMe] unreplaced placeholders', { count: remaining.length, samples: remaining.slice(0, 5) }); } // 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' }); } };