feat: implement Dev Management features for folder structure and loose file handling and ghost dirs

This commit is contained in:
seaznCode 2026-01-20 20:18:24 +01:00
parent e2a6b215d5
commit 295bb85536
4 changed files with 391 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@ -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) {
}
}
module.exports = {
executeDump
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,
listFolderStructureIssues,
createFolderStructure,
listLooseFiles,
moveLooseFilesToContract,
listGhostDirectories
};