CentralBackend/services/contracts/ContractUploadService.js

403 lines
17 KiB
JavaScript

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(/{{(\w+)}}/g, (_, key) => data[key] || '');
}
async function applyProfitPlanetSignatureForHtml({ html, userId }) {
if (!html) return html;
if (!/{{\s*profitplanetSignature\s*}}/i.test(html)) return html;
try {
const { tag } = await DocumentTemplateService.getProfitPlanetSignatureTag({ maxW: 300, maxH: 300 });
return html.replace(/{{\s*profitplanetSignature\s*}}/gi, tag || '');
} catch (e) {
logger.warn('ContractUploadService.uploadContract:profitplanetSignature failed', { userId, msg: e.message });
return html.replace(/{{\s*profitplanetSignature\s*}}/gi, '');
}
}
// 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 = `<img src="data:image/png;base64,${base64}" style="max-width:200px;max-height:100px;">`;
htmlTemplate = htmlTemplate.replace(/{{\s*signatureImage\s*}}/g, tag);
}
// Apply Profit Planet signature placeholder (if any)
htmlTemplate = await applyProfitPlanetSignatureForHtml({ html: htmlTemplate, userId });
// 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;
}
}
}
// Reuse helper for preview endpoints
ContractUploadService.buildTemplateVars = buildTemplateVars;
module.exports = ContractUploadService;