diff --git a/controller/matrix/MatrixController.js b/controller/matrix/MatrixController.js index 3286beb..2525f6a 100644 --- a/controller/matrix/MatrixController.js +++ b/controller/matrix/MatrixController.js @@ -29,9 +29,8 @@ 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 { rootUserId, depth, limit, offset, includeRoot, rogueOnly } = req.query; + const matrixInstanceId = req.query.matrixInstanceId || req.query.matrixId || req.query.id; const topNodeEmail = req.query.topNodeEmail || req.query.email; const data = await MatrixService.getUsers({ @@ -40,8 +39,10 @@ async function getMatrixUserforAdmin(req, res) { limit, offset, includeRoot, + rogueOnly: String(rogueOnly || '').toLowerCase() === 'true', actorUser: req.user, - matrixId, + matrixInstanceId, + matrixId: matrixInstanceId, // backward alias topNodeEmail }); return res.json({ success: true, data }); @@ -55,20 +56,19 @@ async function getMatrixUserforAdmin(req, res) { async function searchCandidates(req, res) { try { const { q, type, limit, offset, rootUserId } = req.query; - const matrixId = req.query.matrixId || req.query.id; + const matrixInstanceId = req.query.matrixInstanceId || 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, + matrixInstanceId, + matrixId: matrixInstanceId, topNodeEmail, actorUser: req.user }); - return res.json({ success: true, data }); } catch (err) { const status = err.status || 500; @@ -80,15 +80,18 @@ async function addUser(req, res) { try { const { rootUserId, + matrixInstanceId, matrixId, topNodeEmail, childUserId, forceParentFallback, parentUserId } = req.body; + const resolvedMatrixId = matrixInstanceId || matrixId; const data = await MatrixService.addUserToMatrix({ rootUserId, - matrixId, + matrixInstanceId: resolvedMatrixId, + matrixId: resolvedMatrixId, topNodeEmail, childUserId, forceParentFallback, @@ -102,10 +105,67 @@ async function addUser(req, res) { } } +// NEW: user-facing matrix overview anchored at requester +async function getMyOverview(req, res) { + try { + const userId = req.user?.id ?? req.user?.userId; + const data = await MatrixService.getMyOverview({ 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 load matrix overview' }); + } +} + +// NEW: admin-only deactivate a matrix instance +async function deactivate(req, res) { + try { + const matrixInstanceId = + req.params.id || + req.body?.matrixInstanceId || + req.body?.matrixId || + req.query?.matrixInstanceId || + req.query?.matrixId; + + const data = await MatrixService.deactivate({ + 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 deactivate matrix' }); + } +} + +// NEW: admin-only activate a matrix instance +async function activate(req, res) { + try { + const matrixInstanceId = + req.params.id || + req.body?.matrixInstanceId || + req.body?.matrixId || + req.query?.matrixInstanceId || + req.query?.matrixId; + + const data = await MatrixService.activate({ + 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 activate matrix' }); + } +} + module.exports = { create, stats, getMatrixUserforAdmin, searchCandidates, - addUser // NEW + addUser, // NEW + getMyOverview, // NEW + deactivate, // NEW + activate // NEW }; diff --git a/database/createDb.js b/database/createDb.js index f987589..656cd92 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -565,12 +565,22 @@ async function createDatabase() { CREATE TABLE IF NOT EXISTS matrix_config ( id TINYINT PRIMARY KEY DEFAULT 1, master_top_user_id INT NOT NULL, + name VARCHAR(255) NULL, -- ADDED (was missing, caused Unknown column 'name') created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_matrix_config_master FOREIGN KEY (master_top_user_id) REFERENCES users(id) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT chk_matrix_singleton CHECK (id = 1) ); `); + // Safeguard: if table pre-existed without name column, add it + try { + await connection.query(`ALTER TABLE matrix_config ADD COLUMN name VARCHAR(255) NULL`); + console.log('🆕 Added missing matrix_config.name column'); + } catch (e) { + if (!/Duplicate column/i.test(e.message)) { + console.log('ℹ️ matrix_config.name alter skipped:', e.message); + } + } console.log('✅ Matrix config table created/verified'); await connection.query(` @@ -578,101 +588,196 @@ async function createDatabase() { id BIGINT AUTO_INCREMENT PRIMARY KEY, parent_user_id INT NOT NULL, child_user_id INT NOT NULL, - position TINYINT NOT NULL, + position INT NOT NULL, -- CHANGED: allow unlimited positions created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_edges_parent FOREIGN KEY (parent_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_edges_child FOREIGN KEY (child_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT uq_child UNIQUE (child_user_id), - CONSTRAINT uq_parent_position UNIQUE (parent_user_id, position), - CONSTRAINT chk_position CHECK (position BETWEEN 1 AND 5) + CONSTRAINT uq_parent_position UNIQUE (parent_user_id, position) + -- REMOVED: chk_position CHECK (position BETWEEN 1 AND 5) ); `); console.log('✅ User tree edges table created/verified'); - await connection.query(` - CREATE TABLE IF NOT EXISTS user_tree_closure ( - ancestor_user_id INT NOT NULL, - descendant_user_id INT NOT NULL, - depth INT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT pk_closure PRIMARY KEY (ancestor_user_id, descendant_user_id), - CONSTRAINT fk_closure_ancestor FOREIGN KEY (ancestor_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT fk_closure_descendant FOREIGN KEY (descendant_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT chk_depth_nonneg CHECK (depth >= 0) - ); - `); - console.log('✅ User tree closure table created/verified'); - - await connection.query(` - CREATE TABLE IF NOT EXISTS user_matrix_metadata ( - root_user_id INT PRIMARY KEY, - ego_activated_at TIMESTAMP NULL, - last_bfs_fill_at TIMESTAMP NULL, - immediate_children_count INT DEFAULT 0, - 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)) - ); - `); - console.log('✅ User matrix metadata table created/verified'); - - // --- Pools (NEW) --- - await connection.query(` - CREATE TABLE IF NOT EXISTS pools ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - description TEXT NULL, - state ENUM('active','inactive','archived') DEFAULT 'active', - created_by INT NOT NULL, - updated_by INT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY uq_pool_name (name), - INDEX idx_pool_name (name), - INDEX idx_pool_state (state), - INDEX idx_pool_created_by (created_by), - INDEX idx_pool_updated_by (updated_by), - CONSTRAINT fk_pools_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT ON UPDATE CASCADE, - CONSTRAINT fk_pools_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE - ); - `); - console.log('✅ Pools table created/verified'); - - // Migrations for existing databases: add 'archived' and actor columns if missing + // --- Migration: relax position constraints on existing schemas --- try { - await connection.query(` - ALTER TABLE pools - MODIFY COLUMN state ENUM('active','inactive','archived') DEFAULT 'active' - `); - console.log('🆙 Pools.state updated to include archived'); + await connection.query(`ALTER TABLE user_tree_edges MODIFY COLUMN position INT NOT NULL`); + console.log('🛠️ user_tree_edges.position changed to INT'); } catch (e) { - console.log('ℹ️ Pools.state alteration skipped:', e.message); + console.log('ℹ️ position type change skipped:', e.message); } try { - await connection.query(`ALTER TABLE pools ADD COLUMN created_by INT NOT NULL`); - await connection.query(`CREATE INDEX idx_pool_created_by ON pools (created_by)`); - await connection.query(` - ALTER TABLE pools ADD CONSTRAINT fk_pools_created_by - FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT ON UPDATE CASCADE - `); - console.log('🆕 Pools.created_by added with FK and index'); + await connection.query(`ALTER TABLE user_tree_edges DROP CHECK chk_position`); + console.log('🧹 Dropped CHECK constraint chk_position on user_tree_edges'); } catch (e) { - console.log('ℹ️ Pools.created_by add skipped:', e.message); + // MySQL versions or engines may report different messages if CHECK is not enforced or named differently + console.log('ℹ️ DROP CHECK chk_position skipped:', e.message); } + + await connection.query(` + CREATE TABLE IF NOT EXISTS matrix_instances ( + id INT AUTO_INCREMENT PRIMARY KEY, + root_user_id INT NOT NULL, + name VARCHAR(255) NULL, + is_active BOOLEAN DEFAULT TRUE, + max_depth INT NULL, + ego_activated_at TIMESTAMP NULL, + immediate_children_count INT DEFAULT 0, + first_free_position TINYINT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_matrix_instances_root FOREIGN KEY (root_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT chk_first_free_pos CHECK (first_free_position IS NULL OR (first_free_position BETWEEN 1 AND 5)) + ); + `); + console.log('✅ matrix_instances table created/verified'); + + // Legacy: ensure legacy matrix_config exists (still needed short-term for migration) + await connection.query(` + CREATE TABLE IF NOT EXISTS matrix_config ( + id TINYINT PRIMARY KEY DEFAULT 1, + master_top_user_id INT NOT NULL, + name VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_matrix_config_master FOREIGN KEY (master_top_user_id) REFERENCES users(id) ON DELETE RESTRICT ON UPDATE CASCADE + ); + `); + console.log('ℹ️ matrix_config (legacy) verified'); + + // --- user_matrix_metadata: add matrix_instance_id + alter PK --- + await connection.query(` + CREATE TABLE IF NOT EXISTS user_matrix_metadata ( + matrix_instance_id INT PRIMARY KEY, + root_user_id INT NOT NULL, + ego_activated_at TIMESTAMP NULL, + last_bfs_fill_at TIMESTAMP NULL, + immediate_children_count INT DEFAULT 0, + first_free_position TINYINT NULL, + name VARCHAR(255) NULL, + is_active BOOLEAN DEFAULT TRUE, + max_depth INT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_meta_instance FOREIGN KEY (matrix_instance_id) REFERENCES matrix_instances(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_meta_root FOREIGN KEY (root_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT chk_meta_first_free CHECK (first_free_position IS NULL OR (first_free_position BETWEEN 1 AND 5)), + INDEX idx_meta_root (root_user_id) + ); + `); + console.log('✅ user_matrix_metadata (multi) created/verified'); + + // Migration: if legacy data without matrix_instances rows + const [instCheck] = await connection.query(`SELECT COUNT(*) AS cnt FROM matrix_instances`); + if (Number(instCheck[0].cnt) === 0) { + const [legacyCfg] = await connection.query(`SELECT master_top_user_id, name FROM matrix_config WHERE id=1`); + if (legacyCfg.length) { + const legacyRoot = legacyCfg[0].master_top_user_id; + const legacyName = legacyCfg[0].name || null; + // Determine existing root children stats + const [legacyEdges] = await connection.query(`SELECT position FROM user_tree_edges WHERE parent_user_id = ?`, [legacyRoot]); + const usedRootPos = new Set(legacyEdges.map(r => Number(r.position))); + let firstFree = null; + for (let i = 1; i <= 5; i++) { if (!usedRootPos.has(i)) { firstFree = i; break; } } + // Create initial instance + const [instRes] = await connection.query(` + INSERT INTO matrix_instances + (root_user_id, name, is_active, max_depth, ego_activated_at, immediate_children_count, first_free_position) + VALUES (?, ?, TRUE, NULL, NOW(), ?, NULL) -- CHANGED: first_free_position NULL for root + `, [legacyRoot, legacyName, legacyEdges.length, /* firstFree removed */ null]); + const firstInstanceId = instRes.insertId; + // Backfill metadata + await connection.query(` + INSERT INTO user_matrix_metadata + (matrix_instance_id, root_user_id, ego_activated_at, immediate_children_count, first_free_position, name, is_active, max_depth) + VALUES (?, ?, NOW(), ?, ?, ?, TRUE, NULL) + `, [firstInstanceId, legacyRoot, legacyEdges.length, firstFree, legacyName]); + console.log('🧩 Migration: created first matrix_instance id=', firstInstanceId); + } + } + + // --- Legacy cleanup: remove old matrix_id / fk_edges_matrix referencing obsolete `matrices` table --- try { - await connection.query(`ALTER TABLE pools ADD COLUMN updated_by INT NULL`); - await connection.query(`CREATE INDEX idx_pool_updated_by ON pools (updated_by)`); - await connection.query(` - ALTER TABLE pools ADD CONSTRAINT fk_pools_updated_by - FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE - `); - console.log('🆕 Pools.updated_by added with FK and index'); + const [legacyCols] = await connection.query(`SHOW COLUMNS FROM user_tree_edges LIKE 'matrix_id'`); + if (legacyCols.length) { + console.log('🧹 Found legacy user_tree_edges.matrix_id; dropping old FK & column'); + try { await connection.query(`ALTER TABLE user_tree_edges DROP FOREIGN KEY fk_edges_matrix`); } catch (e) { + console.log('ℹ️ fk_edges_matrix drop skipped:', e.message); + } + try { await connection.query(`ALTER TABLE user_tree_edges DROP COLUMN matrix_id`); } catch (e) { + console.log('ℹ️ matrix_id column drop skipped:', e.message); + } + } } catch (e) { - console.log('ℹ️ Pools.updated_by add skipped:', e.message); + console.log('ℹ️ Legacy matrix_id cleanup check failed:', e.message); + } + + // --- Ensure multi-instance columns (idempotent) --- + try { await connection.query(`ALTER TABLE user_tree_edges ADD COLUMN matrix_instance_id INT NULL`); } catch (_) {} + try { await connection.query(`ALTER TABLE user_tree_edges ADD COLUMN rogue_user BOOLEAN DEFAULT FALSE`); } catch (_) {} + try { + await connection.query(` + ALTER TABLE user_tree_edges + ADD CONSTRAINT fk_edges_instance FOREIGN KEY (matrix_instance_id) + REFERENCES matrix_instances(id) ON DELETE CASCADE ON UPDATE CASCADE + `); + console.log('🆕 FK fk_edges_instance added'); + } catch (e) { + console.log('ℹ️ fk_edges_instance already exists or failed:', e.message); + } + + // Backfill matrix_instance_id for existing edges + const [firstInstRow] = await connection.query(`SELECT id FROM matrix_instances ORDER BY id ASC LIMIT 1`); + const firstInstanceId = firstInstRow[0]?.id; + if (firstInstanceId) { + await connection.query(`UPDATE user_tree_edges SET matrix_instance_id = ? WHERE matrix_instance_id IS NULL`, [firstInstanceId]); + } + + // Indexes (idempotent) + try { await connection.query(`CREATE INDEX idx_edges_instance_parent ON user_tree_edges (matrix_instance_id, parent_user_id)`); } catch (_) {} + try { await connection.query(`CREATE INDEX idx_edges_instance_child ON user_tree_edges (matrix_instance_id, child_user_id)`); } catch (_) {} + try { await connection.query(`CREATE INDEX idx_edges_rogue ON user_tree_edges (matrix_instance_id, rogue_user)`); } catch (_) {} + + // --- Alter user_tree_closure: add matrix_instance_id --- + await connection.query(` + CREATE TABLE IF NOT EXISTS user_tree_closure ( + matrix_instance_id INT NOT NULL, + ancestor_user_id INT NOT NULL, + descendant_user_id INT NOT NULL, + depth INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_closure PRIMARY KEY (matrix_instance_id, ancestor_user_id, descendant_user_id), + CONSTRAINT fk_closure_instance FOREIGN KEY (matrix_instance_id) REFERENCES matrix_instances(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_closure_ancestor FOREIGN KEY (ancestor_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_closure_descendant FOREIGN KEY (descendant_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT chk_depth_nonneg CHECK (depth >= 0) + ); + `); + console.log('✅ user_tree_closure (multi) created/verified'); + + // If legacy closure rows without matrix_instance_id (older separate table definition), attempt add column & backfill + try { + await connection.query(`ALTER TABLE user_tree_closure ADD COLUMN matrix_instance_id INT NOT NULL`); + console.log('🆕 Added matrix_instance_id column to existing user_tree_closure'); + } catch (e) { + // already integrated new definition + } + if (firstInstanceId) { + try { + await connection.query(`UPDATE user_tree_closure SET matrix_instance_id = ? WHERE matrix_instance_id IS NULL`, [firstInstanceId]); + console.log('🧩 Backfilled closure matrix_instance_id'); + } catch (e) { + console.log('ℹ️ Closure backfill skipped:', e.message); + } + } + try { await connection.query(`CREATE INDEX idx_closure_instance_depth ON user_tree_closure (matrix_instance_id, depth)`); } catch (_) {} + try { await connection.query(`CREATE INDEX idx_closure_instance_ancestor ON user_tree_closure (matrix_instance_id, ancestor_user_id)`); } catch (_) {} + + // Remove singleton constraint if present (best effort) + try { + await connection.query(`ALTER TABLE matrix_config DROP CHECK chk_matrix_singleton`); + console.log('🧹 Dropped chk_matrix_singleton'); + } catch (e) { + console.log('ℹ️ chk_matrix_singleton drop skipped:', e.message); } console.log('🎉 Normalized database schema created/updated successfully!'); diff --git a/models/Matrix.js b/models/Matrix.js index c8edede..0db9e52 100644 --- a/models/Matrix.js +++ b/models/Matrix.js @@ -9,7 +9,8 @@ class Matrix { egoActivatedAt, lastBfsFillAt, immediateChildrenCount, - firstFreePosition + firstFreePosition, + matrixInstanceId // ADDED (was missing → ReferenceError) }) { this.name = typeof name === 'string' ? name : null; this.masterTopUserId = masterTopUserId !== undefined ? Number(masterTopUserId) : null; @@ -22,6 +23,7 @@ class Matrix { this.firstFreePosition = firstFreePosition !== undefined ? (firstFreePosition === null ? null : Number(firstFreePosition)) : null; + this.matrixInstanceId = matrixInstanceId !== undefined ? Number(matrixInstanceId) : null; } } diff --git a/repositories/matrix/MatrixRepository.js b/repositories/matrix/MatrixRepository.js index d741bae..22fbfd4 100644 --- a/repositories/matrix/MatrixRepository.js +++ b/repositories/matrix/MatrixRepository.js @@ -57,79 +57,56 @@ async function ensureUserExistsByEmail(conn, email) { return rows[0]; } -async function createMatrix({ name, topNodeEmail, force = false }) { +async function createMatrix({ name, topNodeEmail }) { const conn = await pool.getConnection(); try { await conn.beginTransaction(); - // ensure "name" column exists on matrix_config (safe if already exists) - try { - await conn.query(`ALTER TABLE matrix_config ADD COLUMN name VARCHAR(255) NULL`); - } catch (_) {} - - // 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 (_) {} + // Ensure columns for multi-instance (idempotent) + try { await conn.query(`ALTER TABLE user_tree_edges ADD COLUMN matrix_instance_id INT NULL`); } catch (_) {} + try { await conn.query(`ALTER TABLE user_tree_edges ADD COLUMN rogue_user BOOLEAN DEFAULT FALSE`); } catch (_) {} + try { await conn.query(`ALTER TABLE user_tree_closure ADD COLUMN matrix_instance_id INT NOT NULL`); } catch (_) {} + try { await conn.query(`ALTER TABLE user_matrix_metadata ADD COLUMN matrix_instance_id INT NOT NULL`); } catch (_) {} const topUser = await ensureUserExistsByEmail(conn, topNodeEmail); - // lock matrix_config row if present - const [cfgRows] = await conn.query( - 'SELECT id, master_top_user_id, name FROM matrix_config WHERE id = 1 FOR UPDATE' - ); - - if (!cfgRows.length) { - await conn.query( - 'INSERT INTO matrix_config (id, master_top_user_id, name) VALUES (1, ?, ?)', - [topUser.id, name] - ); - } else { - const current = cfgRows[0]; - if (!force) { - const err = new Error('Matrix already exists. Pass force=true to overwrite.'); - err.status = 409; - throw err; - } - await conn.query( - 'UPDATE matrix_config SET master_top_user_id = ?, name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1', - [topUser.id, name] - ); - } - - // ensure self-closure for the master top user - await conn.query( - 'INSERT IGNORE INTO user_tree_closure (ancestor_user_id, descendant_user_id, depth) VALUES (?, ?, 0)', - [topUser.id, topUser.id] - ); - - // Also register this top user as an ego matrix entry with name and mark active - 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, max_depth) - VALUES - (?, 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 - `, + // Create new matrix instance + const [instRes] = await conn.query( + `INSERT INTO matrix_instances + (root_user_id, name, is_active, max_depth, ego_activated_at, immediate_children_count, first_free_position) + VALUES (?, ?, TRUE, NULL, NOW(), 0, NULL)`, // CHANGED: first_free_position NULL at root [topUser.id, name] ); + const matrixInstanceId = instRes.insertId; + + // Insert metadata row (primary keyed by matrix_instance_id) + await conn.query( + `INSERT INTO user_matrix_metadata + (matrix_instance_id, 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, NULL)`, + [matrixInstanceId, topUser.id, name] + ); + + // Self closure scoped to instance + await conn.query( + `INSERT IGNORE INTO user_tree_closure + (matrix_instance_id, ancestor_user_id, descendant_user_id, depth) + VALUES (?, ?, ?, 0)`, + [matrixInstanceId, topUser.id, topUser.id] + ); await conn.commit(); return { name, + matrixInstanceId, masterTopUserId: topUser.id, - masterTopUserEmail: topUser.email + masterTopUserEmail: topUser.email, + rootUserId: topUser.id }; - } catch (err) { + } catch (e) { await conn.rollback(); - throw err; + throw e; } finally { conn.release(); } @@ -207,139 +184,143 @@ async function activateEgoMatrix(rootUserId) { async function getMatrixStats() { const conn = await pool.getConnection(); try { - // list all matrices (ego entries) - const [matRows] = await conn.query( - ` - SELECT m.root_user_id, m.name, m.is_active, m.ego_activated_at, u.email - FROM user_matrix_metadata m - JOIN users u ON u.id = m.root_user_id - ORDER BY COALESCE(m.ego_activated_at, u.created_at) ASC - ` - ); - - 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) { - // NEW: counts per matrix up to its configured max_depth (NULL => unlimited) - const [cntRows] = await conn.query( - ` - 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)])); - - // 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] - ); - const activeIds = activeIdsRows.map(r => r.root_user_id); - let totalDistinct = 0; - if (activeIds.length) { - const [totalDistinctRows] = await conn.query( - ` - 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] - ); - totalDistinct = Number(totalDistinctRows[0]?.total || 0); - } - - const activeMatrices = matRows.filter(r => !!r.is_active).length; - const totalMatrices = matRows.length; - - const matrices = matRows.map(r => ({ - rootUserId: r.root_user_id, - name: r.name || null, - isActive: !!r.is_active, - usersCount: countsByRoot.get(Number(r.root_user_id)) || 0, - createdAt: r.ego_activated_at || null, - 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 { - activeMatrices, - totalMatrices, - totalUsersSubscribed: totalDistinct, - matrices - }; - } - - // no matrices registered - return { + const [matRows] = await conn.query(` + SELECT mi.id AS matrixInstanceId, + mi.root_user_id, + mi.name, + mi.is_active, + mi.ego_activated_at, + mi.max_depth, + u.email + FROM matrix_instances mi + JOIN users u ON u.id = mi.root_user_id + ORDER BY mi.id ASC + `); + if (!matRows.length) return { activeMatrices: 0, totalMatrices: 0, totalUsersSubscribed: 0, matrices: [] }; + + const instanceIds = matRows.map(r => r.matrixInstanceId); + + // Count users per instance honoring max_depth + const [cntRows] = await conn.query(` + SELECT c.matrix_instance_id AS mid, COUNT(*) AS cnt + FROM user_tree_closure c + JOIN matrix_instances mi ON mi.id = c.matrix_instance_id + WHERE c.matrix_instance_id IN (?) + AND c.depth BETWEEN 0 AND COALESCE(mi.max_depth, 2147483647) + GROUP BY c.matrix_instance_id + `, [instanceIds]); + + const countsMap = new Map(cntRows.map(r => [Number(r.mid), Number(r.cnt)])); + + // Rogue counts + const [rogueRows] = await conn.query(` + SELECT matrix_instance_id AS mid, COUNT(*) AS rogueCnt + FROM user_tree_edges + WHERE matrix_instance_id IN (?) + AND rogue_user = TRUE + `, [instanceIds]); + const rogueMap = new Map(rogueRows.map(r => [Number(r.mid), Number(r.rogueCnt)])); + + // Distinct subscribed users across active matrices + const [distinctRows] = await conn.query(` + SELECT COUNT(DISTINCT c.descendant_user_id) AS total + FROM user_tree_closure c + JOIN matrix_instances mi ON mi.id = c.matrix_instance_id + WHERE mi.is_active = TRUE + AND c.matrix_instance_id IN (?) + AND c.depth BETWEEN 0 AND COALESCE(mi.max_depth, 2147483647) + `, [instanceIds]); + + const totalDistinct = Number(distinctRows[0]?.total || 0); + const activeMatrices = matRows.filter(r => !!r.is_active).length; + + const matrices = matRows.map(r => ({ + matrixInstanceId: Number(r.matrixInstanceId), + rootUserId: Number(r.root_user_id), + name: r.name || null, + isActive: !!r.is_active, + usersCount: countsMap.get(Number(r.matrixInstanceId)) || 0, + rogueUsersCount: rogueMap.get(Number(r.matrixInstanceId)) || 0, + createdAt: r.ego_activated_at || null, + topNodeEmail: r.email + })); + + return { + activeMatrices, + totalMatrices: matRows.length, + totalUsersSubscribed: totalDistinct, + matrices + }; } finally { conn.release(); } } -async function resolveRootUserId({ rootUserId, matrixId, topNodeEmail }) { +async function resolveRootUserId({ rootUserId, matrixId, matrixInstanceId, 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); + // Prefer explicit matrixInstanceId (matrixId backward compatible) + const midRaw = matrixInstanceId || matrixId; + const mid = Number.parseInt(midRaw, 10); if (Number.isFinite(mid) && mid > 0) { const [rows] = await conn.query( - 'SELECT master_top_user_id FROM matrix_config WHERE id = ? LIMIT 1', + `SELECT id, root_user_id FROM matrix_instances WHERE id = ? LIMIT 1`, [mid] ); if (!rows.length) { - const err = new Error('Matrix not found'); + const err = new Error('Matrix instance not found'); err.status = 404; throw err; } - return Number(rows[0].master_top_user_id); + return { rootUserId: Number(rows[0].root_user_id), matrixInstanceId: Number(rows[0].id) }; } - // try resolving by top node email + // Direct root user id (find first instance with that root) + const rid = Number.parseInt(rootUserId, 10); + if (Number.isFinite(rid) && rid > 0) { + const [rows] = await conn.query( + `SELECT id FROM matrix_instances WHERE root_user_id = ? ORDER BY id ASC LIMIT 1`, + [rid] + ); + if (rows.length) { + return { rootUserId: rid, matrixInstanceId: Number(rows[0].id) }; + } + } + + // Resolve by email const email = (topNodeEmail || '').trim(); if (email) { - const [rows] = await conn.query( - 'SELECT id FROM users WHERE LOWER(email) = LOWER(?) LIMIT 1', + const [uRows] = await conn.query( + `SELECT id FROM users WHERE LOWER(email)=LOWER(?) LIMIT 1`, [email] ); - if (!rows.length) { + if (!uRows.length) { const err = new Error('Top node user not found'); err.status = 404; throw err; } - return Number(rows[0].id); + const uid = Number(uRows[0].id); + const [instRows] = await conn.query( + `SELECT id FROM matrix_instances WHERE root_user_id = ? ORDER BY id ASC LIMIT 1`, + [uid] + ); + if (instRows.length) { + return { rootUserId: uid, matrixInstanceId: Number(instRows[0].id) }; + } } - const err = new Error('rootUserId required (or provide matrixId/topNodeEmail)'); + // Fallback: pick first existing instance + const [firstInst] = await conn.query(`SELECT id, root_user_id FROM matrix_instances ORDER BY id ASC LIMIT 1`); + if (firstInst.length) { + return { rootUserId: Number(firstInst[0].root_user_id), matrixInstanceId: Number(firstInst[0].id) }; + } + + const err = new Error('Matrix resolver parameters required'); err.status = 400; throw err; } finally { @@ -348,153 +329,128 @@ async function resolveRootUserId({ rootUserId, matrixId, topNodeEmail }) { } // NEW: search user candidates to add to a matrix -async function getUserSearchCandidates({ q, type = 'all', rootUserId, limit = 20, offset = 0 }) { +async function getUserSearchCandidates({ q, type = 'all', rootUserId, matrixInstanceId, limit = 20, offset = 0 }) { const conn = await pool.getConnection(); try { const whereParts = []; const params = []; - // Exclude users already in the matrix (including the root) + // Exclude users already in selected matrix instance whereParts.push(`u.id NOT IN ( SELECT descendant_user_id FROM user_tree_closure - WHERE ancestor_user_id = ? + WHERE matrix_instance_id = ? + AND ancestor_user_id = ? )`); - params.push(Number(rootUserId)); + params.push(Number(matrixInstanceId), 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 ? + 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 whereSql = `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 = ` + const [countRows] = await conn.query(` SELECT COUNT(*) AS total FROM users u - ${baseJoins} + LEFT JOIN personal_profiles p ON p.user_id = u.id + LEFT JOIN company_profiles c ON c.user_id = u.id ${whereSql} - `; - const [countRows] = await conn.query(countSql, params); + `, params); const total = Number(countRows[0]?.total || 0); - const selectSql = ` - SELECT - u.id AS userId, - u.email, - u.user_type AS userType, + const [rows] = await conn.query(` + 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, '') + 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} + LEFT JOIN personal_profiles p ON p.user_id = u.id + LEFT JOIN company_profiles c ON c.user_id = u.id ${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); + `, params.concat([Number(limit), Number(offset)])); - const items = rows.map(r => ({ - userId: Number(r.userId), - email: r.email, - userType: r.userType, - name: r.name || '' - })); - - return { total, items }; + return { + total, + items: rows.map(r => ({ + userId: Number(r.userId), + email: r.email, + userType: r.userType, + name: r.name || '' + })) + }; } finally { conn.release(); } } // NEW: fetch matrix users (descendants) under a root -async function getMatrixUsers({ rootUserId, maxDepth = 5, limit = 100, offset = 0, includeRoot = false }) { +async function getMatrixUsers({ rootUserId, matrixInstanceId, maxDepth = 5, limit = 100, offset = 0, includeRoot = false, rogueOnly = 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 + let policyDepth = null; try { const [pRows] = await conn.query( - 'SELECT max_depth FROM user_matrix_metadata WHERE root_user_id = ? LIMIT 1', - [rid] + `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); - } + 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 depthLimit = policyDepth == null ? requestedDepth : Math.min(requestedDepth, policyDepth); const startDepth = includeRoot ? 0 : 1; - if (startDepth > depthLimit) { - return []; - } + if (startDepth > depthLimit) return []; - // Main query: descendants within depth range - const sql = ` + const rogueClause = rogueOnly ? 'AND (e.rogue_user = TRUE)' : ''; + + const [rows] = await conn.query(` SELECT c.descendant_user_id AS userId, - c.depth AS depth, + c.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, + e.position, + e.rogue_user AS rogueUser, 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,'') + 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 + AND e.matrix_instance_id = c.matrix_instance_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 = ? + WHERE c.matrix_instance_id = ? + AND c.ancestor_user_id = ? AND c.depth BETWEEN ? AND ? + ${rogueClause} 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); + `, [matrixInstanceId, rootUserId, startDepth, depthLimit, Number(limit), Number(offset)]); return rows.map(r => ({ userId: Number(r.userId), @@ -505,7 +461,8 @@ async function getMatrixUsers({ rootUserId, maxDepth = 5, limit = 100, offset = 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 + rogueUser: !!r.rogueUser, + name: r.name || r.email, createdAt: r.createdAt })); } finally { @@ -515,6 +472,7 @@ async function getMatrixUsers({ rootUserId, maxDepth = 5, limit = 100, offset = async function addUserToMatrix({ rootUserId, + matrixInstanceId, matrixId, topNodeEmail, childUserId, @@ -526,7 +484,31 @@ async function addUserToMatrix({ try { await conn.beginTransaction(); - const rid = await resolveRootUserId({ rootUserId, matrixId, topNodeEmail }); + // Resolve (matrixId backward compatible) + const resolved = await resolveRootUserId({ + rootUserId, + matrixId: matrixId || matrixInstanceId, + matrixInstanceId, + topNodeEmail + }); + const rid = resolved.rootUserId; + const mid = resolved.matrixInstanceId; + + // PREVENT additions to inactive instances + const [miRows] = await conn.query( + `SELECT is_active FROM matrix_instances WHERE id = ? LIMIT 1`, + [mid] + ); + if (!miRows.length) { + const err = new Error('Matrix instance not found'); + err.status = 404; + throw err; + } + if (!miRows[0].is_active) { + const err = new Error('Matrix instance is inactive'); + err.status = 409; + throw err; + } const cId = Number(childUserId); if (!Number.isFinite(cId) || cId <= 0) { @@ -534,7 +516,7 @@ async function addUserToMatrix({ err.status = 400; throw err; } - const [childRows] = await conn.query('SELECT id FROM users WHERE id = ? LIMIT 1', [cId]); + 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; @@ -542,13 +524,18 @@ async function addUserToMatrix({ } let parentId = Number(parentUserId) > 0 ? Number(parentUserId) : null; + let explicitRootChoice = false; + if (parentId === rid) { + explicitRootChoice = true; // admin explicitly chose root + } + if (!parentId) { + // referral parent discovery 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 + JOIN referral_tokens t ON t.id = u.referral_token_id WHERE u.used_by_user_id = ? ORDER BY u.id ASC LIMIT 1 @@ -557,53 +544,49 @@ async function addUserToMatrix({ ); 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; + let fallback_reason = null; + let rogueFlag = false; + + // Check parent within matrix instance (when parentId exists and is not root) + if (parentId && parentId !== rid) { + const [inMatrixRows] = await conn.query( + ` + SELECT depth FROM user_tree_closure + WHERE matrix_instance_id = ? AND ancestor_user_id = ? AND descendant_user_id = ? + LIMIT 1 + `, + [mid, rid, parentId] + ); + if (!inMatrixRows.length) { + // Referrer not in matrix -> fallback decision + if (forceParentFallback) { + fallback_reason = 'referrer_not_in_matrix'; + parentId = rid; + rogueFlag = true; + } else { + const err = new Error('Referrer is not part of this matrix'); + err.status = 400; + 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; + if (!parentId) { + // No referral parent found + fallback_reason = 'referrer_not_in_matrix'; + parentId = rid; + rogueFlag = true; } + // Duplicate check (already in matrix) const [dupRows] = await conn.query( ` SELECT 1 FROM user_tree_closure - WHERE ancestor_user_id = ? AND descendant_user_id = ? + WHERE matrix_instance_id = ? AND ancestor_user_id = ? AND descendant_user_id = ? LIMIT 1 `, - [rid, cId] + [mid, rid, cId] ); if (dupRows.length) { const err = new Error('User already in matrix'); @@ -611,136 +594,148 @@ async function addUserToMatrix({ 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))); + // Determine policy depth + let maxDepthPolicy = null; + try { + const [pRows] = await conn.query(`SELECT max_depth FROM matrix_instances WHERE id = ? LIMIT 1`, [mid]); + if (pRows.length) maxDepthPolicy = pRows[0].max_depth == null ? null : Number(pRows[0].max_depth); + } catch (_) {} + let assignPos = null; - for (let i = 1; i <= 5; i++) { - if (!used.has(i)) { - assignPos = i; - break; + + if (parentId === rid) { + // ROOT: unlimited children, sequential position + // Do not mark rogue if admin explicitly chose root + if (explicitRootChoice) { + rogueFlag = false; + fallback_reason = null; } - } - - // 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] + const [rootPosRows] = await conn.query( + `SELECT MAX(position) AS maxPos FROM user_tree_edges WHERE matrix_instance_id = ? AND parent_user_id = ?`, + [mid, rid] ); + const maxPos = Number(rootPosRows[0]?.maxPos || 0); + assignPos = maxPos + 1; // sequential position under root + } else { + // NON-ROOT: enforce 1–5 + const [childPosRows] = await conn.query( + `SELECT position FROM user_tree_edges WHERE matrix_instance_id = ? AND parent_user_id = ? ORDER BY position ASC`, + [mid, parentId] + ); + const used = new Set(childPosRows.map(r => Number(r.position))); + for (let i = 1; i <= 5; i++) if (!used.has(i)) { assignPos = i; break; } - if (!candRows.length) { - const err = new Error( - maxDepthPolicy != null - ? `No free positions under referral parent within depth ${maxDepthPolicy}.` - : 'No free positions under referral parent.' + if (!assignPos) { + // BFS within the chosen parent subtree only + const [candRows] = await conn.query( + ` + SELECT d.descendant_user_id AS candidate_id + FROM user_tree_closure AS d + WHERE d.matrix_instance_id = ? + AND d.ancestor_user_id = ? + AND d.descendant_user_id != ? + ORDER BY d.depth ASC, d.descendant_user_id ASC + `, + [mid, parentId, parentId] ); - 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; + for (const row of candRows) { + const candidateParentId = Number(row.candidate_id); + // skip root in BFS + if (candidateParentId === rid) continue; + // ensure candidate within max depth policy (parent depth + 1) + if (maxDepthPolicy != null) { + 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, rid, candidateParentId] + ); + const candDepthFromRoot = Number(depthRows[0]?.depth ?? 0); + if (candDepthFromRoot + 1 > maxDepthPolicy) continue; + } + const [candPosRows] = await conn.query( + `SELECT position FROM user_tree_edges WHERE matrix_instance_id = ? AND parent_user_id = ? ORDER BY position ASC`, + [mid, candidateParentId] + ); + const candUsed = new Set(candPosRows.map(r => Number(r.position))); + for (let i = 1; i <= 5; i++) if (!candUsed.has(i)) { assignPos = i; break; } + if (assignPos) { + parentId = candidateParentId; + break; + } + } + + if (!assignPos) { + // no slot in subtree -> decide fallback + if (forceParentFallback) { + fallback_reason = 'referrer_full'; + parentId = rid; + rogueFlag = true; + // root sequential + const [rootPosRows] = await conn.query( + `SELECT MAX(position) AS maxPos FROM user_tree_edges WHERE matrix_instance_id = ? AND parent_user_id = ?`, + [mid, rid] + ); + const maxPos = Number(rootPosRows[0]?.maxPos || 0); + assignPos = maxPos + 1; + } else { + const err = new Error('Parent subtree is full (5-ary); no available position'); + err.status = 409; + throw err; + } } } - 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; } + // Insert edge await conn.query( - `INSERT INTO user_tree_edges (parent_user_id, child_user_id, position) VALUES (?,?,?)`, - [parentId, cId, assignPos] + `INSERT INTO user_tree_edges + (matrix_instance_id, parent_user_id, child_user_id, position, rogue_user) + VALUES (?,?,?,?,?)`, + [mid, parentId, cId, assignPos, rogueFlag] ); + // Self closure for child await conn.query( - `INSERT IGNORE INTO user_tree_closure (ancestor_user_id, descendant_user_id, depth) VALUES (?, ?, 0)`, - [cId, cId] + `INSERT IGNORE INTO user_tree_closure + (matrix_instance_id, ancestor_user_id, descendant_user_id, depth) + VALUES (?,?,?,0)`, + [mid, cId, cId] ); - // Add closure rows for all ancestors of parent (depth+1) + // Ancestor closure rows const [ancestorRows] = await conn.query( - `SELECT ancestor_user_id, depth FROM user_tree_closure WHERE descendant_user_id = ? ORDER BY depth ASC`, - [parentId] + `SELECT ancestor_user_id, depth + FROM user_tree_closure + WHERE matrix_instance_id = ? AND descendant_user_id = ? + ORDER BY depth ASC`, + [mid, parentId] ); if (ancestorRows.length) { const values = ancestorRows - .map(r => `(${Number(r.ancestor_user_id)}, ${cId}, ${Number(r.depth) + 1})`) + .map(r => `(${mid}, ${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}` + `INSERT IGNORE INTO user_tree_closure + (matrix_instance_id, ancestor_user_id, descendant_user_id, depth) + VALUES ${values}` ); } - // Update root metadata if parent is root - let remainingFreeSlots; + // Update instance metadata: immediate children count for root; do not compute first_free_position for root if (parentId === rid) { const [rootChildrenRows] = await conn.query( - `SELECT position FROM user_tree_edges WHERE parent_user_id = ? ORDER BY position ASC`, - [rid] + `SELECT COUNT(*) AS cnt FROM user_tree_edges WHERE matrix_instance_id = ? AND parent_user_id = ?`, + [mid, 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] + `UPDATE matrix_instances + SET immediate_children_count = ? + WHERE id = ?`, + [Number(rootChildrenRows[0]?.cnt || 0), mid] ); - 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) + // Log try { await conn.query( ` @@ -752,16 +747,182 @@ async function addUserToMatrix({ [ Number(actorUserId) || null, cId, - JSON.stringify({ rootUserId: rid, parentUserId: parentId, position: assignPos }) + JSON.stringify({ + matrixInstanceId: mid, + rootUserId: rid, + parentUserId: parentId, + position: assignPos, + rogue_user: rogueFlag, + fallback_reason + }) ] ); } catch (_) {} await conn.commit(); - return { rootUserId: rid, parentUserId, childUserId: cId, position: assignPos, remainingFreeSlots }; - } catch (err) { + return { + matrixInstanceId: mid, + rootUserId: rid, + parentUserId: parentId, + childUserId: cId, + position: assignPos, + rogue_user: rogueFlag, + fallback_reason + }; + } catch (e) { try { await conn.rollback(); } catch (_) {} - throw err; + throw e; + } finally { + conn.release(); + } +} + +// NEW: resolve instance containing a given user via closure +async function resolveInstanceForUser(userId) { + const conn = await pool.getConnection(); + try { + const [rows] = await conn.query( + `SELECT matrix_instance_id AS mid + FROM user_tree_closure + WHERE descendant_user_id = ? + ORDER BY matrix_instance_id ASC + LIMIT 1`, + [Number(userId)] + ); + if (!rows.length) return null; + return { matrixInstanceId: Number(rows[0].mid) }; + } finally { + conn.release(); + } +} + +// NEW: fetch instance basic info +async function getInstanceInfo(instanceId) { + const conn = await pool.getConnection(); + try { + const [rows] = await conn.query( + `SELECT id, root_user_id, max_depth + FROM matrix_instances + WHERE id = ? LIMIT 1`, + [Number(instanceId)] + ); + return rows.length ? rows[0] : null; + } finally { + conn.release(); + } +} + +// NEW: deactivate a matrix instance by id (idempotent) +async function deactivateInstance(instanceId) { + const conn = await pool.getConnection(); + try { + const id = Number(instanceId); + if (!Number.isFinite(id) || id <= 0) { + const err = new Error('Invalid matrix instance id'); + err.status = 400; + throw err; + } + + const [rows] = await conn.query( + `SELECT id, is_active FROM matrix_instances WHERE id = ? LIMIT 1`, + [id] + ); + if (!rows.length) { + const err = new Error('Matrix instance not found'); + err.status = 404; + throw err; + } + const wasActive = !!rows[0].is_active; + + // Try to set deactivated_at if column exists; fallback otherwise + try { + await conn.query( + `UPDATE matrix_instances + SET is_active = FALSE, + deactivated_at = COALESCE(deactivated_at, NOW()), + updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [id] + ); + } catch (e) { + if (e && e.code === 'ER_BAD_FIELD_ERROR') { + await conn.query( + `UPDATE matrix_instances + SET is_active = FALSE, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [id] + ); + } else { + throw e; + } + } + + return { + matrixInstanceId: id, + wasActive, + isActive: false, + status: wasActive ? 'deactivated' : 'already_inactive' + }; + } finally { + conn.release(); + } +} + +// NEW: activate a matrix instance by id (idempotent) +async function activateInstance(instanceId) { + const conn = await pool.getConnection(); + try { + const id = Number(instanceId); + if (!Number.isFinite(id) || id <= 0) { + const err = new Error('Invalid matrix instance id'); + err.status = 400; + throw err; + } + + const [rows] = await conn.query( + `SELECT id, is_active FROM matrix_instances WHERE id = ? LIMIT 1`, + [id] + ); + if (!rows.length) { + const err = new Error('Matrix instance not found'); + err.status = 404; + throw err; + } + const wasActive = !!rows[0].is_active; + + if (!wasActive) { + // Try to clear deactivated_at if column exists + try { + await conn.query( + `UPDATE matrix_instances + SET is_active = TRUE, + deactivated_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [id] + ); + } catch (e) { + if (e && e.code === 'ER_BAD_FIELD_ERROR') { + await conn.query( + `UPDATE matrix_instances + SET is_active = TRUE, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [id] + ); + } else { + throw e; + } + } + } + + return { + matrixInstanceId: id, + wasActive, + isActive: true, + status: wasActive ? 'already_active' : 'activated' + }; } finally { conn.release(); } @@ -769,10 +930,15 @@ async function addUserToMatrix({ module.exports = { createMatrix, + ensureUserExistsByEmail, activateEgoMatrix, getMatrixStats, resolveRootUserId, getUserSearchCandidates, getMatrixUsers, - addUserToMatrix + addUserToMatrix, + resolveInstanceForUser, // NEW + getInstanceInfo, // NEW + deactivateInstance, // NEW + activateInstance // NEW }; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index dbdba8d..a58b7e8 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -116,6 +116,9 @@ 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 overview for authenticated user +router.get('/matrix/me/overview', authMiddleware, MatrixController.getMyOverview); + // NEW: Matrix POST (admin) router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser); diff --git a/routes/patchRoutes.js b/routes/patchRoutes.js index b145360..985c960 100644 --- a/routes/patchRoutes.js +++ b/routes/patchRoutes.js @@ -8,6 +8,7 @@ const CoffeeController = require('../controller/admin/CoffeeController'); const AdminUserController = require('../controller/admin/AdminUserController'); const PersonalProfileController = require('../controller/profile/PersonalProfileController'); // <-- add const PoolController = require('../controller/pool/PoolController'); // <-- new +const MatrixController = require('../controller/matrix/MatrixController'); // <-- new // Helper middlewares for company-stamp function adminOnly(req, res, next) { @@ -39,6 +40,10 @@ router.patch('/admin/update-user-status/:id', authMiddleware, adminOnly, AdminUs router.patch('/admin/coffee/:id/state', authMiddleware, adminOnly, CoffeeController.setState); // NEW: Admin pool state update router.patch('/admin/pools/:id/state', authMiddleware, adminOnly, PoolController.updateState); +// NEW: deactivate a matrix instance (admin-only) +router.patch('/admin/matrix/:id/deactivate', authMiddleware, adminOnly, MatrixController.deactivate); +// NEW: activate a matrix instance (admin-only) +router.patch('/admin/matrix/:id/activate', authMiddleware, adminOnly, MatrixController.activate); // Personal profile (self-service) - no admin guard router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic); diff --git a/services/matrix/MatrixService.js b/services/matrix/MatrixService.js index b57d69f..39ada8f 100644 --- a/services/matrix/MatrixService.js +++ b/services/matrix/MatrixService.js @@ -19,7 +19,7 @@ function toBool(value, defaultVal = false) { return defaultVal; } -async function create({ name, topNodeEmail, force = false, actorUser }) { +async function create({ name, topNodeEmail, actorUser }) { // force removed (new instance each time) if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; @@ -48,14 +48,13 @@ async function create({ name, topNodeEmail, force = false, actorUser }) { throw err; } - const res = await MatrixRepository.createMatrix({ name: trimmedName, topNodeEmail: email, force }); - + const res = await MatrixRepository.createMatrix({ name: trimmedName, topNodeEmail: email }); return new Matrix({ name: res.name, masterTopUserId: res.masterTopUserId, masterTopUserEmail: res.masterTopUserEmail, - // also expose root user id for frontend - rootUserId: res.masterTopUserId + rootUserId: res.rootUserId, + matrixInstanceId: res.matrixInstanceId }); } @@ -78,29 +77,22 @@ async function getStats({ actorUser }) { activeMatrices: stats.activeMatrices, totalMatrices: stats.totalMatrices, totalUsersSubscribed: stats.totalUsersSubscribed, - 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 - }; - }) + matrices: stats.matrices.map(m => ({ + matrixInstanceId: Number(m.matrixInstanceId), + rootUserId: Number(m.rootUserId), + name: m.name, + isActive: !!m.isActive, + usersCount: m.usersCount, + rogueUsersCount: m.rogueUsersCount, + createdAt: m.createdAt, + topNodeEmail: m.topNodeEmail, + matrixId: Number(m.matrixInstanceId), // backward compatibility + id: Number(m.matrixInstanceId) + })) }; } -async function getUsers({ rootUserId, maxDepth, limit, offset, includeRoot, actorUser, matrixId, topNodeEmail }) { +async function getUsers({ rootUserId, maxDepth, limit, offset, includeRoot, actorUser, matrixId, matrixInstanceId, topNodeEmail, rogueOnly }) { if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; @@ -112,11 +104,9 @@ async function getUsers({ rootUserId, maxDepth, limit, offset, includeRoot, acto 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 }); - } + const resolved = await MatrixRepository.resolveRootUserId({ rootUserId, matrixId, matrixInstanceId, topNodeEmail }); + const rid = resolved.rootUserId; + const mid = resolved.matrixInstanceId; // NEW: do not clamp to 5 globally; repository will enforce per-root policy let depth = Number.parseInt(maxDepth ?? 5, 10); @@ -134,24 +124,28 @@ async function getUsers({ rootUserId, maxDepth, limit, offset, includeRoot, acto const users = await MatrixRepository.getMatrixUsers({ rootUserId: rid, + matrixInstanceId: mid, maxDepth: depth, limit: lim, offset: off, - includeRoot: incRoot + includeRoot: incRoot, + rogueOnly: !!rogueOnly }); return { + matrixInstanceId: mid, rootUserId: rid, maxDepth: depth, limit: lim, offset: off, includeRoot: incRoot, + rogueOnly: !!rogueOnly, users }; } // NEW: search user candidates to add into a matrix -async function getUserCandidates({ q, type, limit, offset, rootUserId, matrixId, topNodeEmail, actorUser }) { +async function getUserCandidates({ q, type, limit, offset, rootUserId, matrixId, matrixInstanceId, topNodeEmail, actorUser }) { if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; @@ -181,23 +175,26 @@ async function getUserCandidates({ q, type, limit, offset, rootUserId, matrixId, q: query, type: normType, rootUserId: null, + matrixInstanceId: null, // ADDED for consistency 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 }); - } + // Always resolve (covers legacy rootUserId only case) + const { rootUserId: rid, matrixInstanceId: mid } = await MatrixRepository.resolveRootUserId({ + rootUserId, + matrixId, + matrixInstanceId, + topNodeEmail + }); const { total, items } = await MatrixRepository.getUserSearchCandidates({ q: query, type: normType, rootUserId: rid, + matrixInstanceId: mid, limit: lim, offset: off }); @@ -206,6 +203,7 @@ async function getUserCandidates({ q, type, limit, offset, rootUserId, matrixId, q: query, type: normType, rootUserId: rid, + matrixInstanceId: mid, limit: lim, offset: off, total, @@ -216,6 +214,7 @@ async function getUserCandidates({ q, type, limit, offset, rootUserId, matrixId, async function addUserToMatrix({ rootUserId, matrixId, + matrixInstanceId, topNodeEmail, childUserId, forceParentFallback, @@ -244,16 +243,17 @@ async function addUserToMatrix({ const summary = await MatrixRepository.addUserToMatrix({ rootUserId, matrixId, + matrixInstanceId, topNodeEmail, childUserId: cId, - forceParentFallback: fallback, + forceParentFallback: fallback, // respected by repository for root fallback parentUserId: parentOverride, actorUserId: actorUser.id }); - // fetch a small updated list (depth 2, limit 25) const users = await MatrixRepository.getMatrixUsers({ rootUserId: summary.rootUserId, + matrixInstanceId: summary.matrixInstanceId, maxDepth: 2, limit: 25, offset: 0, @@ -266,10 +266,205 @@ async function addUserToMatrix({ }; } +function maskName(name, email) { + const safe = (s) => (typeof s === 'string' ? s.trim() : ''); + const n = safe(name); + if (n) { + const parts = n.split(/\s+/).filter(Boolean); + if (parts.length === 1) { + const fi = parts[0][0]?.toLowerCase() || ''; + return fi ? `${fi}…` : '…'; + } + const first = parts[0][0]?.toLowerCase() || ''; + const last = parts[parts.length - 1][0]?.toLowerCase() || ''; + return `${first}… ${last}…`.trim(); + } + const em = safe(email); + if (em && em.includes('@')) { + const [local, domain] = em.split('@'); + const fi = local?.[0]?.toLowerCase() || ''; + const li = domain?.[0]?.toLowerCase() || ''; + return `${fi}… ${li}…`.trim(); + } + return '…'; +} + +// NEW: user-facing overview anchored at requester +async function getMyOverview({ userId, actorUser }) { + if (!actorUser) { + const err = new Error('Unauthorized'); + err.status = 401; + throw err; + } + const uid = Number(userId); + if (!Number.isFinite(uid) || uid <= 0) { + const err = new Error('Invalid user'); + err.status = 400; + throw err; + } + + // Resolve the matrix instance that includes this user + const resolved = await MatrixRepository.resolveInstanceForUser(uid); + if (!resolved) { + return { + matrixInstanceId: null, + totalUsersUnderMe: 0, + levelsFilled: 0, + immediateChildrenCount: 0, + rootSlotsRemaining: null, + level1: [], + level2Plus: [] + }; + } + const mid = resolved.matrixInstanceId; + + // 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; + + // Fetch descendants anchored at requester + const users = await MatrixRepository.getMatrixUsers({ + rootUserId: uid, + matrixInstanceId: mid, + maxDepth: policyDepth, + limit: 1000, + offset: 0, + includeRoot: false + }); + + // Bucket by depth + const depthBuckets = new Map(); + for (const u of users) { + const d = Number(u.depth); + if (!depthBuckets.has(d)) depthBuckets.set(d, []); + depthBuckets.get(d).push(u); + } + + const immediateChildren = depthBuckets.get(1) || []; + // Order level1 by position asc, then createdAt + immediateChildren.sort((a, b) => { + const pa = (a.position ?? 999999); + const pb = (b.position ?? 999999); + if (pa !== pb) return pa - pb; + return (new Date(a.createdAt) - new Date(b.createdAt)); + }); + const level1 = immediateChildren.slice(0, 5).map(u => ({ + userId: u.userId, + email: u.email, + name: u.name || u.email, + position: u.position ?? null + })); + + const level2PlusRaw = users.filter(u => u.depth >= 2); + const level2Plus = level2PlusRaw.map(u => ({ + userId: u.userId, + depth: u.depth, + name: maskName(u.name, u.email) + })); + + // levelsFilled = highest depth having at least one user (bounded by policyDepth) + let levelsFilled = 0; + for (let d = 1; d <= policyDepth; d++) { + if ((depthBuckets.get(d) || []).length > 0) levelsFilled = d; + } + + // rootSlotsRemaining: only meaningful for root; compute among positions 1..5 under root + let rootSlotsRemaining = null; + if (isRoot) { + const firstFiveUsed = immediateChildren + .map(u => Number(u.position)) + .filter(p => Number.isFinite(p) && p >= 1 && p <= 5); + const unique = new Set(firstFiveUsed); + rootSlotsRemaining = Math.max(0, 5 - unique.size); + } + + return { + matrixInstanceId: mid, + totalUsersUnderMe: users.length, + levelsFilled, + immediateChildrenCount: immediateChildren.length, + rootSlotsRemaining, + level1, + level2Plus + }; +} + +// NEW: deactivate a matrix instance (admin-only) +async function deactivate({ matrixInstanceId, matrixId, rootUserId, 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; + } + + let instanceId = Number.parseInt(matrixInstanceId ?? matrixId, 10); + if (!Number.isFinite(instanceId) || instanceId <= 0) { + // Try to resolve via other hints + const resolved = await MatrixRepository.resolveRootUserId({ + rootUserId, + matrixId, + matrixInstanceId, + topNodeEmail + }); + instanceId = resolved.matrixInstanceId; + } + + const res = await MatrixRepository.deactivateInstance(instanceId); + return { + matrixInstanceId: res.matrixInstanceId, + wasActive: res.wasActive, + isActive: res.isActive, + status: res.status + }; +} + +// NEW: activate a matrix instance (admin-only) +async function activate({ matrixInstanceId, matrixId, rootUserId, 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; + } + + let instanceId = Number.parseInt(matrixInstanceId ?? matrixId, 10); + if (!Number.isFinite(instanceId) || instanceId <= 0) { + const resolved = await MatrixRepository.resolveRootUserId({ + rootUserId, + matrixId, + matrixInstanceId, + topNodeEmail + }); + instanceId = resolved.matrixInstanceId; + } + + const res = await MatrixRepository.activateInstance(instanceId); + return { + matrixInstanceId: res.matrixInstanceId, + wasActive: res.wasActive, + isActive: res.isActive, + status: res.status + }; +} + module.exports = { create, getStats, getUsers, getUserCandidates, - addUserToMatrix // NEW + addUserToMatrix, + getMyOverview, // NEW + deactivate, // NEW + activate // NEW };