const UserDocumentRepository = require('../../repositories/documents/UserDocumentRepository'); const { uploadBuffer, s3: exoS3 } = require('../../utils/exoscaleUploader'); const PDFDocument = require('pdfkit'); const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'); const DocumentTemplateService = require('../template/DocumentTemplateService'); const CompanyStampService = require('../stamp/company/CompanyStampService'); const db = require('../../database/database'); const fs = require('fs'); const path = require('path'); const { logger } = require('../../middleware/logger'); const puppeteer = require('puppeteer'); // Robust stream/Body -> Buffer reader (supports async iterable, web streams, node streams, buffers) async function streamToBuffer(body) { if (!body) return Buffer.alloc(0); 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 body type for streamToBuffer'); } function fillTemplate(template, data) { return template.replace(/{{\s*(\w+)\s*}}/g, (_, key) => data[key] || ''); } // Build placeholder variables by combining DB profile data with any contractData overrides (for both personal/company). async function buildTemplateVars({ userId, user_type, contractData = {}, unitOfWork }) { const vars = {}; const setIfEmpty = (key, val) => { if (val === undefined || val === null) return; const str = String(val).trim(); if (!str) return; if (!vars[key] || String(vars[key]).trim() === '') { vars[key] = str; } }; // Initialize known placeholders to empty so replacement removes them if no value exists [ 'fullName','address','zip_code','city','country','phone','fullAddress','email', 'companyFullAddress','companyName','registrationNumber','companyAddress','companyZipCode','companyCity','companyEmail','companyPhone', 'contactPersonName','contactPersonPhone','companyCompanyName','companyRegistrationNumber','currentDate' ].forEach((k) => { vars[k] = ''; }); // Current date placeholder (matches preview controller format) 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())}`; // Load base user row for email fallback try { const [userRows] = await unitOfWork.connection.query('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]); const u = Array.isArray(userRows) ? userRows[0] : userRows; if (u) { setIfEmpty('email', u.email); setIfEmpty('fullName', `${u.first_name || ''} ${u.last_name || ''}`); setIfEmpty('address', u.address || u.street || u.street_address); setIfEmpty('zip_code', u.zip_code || u.zip || u.postal_code || u.postalCode); setIfEmpty('city', u.city || u.town); setIfEmpty('country', u.country); setIfEmpty('phone', u.phone || u.phone_secondary || u.mobile); } } catch (ignored) {} if (user_type === 'personal') { try { const [rows] = await unitOfWork.connection.query('SELECT * FROM personal_profiles WHERE user_id = ? LIMIT 1', [userId]); const p = Array.isArray(rows) ? rows[0] : rows; if (p) { setIfEmpty('fullName', `${p.first_name || ''} ${p.last_name || ''}`); setIfEmpty('address', p.address); setIfEmpty('zip_code', p.zip_code); setIfEmpty('city', p.city); setIfEmpty('country', p.country); setIfEmpty('phone', p.phone || p.phone_secondary); setIfEmpty('fullAddress', p.full_address); } } catch (ignored) {} } else if (user_type === 'company') { try { const [rows] = await unitOfWork.connection.query('SELECT * FROM company_profiles WHERE user_id = ? LIMIT 1', [userId]); const c = Array.isArray(rows) ? rows[0] : rows; 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 || vars.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(', '); // Prefixed variants used in templates vars.companyCompanyName = vars.companyName; vars.companyRegistrationNumber = vars.registrationNumber; vars.companyZipCode = vars.zip_code; vars.companyCity = vars.city; // Display name fallback for signature blocks setIfEmpty('fullName', vars.contactPersonName || vars.companyName); } } catch (ignored) {} } // Build fullAddress if still empty if (!vars.fullAddress) { 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(', '); } // Overlay contractData to allow request-provided values to win if (contractData && typeof contractData === 'object') { Object.entries(contractData).forEach(([key, value]) => { if (value === undefined || value === null) return; // Ignore nested objects like confirmations if (typeof value === 'object') return; vars[key] = String(value); }); } return vars; } const DEBUG_PDF_FILES = !!process.env.DEBUG_PDF_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) { if (!DEBUG_PDF_FILES) return; const p = path.join(ensureDebugDir(), filename); try { fs.writeFileSync(p, data); console.log(`[ContractUploadService][DEBUG] wrote ${p}`); } catch (e) { console.error('[ContractUploadService][DEBUG] failed to write', p, e); } } // Minimal stream reader supporting AWS SDK Body async function streamToString(stream, id) { const chunks = []; let total = 0; if (stream[Symbol.asyncIterator]) { for await (const chunk of stream) { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); chunks.push(buf); total += buf.length; } } else { for await (const chunk of stream) { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); chunks.push(buf); total += buf.length; } } const buffer = Buffer.concat(chunks); if (DEBUG_PDF_FILES || total < 32) saveDebugFile(`contract_template_${id}_html_raw.bin`, buffer); console.log(`[ContractUploadService] read template ${id} bytes=${total}`); return buffer.toString('utf-8'); } class ContractUploadService { static async uploadContract({ userId, file, documentType, contractCategory, unitOfWork, contractData, signatureImage, contractTemplate, templateId, lang, contract_type = 'contract', user_type = 'personal' }) { logger.info('ContractUploadService.uploadContract:start', { userId, documentType, contractCategory, templateId, lang }); let pdfBuffer, originalFilename, mimeType, fileSize; let contractBody; try { // If templateId and lang are provided, fetch HTML template from object storage if (templateId && lang) { logger.info('ContractUploadService.uploadContract:fetching_template', { templateId, lang }); const templateMeta = await DocumentTemplateService.getTemplate(templateId); if (!templateMeta || templateMeta.lang !== lang) throw new Error('Template not found for specified language'); // Fetch HTML from object storage const s3 = new S3Client({ region: process.env.EXOSCALE_REGION }); const getObj = await s3.send(new GetObjectCommand({ Bucket: process.env.AWS_BUCKET, Key: templateMeta.storageKey })); const htmlBuffer = await streamToBuffer(getObj.Body); let htmlTemplate = htmlBuffer.toString('utf-8'); // Fill variables in HTML template contractBody = fillTemplate(htmlTemplate, contractData); logger.info('ContractUploadService.uploadContract:template_fetched', { templateId }); } else if (contractTemplate) { logger.info('ContractUploadService.uploadContract:using_contractTemplate'); contractBody = fillTemplate(contractTemplate, contractData); } else if (contractData) { logger.info('ContractUploadService.uploadContract:using_contractData'); contractBody = `Contract for ${contractData.name}\nDate: ${contractData.date}\nEmail: ${contractData.email}\n\nTerms and Conditions...\n`; } const hasExplicitTemplate = !!(templateId || contractTemplate); if (contractData && signatureImage && hasExplicitTemplate) { logger.info('ContractUploadService.uploadContract:generating_pdf', { userId }); // Generate styled PDF const doc = new PDFDocument({ size: 'A4', margins: { top: 60, bottom: 60, left: 72, right: 72 } }); // Header doc.font('Helvetica-Bold').fontSize(20).text('Contract Agreement', { align: 'center' }); doc.moveDown(1.5); doc.text(`Email: ${contractData.email}`); doc.text(`Date: ${contractData.date}`); doc.moveDown(); // Contract Body Section doc.font('Times-Roman').fontSize(13); doc.text(contractBody, { align: 'justify', lineGap: 4 }); doc.moveDown(2); // Signature Section doc.font('Helvetica-Bold').fontSize(12).text('Signature:', { continued: true }); doc.moveDown(0.5); const imgBuffer = Buffer.isBuffer(signatureImage) ? signatureImage : Buffer.from(signatureImage, 'base64'); doc.image(imgBuffer, doc.x, doc.y, { fit: [180, 60], align: 'left', valign: 'center' }); doc.moveDown(2); // Footer doc.font('Helvetica-Oblique').fontSize(10).fillColor('gray') .text('This contract is generated electronically and is valid without a physical signature.', 72, 780, { align: 'center' }); doc.end(); pdfBuffer = await streamToBuffer(doc); originalFilename = `signed_contract_${userId}_${Date.now()}.pdf`; mimeType = 'application/pdf'; fileSize = pdfBuffer.length; logger.info('ContractUploadService.uploadContract:pdf_generated', { userId, filename: originalFilename, fileSize }); } else if (file) { logger.info('ContractUploadService.uploadContract:using_uploaded_pdf', { userId, filename: file.originalname }); if (file.mimetype !== 'application/pdf') throw new Error('Only PDF files are allowed'); pdfBuffer = file.buffer; originalFilename = file.originalname; mimeType = file.mimetype; fileSize = file.size; } else { // NEW: auto-generate PDF from latest active template (contract_type) when no file provided const tmpl = await DocumentTemplateService.getLatestActiveForUserType(user_type, 'contract', contract_type); if (!tmpl) { throw new Error(`No active ${contract_type} template found for user type ${user_type}`); } logger.info('ContractUploadService.uploadContract:generate_from_template', { userId, tmplId: tmpl.id, contract_type }); // Fetch HTML from Exoscale const client = exoS3 || 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 obj = await client.send(new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: tmpl.storageKey })); const htmlBuffer = await streamToBuffer(obj.Body); let htmlTemplate = htmlBuffer.toString('utf-8'); // Merge DB-derived vars with request data so placeholders fill like the preview endpoint const vars = await buildTemplateVars({ userId, user_type, contractData, unitOfWork }); Object.entries(vars).forEach(([key, value]) => { const re = new RegExp(`{{\\s*${key}\\s*}}`, 'g'); htmlTemplate = htmlTemplate.replace(re, String(value ?? '')); }); if (signatureImage) { const base64 = String(signatureImage).startsWith('data:') ? String(signatureImage).split(',')[1] : String(signatureImage); const tag = ``; htmlTemplate = htmlTemplate.replace(/{{\s*signatureImage\s*}}/g, tag); } // Apply Profit Planet signature stamp if placeholder exists try { if (htmlTemplate.match(/{{\s*profitplanetSignature\s*}}/i)) { const tag = await CompanyStampService.getProfitPlanetSignatureTag({ maxW: 300, maxH: 300 }); htmlTemplate = htmlTemplate.replace(/{{\s*profitplanetSignature\s*}}/gi, tag || ''); } } catch (e) { logger.warn('ContractUploadService.uploadContract:profitplanetSignature failed', { userId, msg: e.message }); htmlTemplate = htmlTemplate.replace(/{{\s*profitplanetSignature\s*}}/gi, ''); } // Render HTML to PDF via Puppeteer (closest to preview output) const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); await page.setContent(htmlTemplate, { waitUntil: 'networkidle0' }); pdfBuffer = await page.pdf({ format: 'A4', printBackground: true }); await browser.close(); originalFilename = `${contract_type}_contract_${userId}_${Date.now()}.pdf`; mimeType = 'application/pdf'; fileSize = pdfBuffer.length; logger.info('ContractUploadService.uploadContract:pdf_generated_from_template', { userId, contract_type, filename: originalFilename, fileSize }); } // Upload to Exoscale logger.info('ContractUploadService.uploadContract:uploading_to_exoscale', { userId, filename: originalFilename }); const uploadResult = await uploadBuffer( pdfBuffer, originalFilename, mimeType, `contracts/${contractCategory}/${userId}/${contract_type}` ); logger.info('ContractUploadService.uploadContract:uploaded', { userId, objectKey: uploadResult.objectKey }); // Insert metadata in user_documents const repo = new UserDocumentRepository(unitOfWork); await repo.insertDocument({ userId, documentType, contractType: contract_type || 'contract', objectStorageId: uploadResult.objectKey, signatureBase64: null, idType: null, idNumber: null, expiryDate: null, originalFilename, fileSize, mimeType }); logger.info('ContractUploadService.uploadContract:document_metadata_inserted', { userId }); // Optionally update user_status (contract_signed) await unitOfWork.connection.query( `UPDATE user_status SET contract_signed = 1, contract_signed_at = NOW() WHERE user_id = ?`, [userId] ); logger.info('ContractUploadService.uploadContract:user_status_updated', { userId }); // Check if all steps are complete and set status to 'pending' if so const UserStatusService = require('../status/UserStatusService'); await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork); logger.info('ContractUploadService.uploadContract:pending_check_complete', { userId }); logger.info('ContractUploadService.uploadContract:success', { userId }); return uploadResult; } catch (error) { logger.error('ContractUploadService.uploadContract:error', { userId, error: error.message }); throw error; } } } module.exports = ContractUploadService;