diff --git a/controller/matrix/MatrixController.js b/controller/matrix/MatrixController.js index c26e100..3286beb 100644 --- a/controller/matrix/MatrixController.js +++ b/controller/matrix/MatrixController.js @@ -26,7 +26,86 @@ async function stats(req, res) { } } +// NEW: admin-only list of matrix users under a root +async function getMatrixUserforAdmin(req, res) { + try { + const { rootUserId, depth, limit, offset, includeRoot } = req.query; + // aliases accepted by backend for convenience + const matrixId = req.query.matrixId || req.query.id; + const topNodeEmail = req.query.topNodeEmail || req.query.email; + + const data = await MatrixService.getUsers({ + rootUserId, + maxDepth: depth, + limit, + offset, + includeRoot, + actorUser: req.user, + matrixId, + topNodeEmail + }); + 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 load matrix users' }); + } +} + +// NEW: admin-only search of user candidates to add into a matrix +async function searchCandidates(req, res) { + try { + const { q, type, limit, offset, rootUserId } = req.query; + const matrixId = req.query.matrixId || req.query.id; + const topNodeEmail = req.query.topNodeEmail || req.query.email; + + const data = await MatrixService.getUserCandidates({ + q, + type, + limit, + offset, + rootUserId, + matrixId, + topNodeEmail, + 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 search user candidates' }); + } +} + +async function addUser(req, res) { + try { + const { + rootUserId, + matrixId, + topNodeEmail, + childUserId, + forceParentFallback, + parentUserId + } = req.body; + const data = await MatrixService.addUserToMatrix({ + rootUserId, + matrixId, + topNodeEmail, + childUserId, + forceParentFallback, + parentUserId, + 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 add user to matrix' }); + } +} + module.exports = { create, - stats + stats, + getMatrixUserforAdmin, + searchCandidates, + addUser // NEW }; diff --git a/database/createDb.js b/database/createDb.js index c68330d..56fdc28 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -568,7 +568,6 @@ async function createDatabase() { id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT NOT NULL, - quantity INT UNSIGNED NOT NULL DEFAULT 0, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, currency CHAR(3) NOT NULL DEFAULT 'EUR', tax_rate DECIMAL(5,2) NULL, @@ -587,6 +586,14 @@ async function createDatabase() { `); console.log('✅ Coffee table created/verified'); + // Ensure quantity column is removed for existing databases + try { + await connection.query(`ALTER TABLE coffee_table DROP COLUMN IF EXISTS quantity`); + console.log('🔧 Removed coffee_table.quantity'); + } catch (e) { + console.log('â„šī¸ coffee_table.quantity already removed or ALTER not required'); + } + // --- Matrix: Global 5-ary tree config and relations --- await connection.query(` CREATE TABLE IF NOT EXISTS matrix_config ( @@ -639,6 +646,7 @@ async function createDatabase() { first_free_position TINYINT NULL, name VARCHAR(255) NULL, -- NEW: matrix display name is_active BOOLEAN DEFAULT TRUE, -- NEW: activation flag + max_depth INT NULL, -- NEW: NULL=unlimited; otherwise enforce per root updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_matrix_meta_root FOREIGN KEY (root_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT chk_first_free_position CHECK (first_free_position IS NULL OR (first_free_position BETWEEN 1 AND 5)) @@ -659,6 +667,33 @@ async function createDatabase() { } catch (e) { console.log('â„šī¸ user_matrix_metadata.is_active already exists or ALTER not required'); } + // NEW: ensure max_depth column exists + try { + await connection.query(`ALTER TABLE user_matrix_metadata ADD COLUMN max_depth INT NULL`); + console.log('🔧 Ensured user_matrix_metadata.max_depth exists'); + } catch (e) { + console.log('â„šī¸ user_matrix_metadata.max_depth already exists or ALTER not required'); + } + + // NEW: backfill max_depth policy + try { + // Master top node gets unlimited depth (NULL) + await connection.query(` + UPDATE user_matrix_metadata + SET max_depth = NULL + WHERE root_user_id IN (SELECT master_top_user_id FROM matrix_config) + `); + // All other matrices default to depth 5 where NULL + await connection.query(` + UPDATE user_matrix_metadata + SET max_depth = 5 + WHERE max_depth IS NULL + AND root_user_id NOT IN (SELECT master_top_user_id FROM matrix_config) + `); + console.log('🧹 Backfilled user_matrix_metadata.max_depth (master=NULL, others=5)'); + } catch (e) { + console.warn('âš ī¸ Could not backfill user_matrix_metadata.max_depth:', e.message); + } // --- Added Index Optimization Section --- try { @@ -709,6 +744,7 @@ async function createDatabase() { await ensureIndex(connection, 'user_tree_closure', 'idx_user_tree_closure_ancestor_depth', 'ancestor_user_id, depth'); await ensureIndex(connection, 'user_tree_closure', 'idx_user_tree_closure_descendant', 'descendant_user_id'); await ensureIndex(connection, 'user_matrix_metadata', 'idx_user_matrix_is_active', 'is_active'); // NEW + await ensureIndex(connection, 'user_matrix_metadata', 'idx_user_matrix_max_depth', 'max_depth'); // NEW console.log('🚀 Performance indexes created/verified'); } catch (e) { diff --git a/repositories/matrix/MatrixRepository.js b/repositories/matrix/MatrixRepository.js index fe839fa..d741bae 100644 --- a/repositories/matrix/MatrixRepository.js +++ b/repositories/matrix/MatrixRepository.js @@ -70,6 +70,8 @@ async function createMatrix({ name, topNodeEmail, force = false }) { // ensure user_matrix_metadata has needed columns (safe if exists) try { await conn.query(`ALTER TABLE user_matrix_metadata ADD COLUMN name VARCHAR(255) NULL`); } catch (_) {} try { await conn.query(`ALTER TABLE user_matrix_metadata ADD COLUMN is_active BOOLEAN DEFAULT TRUE`); } catch (_) {} + // NEW: ensure max_depth column exists + try { await conn.query(`ALTER TABLE user_matrix_metadata ADD COLUMN max_depth INT NULL`); } catch (_) {} const topUser = await ensureUserExistsByEmail(conn, topNodeEmail); @@ -106,12 +108,13 @@ async function createMatrix({ name, topNodeEmail, force = false }) { await conn.query( ` INSERT INTO user_matrix_metadata - (root_user_id, ego_activated_at, last_bfs_fill_at, immediate_children_count, first_free_position, name, is_active) + (root_user_id, ego_activated_at, last_bfs_fill_at, immediate_children_count, first_free_position, name, is_active, max_depth) VALUES - (?, NOW(), NULL, 0, 1, ?, TRUE) + (?, NOW(), NULL, 0, 1, ?, TRUE, NULL) ON DUPLICATE KEY UPDATE name = VALUES(name), is_active = TRUE, + max_depth = NULL, ego_activated_at = COALESCE(user_matrix_metadata.ego_activated_at, VALUES(ego_activated_at)), updated_at = CURRENT_TIMESTAMP `, @@ -163,13 +166,14 @@ async function activateEgoMatrix(rootUserId) { await conn.query( ` INSERT INTO user_matrix_metadata - (root_user_id, ego_activated_at, last_bfs_fill_at, immediate_children_count, first_free_position) + (root_user_id, ego_activated_at, last_bfs_fill_at, immediate_children_count, first_free_position, max_depth) VALUES - (?, NOW(), NULL, ?, ?) + (?, NOW(), NULL, ?, ?, 5) ON DUPLICATE KEY UPDATE ego_activated_at = COALESCE(ego_activated_at, VALUES(ego_activated_at)), immediate_children_count = VALUES(immediate_children_count), first_free_position = VALUES(first_free_position), + max_depth = 5, updated_at = CURRENT_TIMESTAMP `, [rootUserId, immediateChildrenCount, firstFreePosition] @@ -216,21 +220,32 @@ async function getMatrixStats() { const rootIds = matRows.map(r => r.root_user_id); let countsByRoot = new Map(); + // fetch matrix_config (to expose a stable matrixId for frontend mapping) + let cfg = null; + try { + const [cfgRows] = await conn.query( + `SELECT id, master_top_user_id FROM matrix_config ORDER BY id ASC LIMIT 1` + ); + cfg = cfgRows[0] || null; + } catch (_) {} + if (rootIds.length) { - // counts per matrix up to depth 5 + // NEW: counts per matrix up to its configured max_depth (NULL => unlimited) const [cntRows] = await conn.query( ` - SELECT ancestor_user_id AS root_id, COUNT(*) AS cnt - FROM user_tree_closure - WHERE depth BETWEEN 0 AND 5 - AND ancestor_user_id IN (?) - GROUP BY ancestor_user_id + SELECT c.ancestor_user_id AS root_id, COUNT(*) AS cnt + FROM user_tree_closure c + JOIN user_matrix_metadata m + ON m.root_user_id = c.ancestor_user_id + WHERE c.depth BETWEEN 0 AND COALESCE(m.max_depth, 2147483647) + AND c.ancestor_user_id IN (?) + GROUP BY c.ancestor_user_id `, [rootIds] ); countsByRoot = new Map(cntRows.map(r => [Number(r.root_id), Number(r.cnt)])); - // total distinct users across all active matrices + // NEW: total distinct users across all active matrices honoring max_depth const [activeIdsRows] = await conn.query( `SELECT root_user_id FROM user_matrix_metadata WHERE is_active = TRUE AND root_user_id IN (?)`, [rootIds] @@ -240,10 +255,13 @@ async function getMatrixStats() { if (activeIds.length) { const [totalDistinctRows] = await conn.query( ` - SELECT COUNT(DISTINCT descendant_user_id) AS total - FROM user_tree_closure - WHERE depth BETWEEN 0 AND 5 - AND ancestor_user_id IN (?) + SELECT COUNT(DISTINCT c.descendant_user_id) AS total + FROM user_tree_closure c + JOIN user_matrix_metadata m + ON m.root_user_id = c.ancestor_user_id + WHERE m.is_active = TRUE + AND c.ancestor_user_id IN (?) + AND c.depth BETWEEN 0 AND COALESCE(m.max_depth, 2147483647) `, [activeIds] ); @@ -259,7 +277,9 @@ async function getMatrixStats() { isActive: !!r.is_active, usersCount: countsByRoot.get(Number(r.root_user_id)) || 0, createdAt: r.ego_activated_at || null, - topNodeEmail: r.email + topNodeEmail: r.email, + // expose matrix_config id only for the master matrix (stable id frontend may route with) + matrixConfigId: cfg && Number(r.root_user_id) === Number(cfg.master_top_user_id) ? Number(cfg.id) : null })); return { @@ -282,8 +302,477 @@ async function getMatrixStats() { } } +async function resolveRootUserId({ rootUserId, matrixId, topNodeEmail }) { + const conn = await pool.getConnection(); + try { + // prefer direct numeric rootUserId + const rid = Number.parseInt(rootUserId, 10); + if (Number.isFinite(rid) && rid > 0) return rid; + + // try matrix_config.id -> master_top_user_id + const mid = Number.parseInt(matrixId, 10); + if (Number.isFinite(mid) && mid > 0) { + const [rows] = await conn.query( + 'SELECT master_top_user_id FROM matrix_config WHERE id = ? LIMIT 1', + [mid] + ); + if (!rows.length) { + const err = new Error('Matrix not found'); + err.status = 404; + throw err; + } + return Number(rows[0].master_top_user_id); + } + + // try resolving by top node email + const email = (topNodeEmail || '').trim(); + if (email) { + const [rows] = await conn.query( + 'SELECT id FROM users WHERE LOWER(email) = LOWER(?) LIMIT 1', + [email] + ); + if (!rows.length) { + const err = new Error('Top node user not found'); + err.status = 404; + throw err; + } + return Number(rows[0].id); + } + + const err = new Error('rootUserId required (or provide matrixId/topNodeEmail)'); + err.status = 400; + throw err; + } finally { + conn.release(); + } +} + +// NEW: search user candidates to add to a matrix +async function getUserSearchCandidates({ q, type = 'all', rootUserId, limit = 20, offset = 0 }) { + const conn = await pool.getConnection(); + try { + const whereParts = []; + const params = []; + + // Exclude users already in the matrix (including the root) + whereParts.push(`u.id NOT IN ( + SELECT descendant_user_id + FROM user_tree_closure + WHERE ancestor_user_id = ? + )`); + params.push(Number(rootUserId)); + + // Optional: exclude admins + whereParts.push(`u.role = 'user'`); + + // Filter by user_type if needed + if (type === 'personal' || type === 'company') { + whereParts.push(`u.user_type = ?`); + params.push(type); + } + + // Search filter (email OR personal full name OR company name) + const like = `%${q.toLowerCase()}%`; + whereParts.push(`( + LOWER(u.email) LIKE ? + OR LOWER(CONCAT(COALESCE(p.first_name, ''), ' ', COALESCE(p.last_name, ''))) LIKE ? + OR LOWER(COALESCE(c.company_name, '')) LIKE ? + )`); + params.push(like, like, like); + + const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; + + const baseJoins = ` + LEFT JOIN personal_profiles p ON p.user_id = u.id + LEFT JOIN company_profiles c ON c.user_id = u.id + `; + + const countSql = ` + SELECT COUNT(*) AS total + FROM users u + ${baseJoins} + ${whereSql} + `; + const [countRows] = await conn.query(countSql, params); + const total = Number(countRows[0]?.total || 0); + + const selectSql = ` + SELECT + u.id AS userId, + u.email, + u.user_type AS userType, + CASE + WHEN u.user_type = 'personal' THEN TRIM(CONCAT(COALESCE(p.first_name, ''), ' ', COALESCE(p.last_name, ''))) + WHEN u.user_type = 'company' THEN COALESCE(c.company_name, '') + ELSE '' + END AS name + FROM users u + ${baseJoins} + ${whereSql} + ORDER BY u.created_at DESC, u.email ASC + LIMIT ? OFFSET ? + `; + const selectParams = params.concat([Number(limit), Number(offset)]); + const [rows] = await conn.query(selectSql, selectParams); + + const items = rows.map(r => ({ + userId: Number(r.userId), + email: r.email, + userType: r.userType, + name: r.name || '' + })); + + return { total, items }; + } finally { + conn.release(); + } +} + +// NEW: fetch matrix users (descendants) under a root +async function getMatrixUsers({ rootUserId, maxDepth = 5, limit = 100, offset = 0, includeRoot = false }) { + const conn = await pool.getConnection(); + try { + const rid = Number(rootUserId); + if (!Number.isFinite(rid) || rid <= 0) { + const err = new Error('Invalid rootUserId'); + err.status = 400; + throw err; + } + // NEW: load per-root depth policy + let policyDepth = null; // null => unlimited + try { + const [pRows] = await conn.query( + 'SELECT max_depth FROM user_matrix_metadata WHERE root_user_id = ? LIMIT 1', + [rid] + ); + if (pRows.length) { + policyDepth = pRows[0].max_depth == null ? null : Number(pRows[0].max_depth); + } + } catch (_) {} + + // Requested depth sanitization + let requestedDepth = Number(maxDepth); + if (!Number.isFinite(requestedDepth) || requestedDepth < 0) requestedDepth = 0; + + // Clamp to policy: NULL => unlimited; otherwise min(requested, policy) + let depthLimit = policyDepth == null ? requestedDepth : Math.min(requestedDepth, policyDepth); + + // Starting depth (exclude root if includeRoot = false) + const startDepth = includeRoot ? 0 : 1; + if (startDepth > depthLimit) { + return []; + } + + // Main query: descendants within depth range + const sql = ` + SELECT + c.descendant_user_id AS userId, + c.depth AS depth, + u.email, + u.user_type AS userType, + u.role, + u.created_at AS createdAt, + e.parent_user_id AS parentUserId, + e.position AS position, + CASE + WHEN u.user_type = 'personal' + THEN TRIM(CONCAT(COALESCE(pp.first_name,''), ' ', COALESCE(pp.last_name,''))) + WHEN u.user_type = 'company' + THEN COALESCE(cp.company_name,'') + ELSE '' + END AS name + FROM user_tree_closure c + JOIN users u ON u.id = c.descendant_user_id + LEFT JOIN user_tree_edges e + ON e.child_user_id = c.descendant_user_id + AND e.parent_user_id != e.child_user_id + LEFT JOIN personal_profiles pp ON pp.user_id = u.id + LEFT JOIN company_profiles cp ON cp.user_id = u.id + WHERE c.ancestor_user_id = ? + AND c.depth BETWEEN ? AND ? + ORDER BY c.depth ASC, u.created_at ASC, u.id ASC + LIMIT ? OFFSET ? + `; + const params = [rid, startDepth, depthLimit, Number(limit), Number(offset)]; + const [rows] = await conn.query(sql, params); + + return rows.map(r => ({ + userId: Number(r.userId), + email: r.email, + userType: r.userType, + role: r.role, + depth: Number(r.depth), + level: Number(r.depth), + parentUserId: r.parentUserId ? Number(r.parentUserId) : null, + position: r.position != null ? Number(r.position) : null, + name: r.name || r.email, // NEW + createdAt: r.createdAt + })); + } finally { + conn.release(); + } +} + +async function addUserToMatrix({ + rootUserId, + matrixId, + topNodeEmail, + childUserId, + forceParentFallback = false, + parentUserId, + actorUserId +}) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + const rid = await resolveRootUserId({ rootUserId, matrixId, topNodeEmail }); + + const cId = Number(childUserId); + if (!Number.isFinite(cId) || cId <= 0) { + const err = new Error('Invalid childUserId'); + err.status = 400; + throw err; + } + const [childRows] = await conn.query('SELECT id FROM users WHERE id = ? LIMIT 1', [cId]); + if (!childRows.length) { + const err = new Error('Child user not found'); + err.status = 404; + throw err; + } + + let parentId = Number(parentUserId) > 0 ? Number(parentUserId) : null; + if (!parentId) { + const [refRows] = await conn.query( + ` + SELECT t.created_by_user_id AS parent_user_id + FROM referral_token_usage u + JOIN referral_tokens t + ON t.id = u.referral_token_id -- FIX: correct column name + WHERE u.used_by_user_id = ? + ORDER BY u.id ASC + LIMIT 1 + `, + [cId] + ); + if (refRows.length) parentId = Number(refRows[0].parent_user_id); + } + if (!parentId) { + const err = new Error('Referral parent not found'); + err.status = 404; + throw err; + } + + const [parentInMatrixRows] = await conn.query( + ` + SELECT depth FROM user_tree_closure + WHERE ancestor_user_id = ? AND descendant_user_id = ? + LIMIT 1 + `, + [rid, parentId] + ); + if (!parentInMatrixRows.length) { + if (forceParentFallback) { + parentId = rid; + } else { + const err = new Error('Referral parent not in matrix'); + err.status = 409; + throw err; + } + } + const parentDepth = parentId === rid ? 0 : Number(parentInMatrixRows[0]?.depth || 0); + + // NEW: enforce per-root max_depth + let maxDepthPolicy = null; + try { + const [pRows] = await conn.query( + 'SELECT max_depth FROM user_matrix_metadata WHERE root_user_id = ? LIMIT 1', + [rid] + ); + if (pRows.length) maxDepthPolicy = pRows[0].max_depth == null ? null : Number(pRows[0].max_depth); + } catch (_) {} + if (maxDepthPolicy != null && parentDepth >= maxDepthPolicy) { + const err = new Error(`Cannot add beyond max depth ${maxDepthPolicy}`); + err.status = 409; + throw err; + } + + const [dupRows] = await conn.query( + ` + SELECT 1 FROM user_tree_closure + WHERE ancestor_user_id = ? AND descendant_user_id = ? + LIMIT 1 + `, + [rid, cId] + ); + if (dupRows.length) { + const err = new Error('User already in matrix'); + err.status = 409; + throw err; + } + + // Try direct placement under chosen parent + const [childPosRows] = await conn.query( + `SELECT position FROM user_tree_edges WHERE parent_user_id = ? ORDER BY position ASC`, + [parentId] + ); + const used = new Set(childPosRows.map(r => Number(r.position))); + let assignPos = null; + for (let i = 1; i <= 5; i++) { + if (!used.has(i)) { + assignPos = i; + break; + } + } + + // Track the child count before insert for the final parent we will use + let parentChildCountBefore = childPosRows.length; + + if (!assignPos) { + // NEW: BFS fallback within referral parent's subtree, honoring policy (if not master) + // Find the nearest descendant of parentId with <5 children and (depth_from_root + 1) <= policy when set + const [candRows] = await conn.query( + ` + SELECT + pc.descendant_user_id AS candidate_id, + rc.depth AS depth_from_root + FROM user_tree_closure pc + JOIN user_tree_closure rc + ON rc.descendant_user_id = pc.descendant_user_id + AND rc.ancestor_user_id = ? + LEFT JOIN ( + SELECT parent_user_id, COUNT(*) AS cnt + FROM user_tree_edges + GROUP BY parent_user_id + ) ch ON ch.parent_user_id = pc.descendant_user_id + WHERE pc.ancestor_user_id = ? + AND pc.descendant_user_id NOT IN (?, ?) + AND COALESCE(ch.cnt, 0) < 5 + AND (? IS NULL OR rc.depth + 1 <= ?) + ORDER BY pc.depth ASC, pc.descendant_user_id ASC + LIMIT 1 + `, + [rid, parentId, parentId, cId, maxDepthPolicy, maxDepthPolicy] + ); + + if (!candRows.length) { + const err = new Error( + maxDepthPolicy != null + ? `No free positions under referral parent within depth ${maxDepthPolicy}.` + : 'No free positions under referral parent.' + ); + err.status = 409; + throw err; + } + + // Re-check candidate's free slot to avoid races + const candidateParentId = Number(candRows[0].candidate_id); + const [candPosRows] = await conn.query( + `SELECT position FROM user_tree_edges WHERE parent_user_id = ? ORDER BY position ASC`, + [candidateParentId] + ); + const candUsed = new Set(candPosRows.map(r => Number(r.position))); + let candAssign = null; + for (let i = 1; i <= 5; i++) { + if (!candUsed.has(i)) { + candAssign = i; + break; + } + } + if (!candAssign) { + const err = new Error('Concurrent update: candidate parent has no free positions'); + err.status = 409; + throw err; + } + + // Use candidate as new parent + parentId = candidateParentId; + assignPos = candAssign; + parentChildCountBefore = candPosRows.length; + } + + await conn.query( + `INSERT INTO user_tree_edges (parent_user_id, child_user_id, position) VALUES (?,?,?)`, + [parentId, cId, assignPos] + ); + + await conn.query( + `INSERT IGNORE INTO user_tree_closure (ancestor_user_id, descendant_user_id, depth) VALUES (?, ?, 0)`, + [cId, cId] + ); + + // Add closure rows for all ancestors of parent (depth+1) + const [ancestorRows] = await conn.query( + `SELECT ancestor_user_id, depth FROM user_tree_closure WHERE descendant_user_id = ? ORDER BY depth ASC`, + [parentId] + ); + if (ancestorRows.length) { + const values = ancestorRows + .map(r => `(${Number(r.ancestor_user_id)}, ${cId}, ${Number(r.depth) + 1})`) + .join(','); + await conn.query( + `INSERT IGNORE INTO user_tree_closure (ancestor_user_id, descendant_user_id, depth) VALUES ${values}` + ); + } + + // Update root metadata if parent is root + let remainingFreeSlots; + if (parentId === rid) { + const [rootChildrenRows] = await conn.query( + `SELECT position FROM user_tree_edges WHERE parent_user_id = ? ORDER BY position ASC`, + [rid] + ); + const usedRoot = new Set(rootChildrenRows.map(r => Number(r.position))); + let firstFree = null; + for (let i = 1; i <= 5; i++) { + if (!usedRoot.has(i)) { firstFree = i; break; } + } + await conn.query( + `UPDATE user_matrix_metadata + SET immediate_children_count = ?, + first_free_position = ? + WHERE root_user_id = ?`, + [rootChildrenRows.length, firstFree, rid] + ); + remainingFreeSlots = 5 - rootChildrenRows.length; + } else { + // NEW: compute remaining slots for the actual parent we inserted under + remainingFreeSlots = 5 - (parentChildCountBefore + 1); + } + + // Optional audit log (ignore failures) + try { + await conn.query( + ` + INSERT INTO user_action_logs + (action, performed_by_user_id, affected_user_id, details, created_at) + VALUES + ('matrix_add_user', ?, ?, ?, NOW()) + `, + [ + Number(actorUserId) || null, + cId, + JSON.stringify({ rootUserId: rid, parentUserId: parentId, position: assignPos }) + ] + ); + } catch (_) {} + + await conn.commit(); + return { rootUserId: rid, parentUserId, childUserId: cId, position: assignPos, remainingFreeSlots }; + } catch (err) { + try { await conn.rollback(); } catch (_) {} + throw err; + } finally { + conn.release(); + } +} + module.exports = { createMatrix, activateEgoMatrix, - getMatrixStats + getMatrixStats, + resolveRootUserId, + getUserSearchCandidates, + getMatrixUsers, + addUserToMatrix }; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 4f669b3..e750760 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -110,8 +110,13 @@ router.get('/admin/coffee', authMiddleware, adminOnly, CoffeeController.list); // Matrix GETs -router.get('/matrix/create', authMiddleware, adminOnly, MatrixController.create); // ?name=...&email=...&force=true -router.get('/matrix/stats', authMiddleware, adminOnly, MatrixController.stats); // NEW: real stats for dashboard +router.get('/matrix/create', authMiddleware, adminOnly, MatrixController.create); +router.get('/matrix/stats', authMiddleware, adminOnly, MatrixController.stats); +router.get('/admin/matrix/users', authMiddleware, adminOnly, MatrixController.getMatrixUserforAdmin); +router.get('/admin/matrix/user-candidates', authMiddleware, adminOnly, MatrixController.searchCandidates); + +// NEW: Matrix POST (admin) +router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser); // export module.exports = router; \ No newline at end of file diff --git a/routes/postRoutes.js b/routes/postRoutes.js index aceaecc..88668a0 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -21,6 +21,7 @@ const PersonalProfileController = require('../controller/profile/PersonalProfile const CompanyProfileController = require('../controller/profile/CompanyProfileController'); const AdminUserController = require('../controller/admin/AdminUserController'); const CompanyStampController = require('../controller/companyStamp/CompanyStampController'); // <-- added +const MatrixController = require('../controller/matrix/MatrixController'); // Matrix admin operations const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -118,6 +119,8 @@ function forceCompanyForAdmin(req, res, next) { router.post('/company-stamps', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.upload); // Admin: create coffee product (supports multipart file 'picture') 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 // Existing registration handlers (keep) router.post('/register/personal', (req, res) => { diff --git a/services/matrix/MatrixService.js b/services/matrix/MatrixService.js index ee63088..b57d69f 100644 --- a/services/matrix/MatrixService.js +++ b/services/matrix/MatrixService.js @@ -9,6 +9,16 @@ function isValidEmail(s) { return typeof s === 'string' && /\S+@\S+\.\S+/.test(s); } +function toBool(value, defaultVal = false) { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const v = value.trim().toLowerCase(); + if (v === 'true') return true; + if (v === 'false') return false; + } + return defaultVal; +} + async function create({ name, topNodeEmail, force = false, actorUser }) { if (!actorUser) { const err = new Error('Unauthorized'); @@ -43,7 +53,9 @@ async function create({ name, topNodeEmail, force = false, actorUser }) { return new Matrix({ name: res.name, masterTopUserId: res.masterTopUserId, - masterTopUserEmail: res.masterTopUserEmail + masterTopUserEmail: res.masterTopUserEmail, + // also expose root user id for frontend + rootUserId: res.masterTopUserId }); } @@ -66,18 +78,198 @@ async function getStats({ actorUser }) { activeMatrices: stats.activeMatrices, totalMatrices: stats.totalMatrices, totalUsersSubscribed: stats.totalUsersSubscribed, - matrices: stats.matrices.map(m => ({ - rootUserId: m.rootUserId, - name: m.name, - isActive: !!m.isActive, - usersCount: m.usersCount, - createdAt: m.createdAt, // equals ego_activated_at - topNodeEmail: m.topNodeEmail - })) + matrices: stats.matrices.map(m => { + const rootId = Number(m.rootUserId); + const matrixConfigId = m.matrixConfigId != null ? Number(m.matrixConfigId) : null; + const matrixId = matrixConfigId != null ? matrixConfigId : rootId; // prefer config id when available + return { + // primary fields + rootUserId: rootId, + name: m.name, + isActive: !!m.isActive, + usersCount: m.usersCount, + createdAt: m.createdAt, // equals ego_activated_at + topNodeEmail: m.topNodeEmail, + // compatibility and routing aliases for frontend + root_user_id: rootId, + matrixConfigId, + matrixId, + id: matrixId + }; + }) + }; +} + +async function getUsers({ rootUserId, maxDepth, limit, offset, includeRoot, actorUser, matrixId, topNodeEmail }) { + 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; + } + + // resolve root id from any of the accepted identifiers + let rid = Number.parseInt(rootUserId, 10); + if (!Number.isFinite(rid) || rid <= 0) { + rid = await MatrixRepository.resolveRootUserId({ rootUserId, matrixId, topNodeEmail }); + } + + // NEW: do not clamp to 5 globally; repository will enforce per-root policy + let depth = Number.parseInt(maxDepth ?? 5, 10); + if (!Number.isFinite(depth)) depth = 5; + if (depth < 0) depth = 0; + + let lim = Number.parseInt(limit ?? 100, 10); + if (!Number.isFinite(lim) || lim <= 0) lim = 100; + if (lim > 500) lim = 500; + + let off = Number.parseInt(offset ?? 0, 10); + if (!Number.isFinite(off) || off < 0) off = 0; + + const incRoot = toBool(includeRoot, false); + + const users = await MatrixRepository.getMatrixUsers({ + rootUserId: rid, + maxDepth: depth, + limit: lim, + offset: off, + includeRoot: incRoot + }); + + return { + rootUserId: rid, + maxDepth: depth, + limit: lim, + offset: off, + includeRoot: incRoot, + users + }; +} + +// NEW: search user candidates to add into a matrix +async function getUserCandidates({ q, type, limit, offset, rootUserId, matrixId, topNodeEmail, 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 query = (q || '').trim(); + const normType = ['personal', 'company', 'all'].includes(String(type).toLowerCase()) + ? String(type).toLowerCase() + : 'all'; + + let lim = Number.parseInt(limit ?? 20, 10); + if (!Number.isFinite(lim) || lim <= 0) lim = 20; + if (lim > 50) lim = 50; + + let off = Number.parseInt(offset ?? 0, 10); + if (!Number.isFinite(off) || off < 0) off = 0; + + // Return empty list when query too short + if (query.length < 2) { + return { + q: query, + type: normType, + rootUserId: null, + limit: lim, + offset: off, + total: 0, + items: [] + }; + } + + // Resolve root id if not a valid positive number + let rid = Number.parseInt(rootUserId, 10); + if (!Number.isFinite(rid) || rid <= 0) { + rid = await MatrixRepository.resolveRootUserId({ rootUserId, matrixId, topNodeEmail }); + } + + const { total, items } = await MatrixRepository.getUserSearchCandidates({ + q: query, + type: normType, + rootUserId: rid, + limit: lim, + offset: off + }); + + return { + q: query, + type: normType, + rootUserId: rid, + limit: lim, + offset: off, + total, + items + }; +} + +async function addUserToMatrix({ + rootUserId, + matrixId, + topNodeEmail, + childUserId, + forceParentFallback, + parentUserId, + 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 cId = Number(childUserId); + if (!Number.isFinite(cId) || cId <= 0) { + const err = new Error('Invalid childUserId'); + err.status = 400; + throw err; + } + const fallback = toBool(forceParentFallback, false); + const parentOverride = Number(parentUserId) > 0 ? Number(parentUserId) : undefined; + + const summary = await MatrixRepository.addUserToMatrix({ + rootUserId, + matrixId, + topNodeEmail, + childUserId: cId, + forceParentFallback: fallback, + parentUserId: parentOverride, + actorUserId: actorUser.id + }); + + // fetch a small updated list (depth 2, limit 25) + const users = await MatrixRepository.getMatrixUsers({ + rootUserId: summary.rootUserId, + maxDepth: 2, + limit: 25, + offset: 0, + includeRoot: true + }); + + return { + ...summary, + usersPreview: users }; } module.exports = { create, - getStats + getStats, + getUsers, + getUserCandidates, + addUserToMatrix // NEW };