feat: add admin endpoints for listing and moving user contract documents

This commit is contained in:
seaznCode 2026-02-04 15:28:39 +01:00
parent 5764ce5cdc
commit bf8f94b848
3 changed files with 345 additions and 37 deletions

View File

@ -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/<category>/<userId>/<contract_type> 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/<category>/<userId>/<contract_type> 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(`
<html><head><meta charset="utf-8" /><title>${contractType.toUpperCase()} Preview</title></head>
<html><head><meta charset="utf-8" /><title>${resolvedContractType.toUpperCase()} Preview</title></head>
<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;" />
</body></html>
@ -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) => {

View File

@ -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);

View File

@ -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);