document upload + user mgmt preview

This commit is contained in:
seaznCode 2026-01-13 19:04:29 +01:00
parent 66d1ede207
commit dfbd731f53
8 changed files with 305 additions and 224 deletions

View File

@ -12,6 +12,7 @@ const db = require('../../database/database');
const UnitOfWork = require('../../database/UnitOfWork'); const UnitOfWork = require('../../database/UnitOfWork');
const { logger } = require('../../middleware/logger'); const { logger } = require('../../middleware/logger');
const CompanyStampService = require('../../services/stamp/company/CompanyStampService'); const CompanyStampService = require('../../services/stamp/company/CompanyStampService');
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
// Ensure debug directory exists and helper to save files // Ensure debug directory exists and helper to save files
function ensureDebugDir() { 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) // Helper to convert S3 stream to string (hardened + debug)
function streamToString(s3BodyStream, templateId) { function streamToString(s3BodyStream, templateId) {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
@ -281,6 +321,7 @@ exports.listTemplatesPublic = async (req, res) => {
exports.uploadTemplate = async (req, res) => { exports.uploadTemplate = async (req, res) => {
const { name, type, description, lang } = req.body; 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 rawUserType = req.body.user_type || req.body.userType;
const allowed = ['personal','company','both']; const allowed = ['personal','company','both'];
const user_type = allowed.includes(rawUserType) ? rawUserType : '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 (!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' }); 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 // Use "english" for en, "german" for de
const langFolder = lang === 'en' ? 'english' : 'german'; const langFolder = lang === 'en' ? 'english' : 'german';
const key = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`; const key = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`;
@ -307,7 +353,7 @@ exports.uploadTemplate = async (req, res) => {
ContentType: file.mimetype 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 // Enrich with previewUrl, fileUrl, html
const enriched = await enrichTemplate(template, s3); const enriched = await enrichTemplate(template, s3);
res.status(201).json(enriched); res.status(201).json(enriched);
@ -337,6 +383,7 @@ exports.updateTemplate = async (req, res) => {
const id = req.params.id; const id = req.params.id;
const { name, type, description, lang } = req.body; const { name, type, description, lang } = req.body;
const rawUserType = req.body.user_type || req.body.userType; const rawUserType = req.body.user_type || req.body.userType;
const rawContractType = req.body.contract_type || req.body.contractType;
const allowed = ['personal','company','both']; const allowed = ['personal','company','both'];
const user_type = allowed.includes(rawUserType) ? rawUserType : undefined; const user_type = allowed.includes(rawUserType) ? rawUserType : undefined;
let storageKey; let storageKey;
@ -345,6 +392,18 @@ exports.updateTemplate = async (req, res) => {
const current = await DocumentTemplateService.getTemplate(id); const current = await DocumentTemplateService.getTemplate(id);
if (!current) return res.status(404).json({ error: 'Template not found' }); 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 // Use "english" for en, "german" for de
const langFolder = lang ? (lang === 'en' ? 'english' : 'german') : (current.lang === 'en' ? 'english' : 'german'); const langFolder = lang ? (lang === 'en' ? 'english' : 'german') : (current.lang === 'en' ? 'english' : 'german');
if (file) { if (file) {
@ -367,7 +426,8 @@ exports.updateTemplate = async (req, res) => {
const updateData = { const updateData = {
name: name !== undefined ? name : current.name, name: name !== undefined ? name : current.name,
type: type !== undefined ? type : current.type, type: nextType,
contract_type,
description: description !== undefined ? description : current.description, description: description !== undefined ? description : current.description,
lang: lang !== undefined ? lang : current.lang, lang: lang !== undefined ? lang : current.lang,
storageKey: storageKey || current.storageKey, 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 // 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&type=contract // GET /api/admin/contracts/:id/preview?userType=personal|company&contract_type=contract|gdpr
exports.previewLatestForUser = async (req, res) => { exports.previewLatestForUser = async (req, res) => {
const targetUserId = parseInt(req.params.id, 10); const targetUserId = parseInt(req.params.id, 10);
const overrideUserType = (req.query.userType || req.query.user_type || '').toString().toLowerCase(); const contractTypeParam = (req.query.contract_type || req.query.contractType || '').toString().toLowerCase();
const templateType = (req.query.type || 'contract').toString(); const allowedContractTypes = ['contract', 'gdpr'];
const contractType = allowedContractTypes.includes(contractTypeParam) ? contractTypeParam : 'contract';
logger.info('[previewLatestForUser] start', { logger.info('[previewLatestForUser] start', { targetUserId, contractType, requestId: req.id });
targetUserId,
overrideUserType,
templateType,
requestId: req.id
});
if (!req.user || !['admin', 'super_admin'].includes(req.user.role)) { if (!req.user || !['admin', 'super_admin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden: Admins only' }); return res.status(403).json({ error: 'Forbidden: Admins only' });
@ -1300,7 +1356,7 @@ exports.previewLatestForUser = async (req, res) => {
} }
try { 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; let userRow = null;
try { try {
const [uRows] = await db.execute('SELECT id, email, user_type, role FROM users WHERE id = ? LIMIT 1', [targetUserId]); const [uRows] = await db.execute('SELECT id, email, user_type, role FROM users WHERE id = ? LIMIT 1', [targetUserId]);
@ -1308,40 +1364,44 @@ exports.previewLatestForUser = async (req, res) => {
} catch (e) { } catch (e) {
logger.error('[previewLatestForUser] failed to load users row', e && e.message); 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') // Choose document_type set based on contractType (aligned with ContractUploadService paths)
? overrideUserType // uploadContract stores under contracts/<category>/<userId>/<contract_type> with document_type 'contract'
: (userRow ? userRow.user_type : 'personal'); // so use contract_type column to disambiguate between contract vs gdpr
logger.info('[previewLatestForUser] user resolved/fallback', { targetUserId, userType, requestId: req.id }); 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 { try {
// order by upload_at (actual column) then id as tiebreaker const [rows] = await db.execute(
const [docRows] = await db.execute(
`SELECT object_storage_id `SELECT object_storage_id
FROM user_documents FROM user_documents
WHERE user_id = ? 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 object_storage_id IS NOT NULL
AND (contract_type = ? OR (contract_type IS NULL AND ? = 'contract'))
ORDER BY upload_at DESC, id DESC ORDER BY upload_at DESC, id DESC
LIMIT 1`, LIMIT 1`,
[targetUserId] [targetUserId, ...docTypes, contractType, contractType]
); );
const docRowsArr = Array.isArray(docRows) ? docRows : (docRows ? [docRows] : []); const arr = Array.isArray(rows) ? rows : (rows ? [rows] : []);
logger.info('[previewLatestForUser] contract rows fetched', { doc = arr[0] || null;
targetUserId, logger.info('[previewLatestForUser] user_documents lookup', { targetUserId, contractType, count: arr.length });
count: docRowsArr.length, } catch (e) {
rows: docRowsArr, logger.warn('[previewLatestForUser] user_documents lookup failed', e && (e.stack || e.message));
requestId: req.id }
});
const doc = docRowsArr[0]; if (!doc || !doc.object_storage_id) {
if (doc && doc.object_storage_id) { return res.status(404).json({ message: `No uploaded ${contractType.toUpperCase()} file found for this user` });
// Use explicit endpoint + path-style to match Exoscale object storage }
const s3File = new S3Client({
try {
const s3File = sharedExoscaleClient || new S3Client({
region: process.env.EXOSCALE_REGION, region: process.env.EXOSCALE_REGION,
endpoint: process.env.EXOSCALE_ENDPOINT, endpoint: process.env.EXOSCALE_ENDPOINT,
forcePathStyle: true, forcePathStyle: true,
@ -1353,133 +1413,34 @@ exports.previewLatestForUser = async (req, res) => {
logger.info('[previewLatestForUser] attempting S3 fetch', { logger.info('[previewLatestForUser] attempting S3 fetch', {
bucket: process.env.EXOSCALE_BUCKET, bucket: process.env.EXOSCALE_BUCKET,
key: doc.object_storage_id, key: doc.object_storage_id,
userId: targetUserId userId: targetUserId,
contractType
}); });
const cmd = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: doc.object_storage_id }); const cmd = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: doc.object_storage_id });
const fileObj = await s3File.send(cmd); const fileObj = await s3File.send(cmd);
if (!fileObj.Body) { const pdfBuffer = await s3BodyToBuffer(fileObj.Body);
logger.warn('[previewLatestForUser] S3 returned empty Body', { if (!pdfBuffer || !pdfBuffer.length) {
bucket: process.env.EXOSCALE_BUCKET, logger.warn('[previewLatestForUser] S3 returned empty Body', { key: doc.object_storage_id, userId: targetUserId, contractType });
key: doc.object_storage_id, return res.status(404).json({ message: `${contractType.toUpperCase()} file not available` });
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 b64 = pdfBuffer.toString('base64');
const html = ensureHtmlDocument(` const html = ensureHtmlDocument(`
<html><head><meta charset="utf-8" /><title>Contract Preview</title></head> <html><head><meta charset="utf-8" /><title>${contractType.toUpperCase()} Preview</title></head>
<body style="margin:0;padding:0;height:100vh;background:#0f172a;"> <body style="margin:0;padding:0;height:100vh;background:#0f172a;">
<embed src="data:application/pdf;base64,${b64}" type="application/pdf" style="width:100%;height:100%;border:none;" /> <embed src="data:application/pdf;base64,${b64}" type="application/pdf" style="width:100%;height:100%;border:none;" />
</body></html> </body></html>
`); `);
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8');
return res.send(html); return res.send(html);
}
logger.warn('[previewLatestForUser] no contract row with object_storage_id', { targetUserId, requestId: req.id });
} catch (e) { } catch (e) {
logger.warn('[previewLatestForUser] reading user_documents failed', e && e.message); if (e && (e.name === 'NoSuchKey' || (e.$metadata && e.$metadata.httpStatusCode === 404))) {
return res.status(500).json({ message: 'Failed to load user contract file' }); 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));
// If no uploaded/signed contract exists, return not found (do not fallback to template) return res.status(500).json({ message: 'Failed to load user document' });
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);
}
}
// Company profile fields
if (userType === 'company') {
try {
const [cRows] = await db.execute('SELECT * FROM company_profiles WHERE user_id = ? LIMIT 1', [targetUserId]);
const c = (cRows && cRows[0]) ? cRows[0] : null;
if (c) {
vars.companyName = c.company_name || '';
vars.registrationNumber = c.registration_number || '';
vars.companyAddress = c.address || '';
// generic address keys used by some templates
vars.address = vars.companyAddress;
vars.zip_code = c.zip_code || '';
vars.city = c.city || '';
vars.country = c.country || '';
vars.contactPersonName = c.contact_person_name || '';
vars.contactPersonPhone = c.contact_person_phone || c.phone || '';
vars.companyEmail = c.email || c.company_email || c.contact_email || userRow.email || '';
vars.companyPhone = c.phone || c.contact_person_phone || '';
const addrParts = [];
if (vars.companyAddress) addrParts.push(vars.companyAddress);
const zipCity = [vars.zip_code, vars.city].filter(Boolean).join(' ');
if (zipCity) addrParts.push(zipCity);
vars.companyFullAddress = addrParts.join(', ');
// Ensure template-prefixed company placeholders are populated
vars.companyCompanyName = vars.companyName;
vars.companyRegistrationNumber = vars.registrationNumber;
vars.companyZipCode = vars.zip_code;
vars.companyCity = vars.city;
}
} catch (e) {
logger.warn('[previewLatestForUser] load company_profiles failed', e && e.message);
}
}
// Replace placeholders with resolved variables (simple global replace)
if (vars && typeof vars === 'object') {
Object.entries(vars).forEach(([key, value]) => {
const before = (html.match(new RegExp(`{{\\s*${key}\\s*}}`, 'g')) || []).length;
if (before) {
html = html.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), String(value ?? ''));
}
});
}
// Apply stamp/signature placeholders (harmless if placeholders are absent)
try { html = await applyCompanyStampPlaceholders(html, { ...req, user: { ...userRow, user_type: userType } }); } catch (e) { logger.warn('[previewLatestForUser] applyCompanyStampPlaceholders failed', e && e.message); }
try { html = await applyProfitPlanetSignature(html); } catch (e) { logger.warn('[previewLatestForUser] applyProfitPlanetSignature failed', e && e.message); }
res.setHeader('Content-Type', 'text/html; charset=utf-8');
return res.send(ensureHtmlDocument(html));
} catch (err) { } catch (err) {
logger.error('[previewLatestForUser] error', err && err.stack ? err.stack : err); logger.error('[previewLatestForUser] error', err && err.stack ? err.stack : err);
return res.status(500).json({ error: 'Failed to render preview' }); 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; const userType = req.user.user_type || req.user.userType;
if (!targetUserId || !userType) return res.status(400).json({ error: 'Invalid authenticated user' }); 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 { try {
// Find the latest active template for this user type // 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) { if (!latest) {
return res.status(404).json({ error: 'No active template found for your user type' }); return res.status(404).json({ error: 'No active template found for your user type' });
} }

View File

@ -6,25 +6,42 @@ class ContractUploadController {
static async uploadPersonalContract(req, res) { static async uploadPersonalContract(req, res) {
const userId = req.user.userId; const userId = req.user.userId;
logger.info(`[ContractUploadController] uploadPersonalContract called for userId: ${userId}`); logger.info(`[ContractUploadController] uploadPersonalContract called for userId: ${userId}`);
const file = req.file; const file = req.file; // optional, we now generate from templates when absent
// Accept contractData and signatureImage from body (JSON or multipart)
const contractData = req.body.contractData ? JSON.parse(req.body.contractData) : undefined; 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(); const unitOfWork = new UnitOfWork();
await unitOfWork.start(); await unitOfWork.start();
try { try {
const result = await ContractUploadService.uploadContract({ const uploads = [];
// Primary contract
uploads.push(await ContractUploadService.uploadContract({
userId, userId,
file, file,
documentType: 'contract', documentType: 'contract',
contractCategory: 'personal', contractCategory: 'personal',
unitOfWork, unitOfWork,
contractData, 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(); await unitOfWork.commit();
logger.info(`[ContractUploadController] uploadPersonalContract success for userId: ${userId}`); 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) { } catch (error) {
logger.error(`[ContractUploadController] uploadPersonalContract error for userId: ${userId}`, { error }); logger.error(`[ContractUploadController] uploadPersonalContract error for userId: ${userId}`, { error });
await unitOfWork.rollback(error); await unitOfWork.rollback(error);
@ -48,7 +65,9 @@ class ContractUploadController {
contractCategory: 'company', contractCategory: 'company',
unitOfWork, unitOfWork,
contractData, contractData,
signatureImage signatureImage,
contract_type: 'contract',
user_type: 'company'
}); });
await unitOfWork.commit(); await unitOfWork.commit();
logger.info(`[ContractUploadController] uploadCompanyContract success for userId: ${userId}`); logger.info(`[ContractUploadController] uploadCompanyContract success for userId: ${userId}`);

View File

@ -270,6 +270,7 @@ async function createDatabase() {
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL, user_id INT NOT NULL,
document_type ENUM('personal_id', 'company_id', 'signature', 'contract', 'other') 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, object_storage_id VARCHAR(255) UNIQUE NOT NULL,
original_filename VARCHAR(255), original_filename VARCHAR(255),
file_size INT, file_size INT,
@ -279,9 +280,18 @@ async function createDatabase() {
admin_verified_at TIMESTAMP NULL, admin_verified_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, 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_document_type (user_id, document_type),
INDEX idx_user_contract_type (user_id, contract_type),
INDEX idx_object_storage_id (object_storage_id) 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'); console.log('✅ User documents table created/verified');
// 8c. document_templates table: Stores template metadata and object storage keys // 8c. document_templates table: Stores template metadata and object storage keys
@ -290,6 +300,7 @@ async function createDatabase() {
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL, type VARCHAR(100) NOT NULL,
contract_type ENUM('contract','gdpr') NULL DEFAULT NULL,
storageKey VARCHAR(255) NOT NULL, storageKey VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
lang VARCHAR(10) NOT NULL, lang VARCHAR(10) NOT NULL,
@ -297,7 +308,11 @@ async function createDatabase() {
version INT DEFAULT 1, version INT DEFAULT 1,
state ENUM('active','inactive') DEFAULT 'inactive', state ENUM('active','inactive') DEFAULT 'inactive',
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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'); console.log('✅ Document templates table created/verified');

View File

@ -8,6 +8,7 @@ class UserDocumentRepository {
async insertDocument({ async insertDocument({
userId, userId,
documentType, documentType,
contractType = 'contract',
objectStorageId, objectStorageId,
originalFilename, originalFilename,
fileSize, fileSize,
@ -17,14 +18,15 @@ class UserDocumentRepository {
const conn = this.unitOfWork.connection; const conn = this.unitOfWork.connection;
const query = ` const query = `
INSERT INTO user_documents ( 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 original_filename, file_size, mime_type
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?)
`; `;
try { try {
const [result] = await conn.query(query, [ const [result] = await conn.query(query, [
userId, userId,
documentType, documentType,
contractType,
objectStorageId, objectStorageId,
originalFilename, originalFilename,
fileSize, fileSize,

View File

@ -22,22 +22,27 @@ class DocumentTemplateRepository {
const lang = String(data.lang); const lang = String(data.lang);
const allowedUserTypes = new Set(['personal', 'company', 'both']); const allowedUserTypes = new Set(['personal', 'company', 'both']);
const user_type = allowedUserTypes.has(data.user_type || data.userType) ? (data.user_type || data.userType) : '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 = ` const query = `
INSERT INTO document_templates (name, type, storageKey, description, lang, user_type, version, state, createdAt, updatedAt) INSERT INTO document_templates (name, type, contract_type, storageKey, description, lang, user_type, version, state, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, 1, 'inactive', NOW(), NOW()) VALUES (?, ?, ?, ?, ?, ?, ?, 1, 'inactive', NOW(), NOW())
`; `;
try { try {
if (conn) { 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); const insertId = res && (res.insertId || res[0]?.insertId);
logger.info('DocumentTemplateRepository.create:success', { id: insertId || res.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); 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 }); 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) { } catch (error) {
logger.error('DocumentTemplateRepository.create:error', { error: error.message }); logger.error('DocumentTemplateRepository.create:error', { error: error.message });
throw error; throw error;
@ -90,7 +95,7 @@ class DocumentTemplateRepository {
// data: { name, type, storageKey, description, lang, version } // data: { name, type, storageKey, description, lang, version }
const fields = []; const fields = [];
const values = []; 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) { if (data[key] !== undefined) {
fields.push(`${key} = ?`); fields.push(`${key} = ?`);
values.push(data[key]); values.push(data[key]);
@ -150,8 +155,8 @@ class DocumentTemplateRepository {
} }
} }
async findActiveByUserType(userType, templateType = null, conn) { async findActiveByUserType(userType, templateType = null, contractType = null, conn) {
logger.info('DocumentTemplateRepository.findActiveByUserType:start', { userType, templateType }); logger.info('DocumentTemplateRepository.findActiveByUserType:start', { userType, templateType, contractType });
const safeType = (userType === 'personal' || userType === 'company') ? userType : 'personal'; const safeType = (userType === 'personal' || userType === 'company') ? userType : 'personal';
let query = `SELECT * FROM document_templates WHERE state = 'active' AND (user_type = ? OR user_type = 'both')`; let query = `SELECT * FROM document_templates WHERE state = 'active' AND (user_type = ? OR user_type = 'both')`;
const params = [safeType]; const params = [safeType];
@ -159,6 +164,10 @@ class DocumentTemplateRepository {
query += ` AND type = ?`; query += ` AND type = ?`;
params.push(templateType); params.push(templateType);
} }
if (contractType && templateType === 'contract') {
query += ` AND contract_type = ?`;
params.push(contractType);
}
query += ` ORDER BY createdAt DESC`; query += ` ORDER BY createdAt DESC`;
try { try {
if (conn) { if (conn) {

View File

@ -3,10 +3,11 @@ const UnitOfWork = require('../database/UnitOfWork');
const argon2 = require('argon2'); const argon2 = require('argon2');
async function createAdminUser() { async function createAdminUser() {
const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; // const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com';
// const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; // const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com';
// const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025'; const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'Chalanger75$%'; 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 firstName = process.env.ADMIN_FIRST_NAME || 'Admin';
const lastName = process.env.ADMIN_LAST_NAME || 'User'; const lastName = process.env.ADMIN_LAST_NAME || 'User';

View File

@ -1,12 +1,51 @@
const UserDocumentRepository = require('../../repositories/documents/UserDocumentRepository'); const UserDocumentRepository = require('../../repositories/documents/UserDocumentRepository');
const { uploadBuffer } = require('../../utils/exoscaleUploader'); const { uploadBuffer, s3: exoS3 } = require('../../utils/exoscaleUploader');
const PDFDocument = require('pdfkit'); const PDFDocument = require('pdfkit');
const getStream = require('get-stream');
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'); const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
const DocumentTemplateService = require('../template/DocumentTemplateService'); const DocumentTemplateService = require('../template/DocumentTemplateService');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { logger } = require('../../middleware/logger'); 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) { function fillTemplate(template, data) {
return template.replace(/{{(\w+)}}/g, (_, key) => data[key] || ''); return template.replace(/{{(\w+)}}/g, (_, key) => data[key] || '');
@ -61,7 +100,9 @@ class ContractUploadService {
signatureImage, signatureImage,
contractTemplate, contractTemplate,
templateId, templateId,
lang lang,
contract_type = 'contract',
user_type = 'personal'
}) { }) {
logger.info('ContractUploadService.uploadContract:start', { userId, documentType, contractCategory, templateId, lang }); logger.info('ContractUploadService.uploadContract:start', { userId, documentType, contractCategory, templateId, lang });
let pdfBuffer, originalFilename, mimeType, fileSize; let pdfBuffer, originalFilename, mimeType, fileSize;
@ -79,7 +120,7 @@ class ContractUploadService {
Bucket: process.env.AWS_BUCKET, Bucket: process.env.AWS_BUCKET,
Key: templateMeta.storageKey Key: templateMeta.storageKey
})); }));
const htmlBuffer = await getStream.buffer(getObj.Body); const htmlBuffer = await streamToBuffer(getObj.Body);
let htmlTemplate = htmlBuffer.toString('utf-8'); let htmlTemplate = htmlBuffer.toString('utf-8');
// Fill variables in HTML template // Fill variables in HTML template
contractBody = fillTemplate(htmlTemplate, contractData); contractBody = fillTemplate(htmlTemplate, contractData);
@ -103,10 +144,6 @@ class ContractUploadService {
// Header // Header
doc.font('Helvetica-Bold').fontSize(20).text('Contract Agreement', { align: 'center' }); doc.font('Helvetica-Bold').fontSize(20).text('Contract Agreement', { align: 'center' });
doc.moveDown(1.5); doc.moveDown(1.5);
// User Info Section
doc.font('Helvetica').fontSize(12);
doc.text(`Name: ${contractData.name}`);
doc.text(`Email: ${contractData.email}`); doc.text(`Email: ${contractData.email}`);
doc.text(`Date: ${contractData.date}`); doc.text(`Date: ${contractData.date}`);
doc.moveDown(); doc.moveDown();
@ -133,7 +170,7 @@ class ContractUploadService {
}); });
doc.end(); doc.end();
pdfBuffer = await getStream.buffer(doc); pdfBuffer = await streamToBuffer(doc);
originalFilename = `signed_contract_${userId}_${Date.now()}.pdf`; originalFilename = `signed_contract_${userId}_${Date.now()}.pdf`;
mimeType = 'application/pdf'; mimeType = 'application/pdf';
fileSize = pdfBuffer.length; fileSize = pdfBuffer.length;
@ -146,8 +183,29 @@ class ContractUploadService {
mimeType = file.mimetype; mimeType = file.mimetype;
fileSize = file.size; fileSize = file.size;
} else { } else {
logger.warn('ContractUploadService.uploadContract:no_contract_file_or_data', { userId }); // NEW: auto-generate PDF from latest active template (contract_type) when no file provided
throw new Error('No contract file or data uploaded'); 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 // Upload to Exoscale
@ -156,7 +214,7 @@ class ContractUploadService {
pdfBuffer, pdfBuffer,
originalFilename, originalFilename,
mimeType, mimeType,
`contracts/${contractCategory}/${userId}` `contracts/${contractCategory}/${userId}/${contract_type}`
); );
logger.info('ContractUploadService.uploadContract:uploaded', { userId, objectKey: uploadResult.objectKey }); logger.info('ContractUploadService.uploadContract:uploaded', { userId, objectKey: uploadResult.objectKey });
@ -165,6 +223,7 @@ class ContractUploadService {
await repo.insertDocument({ await repo.insertDocument({
userId, userId,
documentType, documentType,
contractType: contract_type || 'contract',
objectStorageId: uploadResult.objectKey, objectStorageId: uploadResult.objectKey,
idType: null, idType: null,
idNumber: null, idNumber: null,

View File

@ -25,7 +25,11 @@ class DocumentTemplateService {
await uow.start(); await uow.start();
const allowed = ['personal','company','both']; const allowed = ['personal','company','both'];
const user_type = allowed.includes(data.user_type || data.userType) ? (data.user_type || data.userType) : '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(); await uow.commit();
logger.info('DocumentTemplateService.uploadTemplate:success', { id: created.id }); logger.info('DocumentTemplateService.uploadTemplate:success', { id: created.id });
return created; return created;
@ -83,10 +87,17 @@ class DocumentTemplateService {
const allowed = ['personal','company','both']; const allowed = ['personal','company','both'];
if (data.userType && !allowed.includes(data.userType)) delete data.userType; if (data.userType && !allowed.includes(data.userType)) delete data.userType;
if (data.user_type && !allowed.includes(data.user_type)) delete data.user_type; 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; const newVersion = (current.version || 1) + 1;
await DocumentTemplateRepository.update( await DocumentTemplateRepository.update(
id, 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 uow.connection
); );
const updated = await DocumentTemplateRepository.findById(id, uow.connection); const updated = await DocumentTemplateRepository.findById(id, uow.connection);
@ -117,10 +128,10 @@ class DocumentTemplateService {
} }
} }
async getActiveTemplatesForUserType(userType, templateType = null) { async getActiveTemplatesForUserType(userType, templateType = null, contractType = null) {
logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType }); logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType, contractType });
try { try {
const rows = await DocumentTemplateRepository.findActiveByUserType(userType, templateType); const rows = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType);
logger.info('DocumentTemplateService.getActiveTemplatesForUserType:success', { count: rows.length }); logger.info('DocumentTemplateService.getActiveTemplatesForUserType:success', { count: rows.length });
return rows.map(t => ({ ...t, lang: t.lang })); return rows.map(t => ({ ...t, lang: t.lang }));
} catch (error) { } catch (error) {
@ -130,10 +141,10 @@ class DocumentTemplateService {
} }
// Convenience: return the most recent active template for a user type (by createdAt desc) // Convenience: return the most recent active template for a user type (by createdAt desc)
async getLatestActiveForUserType(userType, templateType = 'contract') { async getLatestActiveForUserType(userType, templateType = 'contract', contractType = null) {
logger.info('DocumentTemplateService.getLatestActiveForUserType:start', { userType, templateType }); logger.info('DocumentTemplateService.getLatestActiveForUserType:start', { userType, templateType, contractType });
try { 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; const latest = Array.isArray(list) && list.length ? list[0] : null;
logger.info('DocumentTemplateService.getLatestActiveForUserType:result', { found: !!latest, id: latest?.id }); logger.info('DocumentTemplateService.getLatestActiveForUserType:result', { found: !!latest, id: latest?.id });
return latest; return latest;