diff --git a/controller/matrix/MatrixController.js b/controller/matrix/MatrixController.js index b07bfd3..1971b22 100644 --- a/controller/matrix/MatrixController.js +++ b/controller/matrix/MatrixController.js @@ -197,6 +197,39 @@ async function getMyMatrixSummary(req, res) { } } +async function removeUser(req, res) { + try { + const { matrixInstanceId, userId } = req.body; + const data = await MatrixService.removeUser({ matrixInstanceId, userId, actorUser: req.user }); + return res.json({ success: true, data }); + } catch (err) { + const status = err.status || 500; + return res.status(status).json({ success: false, message: err.message || 'Could not remove user' }); + } +} + +async function assignVacancy(req, res) { + try { + const { matrixInstanceId, parentUserId, position, userId } = req.body; + const data = await MatrixService.assignVacancy({ matrixInstanceId, parentUserId, position, userId, actorUser: req.user }); + return res.json({ success: true, data }); + } catch (err) { + const status = err.status || 500; + return res.status(status).json({ success: false, message: err.message || 'Could not assign vacancy' }); + } +} + +async function listVacancies(req, res) { + try { + const matrixInstanceId = req.query.matrixInstanceId || req.query.matrixId || req.query.id; + const data = await MatrixService.listVacancies({ matrixInstanceId, actorUser: req.user }); + return res.json({ success: true, data }); + } catch (err) { + const status = err.status || 500; + return res.status(status).json({ success: false, message: err.message || 'Could not list vacancies' }); + } +} + module.exports = { create, stats, @@ -208,5 +241,8 @@ module.exports = { activate, // NEW listMyMatrices, // NEW getMyOverviewByInstance, // NEW - getMyMatrixSummary // NEW + getMyMatrixSummary, // NEW + removeUser, // NEW + assignVacancy, // NEW + listVacancies // NEW }; diff --git a/repositories/matrix/MatrixRepository.js b/repositories/matrix/MatrixRepository.js index 4eea9c3..78b8723 100644 --- a/repositories/matrix/MatrixRepository.js +++ b/repositories/matrix/MatrixRepository.js @@ -403,19 +403,13 @@ async function getUserSearchCandidates({ q, type = 'all', rootUserId, matrixInst async function getMatrixUsers({ rootUserId, matrixInstanceId, maxDepth = 5, limit = 100, offset = 0, includeRoot = false, rogueOnly = false }) { const conn = await pool.getConnection(); try { - let policyDepth = null; - try { - const [pRows] = await conn.query( - `SELECT max_depth FROM matrix_instances WHERE id = ? LIMIT 1`, - [matrixInstanceId] - ); - if (pRows.length) policyDepth = pRows[0].max_depth == null ? null : Number(pRows[0].max_depth); - } catch (_) {} - + // Determine policy: root unlimited, ego capped at 5 + let startDepth = includeRoot ? 0 : 1; + const [instRows] = await conn.query(`SELECT root_user_id FROM matrix_instances WHERE id = ? LIMIT 1`, [matrixInstanceId]); + const isRootAnchor = Number(instRows[0]?.root_user_id) === Number(rootUserId); let requestedDepth = Number(maxDepth); - if (!Number.isFinite(requestedDepth) || requestedDepth < 0) requestedDepth = 0; - const depthLimit = policyDepth == null ? requestedDepth : Math.min(requestedDepth, policyDepth); - const startDepth = includeRoot ? 0 : 1; + if (!Number.isFinite(requestedDepth) || requestedDepth < 0) requestedDepth = isRootAnchor ? 20 : 5; + const depthLimit = isRootAnchor ? requestedDepth : Math.min(requestedDepth, 5); if (startDepth > depthLimit) return []; const rogueClause = rogueOnly ? 'AND (e.rogue_user = TRUE)' : ''; @@ -968,6 +962,187 @@ async function activateInstance(instanceId) { } } +// NEW: ensure vacancy table exists +async function ensureVacancyTable(conn) { + await conn.query(` + CREATE TABLE IF NOT EXISTS matrix_vacancies ( + id INT AUTO_INCREMENT PRIMARY KEY, + matrix_instance_id INT NOT NULL, + parent_user_id INT NOT NULL, + position INT NOT NULL, + depth INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_vacancy (matrix_instance_id, parent_user_id, position) + ) + `); +} + +// NEW: remove user and create vacancy +async function removeUserAndCreateVacancy(matrixInstanceId, userId) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const mid = Number(matrixInstanceId); + const uid = Number(userId); + if (!Number.isFinite(mid) || mid <= 0 || !Number.isFinite(uid) || uid <= 0) { + const err = new Error('Invalid parameters'); + err.status = 400; + throw err; + } + const [rootRow] = await conn.query(`SELECT root_user_id FROM matrix_instances WHERE id = ? LIMIT 1`, [mid]); + if (!rootRow.length) { const err = new Error('Matrix instance not found'); err.status = 404; throw err; } + if (Number(rootRow[0].root_user_id) === uid) { + const err = new Error('Cannot remove root user'); + err.status = 400; + throw err; + } + const [edgeRows] = await conn.query( + `SELECT parent_user_id, position FROM user_tree_edges WHERE matrix_instance_id = ? AND child_user_id = ? LIMIT 1`, + [mid, uid] + ); + if (!edgeRows.length) { const err = new Error('User not in matrix'); err.status = 404; throw err; } + const parentId = Number(edgeRows[0].parent_user_id); + const pos = Number(edgeRows[0].position); + await ensureVacancyTable(conn); + const [depthRows] = await conn.query( + `SELECT depth FROM user_tree_closure WHERE matrix_instance_id = ? AND ancestor_user_id = ? AND descendant_user_id = ? LIMIT 1`, + [mid, rootRow[0].root_user_id, parentId] + ); + const depth = Number(depthRows[0]?.depth || 0) + 1; + await conn.query( + `INSERT INTO matrix_vacancies (matrix_instance_id, parent_user_id, position, depth) + VALUES (?,?,?,?) + ON DUPLICATE KEY UPDATE depth = VALUES(depth), created_at = CURRENT_TIMESTAMP`, + [mid, parentId, pos, depth] + ); + // delete subtree closure and edges + await conn.query( + `DELETE c FROM user_tree_closure c + JOIN user_tree_closure d ON d.matrix_instance_id = c.matrix_instance_id AND d.descendant_user_id = c.descendant_user_id + WHERE d.matrix_instance_id = ? AND d.ancestor_user_id = ?`, + [mid, uid] + ); + await conn.query( + `DELETE FROM user_tree_edges WHERE matrix_instance_id = ? AND child_user_id IN ( + SELECT descendant_user_id FROM user_tree_closure WHERE matrix_instance_id = ? AND ancestor_user_id = ? + )`, + [mid, mid, uid] + ); + await conn.commit(); + return { matrixInstanceId: mid, vacancy: { parentUserId: parentId, position: pos, depth } }; + } catch (e) { + try { await conn.rollback(); } catch (_) {} + throw e; + } finally { + conn.release(); + } +} + +// NEW: list vacancies +async function listVacancies(matrixInstanceId) { + const conn = await pool.getConnection(); + try { + const mid = Number(matrixInstanceId); + if (!Number.isFinite(mid) || mid <= 0) return []; + await ensureVacancyTable(conn); + const [rootRow] = await conn.query(`SELECT root_user_id FROM matrix_instances WHERE id = ? LIMIT 1`, [mid]); + const rootId = Number(rootRow[0]?.root_user_id || 0); + const [rows] = await conn.query( + `SELECT v.matrix_instance_id AS matrixInstanceId, v.parent_user_id AS parentUserId, v.position, v.depth, v.created_at AS createdAt + FROM matrix_vacancies v + WHERE v.matrix_instance_id = ? + ORDER BY v.created_at DESC`, + [mid] + ); + return rows.map(r => ({ + matrixInstanceId: mid, + parentUserId: Number(r.parentUserId), + position: Number(r.position), + depth: Number(r.depth), + root: rootId === Number(r.parentUserId), + createdAt: r.createdAt + })); + } finally { + conn.release(); + } +} + +// NEW: assign user to vacancy +async function assignUserToVacancy({ matrixInstanceId, parentUserId, position, userId }) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const mid = Number(matrixInstanceId); + const pid = Number(parentUserId); + const pos = Number(position); + const uid = Number(userId); + if (![mid, pid, pos, uid].every(n => Number.isFinite(n) && n > 0)) { + const err = new Error('Invalid parameters'); + err.status = 400; + throw err; + } + await ensureVacancyTable(conn); + const [vacRows] = await conn.query( + `SELECT depth FROM matrix_vacancies WHERE matrix_instance_id = ? AND parent_user_id = ? AND position = ? LIMIT 1`, + [mid, pid, pos] + ); + if (!vacRows.length) { const err = new Error('Vacancy not found'); err.status = 404; throw err; } + const depthFromParent = Number(vacRows[0].depth); + const [rootRow] = await conn.query(`SELECT root_user_id FROM matrix_instances WHERE id = ? LIMIT 1`, [mid]); + const rootId = Number(rootRow[0]?.root_user_id || 0); + const [depthRows] = await conn.query( + `SELECT depth FROM user_tree_closure WHERE matrix_instance_id = ? AND ancestor_user_id = ? AND descendant_user_id = ? LIMIT 1`, + [mid, rootId, pid] + ); + const parentDepth = Number(depthRows[0]?.depth || 0); + if (pid !== rootId && parentDepth + 1 > 5) { + const err = new Error('Depth exceeds ego policy'); + err.status = 400; + throw err; + } + const [inMatrix] = await conn.query( + `SELECT 1 FROM user_tree_closure WHERE matrix_instance_id = ? AND descendant_user_id = ? LIMIT 1`, + [mid, uid] + ); + if (inMatrix.length) { + const err = new Error('User already in matrix'); + err.status = 409; + throw err; + } + await conn.query( + `INSERT INTO user_tree_edges (matrix_instance_id, parent_user_id, child_user_id, position, rogue_user) + VALUES (?,?,?,?,FALSE)`, + [mid, pid, uid, pos] + ); + await conn.query( + `INSERT IGNORE INTO user_tree_closure (matrix_instance_id, ancestor_user_id, descendant_user_id, depth) + VALUES (?,?,?,0)`, + [mid, uid, uid] + ); + const [ancRows] = await conn.query( + `SELECT ancestor_user_id, depth FROM user_tree_closure WHERE matrix_instance_id = ? AND descendant_user_id = ?`, + [mid, pid] + ); + if (ancRows.length) { + const values = ancRows.map(r => `(${mid}, ${Number(r.ancestor_user_id)}, ${uid}, ${Number(r.depth) + 1})`).join(','); + await conn.query( + `INSERT IGNORE INTO user_tree_closure (matrix_instance_id, ancestor_user_id, descendant_user_id, depth) VALUES ${values}` + ); + } + await conn.query( + `DELETE FROM matrix_vacancies WHERE matrix_instance_id = ? AND parent_user_id = ? AND position = ?`, + [mid, pid, pos] + ); + await conn.commit(); + return { matrixInstanceId: mid, parentUserId: pid, position: pos, userId: uid }; + } catch (e) { + try { await conn.rollback(); } catch (_) {} + throw e; + } finally { + conn.release(); + } +} + module.exports = { createMatrix, ensureUserExistsByEmail, @@ -982,5 +1157,8 @@ module.exports = { deactivateInstance, // NEW activateInstance, // NEW listInstancesForUser, // NEW - userBelongsToInstance // NEW + userBelongsToInstance, // NEW + removeUserAndCreateVacancy, // NEW + listVacancies, // NEW + assignUserToVacancy // NEW }; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 4fa57a3..8ee90b1 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -134,8 +134,11 @@ router.get('/matrix/:id/overview', authMiddleware, MatrixController.getMyOvervie router.get('/matrix/:id/summary', authMiddleware, MatrixController.getMyMatrixSummary); // Tax GETs -router.get('/tax/vat-rates', authMiddleware, adminOnly, TaxController.getAllVatRates); +router.get('/tax/vat-rates', authMiddleware, TaxController.getAllVatRates); router.get('/tax/vat-history/:countryCode', authMiddleware, adminOnly, TaxController.getVatHistory); +// NEW: Admin list vacancies for a matrix +router.get('/admin/matrix/vacancies', authMiddleware, adminOnly, MatrixController.listVacancies); + // export module.exports = router; \ No newline at end of file diff --git a/routes/postRoutes.js b/routes/postRoutes.js index d680c61..1e3d079 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -123,6 +123,10 @@ router.post('/company-stamps', authMiddleware, adminOnly, forceCompanyForAdmin, router.post('/admin/coffee', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.create); // NEW: add user into matrix router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser); // already added +// NEW: remove matrix user and create vacancy +router.post('/admin/matrix/remove-user', authMiddleware, adminOnly, MatrixController.removeUser); +// NEW: assign user to vacancy +router.post('/admin/matrix/assign-vacancy', authMiddleware, adminOnly, MatrixController.assignVacancy); // NEW: Admin create pool router.post('/admin/pools', authMiddleware, adminOnly, PoolController.create); // NEW: import VAT rates CSV diff --git a/services/matrix/MatrixService.js b/services/matrix/MatrixService.js index 961e001..106c1e8 100644 --- a/services/matrix/MatrixService.js +++ b/services/matrix/MatrixService.js @@ -320,9 +320,11 @@ async function getMyOverview({ userId, actorUser }) { // Load instance policy and root user const instanceInfo = await MatrixRepository.getInstanceInfo(mid); // helper added below - const policyDepth = instanceInfo?.max_depth == null ? 5 : Number(instanceInfo.max_depth); const rootUserId = Number(instanceInfo?.root_user_id || 0); const isRoot = rootUserId === uid; + const policyDepth = instanceInfo?.max_depth == null + ? (isRoot ? 20 : 5) + : (isRoot ? Math.max(20, Number(instanceInfo.max_depth)) : Math.min(5, Number(instanceInfo.max_depth))); // Fetch descendants anchored at requester const users = await MatrixRepository.getMatrixUsers({ @@ -413,7 +415,9 @@ async function listMyMatrices({ userId, actorUser }) { const mid = Number(m.id ?? m.matrixInstanceId); // Load instance policy and root user const instanceInfo = await MatrixRepository.getInstanceInfo(mid); - const policyDepth = instanceInfo?.max_depth == null ? 5 : Number(instanceInfo.max_depth); + const policyDepth = instanceInfo?.max_depth == null + ? (instanceInfo?.root_user_id === uid ? 20 : 5) + : (instanceInfo?.root_user_id === uid ? Math.max(20, Number(instanceInfo.max_depth)) : Math.min(5, Number(instanceInfo.max_depth))); const users = await MatrixRepository.getMatrixUsers({ rootUserId: uid, @@ -500,9 +504,11 @@ async function getMyOverviewByInstance({ userId, matrixInstanceId, actorUser }) err.status = 404; throw err; } - const policyDepth = instanceInfo?.max_depth == null ? 5 : Number(instanceInfo.max_depth); const rootUserId = Number(instanceInfo?.root_user_id || 0); const isRoot = rootUserId === uid; + const policyDepth = instanceInfo?.max_depth == null + ? (isRoot ? 20 : 5) + : (isRoot ? Math.max(20, Number(instanceInfo.max_depth)) : Math.min(5, Number(instanceInfo.max_depth))); // Fetch descendants anchored at requester within this instance const users = await MatrixRepository.getMatrixUsers({ @@ -602,7 +608,7 @@ async function getMyMatrixSummary({ userId, matrixInstanceId, actorUser }) { err.status = 404; throw err; } - const policyDepth = instanceInfo?.max_depth == null ? 5 : Number(instanceInfo.max_depth); + const policyDepth = instanceInfo?.max_depth == null ? (instanceInfo?.root_user_id === uid ? 20 : 5) : (instanceInfo?.root_user_id === uid ? Math.max(20, Number(instanceInfo.max_depth)) : Math.min(5, Number(instanceInfo.max_depth))); // Fetch all descendants anchored at requester within this instance const users = await MatrixRepository.getMatrixUsers({ @@ -719,6 +725,34 @@ async function activate({ matrixInstanceId, matrixId, rootUserId, topNodeEmail, }; } +// NEW: remove user and create vacancy (admin) +async function removeUser({ matrixInstanceId, userId, actorUser }) { + if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; throw err; } + if (!isAdmin(actorUser)) { const err = new Error('Forbidden: Admins only'); err.status = 403; throw err; } + const mid = Number(matrixInstanceId); + const uid = Number(userId); + if (!Number.isFinite(mid) || mid <= 0 || !Number.isFinite(uid) || uid <= 0) { + const err = new Error('Invalid parameters'); + err.status = 400; + throw err; + } + return await MatrixRepository.removeUserAndCreateVacancy(mid, uid); +} + +// NEW: assign user to vacancy (admin) +async function assignVacancy({ matrixInstanceId, parentUserId, position, userId, actorUser }) { + if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; throw err; } + if (!isAdmin(actorUser)) { const err = new Error('Forbidden: Admins only'); err.status = 403; throw err; } + return await MatrixRepository.assignUserToVacancy({ matrixInstanceId, parentUserId, position, userId }); +} + +// NEW: list vacancies (admin) +async function listVacancies({ matrixInstanceId, actorUser }) { + if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; throw err; } + if (!isAdmin(actorUser)) { const err = new Error('Forbidden: Admins only'); err.status = 403; throw err; } + return await MatrixRepository.listVacancies(matrixInstanceId); +} + module.exports = { create, getStats, @@ -730,5 +764,8 @@ module.exports = { getMyOverviewByInstance, // NEW getMyMatrixSummary, // NEW deactivate, // NEW - activate // NEW + activate, // NEW + removeUser, // NEW + assignVacancy, // NEW + listVacancies // NEW };