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(); try { const [rows, fields] = await conn.query(sql); return { rows, fields }; } finally { try { await conn.end(); } catch (_) {} } } 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, contractCategory: target.contractCategory, 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, contractCategory: target.contractCategory, 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, listFolderStructureIssues, createFolderStructure, listLooseFiles, moveLooseFilesToContract, listGhostDirectories };