diff --git a/controller/matrix/MatrixController.js b/controller/matrix/MatrixController.js new file mode 100644 index 0000000..c26e100 --- /dev/null +++ b/controller/matrix/MatrixController.js @@ -0,0 +1,32 @@ +const MatrixService = require('../../services/matrix/MatrixService'); + +async function create(req, res) { + try { + const { name, email, force } = req.query; // email of the top node account + const result = await MatrixService.create({ + name, + topNodeEmail: email, + force: String(force || '').toLowerCase() === 'true', + actorUser: req.user + }); + return res.json({ success: true, data: result }); + } catch (err) { + const status = err.status || 500; + return res.status(status).json({ success: false, message: err.message || 'Matrix creation failed' }); + } +} + +async function stats(req, res) { + try { + const data = await MatrixService.getStats({ 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 stats' }); + } +} + +module.exports = { + create, + stats +}; diff --git a/database/createDb.js b/database/createDb.js index 5d94c33..a58170a 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -540,6 +540,79 @@ async function createDatabase() { `); console.log('✅ Company stamps table created/verified'); + // --- Matrix: Global 5-ary tree config and relations --- + await connection.query(` + CREATE TABLE IF NOT EXISTS matrix_config ( + id TINYINT PRIMARY KEY DEFAULT 1, + master_top_user_id INT NOT 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, + CONSTRAINT chk_matrix_singleton CHECK (id = 1) + ); + `); + console.log('✅ Matrix config table created/verified'); + + await connection.query(` + CREATE TABLE IF NOT EXISTS user_tree_edges ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + parent_user_id INT NOT NULL, + child_user_id INT NOT NULL, + position TINYINT NOT NULL, + 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) + ); + `); + 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 + 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'); + + // Ensure new columns exist if table already existed + try { + await connection.query(`ALTER TABLE user_matrix_metadata ADD COLUMN name VARCHAR(255) NULL`); + console.log('🔧 Ensured user_matrix_metadata.name exists'); + } catch (e) { + console.log('ℹ️ user_matrix_metadata.name already exists or ALTER not required'); + } + try { + await connection.query(`ALTER TABLE user_matrix_metadata ADD COLUMN is_active BOOLEAN DEFAULT TRUE`); + console.log('🔧 Ensured user_matrix_metadata.is_active exists'); + } catch (e) { + console.log('ℹ️ user_matrix_metadata.is_active already exists or ALTER not required'); + } + // --- Added Index Optimization Section --- try { // Core / status @@ -576,6 +649,14 @@ async function createDatabase() { await ensureIndex(connection, 'document_templates', 'idx_document_templates_state_user_type', 'state, user_type'); // NEW composite index await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_company', 'company_id'); await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_active', 'is_active'); + + // Matrix indexes + await ensureIndex(connection, 'user_tree_edges', 'idx_user_tree_edges_parent', 'parent_user_id'); + // child_user_id already has a UNIQUE constraint; extra index not needed + 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 + console.log('🚀 Performance indexes created/verified'); } catch (e) { console.warn('⚠️ Index optimization phase encountered an issue:', e.message); diff --git a/models/Matrix.js b/models/Matrix.js new file mode 100644 index 0000000..c8edede --- /dev/null +++ b/models/Matrix.js @@ -0,0 +1,28 @@ +class Matrix { + constructor({ + // config (create) + name, + masterTopUserId, + masterTopUserEmail, + // ego activation (optional legacy fields) + rootUserId, + egoActivatedAt, + lastBfsFillAt, + immediateChildrenCount, + firstFreePosition + }) { + this.name = typeof name === 'string' ? name : null; + this.masterTopUserId = masterTopUserId !== undefined ? Number(masterTopUserId) : null; + this.masterTopUserEmail = masterTopUserEmail || null; + + this.rootUserId = rootUserId !== undefined ? Number(rootUserId) : null; + this.egoActivatedAt = egoActivatedAt || null; + this.lastBfsFillAt = lastBfsFillAt || null; + this.immediateChildrenCount = immediateChildrenCount !== undefined ? Number(immediateChildrenCount || 0) : null; + this.firstFreePosition = firstFreePosition !== undefined + ? (firstFreePosition === null ? null : Number(firstFreePosition)) + : null; + } +} + +module.exports = Matrix; diff --git a/repositories/matrix/MatrixRepository.js b/repositories/matrix/MatrixRepository.js new file mode 100644 index 0000000..fe839fa --- /dev/null +++ b/repositories/matrix/MatrixRepository.js @@ -0,0 +1,289 @@ +const mysql = require('mysql2/promise'); +const fs = require('fs'); +const path = require('path'); + +const NODE_ENV = process.env.NODE_ENV || 'development'; + +function getSSLConfig() { + const useSSL = String(process.env.DB_SSL || '').toLowerCase() === 'true'; + const caPath = process.env.DB_SSL_CA_PATH; + if (!useSSL) return undefined; + try { + if (caPath) { + const resolved = path.resolve(caPath); + if (fs.existsSync(resolved)) { + return { ca: fs.readFileSync(resolved), rejectUnauthorized: false }; + } + } + } catch (_) {} + return { rejectUnauthorized: false }; +} + +let dbConfig; +if (NODE_ENV === 'development') { + dbConfig = { + host: process.env.DEV_DB_HOST || 'localhost', + port: Number(process.env.DEV_DB_PORT) || 3306, + user: process.env.DEV_DB_USER || 'root', + password: process.env.DEV_DB_PASSWORD || '', + database: process.env.DEV_DB_NAME || 'profitplanet_centralserver', + ssl: undefined + }; +} else { + dbConfig = { + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT) || 3306, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + ssl: getSSLConfig() + }; +} + +const pool = mysql.createPool({ + ...dbConfig, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 +}); + +async function ensureUserExistsByEmail(conn, email) { + const [rows] = await conn.query('SELECT id, email 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 rows[0]; +} + +async function createMatrix({ name, topNodeEmail, force = false }) { + 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 (_) {} + + 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) + VALUES + (?, NOW(), NULL, 0, 1, ?, TRUE) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + is_active = TRUE, + ego_activated_at = COALESCE(user_matrix_metadata.ego_activated_at, VALUES(ego_activated_at)), + updated_at = CURRENT_TIMESTAMP + `, + [topUser.id, name] + ); + + await conn.commit(); + return { + name, + masterTopUserId: topUser.id, + masterTopUserEmail: topUser.email + }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } +} + +async function ensureUserExists(conn, userId) { + const [rows] = await conn.query('SELECT id FROM users WHERE id = ?', [userId]); + if (!rows.length) { + const err = new Error('User not found'); + err.status = 404; + throw err; + } +} + +async function activateEgoMatrix(rootUserId) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await ensureUserExists(conn, rootUserId); + await conn.query( + 'INSERT IGNORE INTO user_tree_closure (ancestor_user_id, descendant_user_id, depth) VALUES (?, ?, 0)', + [rootUserId, rootUserId] + ); + const [childRows] = await conn.query( + 'SELECT position FROM user_tree_edges WHERE parent_user_id = ? ORDER BY position ASC', + [rootUserId] + ); + const positions = new Set(childRows.map(r => r.position)); + let firstFreePosition = null; + for (let i = 1; i <= 5; i++) { + if (!positions.has(i)) { firstFreePosition = i; break; } + } + const immediateChildrenCount = childRows.length; + await conn.query( + ` + INSERT INTO user_matrix_metadata + (root_user_id, ego_activated_at, last_bfs_fill_at, immediate_children_count, first_free_position) + VALUES + (?, NOW(), NULL, ?, ?) + 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), + updated_at = CURRENT_TIMESTAMP + `, + [rootUserId, immediateChildrenCount, firstFreePosition] + ); + const [metaRows] = await conn.query( + 'SELECT ego_activated_at, last_bfs_fill_at, immediate_children_count, first_free_position FROM user_matrix_metadata WHERE root_user_id = ?', + [rootUserId] + ); + const meta = metaRows[0] || { + ego_activated_at: null, + last_bfs_fill_at: null, + immediate_children_count: immediateChildrenCount, + first_free_position: firstFreePosition + }; + await conn.commit(); + return { + rootUserId, + egoActivatedAt: meta.ego_activated_at, + lastBfsFillAt: meta.last_bfs_fill_at, + immediateChildrenCount: meta.immediate_children_count, + firstFreePosition: meta.first_free_position + }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } +} + +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(); + + if (rootIds.length) { + // counts per matrix up to depth 5 + 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 + `, + [rootIds] + ); + countsByRoot = new Map(cntRows.map(r => [Number(r.root_id), Number(r.cnt)])); + + // total distinct users across all active matrices + 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 descendant_user_id) AS total + FROM user_tree_closure + WHERE depth BETWEEN 0 AND 5 + AND ancestor_user_id IN (?) + `, + [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 + })); + + return { + activeMatrices, + totalMatrices, + totalUsersSubscribed: totalDistinct, + matrices + }; + } + + // no matrices registered + return { + activeMatrices: 0, + totalMatrices: 0, + totalUsersSubscribed: 0, + matrices: [] + }; + } finally { + conn.release(); + } +} + +module.exports = { + createMatrix, + activateEgoMatrix, + getMatrixStats +}; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index b193f27..be5613e 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -14,6 +14,7 @@ const ServerStatusController = require('../controller/admin/ServerStatusControll const UserController = require('../controller/auth/UserController'); const UserStatusController = require('../controller/auth/UserStatusController'); const CompanyStampController = require('../controller/companyStamp/CompanyStampController'); // <-- added +const MatrixController = require('../controller/matrix/MatrixController'); // <-- added // small helpers copied from original files function adminOnly(req, res, next) { @@ -96,5 +97,9 @@ router.get('/api/document-templates', authMiddleware, adminOnly, DocumentTemplat router.get('/company-stamps/mine', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.listMine); router.get('/company-stamps/mine/active', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.activeMine); +// 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 + // export module.exports = router; \ No newline at end of file diff --git a/services/matrix/MatrixService.js b/services/matrix/MatrixService.js new file mode 100644 index 0000000..ee63088 --- /dev/null +++ b/services/matrix/MatrixService.js @@ -0,0 +1,83 @@ +const MatrixRepository = require('../../repositories/matrix/MatrixRepository'); +const Matrix = require('../../models/Matrix'); + +function isAdmin(user) { + return !!user && ['admin', 'super_admin'].includes(user.role); +} + +function isValidEmail(s) { + return typeof s === 'string' && /\S+@\S+\.\S+/.test(s); +} + +async function create({ name, topNodeEmail, force = false, 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 trimmedName = (name || '').trim(); + if (!trimmedName) { + const err = new Error('Matrix name is required'); + err.status = 400; + throw err; + } + if (trimmedName.length > 255) { + const err = new Error('Matrix name is too long'); + err.status = 400; + throw err; + } + const email = (topNodeEmail || '').trim().toLowerCase(); + if (!isValidEmail(email)) { + const err = new Error('Valid top node email is required'); + err.status = 400; + throw err; + } + + const res = await MatrixRepository.createMatrix({ name: trimmedName, topNodeEmail: email, force }); + + return new Matrix({ + name: res.name, + masterTopUserId: res.masterTopUserId, + masterTopUserEmail: res.masterTopUserEmail + }); +} + +async function getStats({ 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 stats = await MatrixRepository.getMatrixStats(); + + // Keep the response shape straightforward for the dashboard + return { + 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 + })) + }; +} + +module.exports = { + create, + getStats +};