diff --git a/controller/documentTemplate/DocumentTemplateController.js b/controller/documentTemplate/DocumentTemplateController.js index b15cece..5aa9e5f 100644 --- a/controller/documentTemplate/DocumentTemplateController.js +++ b/controller/documentTemplate/DocumentTemplateController.js @@ -12,6 +12,7 @@ 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() { @@ -154,6 +155,45 @@ async function enrichTemplate(template, s3, serverBaseUrl = null) { }; } +// 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) => { @@ -281,6 +321,7 @@ exports.listTemplatesPublic = async (req, res) => { 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'; @@ -288,6 +329,11 @@ exports.uploadTemplate = async (req, res) => { 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}`; @@ -307,7 +353,7 @@ exports.uploadTemplate = async (req, res) => { ContentType: file.mimetype })); - const template = await DocumentTemplateService.uploadTemplate({ name, type, storageKey: key, description, lang, version: 1, user_type }); + 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); @@ -337,6 +383,7 @@ 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; @@ -345,6 +392,18 @@ exports.updateTemplate = async (req, res) => { 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) { @@ -367,7 +426,8 @@ exports.updateTemplate = async (req, res) => { const updateData = { name: name !== undefined ? name : current.name, - type: type !== undefined ? type : current.type, + type: nextType, + contract_type, description: description !== undefined ? description : current.description, lang: lang !== undefined ? lang : current.lang, storageKey: storageKey || current.storageKey, @@ -1278,19 +1338,15 @@ exports.previewPdf = async (req, res) => { } }; -// NEW: Admin-only endpoint to preview latest active contract for a specific user with DB-filled placeholders -// GET /api/admin/contracts/:id/preview?userType=personal|company&type=contract +// 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 overrideUserType = (req.query.userType || req.query.user_type || '').toString().toLowerCase(); - const templateType = (req.query.type || 'contract').toString(); + const contractTypeParam = (req.query.contract_type || req.query.contractType || '').toString().toLowerCase(); + const allowedContractTypes = ['contract', 'gdpr']; + const contractType = allowedContractTypes.includes(contractTypeParam) ? contractTypeParam : 'contract'; - logger.info('[previewLatestForUser] start', { - targetUserId, - overrideUserType, - templateType, - requestId: req.id - }); + 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' }); @@ -1300,7 +1356,7 @@ exports.previewLatestForUser = async (req, res) => { } try { - // Resolve target user and determine user_type if not overridden + // 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]); @@ -1308,178 +1364,83 @@ exports.previewLatestForUser = async (req, res) => { } catch (e) { logger.error('[previewLatestForUser] failed to load users row', e && e.message); } - if (!userRow) { - // Fallback: allow preview based solely on existing contract file even if user row is missing - logger.warn('[previewLatestForUser] user not found, continuing with contract lookup only', { targetUserId, requestId: req.id }); - } - const userType = (overrideUserType === 'personal' || overrideUserType === 'company') - ? overrideUserType - : (userRow ? userRow.user_type : 'personal'); - logger.info('[previewLatestForUser] user resolved/fallback', { targetUserId, userType, requestId: req.id }); + // 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(','); - // NEW: Preview the actual signed contract the user uploaded/signed (latest by created_at) + // Fetch latest uploaded document for this type from user_documents + let doc = null; try { - // order by upload_at (actual column) then id as tiebreaker - const [docRows] = await db.execute( + const [rows] = await db.execute( `SELECT object_storage_id FROM user_documents WHERE user_id = ? - AND document_type IN ('contract','signed_contract','contract_pdf','signed_contract_pdf') + 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] + [targetUserId, ...docTypes, contractType, contractType] ); - const docRowsArr = Array.isArray(docRows) ? docRows : (docRows ? [docRows] : []); - logger.info('[previewLatestForUser] contract rows fetched', { - targetUserId, - count: docRowsArr.length, - rows: docRowsArr, - requestId: req.id - }); - const doc = docRowsArr[0]; - if (doc && doc.object_storage_id) { - // Use explicit endpoint + path-style to match Exoscale object storage - const 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 - } - }); - logger.info('[previewLatestForUser] attempting S3 fetch', { - bucket: process.env.EXOSCALE_BUCKET, - key: doc.object_storage_id, - userId: targetUserId - }); - const cmd = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: doc.object_storage_id }); - const fileObj = await s3File.send(cmd); - if (!fileObj.Body) { - logger.warn('[previewLatestForUser] S3 returned empty Body', { - bucket: process.env.EXOSCALE_BUCKET, - key: doc.object_storage_id, - userId: targetUserId - }); - return res.status(404).json({ message: 'Contract file not available' }); - } - const chunks = []; - for await (const chunk of fileObj.Body) { - chunks.push(Buffer.from(chunk)); - } - const pdfBuffer = Buffer.concat(chunks); - logger.info('[previewLatestForUser] S3 fetch success', { - bucket: process.env.EXOSCALE_BUCKET, - key: doc.object_storage_id, - userId: targetUserId, - size: pdfBuffer.length - }); - const b64 = pdfBuffer.toString('base64'); - const html = ensureHtmlDocument(` - Contract Preview - - - - `); - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - return res.send(html); - } - logger.warn('[previewLatestForUser] no contract row with object_storage_id', { targetUserId, requestId: req.id }); + 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] reading user_documents failed', e && e.message); - return res.status(500).json({ message: 'Failed to load user contract file' }); + logger.warn('[previewLatestForUser] user_documents lookup failed', e && (e.stack || e.message)); } - // If no uploaded/signed contract exists, return not found (do not fallback to template) - return res.status(404).json({ message: 'No signed contract found for this user' }); - - // Build variable map from DB for target user - const vars = {}; - // Always include email and currentDate - vars.email = userRow.email || ''; - const now = new Date(); - const pad = (n) => String(n).padStart(2, '0'); - vars.currentDate = `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; - - // Personal profile fields - if (userType === 'personal') { - try { - const [pRows] = await db.execute('SELECT * FROM personal_profiles WHERE user_id = ? LIMIT 1', [targetUserId]); - const p = (pRows && pRows[0]) ? pRows[0] : null; - if (p) { - const first = p.first_name || ''; - const last = p.last_name || ''; - const fullName = `${first} ${last}`.trim(); - vars.fullName = fullName; - vars.address = p.address || ''; - vars.zip_code = p.zip_code || ''; - vars.city = p.city || ''; - vars.country = p.country || ''; - vars.phone = p.phone || ''; - const fullAddressParts = []; - if (vars.address) fullAddressParts.push(vars.address); - const zipCity = [vars.zip_code, vars.city].filter(Boolean).join(' '); - if (zipCity) fullAddressParts.push(zipCity); - vars.fullAddress = fullAddressParts.join(', '); - } - } catch (e) { - logger.warn('[previewLatestForUser] load personal_profiles failed', e && e.message); - } + if (!doc || !doc.object_storage_id) { + return res.status(404).json({ message: `No uploaded ${contractType.toUpperCase()} file found for this user` }); } - // Company profile fields - if (userType === 'company') { - try { - const [cRows] = await db.execute('SELECT * FROM company_profiles WHERE user_id = ? LIMIT 1', [targetUserId]); - const c = (cRows && cRows[0]) ? cRows[0] : null; - if (c) { - vars.companyName = c.company_name || ''; - vars.registrationNumber = c.registration_number || ''; - vars.companyAddress = c.address || ''; - // generic address keys used by some templates - vars.address = vars.companyAddress; - vars.zip_code = c.zip_code || ''; - vars.city = c.city || ''; - vars.country = c.country || ''; - vars.contactPersonName = c.contact_person_name || ''; - vars.contactPersonPhone = c.contact_person_phone || c.phone || ''; - vars.companyEmail = c.email || c.company_email || c.contact_email || userRow.email || ''; - vars.companyPhone = c.phone || c.contact_person_phone || ''; - const addrParts = []; - if (vars.companyAddress) addrParts.push(vars.companyAddress); - const zipCity = [vars.zip_code, vars.city].filter(Boolean).join(' '); - if (zipCity) addrParts.push(zipCity); - vars.companyFullAddress = addrParts.join(', '); - - // Ensure template-prefixed company placeholders are populated - vars.companyCompanyName = vars.companyName; - vars.companyRegistrationNumber = vars.registrationNumber; - vars.companyZipCode = vars.zip_code; - vars.companyCity = vars.city; - } - } catch (e) { - logger.warn('[previewLatestForUser] load company_profiles failed', e && e.message); - } - } - - // Replace placeholders with resolved variables (simple global replace) - if (vars && typeof vars === 'object') { - Object.entries(vars).forEach(([key, value]) => { - const before = (html.match(new RegExp(`{{\\s*${key}\\s*}}`, 'g')) || []).length; - if (before) { - html = html.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), String(value ?? '')); + try { + const s3File = 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 } }); + logger.info('[previewLatestForUser] attempting S3 fetch', { + bucket: process.env.EXOSCALE_BUCKET, + key: doc.object_storage_id, + userId: targetUserId, + contractType + }); + const cmd = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: doc.object_storage_id }); + const 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 }); + return res.status(404).json({ message: `${contractType.toUpperCase()} file not available` }); + } + + const b64 = pdfBuffer.toString('base64'); + const html = ensureHtmlDocument(` + ${contractType.toUpperCase()} Preview + + + + `); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + 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 }); + return res.status(404).json({ message: `${contractType.toUpperCase()} file not available` }); + } + logger.error('[previewLatestForUser] S3 fetch failed', e && (e.stack || e.message)); + return res.status(500).json({ message: 'Failed to load user document' }); } - - // Apply stamp/signature placeholders (harmless if placeholders are absent) - try { html = await applyCompanyStampPlaceholders(html, { ...req, user: { ...userRow, user_type: userType } }); } catch (e) { logger.warn('[previewLatestForUser] applyCompanyStampPlaceholders failed', e && e.message); } - try { html = await applyProfitPlanetSignature(html); } catch (e) { logger.warn('[previewLatestForUser] applyProfitPlanetSignature failed', e && e.message); } - - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - return res.send(ensureHtmlDocument(html)); } catch (err) { logger.error('[previewLatestForUser] error', err && err.stack ? err.stack : err); return res.status(500).json({ error: 'Failed to render preview' }); @@ -1494,9 +1455,13 @@ exports.previewLatestForMe = async (req, res) => { const userType = req.user.user_type || req.user.userType; 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'); + const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', contractType); if (!latest) { return res.status(404).json({ error: 'No active template found for your user type' }); } diff --git a/controller/documents/ContractUploadController.js b/controller/documents/ContractUploadController.js index a4a899a..ceada97 100644 --- a/controller/documents/ContractUploadController.js +++ b/controller/documents/ContractUploadController.js @@ -6,25 +6,42 @@ class ContractUploadController { static async uploadPersonalContract(req, res) { const userId = req.user.userId; logger.info(`[ContractUploadController] uploadPersonalContract called for userId: ${userId}`); - const file = req.file; - // Accept contractData and signatureImage from body (JSON or multipart) + const file = req.file; // optional, we now generate from templates when absent const contractData = req.body.contractData ? JSON.parse(req.body.contractData) : undefined; - const signatureImage = req.body.signatureImage; // base64 string or Buffer + const signatureImage = req.body.signatureImage; + const unitOfWork = new UnitOfWork(); await unitOfWork.start(); try { - const result = await ContractUploadService.uploadContract({ + const uploads = []; + // Primary contract + uploads.push(await ContractUploadService.uploadContract({ userId, file, documentType: 'contract', contractCategory: 'personal', unitOfWork, contractData, - signatureImage - }); + signatureImage, + contract_type: 'contract', + user_type: 'personal' + })); + + // GDPR contract (auto-generated from latest GDPR template) + uploads.push(await ContractUploadService.uploadContract({ + userId, + documentType: 'contract', + contractCategory: 'personal', + unitOfWork, + contractData, + signatureImage, + contract_type: 'gdpr', + user_type: 'personal' + })); + await unitOfWork.commit(); logger.info(`[ContractUploadController] uploadPersonalContract success for userId: ${userId}`); - res.json({ success: true, upload: result, downloadUrl: result.url || null }); + res.json({ success: true, uploads, downloadUrls: uploads.map(u => u.url || null) }); } catch (error) { logger.error(`[ContractUploadController] uploadPersonalContract error for userId: ${userId}`, { error }); await unitOfWork.rollback(error); @@ -48,7 +65,9 @@ class ContractUploadController { contractCategory: 'company', unitOfWork, contractData, - signatureImage + signatureImage, + contract_type: 'contract', + user_type: 'company' }); await unitOfWork.commit(); logger.info(`[ContractUploadController] uploadCompanyContract success for userId: ${userId}`); diff --git a/database/createDb.js b/database/createDb.js index 407c7ff..3b15864 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -267,21 +267,31 @@ async function createDatabase() { // 8. user_documents table: Stores object storage IDs await connection.query(` CREATE TABLE IF NOT EXISTS user_documents ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - document_type ENUM('personal_id', 'company_id', 'signature', 'contract', 'other') NOT NULL, - object_storage_id VARCHAR(255) UNIQUE NOT NULL, - original_filename VARCHAR(255), - file_size INT, - mime_type VARCHAR(100), - upload_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - verified_by_admin BOOLEAN DEFAULT FALSE, - admin_verified_at TIMESTAMP NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, - INDEX idx_user_document_type (user_id, document_type), - INDEX idx_object_storage_id (object_storage_id) + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + document_type ENUM('personal_id', 'company_id', 'signature', 'contract', 'other') NOT NULL, + contract_type ENUM('contract','gdpr') NOT NULL DEFAULT 'contract', + object_storage_id VARCHAR(255) UNIQUE NOT NULL, + original_filename VARCHAR(255), + file_size INT, + mime_type VARCHAR(100), + upload_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + verified_by_admin BOOLEAN DEFAULT FALSE, + admin_verified_at TIMESTAMP NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, + INDEX idx_user_document_type (user_id, document_type), + INDEX idx_user_contract_type (user_id, contract_type), + INDEX idx_object_storage_id (object_storage_id) ); `); + // Backfill in case table already existed without contract_type + await connection.query(` + ALTER TABLE user_documents + ADD COLUMN IF NOT EXISTS contract_type ENUM('contract','gdpr') NOT NULL DEFAULT 'contract' AFTER document_type; + `); + await connection.query(` + CREATE INDEX IF NOT EXISTS idx_user_contract_type ON user_documents (user_id, contract_type); + `); console.log('✅ User documents table created/verified'); // 8c. document_templates table: Stores template metadata and object storage keys @@ -290,6 +300,7 @@ async function createDatabase() { id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, type VARCHAR(100) NOT NULL, + contract_type ENUM('contract','gdpr') NULL DEFAULT NULL, storageKey VARCHAR(255) NOT NULL, description TEXT, lang VARCHAR(10) NOT NULL, @@ -297,7 +308,11 @@ async function createDatabase() { version INT DEFAULT 1, state ENUM('active','inactive') DEFAULT 'inactive', createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CHECK ( + (type <> 'contract' AND contract_type IS NULL) + OR (type = 'contract' AND contract_type IN ('contract','gdpr')) + ) ); `); console.log('✅ Document templates table created/verified'); diff --git a/repositories/documents/UserDocumentRepository.js b/repositories/documents/UserDocumentRepository.js index 23c1b0f..a578ce4 100644 --- a/repositories/documents/UserDocumentRepository.js +++ b/repositories/documents/UserDocumentRepository.js @@ -8,6 +8,7 @@ class UserDocumentRepository { async insertDocument({ userId, documentType, + contractType = 'contract', objectStorageId, originalFilename, fileSize, @@ -17,14 +18,15 @@ class UserDocumentRepository { const conn = this.unitOfWork.connection; const query = ` INSERT INTO user_documents ( - user_id, document_type, object_storage_id, + user_id, document_type, contract_type, object_storage_id, original_filename, file_size, mime_type - ) VALUES (?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?) `; try { const [result] = await conn.query(query, [ userId, documentType, + contractType, objectStorageId, originalFilename, fileSize, diff --git a/repositories/template/DocumentTemplateRepository.js b/repositories/template/DocumentTemplateRepository.js index 8481cc2..57774a5 100644 --- a/repositories/template/DocumentTemplateRepository.js +++ b/repositories/template/DocumentTemplateRepository.js @@ -22,22 +22,27 @@ class DocumentTemplateRepository { const lang = String(data.lang); const allowedUserTypes = new Set(['personal', 'company', 'both']); const user_type = allowedUserTypes.has(data.user_type || data.userType) ? (data.user_type || data.userType) : 'both'; + const allowedContractTypes = new Set(['contract', 'gdpr']); + const contract_type = type === 'contract' + ? (allowedContractTypes.has(data.contract_type || data.contractType) ? (data.contract_type || data.contractType) : null) + : null; + const finalContractType = type === 'contract' ? (contract_type || 'contract') : null; const query = ` - INSERT INTO document_templates (name, type, storageKey, description, lang, user_type, version, state, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, 1, 'inactive', NOW(), NOW()) + INSERT INTO document_templates (name, type, contract_type, storageKey, description, lang, user_type, version, state, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, 'inactive', NOW(), NOW()) `; try { if (conn) { - const [res] = await conn.execute(query, [name, type, storageKey, description, lang, user_type]); + const [res] = await conn.execute(query, [name, type, finalContractType, storageKey, description, lang, user_type]); const insertId = res && (res.insertId || res[0]?.insertId); logger.info('DocumentTemplateRepository.create:success', { id: insertId || res.insertId }); - return { id: insertId || res.insertId, name, type, storageKey, description, lang, user_type, version: 1, state: 'inactive' }; + return { id: insertId || res.insertId, name, type, contract_type: finalContractType, storageKey, description, lang, user_type, version: 1, state: 'inactive' }; } - const result = await db.execute(query, [name, type, storageKey, description, lang, user_type]); + const result = await db.execute(query, [name, type, finalContractType, storageKey, description, lang, user_type]); const insertId = result && result.insertId ? result.insertId : (Array.isArray(result) && result[0] && result[0].insertId ? result[0].insertId : undefined); logger.info('DocumentTemplateRepository.create:success', { id: insertId }); - return { id: insertId, name, type, storageKey, description, lang, user_type, version: 1, state: 'inactive' }; + return { id: insertId, name, type, contract_type: finalContractType, storageKey, description, lang, user_type, version: 1, state: 'inactive' }; } catch (error) { logger.error('DocumentTemplateRepository.create:error', { error: error.message }); throw error; @@ -90,7 +95,7 @@ class DocumentTemplateRepository { // data: { name, type, storageKey, description, lang, version } const fields = []; const values = []; - for (const key of ['name', 'type', 'storageKey', 'description', 'lang', 'version', 'user_type']) { + for (const key of ['name', 'type', 'contract_type', 'storageKey', 'description', 'lang', 'version', 'user_type']) { if (data[key] !== undefined) { fields.push(`${key} = ?`); values.push(data[key]); @@ -150,8 +155,8 @@ class DocumentTemplateRepository { } } - async findActiveByUserType(userType, templateType = null, conn) { - logger.info('DocumentTemplateRepository.findActiveByUserType:start', { userType, templateType }); + async findActiveByUserType(userType, templateType = null, contractType = null, conn) { + logger.info('DocumentTemplateRepository.findActiveByUserType:start', { userType, templateType, contractType }); const safeType = (userType === 'personal' || userType === 'company') ? userType : 'personal'; let query = `SELECT * FROM document_templates WHERE state = 'active' AND (user_type = ? OR user_type = 'both')`; const params = [safeType]; @@ -159,6 +164,10 @@ class DocumentTemplateRepository { query += ` AND type = ?`; params.push(templateType); } + if (contractType && templateType === 'contract') { + query += ` AND contract_type = ?`; + params.push(contractType); + } query += ` ORDER BY createdAt DESC`; try { if (conn) { diff --git a/scripts/createAdminUser.js b/scripts/createAdminUser.js index e9eaf30..0cdcf4c 100644 --- a/scripts/createAdminUser.js +++ b/scripts/createAdminUser.js @@ -3,10 +3,11 @@ const UnitOfWork = require('../database/UnitOfWork'); const argon2 = require('argon2'); async function createAdminUser() { - const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; - // const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; - // const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025'; + // const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com'; + // const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; + const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; const adminPassword = process.env.ADMIN_PASSWORD || 'Chalanger75$%'; + // const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025'; const firstName = process.env.ADMIN_FIRST_NAME || 'Admin'; const lastName = process.env.ADMIN_LAST_NAME || 'User'; diff --git a/services/contracts/ContractUploadService.js b/services/contracts/ContractUploadService.js index cf1ed03..b6ff13c 100644 --- a/services/contracts/ContractUploadService.js +++ b/services/contracts/ContractUploadService.js @@ -1,12 +1,51 @@ const UserDocumentRepository = require('../../repositories/documents/UserDocumentRepository'); -const { uploadBuffer } = require('../../utils/exoscaleUploader'); +const { uploadBuffer, s3: exoS3 } = require('../../utils/exoscaleUploader'); const PDFDocument = require('pdfkit'); -const getStream = require('get-stream'); const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'); const DocumentTemplateService = require('../template/DocumentTemplateService'); 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] || ''); @@ -61,7 +100,9 @@ class ContractUploadService { signatureImage, contractTemplate, templateId, - lang + lang, + contract_type = 'contract', + user_type = 'personal' }) { logger.info('ContractUploadService.uploadContract:start', { userId, documentType, contractCategory, templateId, lang }); let pdfBuffer, originalFilename, mimeType, fileSize; @@ -79,7 +120,7 @@ class ContractUploadService { Bucket: process.env.AWS_BUCKET, Key: templateMeta.storageKey })); - const htmlBuffer = await getStream.buffer(getObj.Body); + const htmlBuffer = await streamToBuffer(getObj.Body); let htmlTemplate = htmlBuffer.toString('utf-8'); // Fill variables in HTML template contractBody = fillTemplate(htmlTemplate, contractData); @@ -103,10 +144,6 @@ class ContractUploadService { // Header doc.font('Helvetica-Bold').fontSize(20).text('Contract Agreement', { align: 'center' }); doc.moveDown(1.5); - - // User Info Section - doc.font('Helvetica').fontSize(12); - doc.text(`Name: ${contractData.name}`); doc.text(`Email: ${contractData.email}`); doc.text(`Date: ${contractData.date}`); doc.moveDown(); @@ -133,7 +170,7 @@ class ContractUploadService { }); doc.end(); - pdfBuffer = await getStream.buffer(doc); + pdfBuffer = await streamToBuffer(doc); originalFilename = `signed_contract_${userId}_${Date.now()}.pdf`; mimeType = 'application/pdf'; fileSize = pdfBuffer.length; @@ -146,8 +183,29 @@ class ContractUploadService { mimeType = file.mimetype; fileSize = file.size; } else { - logger.warn('ContractUploadService.uploadContract:no_contract_file_or_data', { userId }); - throw new Error('No contract file or data uploaded'); + // 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); + const htmlTemplate = htmlBuffer.toString('utf-8'); + + // 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 @@ -156,7 +214,7 @@ class ContractUploadService { pdfBuffer, originalFilename, mimeType, - `contracts/${contractCategory}/${userId}` + `contracts/${contractCategory}/${userId}/${contract_type}` ); logger.info('ContractUploadService.uploadContract:uploaded', { userId, objectKey: uploadResult.objectKey }); @@ -165,6 +223,7 @@ class ContractUploadService { await repo.insertDocument({ userId, documentType, + contractType: contract_type || 'contract', objectStorageId: uploadResult.objectKey, idType: null, idNumber: null, diff --git a/services/template/DocumentTemplateService.js b/services/template/DocumentTemplateService.js index a87d38a..65003a2 100644 --- a/services/template/DocumentTemplateService.js +++ b/services/template/DocumentTemplateService.js @@ -25,7 +25,11 @@ class DocumentTemplateService { await uow.start(); const allowed = ['personal','company','both']; const user_type = allowed.includes(data.user_type || data.userType) ? (data.user_type || data.userType) : 'both'; - const created = await DocumentTemplateRepository.create({ ...data, user_type }, uow.connection); + const allowedContractTypes = ['contract', 'gdpr']; + const contract_type = (data.type === 'contract' && allowedContractTypes.includes(data.contract_type || data.contractType)) + ? (data.contract_type || data.contractType) + : (data.type === 'contract' ? 'contract' : null); + const created = await DocumentTemplateRepository.create({ ...data, user_type, contract_type }, uow.connection); await uow.commit(); logger.info('DocumentTemplateService.uploadTemplate:success', { id: created.id }); return created; @@ -83,10 +87,17 @@ class DocumentTemplateService { const allowed = ['personal','company','both']; if (data.userType && !allowed.includes(data.userType)) delete data.userType; if (data.user_type && !allowed.includes(data.user_type)) delete data.user_type; + const nextType = data.type !== undefined ? data.type : current.type; + const allowedContractTypes = ['contract', 'gdpr']; + const contract_type = nextType === 'contract' + ? (allowedContractTypes.includes(data.contract_type || data.contractType || current.contract_type) + ? (data.contract_type || data.contractType || current.contract_type) + : 'contract') + : null; const newVersion = (current.version || 1) + 1; await DocumentTemplateRepository.update( id, - { ...data, version: newVersion, user_type: data.user_type || data.userType || current.user_type }, + { ...data, version: newVersion, user_type: data.user_type || data.userType || current.user_type, contract_type }, uow.connection ); const updated = await DocumentTemplateRepository.findById(id, uow.connection); @@ -117,10 +128,10 @@ class DocumentTemplateService { } } - async getActiveTemplatesForUserType(userType, templateType = null) { - logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType }); + async getActiveTemplatesForUserType(userType, templateType = null, contractType = null) { + logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType, contractType }); try { - const rows = await DocumentTemplateRepository.findActiveByUserType(userType, templateType); + const rows = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType); logger.info('DocumentTemplateService.getActiveTemplatesForUserType:success', { count: rows.length }); return rows.map(t => ({ ...t, lang: t.lang })); } catch (error) { @@ -130,10 +141,10 @@ class DocumentTemplateService { } // Convenience: return the most recent active template for a user type (by createdAt desc) - async getLatestActiveForUserType(userType, templateType = 'contract') { - logger.info('DocumentTemplateService.getLatestActiveForUserType:start', { userType, templateType }); + async getLatestActiveForUserType(userType, templateType = 'contract', contractType = null) { + logger.info('DocumentTemplateService.getLatestActiveForUserType:start', { userType, templateType, contractType }); try { - const list = await DocumentTemplateRepository.findActiveByUserType(userType, templateType); + const list = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType); const latest = Array.isArray(list) && list.length ? list[0] : null; logger.info('DocumentTemplateService.getLatestActiveForUserType:result', { found: !!latest, id: latest?.id }); return latest;