diff --git a/controller/documentTemplate/DocumentTemplateController.js b/controller/documentTemplate/DocumentTemplateController.js index bfa69d1..514703f 100644 --- a/controller/documentTemplate/DocumentTemplateController.js +++ b/controller/documentTemplate/DocumentTemplateController.js @@ -1,6 +1,6 @@ const DocumentTemplateService = require('../../services/template/DocumentTemplateService'); const ContractUploadService = require('../../services/contracts/ContractUploadService'); -const { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3'); +const { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, CopyObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const PDFDocument = require('pdfkit'); const stream = require('stream'); @@ -253,6 +253,48 @@ function streamToString(s3BodyStream, templateId) { }); } +async function listAllKeys(s3Client, prefix) { + const keys = []; + let token = undefined; + do { + const res = await s3Client.send(new ListObjectsV2Command({ + Bucket: process.env.EXOSCALE_BUCKET, + Prefix: prefix, + ContinuationToken: token + })); + const batch = (res && res.Contents ? res.Contents : []) + .map(item => item && item.Key) + .filter(Boolean); + keys.push(...batch); + token = res && res.IsTruncated ? res.NextContinuationToken : undefined; + } while (token); + return keys; +} + +function getTimestamp() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; +} + +function appendTimestampToKey(key) { + const ext = path.posix.extname(key); + const base = ext ? key.slice(0, -ext.length) : key; + return `${base}-${getTimestamp()}${ext}`; +} + +async function objectExists(s3Client, key) { + try { + const { HeadObjectCommand } = require('@aws-sdk/client-s3'); + await s3Client.send(new HeadObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key })); + return true; + } catch (e) { + const status = e && e.$metadata && e.$metadata.httpStatusCode; + if (status === 404) return false; + throw e; + } +} + function formatS3Error(err) { if (!err) return null; if (typeof err === 'string') return { message: err }; @@ -1423,6 +1465,9 @@ exports.previewLatestForUser = async (req, res) => { const allowedContractTypes = ['contract', 'gdpr']; const contractType = allowedContractTypes.includes(contractTypeParam) ? contractTypeParam : 'contract'; + const documentId = parseInt((req.query.documentId || req.query.document_id || '').toString(), 10); + const objectKeyParam = (req.query.objectKey || req.query.object_key || '').toString(); + let folderStructureWarning = null; logger.info('[previewLatestForUser] start', { targetUserId, contractType, requestId: req.id }); @@ -1492,40 +1537,75 @@ exports.previewLatestForUser = async (req, res) => { return { ...payload, warning: folderStructureWarning }; }; - // 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(','); - - // Fetch latest uploaded document for this type from user_documents + // Fetch a specific document by id (if provided), by objectKey, otherwise latest by contractType let doc = null; - try { - const [rows] = await db.execute( - `SELECT object_storage_id - FROM user_documents - WHERE user_id = ? - 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, ...docTypes, contractType, contractType] - ); - 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] user_documents lookup failed', e && (e.stack || e.message)); + let resolvedContractType = contractType; + if (Number.isFinite(documentId) && documentId > 0) { + try { + const [rows] = await db.execute( + `SELECT id, object_storage_id, contract_type + FROM user_documents + WHERE id = ? AND user_id = ? + LIMIT 1`, + [documentId, targetUserId] + ); + const arr = Array.isArray(rows) ? rows : (rows ? [rows] : []); + doc = arr[0] || null; + if (doc && doc.contract_type && allowedContractTypes.includes(String(doc.contract_type).toLowerCase())) { + resolvedContractType = String(doc.contract_type).toLowerCase(); + } else if (doc && doc.object_storage_id && /\/gdpr\//i.test(doc.object_storage_id)) { + resolvedContractType = 'gdpr'; + } + logger.info('[previewLatestForUser] user_documents lookup by documentId', { targetUserId, documentId, resolvedContractType, found: !!doc }); + } catch (e) { + logger.warn('[previewLatestForUser] user_documents lookup by documentId failed', e && (e.stack || e.message)); + } + } else if (objectKeyParam) { + const key = objectKeyParam; + const basePrefix = `contracts/${contractCategory}/${targetUserId}/`; + if (!key.startsWith(basePrefix)) { + return res.status(400).json({ error: 'Invalid object key' }); + } + if (key.includes('/gdpr/')) resolvedContractType = 'gdpr'; + else if (key.includes('/contract/')) resolvedContractType = 'contract'; + doc = { object_storage_id: key }; + logger.info('[previewLatestForUser] using objectKey directly', { targetUserId, resolvedContractType, key }); + } else { + // 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(','); + + try { + const [rows] = await db.execute( + `SELECT object_storage_id + FROM user_documents + WHERE user_id = ? + 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, ...docTypes, contractType, contractType] + ); + 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] user_documents lookup failed', e && (e.stack || e.message)); + } } + const activeContractType = resolvedContractType || contractType; + if (!doc || !doc.object_storage_id) { if (folderStructureWarning) res.setHeader('X-Contract-Preview-Warning', folderStructureWarning); - return res.status(404).json(jsonWithWarning({ message: `No uploaded ${contractType.toUpperCase()} file found for this user` })); + return res.status(404).json(jsonWithWarning({ message: `No uploaded ${activeContractType.toUpperCase()} file found for this user` })); } try { @@ -1543,7 +1623,7 @@ exports.previewLatestForUser = async (req, res) => { bucket: process.env.EXOSCALE_BUCKET, key: doc.object_storage_id, userId: targetUserId, - contractType + contractType: activeContractType }); const cmd = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: doc.object_storage_id }); let fileObj; @@ -1569,14 +1649,14 @@ exports.previewLatestForUser = async (req, res) => { } 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 }); + logger.warn('[previewLatestForUser] S3 returned empty Body', { key: doc.object_storage_id, userId: targetUserId, contractType: activeContractType }); if (folderStructureWarning) res.setHeader('X-Contract-Preview-Warning', folderStructureWarning); - return res.status(404).json(jsonWithWarning({ message: `${contractType.toUpperCase()} file not available` })); + return res.status(404).json(jsonWithWarning({ message: `${activeContractType.toUpperCase()} file not available` })); } const b64 = pdfBuffer.toString('base64'); const html = ensureHtmlDocument(` - ${contractType.toUpperCase()} Preview + ${resolvedContractType.toUpperCase()} Preview @@ -1586,14 +1666,14 @@ exports.previewLatestForUser = async (req, res) => { 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 }); + logger.warn('[previewLatestForUser] object missing in storage', { key: doc.object_storage_id, userId: targetUserId, contractType: activeContractType }); if (folderStructureWarning) res.setHeader('X-Contract-Preview-Warning', folderStructureWarning); - return res.status(404).json(jsonWithWarning({ message: `${contractType.toUpperCase()} file not available` })); + return res.status(404).json(jsonWithWarning({ message: `${activeContractType.toUpperCase()} file not available` })); } logger.error('[previewLatestForUser] S3 fetch failed', { key: doc.object_storage_id, userId: targetUserId, - contractType, + contractType: activeContractType, error: formatS3Error(e) }); if (folderStructureWarning) res.setHeader('X-Contract-Preview-Warning', folderStructureWarning); @@ -1605,6 +1685,227 @@ exports.previewLatestForUser = async (req, res) => { } }; +// NEW: Admin-only endpoint to list contract files from object storage for a user +// GET /api/admin/contracts/:id/files?userType=personal|company +exports.listUserContractFiles = async (req, res) => { + const targetUserId = parseInt(req.params.id, 10); + 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 { + const [uRows] = await db.execute('SELECT id, user_type FROM users WHERE id = ? LIMIT 1', [targetUserId]); + const userRow = (uRows && uRows[0]) ? uRows[0] : null; + const userType = (userRow && userRow.user_type) ? String(userRow.user_type).toLowerCase() : null; + const contractCategory = userType === 'company' ? 'company' : 'personal'; + + const s3Client = 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 + } + }); + + const basePrefix = `contracts/${contractCategory}/${targetUserId}/`; + const contractPrefix = `${basePrefix}contract/`; + const gdprPrefix = `${basePrefix}gdpr/`; + + const [contractKeysRaw, gdprKeysRaw, docRows] = await Promise.all([ + listAllKeys(s3Client, contractPrefix), + listAllKeys(s3Client, gdprPrefix), + db.execute( + `SELECT id, object_storage_id, contract_type + FROM user_documents + WHERE user_id = ? AND document_type = 'contract'`, + [targetUserId] + ) + ]); + const normalizeKeys = (keys) => (Array.isArray(keys) ? keys : []) + .filter(k => !!k && !k.endsWith('/')) + .filter(k => /\.pdf$/i.test(String(k))); + const contractKeys = normalizeKeys(contractKeysRaw); + const gdprKeys = normalizeKeys(gdprKeysRaw); + const rows = Array.isArray(docRows && docRows[0]) ? docRows[0] : []; + const idMap = new Map(rows.map(r => [String(r.object_storage_id), r])); + + const toItem = (key) => { + const doc = idMap.get(String(key)); + return { + key, + filename: path.posix.basename(key), + documentId: doc ? doc.id : null, + contract_type: doc ? doc.contract_type : null + }; + }; + + return res.json({ + userType, + contract: contractKeys.map(toItem), + gdpr: gdprKeys.map(toItem) + }); + } catch (err) { + logger.error('[listUserContractFiles] error', err && err.stack ? err.stack : err); + return res.status(500).json({ error: 'Failed to list user contract files' }); + } +}; + +// NEW: Admin-only endpoint to list contract documents for a user +// GET /api/admin/contracts/:id/documents?userType=personal|company +exports.listUserContractDocuments = async (req, res) => { + const targetUserId = parseInt(req.params.id, 10); + 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 { + let userRow = null; + try { + const [uRows] = await db.execute('SELECT id, email, user_type FROM users WHERE id = ? LIMIT 1', [targetUserId]); + userRow = (uRows && uRows[0]) ? uRows[0] : null; + } catch (e) { + logger.warn('[listUserContractDocuments] failed to load users row', e && e.message); + } + + const [rows] = await db.execute( + `SELECT id, user_id, document_type, contract_type, object_storage_id, original_filename, file_size, mime_type, upload_at + FROM user_documents + WHERE user_id = ? AND document_type = 'contract' + ORDER BY upload_at DESC, id DESC`, + [targetUserId] + ); + + const docs = (Array.isArray(rows) ? rows : []).map((d) => { + const key = d.object_storage_id || ''; + let folderType = 'loose'; + if (/\/gdpr\//i.test(key)) folderType = 'gdpr'; + else if (/\/contract\//i.test(key)) folderType = 'contract'; + return { + ...d, + folderType + }; + }); + + return res.json({ + userType: userRow ? userRow.user_type : null, + documents: docs + }); + } catch (err) { + logger.error('[listUserContractDocuments] error', err && err.stack ? err.stack : err); + return res.status(500).json({ error: 'Failed to list user contract documents' }); + } +}; + +// NEW: Admin-only endpoint to move a contract document between /contract and /gdpr +// POST /api/admin/contracts/:id/move { documentId, targetType } +exports.moveUserContractDocument = async (req, res) => { + const targetUserId = parseInt(req.params.id, 10); + const { documentId, targetType, objectKey } = req.body || {}; + const allowedTypes = ['contract', 'gdpr']; + const target = (targetType || '').toString().toLowerCase(); + + 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' }); + } + if ((!Number.isFinite(documentId) || documentId <= 0) && !objectKey) { + return res.status(400).json({ error: 'Invalid document id' }); + } + if (!allowedTypes.includes(target)) { + return res.status(400).json({ error: 'Invalid target type' }); + } + + try { + const [uRows] = await db.execute('SELECT id, user_type FROM users WHERE id = ? LIMIT 1', [targetUserId]); + const userRow = (uRows && uRows[0]) ? uRows[0] : null; + const userType = (userRow && userRow.user_type) ? String(userRow.user_type).toLowerCase() : null; + const contractCategory = userType === 'company' ? 'company' : 'personal'; + + let currentKey = null; + if (objectKey) { + const key = String(objectKey); + const basePrefix = `contracts/${contractCategory}/${targetUserId}/`; + if (!key.startsWith(basePrefix) || (!key.includes('/contract/') && !key.includes('/gdpr/'))) { + return res.status(400).json({ error: 'Invalid object key' }); + } + currentKey = key; + } else { + const [rows] = await db.execute( + `SELECT id, object_storage_id, contract_type + FROM user_documents + WHERE id = ? AND user_id = ? + LIMIT 1`, + [documentId, targetUserId] + ); + const doc = (rows && rows[0]) ? rows[0] : null; + if (!doc || !doc.object_storage_id) { + return res.status(404).json({ error: 'Document not found' }); + } + currentKey = String(doc.object_storage_id); + } + const fileName = path.posix.basename(currentKey); + const basePrefix = `contracts/${contractCategory}/${targetUserId}/`; + let destKey = `${basePrefix}${target}/${fileName}`; + + const s3Client = 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 + } + }); + + if (currentKey !== destKey) { + try { + if (await objectExists(s3Client, destKey)) { + destKey = appendTimestampToKey(destKey); + } + } catch (e) { + logger.warn('[moveUserContractDocument] objectExists failed', { destKey, error: formatS3Error(e) }); + } + + const copySource = `${process.env.EXOSCALE_BUCKET}/${encodeURIComponent(currentKey)}`; + await s3Client.send(new CopyObjectCommand({ + Bucket: process.env.EXOSCALE_BUCKET, + CopySource: copySource, + Key: destKey + })); + await s3Client.send(new DeleteObjectCommand({ + Bucket: process.env.EXOSCALE_BUCKET, + Key: currentKey + })); + } + + await db.execute( + `UPDATE user_documents SET object_storage_id = ?, contract_type = ? WHERE object_storage_id = ?`, + [destKey, target, currentKey] + ); + + return res.json({ + success: true, + documentId, + from: currentKey, + to: destKey, + contract_type: target + }); + } catch (err) { + logger.error('[moveUserContractDocument] error', err && err.stack ? err.stack : err); + return res.status(500).json({ error: 'Failed to move document' }); + } +}; + // 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) => { diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 5cfe501..6f7b7be 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -64,6 +64,10 @@ router.get('/admin/dev/exoscale/loose-files', authMiddleware, adminOnly, DevMana router.get('/admin/dev/exoscale/ghost-directories', authMiddleware, adminOnly, DevManagementController.listGhostDirectories); // Contract preview for admin: latest active by user type router.get('/admin/contracts/:id/preview', authMiddleware, adminOnly, DocumentTemplateController.previewLatestForUser); +// Admin: list all contract documents for a user +router.get('/admin/contracts/:id/documents', authMiddleware, adminOnly, DocumentTemplateController.listUserContractDocuments); +// Admin: list contract files from object storage for a user +router.get('/admin/contracts/:id/files', authMiddleware, adminOnly, DocumentTemplateController.listUserContractFiles); // permissions.js GETs router.get('/permissions', authMiddleware, PermissionController.list); diff --git a/routes/postRoutes.js b/routes/postRoutes.js index 313db69..4f9b9d6 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -168,6 +168,9 @@ router.post('/admin/dev/sql', authMiddleware, adminOnly, upload.single('file'), router.post('/admin/dev/exoscale/create-folder-structure', authMiddleware, adminOnly, DevManagementController.createFolderStructure); router.post('/admin/dev/exoscale/move-loose-files', authMiddleware, adminOnly, DevManagementController.moveLooseFilesToContract); +// NEW: Admin move contract documents between /contract and /gdpr +router.post('/admin/contracts/:id/move', authMiddleware, adminOnly, DocumentTemplateController.moveUserContractDocument); + // Abonement POSTs router.post('/abonements/subscribe', authMiddleware, AbonemmentController.subscribe); router.post('/abonements/:id/pause', authMiddleware, AbonemmentController.pause);