diff --git a/controller/dev/DevManagementController.js b/controller/dev/DevManagementController.js index e3b7914..96810eb 100644 --- a/controller/dev/DevManagementController.js +++ b/controller/dev/DevManagementController.js @@ -32,3 +32,55 @@ exports.importSqlDump = async (req, res) => { return res.status(500).json({ success: false, error: e?.message || 'SQL execution failed' }); } }; + +exports.listFolderStructureIssues = async (_req, res) => { + try { + const { list, meta } = await DevManagementService.listFolderStructureIssues(); + return res.json({ success: true, data: list, meta }); + } catch (e) { + logger.error('[DevManagementController.listFolderStructureIssues] error', { msg: e?.message }); + return res.status(500).json({ success: false, error: e?.message || 'Failed to list folder structure issues' }); + } +}; + +exports.createFolderStructure = async (req, res) => { + try { + const userId = req.body && (req.body.userId || req.body.user_id); + const { results, meta } = await DevManagementService.createFolderStructure({ userId }); + return res.json({ success: true, data: results, meta }); + } catch (e) { + logger.error('[DevManagementController.createFolderStructure] error', { msg: e?.message }); + return res.status(500).json({ success: false, error: e?.message || 'Failed to create folder structure' }); + } +}; + +exports.listLooseFiles = async (_req, res) => { + try { + const { list, meta } = await DevManagementService.listLooseFiles(); + return res.json({ success: true, data: list, meta }); + } catch (e) { + logger.error('[DevManagementController.listLooseFiles] error', { msg: e?.message }); + return res.status(500).json({ success: false, error: e?.message || 'Failed to list loose files' }); + } +}; + +exports.moveLooseFilesToContract = async (req, res) => { + try { + const userId = req.body && (req.body.userId || req.body.user_id); + const { results, meta } = await DevManagementService.moveLooseFilesToContract({ userId }); + return res.json({ success: true, data: results, meta }); + } catch (e) { + logger.error('[DevManagementController.moveLooseFilesToContract] error', { msg: e?.message }); + return res.status(500).json({ success: false, error: e?.message || 'Failed to move loose files' }); + } +}; + +exports.listGhostDirectories = async (_req, res) => { + try { + const { list, meta } = await DevManagementService.listGhostDirectories(); + return res.json({ success: true, data: list, meta }); + } catch (e) { + logger.error('[DevManagementController.listGhostDirectories] error', { msg: e?.message }); + return res.status(500).json({ success: false, error: e?.message || 'Failed to list ghost directories' }); + } +}; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index de114de..ef21c5b 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -26,6 +26,7 @@ const AffiliateController = require('../controller/affiliate/AffiliateController const AbonemmentController = require('../controller/abonemments/AbonemmentController'); const NewsController = require('../controller/news/NewsController'); const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW +const DevManagementController = require('../controller/dev/DevManagementController'); // small helpers copied from original files @@ -58,6 +59,9 @@ router.get('/admin/verification-pending-users', authMiddleware, adminOnly, Admin router.get('/admin/unverified-users', authMiddleware, adminOnly, AdminUserController.getUnverifiedUsers); router.get('/admin/user/:id/documents', authMiddleware, adminOnly, UserDocumentController.getAllDocumentsForUser); router.get('/admin/server-status', authMiddleware, adminOnly, ServerStatusController.getStatus); +router.get('/admin/dev/exoscale/folder-structure-issues', authMiddleware, adminOnly, DevManagementController.listFolderStructureIssues); +router.get('/admin/dev/exoscale/loose-files', authMiddleware, adminOnly, DevManagementController.listLooseFiles); +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); diff --git a/routes/postRoutes.js b/routes/postRoutes.js index 5dbaeb1..2a493f3 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -162,6 +162,9 @@ router.post('/admin/news', authMiddleware, adminOnly, upload.single('image'), Ne // NEW: Dev Management SQL dump import (admin + super_admin) router.post('/admin/dev/sql', authMiddleware, adminOnly, upload.single('file'), DevManagementController.importSqlDump); +// NEW: Dev Management Exoscale folder structure + loose file actions (admin + super_admin) +router.post('/admin/dev/exoscale/create-folder-structure', authMiddleware, adminOnly, DevManagementController.createFolderStructure); +router.post('/admin/dev/exoscale/move-loose-files', authMiddleware, adminOnly, DevManagementController.moveLooseFilesToContract); // Abonement POSTs router.post('/abonements/subscribe', authMiddleware, AbonemmentController.subscribe); diff --git a/services/dev/DevManagementService.js b/services/dev/DevManagementService.js index f80598b..ea3a735 100644 --- a/services/dev/DevManagementService.js +++ b/services/dev/DevManagementService.js @@ -1,4 +1,9 @@ const db = require('../../database/database'); +const AdminRepository = require('../../repositories/admin/AdminRepository'); +const { s3 } = require('../../utils/exoscaleUploader'); +const { ListObjectsV2Command, CopyObjectCommand, DeleteObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); +const { logger } = require('../../middleware/logger'); +const path = require('path'); async function executeDump(sql) { const conn = await db.getMultiStatementConnection(); @@ -10,6 +15,332 @@ async function executeDump(sql) { } } +async function listAllKeys(prefix) { + const keys = []; + let token = undefined; + do { + const res = await s3.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; +} + +async function listTopLevelFolders(prefix) { + const folders = []; + let token = undefined; + do { + const res = await s3.send(new ListObjectsV2Command({ + Bucket: process.env.EXOSCALE_BUCKET, + Prefix: prefix, + Delimiter: '/', + ContinuationToken: token + })); + const batch = (res && res.CommonPrefixes ? res.CommonPrefixes : []) + .map(item => item && item.Prefix) + .filter(Boolean); + folders.push(...batch); + token = res && res.IsTruncated ? res.NextContinuationToken : undefined; + } while (token); + return folders; +} + +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())}`; +} + +async function objectExists(key) { + try { + await s3.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; + return false; + } +} + +function appendTimestampToKey(key) { + const ext = path.posix.extname(key); + const base = ext ? key.slice(0, -ext.length) : key; + return `${base}-${getTimestamp()}${ext}`; +} + +async function getUsersForScan() { + const conn = await db.getConnection(); + try { + return await AdminRepository.getUserList(conn); + } finally { + try { conn.release(); } catch (_) {} + } +} + +function getDisplayName(user) { + if (!user) return null; + if ((user.user_type || '').toLowerCase() === 'company') return user.company_name || null; + const first = user.first_name || ''; + const last = user.last_name || ''; + const full = `${first} ${last}`.trim(); + return full || null; +} + +async function listFolderStructureIssues() { + const users = await getUsersForScan(); + const results = []; + let scannedUsers = 0; + + for (const user of users) { + const userType = (user.user_type || '').toLowerCase(); + if (!['personal', 'company'].includes(userType)) continue; + scannedUsers += 1; + + const contractCategory = userType === 'company' ? 'company' : 'personal'; + const basePrefix = `contracts/${contractCategory}/${user.id}/`; + const keys = await listAllKeys(basePrefix); + if (!keys.length) continue; + + const hasContractFolder = keys.some(k => k.startsWith(`${basePrefix}contract/`)); + const hasGdprFolder = keys.some(k => k.startsWith(`${basePrefix}gdpr/`)); + if (hasContractFolder && hasGdprFolder) continue; + + results.push({ + userId: user.id, + email: user.email, + userType, + name: getDisplayName(user), + contractCategory, + basePrefix, + totalObjects: keys.length, + hasContractFolder, + hasGdprFolder + }); + } + + return { + list: results, + meta: { + scannedUsers, + invalidCount: results.length + } + }; +} + +async function createFolderStructure({ userId } = {}) { + const { list } = await listFolderStructureIssues(); + const targetId = userId ? Number(userId) : null; + const targets = targetId ? list.filter(u => u.userId === targetId) : list; + const results = []; + let createdTotal = 0; + let errorCount = 0; + + for (const target of targets) { + const prefix = target.basePrefix; + const keys = await listAllKeys(prefix); + const hasContractFolder = keys.some(k => k.startsWith(`${prefix}contract/`)); + const hasGdprFolder = keys.some(k => k.startsWith(`${prefix}gdpr/`)); + + let created = 0; + const errors = []; + + const ensureFolder = async (folderKey) => { + try { + await s3.send(new PutObjectCommand({ + Bucket: process.env.EXOSCALE_BUCKET, + Key: folderKey, + Body: '' + })); + created += 1; + } catch (e) { + errorCount += 1; + const message = e && (e.message || e.toString()); + logger.error('DevManagementService.createFolderStructure:create_failed', { folderKey, error: message }); + errors.push({ folderKey, message }); + } + }; + + if (!hasContractFolder) await ensureFolder(`${prefix}contract/`); + if (!hasGdprFolder) await ensureFolder(`${prefix}gdpr/`); + + createdTotal += created; + results.push({ + userId: target.userId, + created, + errors + }); + } + + return { + results, + meta: { + processedUsers: targets.length, + createdTotal, + errorCount + } + }; +} + +async function listLooseFiles() { + const users = await getUsersForScan(); + const results = []; + let scannedUsers = 0; + + for (const user of users) { + const userType = (user.user_type || '').toLowerCase(); + if (!['personal', 'company'].includes(userType)) continue; + scannedUsers += 1; + + const contractCategory = userType === 'company' ? 'company' : 'personal'; + const basePrefix = `contracts/${contractCategory}/${user.id}/`; + const keys = await listAllKeys(basePrefix); + if (!keys.length) continue; + + const looseKeys = keys + .filter(k => !!k) + .filter(k => !k.endsWith('/')) + .filter(k => !k.startsWith(`${basePrefix}contract/`)) + .filter(k => !k.startsWith(`${basePrefix}gdpr/`)); + + if (!looseKeys.length) continue; + + results.push({ + userId: user.id, + email: user.email, + userType, + name: getDisplayName(user), + contractCategory, + basePrefix, + looseObjects: looseKeys.length, + sampleKeys: looseKeys.slice(0, 5) + }); + } + + return { + list: results, + meta: { + scannedUsers, + looseCount: results.length + } + }; +} + +async function moveLooseFilesToContract({ userId } = {}) { + const { list } = await listLooseFiles(); + const targetId = userId ? Number(userId) : null; + const targets = targetId ? list.filter(u => u.userId === targetId) : list; + const results = []; + let movedTotal = 0; + let errorCount = 0; + + for (const target of targets) { + const prefix = target.basePrefix; + const keys = await listAllKeys(prefix); + const toMove = keys + .filter(k => !!k) + .filter(k => !k.endsWith('/')) + .filter(k => !k.startsWith(`${prefix}contract/`)) + .filter(k => !k.startsWith(`${prefix}gdpr/`)); + + let moved = 0; + let skipped = 0; + const errors = []; + + for (const key of toMove) { + const baseName = path.posix.basename(key); + let destKey = `${prefix}contract/${baseName}`; + if (await objectExists(destKey)) { + destKey = appendTimestampToKey(destKey); + } + if (destKey === key) { + skipped += 1; + continue; + } + try { + const copySource = `${process.env.EXOSCALE_BUCKET}/${encodeURIComponent(key)}`; + await s3.send(new CopyObjectCommand({ + Bucket: process.env.EXOSCALE_BUCKET, + CopySource: copySource, + Key: destKey + })); + await s3.send(new DeleteObjectCommand({ + Bucket: process.env.EXOSCALE_BUCKET, + Key: key + })); + moved += 1; + } catch (e) { + errorCount += 1; + const message = e && (e.message || e.toString()); + logger.error('DevManagementService.moveLooseFilesToContract:move_failed', { key, destKey, error: message }); + errors.push({ key, destKey, message }); + } + } + + movedTotal += moved; + results.push({ + userId: target.userId, + moved, + skipped, + errors + }); + } + + return { + results, + meta: { + processedUsers: targets.length, + movedTotal, + errorCount + } + }; +} + +async function listGhostDirectories() { + const users = await getUsersForScan(); + const userIds = new Set(users.map(u => Number(u.id)).filter(n => Number.isFinite(n))); + const results = []; + + const categories = ['personal', 'company']; + for (const category of categories) { + const base = `contracts/${category}/`; + const prefixes = await listTopLevelFolders(base); + for (const prefix of prefixes) { + const idPart = prefix.replace(base, '').replace(/\/$/, ''); + if (!/^[0-9]+$/.test(idPart)) continue; + const uid = Number(idPart); + if (!userIds.has(uid)) { + results.push({ + userId: uid, + contractCategory: category, + basePrefix: prefix + }); + } + } + } + + return { + list: results, + meta: { + ghostCount: results.length + } + }; +} + module.exports = { - executeDump + executeDump, + listFolderStructureIssues, + createFolderStructure, + listLooseFiles, + moveLooseFilesToContract, + listGhostDirectories };