Merge pull request 'feat: add endpoints for admin and authenticated user to preview latest active contracts with DB-filled placeholders' (#3) from sz/contract-mgmt into main

Reviewed-on: #3
This commit is contained in:
Seazn 2025-11-08 14:59:29 +00:00
commit cebe1a1549
3 changed files with 257 additions and 0 deletions

View File

@ -1277,6 +1277,244 @@ 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
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();
if (!req.user || !['admin', 'super_admin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden: Admins only' });
}
if (!Number.isFinite(targetUserId) || targetUserId <= 0) {
return res.status(400).json({ error: 'Invalid user id' });
}
try {
// Resolve target user and determine user_type if not overridden
let userRow = null;
try {
const [uRows] = await db.execute('SELECT id, email, user_type, role FROM users WHERE id = ? LIMIT 1', [targetUserId]);
userRow = (uRows && uRows[0]) ? uRows[0] : null;
} catch (e) {
logger.error('[previewLatestForUser] failed to load users row', e && e.message);
}
if (!userRow) {
return res.status(404).json({ error: 'User not found' });
}
const userType = (overrideUserType === 'personal' || overrideUserType === 'company') ? overrideUserType : userRow.user_type;
// Find the latest active template for this user type
const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, templateType);
if (!latest) {
return res.status(404).json({ error: 'No active template found for user type' });
}
// Fetch template HTML from S3
const s3 = new S3Client({
region: process.env.EXOSCALE_REGION,
endpoint: process.env.EXOSCALE_ENDPOINT,
credentials: {
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
}
});
const command = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: latest.storageKey });
const fileObj = await s3.send(command);
if (!fileObj.Body) {
return res.status(404).json({ error: 'Template file not available' });
}
let html = await streamToString(fileObj.Body, latest.id);
// Build 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(', ');
}
} 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) {
logger.error('[previewLatestForUser] error', err && err.stack ? err.stack : err);
return res.status(500).json({ error: 'Failed to render preview' });
}
};
// NEW: Authenticated user endpoint to preview their own latest active contract with DB-filled placeholders
// GET /api/contracts/preview/latest
exports.previewLatestForMe = async (req, res) => {
if (!req.user) return res.status(401).json({ error: 'Unauthorized' });
const targetUserId = req.user.id || req.user.userId;
const userType = req.user.user_type || req.user.userType;
if (!targetUserId || !userType) return res.status(400).json({ error: 'Invalid authenticated user' });
try {
// Find the latest active template for this user type
const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract');
if (!latest) {
return res.status(404).json({ error: 'No active template found for your user type' });
}
// Fetch template HTML from storage
const s3 = new S3Client({
region: process.env.EXOSCALE_REGION,
endpoint: process.env.EXOSCALE_ENDPOINT,
credentials: {
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
secretAccessKey: process.env.EXOSCALE_SECRET_KEY
}
});
const command = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: latest.storageKey });
const fileObj = await s3.send(command);
if (!fileObj.Body) return res.status(404).json({ error: 'Template file not available' });
let html = await streamToString(fileObj.Body, latest.id);
// Build variables from the authenticated user's DB data
const vars = {};
// base fields
vars.email = req.user.email || '';
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
vars.currentDate = `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
if (userType === 'personal') {
try {
const [rows] = await db.execute('SELECT * FROM personal_profiles WHERE user_id = ? LIMIT 1', [targetUserId]);
const p = rows && rows[0] ? rows[0] : null;
if (p) {
const fullName = `${p.first_name || ''} ${p.last_name || ''}`.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 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(', ');
}
} catch (e) {
logger.warn('[previewLatestForMe] personal profile lookup failed', e && e.message);
}
} else if (userType === 'company') {
try {
const [rows] = await db.execute('SELECT * FROM company_profiles WHERE user_id = ? LIMIT 1', [targetUserId]);
const c = rows && rows[0] ? rows[0] : null;
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 || req.user.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(', ');
}
} catch (e) {
logger.warn('[previewLatestForMe] company profile lookup failed', e && e.message);
}
}
// Replace placeholders
Object.entries(vars).forEach(([k, v]) => {
html = html.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v ?? ''));
});
// Apply company stamp and signature where applicable
try { html = await applyCompanyStampPlaceholders(html, req); } catch (e) {}
try { html = await applyProfitPlanetSignature(html); } catch (e) {}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
return res.send(ensureHtmlDocument(html));
} catch (err) {
logger.error('[previewLatestForMe] error', err && err.stack ? err.stack : err);
return res.status(500).json({ error: 'Failed to render preview' });
}
};
// NEW controller: download sanitized PDF (variables emptied)
// Keeps stamp/signature placeholders so company stamps or profitplanetSignature can still be applied if available.
exports.downloadPdf = async (req, res) => {

View File

@ -59,6 +59,8 @@ router.get('/admin/verification-pending-users', authMiddleware, requireAdmin, Ad
router.get('/admin/unverified-users', authMiddleware, requireAdmin, AdminUserController.getUnverifiedUsers);
router.get('/admin/user/:id/documents', authMiddleware, requireAdmin, UserDocumentController.getAllDocumentsForUser);
router.get('/admin/server-status', authMiddleware, requireAdmin, ServerStatusController.getStatus);
// Contract preview for admin: latest active by user type
router.get('/admin/contracts/:id/preview', authMiddleware, requireAdmin, DocumentTemplateController.previewLatestForUser);
// permissions.js GETs
router.get('/permissions', authMiddleware, PermissionController.list);
@ -86,6 +88,9 @@ router.get('/contracts/company', authMiddleware, (req, res) => {
});
});
// User: preview latest active contract (HTML) for authenticated user
router.get('/contracts/preview/latest', authMiddleware, DocumentTemplateController.previewLatestForMe);
// documentTemplates.js GETs
router.get('/document-templates', authMiddleware, DocumentTemplateController.listTemplates);
router.get('/document-templates/:id', authMiddleware, DocumentTemplateController.getTemplate);

View File

@ -128,6 +128,20 @@ class DocumentTemplateService {
throw error;
}
}
// 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 });
try {
const list = await DocumentTemplateRepository.findActiveByUserType(userType, templateType);
const latest = Array.isArray(list) && list.length ? list[0] : null;
logger.info('DocumentTemplateService.getLatestActiveForUserType:result', { found: !!latest, id: latest?.id });
return latest;
} catch (error) {
logger.error('DocumentTemplateService.getLatestActiveForUserType:error', { error: error.message });
throw error;
}
}
// NEW: Retrieve Profit Planet signature (company stamp) as <img> tag
// Fallback order: