feat: matrix adjustments

This commit is contained in:
DeathKaioken 2025-11-30 12:20:36 +01:00
parent 1fce1f1831
commit 9f5458f0a8
7 changed files with 1043 additions and 507 deletions

View File

@ -29,9 +29,8 @@ async function stats(req, res) {
// NEW: admin-only list of matrix users under a root // NEW: admin-only list of matrix users under a root
async function getMatrixUserforAdmin(req, res) { async function getMatrixUserforAdmin(req, res) {
try { try {
const { rootUserId, depth, limit, offset, includeRoot } = req.query; const { rootUserId, depth, limit, offset, includeRoot, rogueOnly } = req.query;
// aliases accepted by backend for convenience const matrixInstanceId = req.query.matrixInstanceId || req.query.matrixId || req.query.id;
const matrixId = req.query.matrixId || req.query.id;
const topNodeEmail = req.query.topNodeEmail || req.query.email; const topNodeEmail = req.query.topNodeEmail || req.query.email;
const data = await MatrixService.getUsers({ const data = await MatrixService.getUsers({
@ -40,8 +39,10 @@ async function getMatrixUserforAdmin(req, res) {
limit, limit,
offset, offset,
includeRoot, includeRoot,
rogueOnly: String(rogueOnly || '').toLowerCase() === 'true',
actorUser: req.user, actorUser: req.user,
matrixId, matrixInstanceId,
matrixId: matrixInstanceId, // backward alias
topNodeEmail topNodeEmail
}); });
return res.json({ success: true, data }); return res.json({ success: true, data });
@ -55,20 +56,19 @@ async function getMatrixUserforAdmin(req, res) {
async function searchCandidates(req, res) { async function searchCandidates(req, res) {
try { try {
const { q, type, limit, offset, rootUserId } = req.query; 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 topNodeEmail = req.query.topNodeEmail || req.query.email;
const data = await MatrixService.getUserCandidates({ const data = await MatrixService.getUserCandidates({
q, q,
type, type,
limit, limit,
offset, offset,
rootUserId, rootUserId,
matrixId, matrixInstanceId,
matrixId: matrixInstanceId,
topNodeEmail, topNodeEmail,
actorUser: req.user actorUser: req.user
}); });
return res.json({ success: true, data }); return res.json({ success: true, data });
} catch (err) { } catch (err) {
const status = err.status || 500; const status = err.status || 500;
@ -80,15 +80,18 @@ async function addUser(req, res) {
try { try {
const { const {
rootUserId, rootUserId,
matrixInstanceId,
matrixId, matrixId,
topNodeEmail, topNodeEmail,
childUserId, childUserId,
forceParentFallback, forceParentFallback,
parentUserId parentUserId
} = req.body; } = req.body;
const resolvedMatrixId = matrixInstanceId || matrixId;
const data = await MatrixService.addUserToMatrix({ const data = await MatrixService.addUserToMatrix({
rootUserId, rootUserId,
matrixId, matrixInstanceId: resolvedMatrixId,
matrixId: resolvedMatrixId,
topNodeEmail, topNodeEmail,
childUserId, childUserId,
forceParentFallback, 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 = { module.exports = {
create, create,
stats, stats,
getMatrixUserforAdmin, getMatrixUserforAdmin,
searchCandidates, searchCandidates,
addUser // NEW addUser, // NEW
getMyOverview, // NEW
deactivate, // NEW
activate // NEW
}; };

View File

@ -565,12 +565,22 @@ async function createDatabase() {
CREATE TABLE IF NOT EXISTS matrix_config ( CREATE TABLE IF NOT EXISTS matrix_config (
id TINYINT PRIMARY KEY DEFAULT 1, id TINYINT PRIMARY KEY DEFAULT 1,
master_top_user_id INT NOT NULL, master_top_user_id INT NOT NULL,
name VARCHAR(255) NULL, -- ADDED (was missing, caused Unknown column 'name')
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE 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 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) 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'); console.log('✅ Matrix config table created/verified');
await connection.query(` await connection.query(`
@ -578,101 +588,196 @@ async function createDatabase() {
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
parent_user_id INT NOT NULL, parent_user_id INT NOT NULL,
child_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, 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_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 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_child UNIQUE (child_user_id),
CONSTRAINT uq_parent_position UNIQUE (parent_user_id, position), CONSTRAINT uq_parent_position UNIQUE (parent_user_id, position)
CONSTRAINT chk_position CHECK (position BETWEEN 1 AND 5) -- REMOVED: chk_position CHECK (position BETWEEN 1 AND 5)
); );
`); `);
console.log('✅ User tree edges table created/verified'); console.log('✅ User tree edges table created/verified');
// --- Migration: relax position constraints on existing schemas ---
try {
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(' position type change skipped:', e.message);
}
try {
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) {
// 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 {
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(' 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(` await connection.query(`
CREATE TABLE IF NOT EXISTS user_tree_closure ( CREATE TABLE IF NOT EXISTS user_tree_closure (
matrix_instance_id INT NOT NULL,
ancestor_user_id INT NOT NULL, ancestor_user_id INT NOT NULL,
descendant_user_id INT NOT NULL, descendant_user_id INT NOT NULL,
depth INT NOT NULL, depth INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_closure PRIMARY KEY (ancestor_user_id, descendant_user_id), 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_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 fk_closure_descendant FOREIGN KEY (descendant_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT chk_depth_nonneg CHECK (depth >= 0) CONSTRAINT chk_depth_nonneg CHECK (depth >= 0)
); );
`); `);
console.log('✅ User tree closure table created/verified'); console.log('✅ user_tree_closure (multi) created/verified');
await connection.query(` // If legacy closure rows without matrix_instance_id (older separate table definition), attempt add column & backfill
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
try { try {
await connection.query(` await connection.query(`ALTER TABLE user_tree_closure ADD COLUMN matrix_instance_id INT NOT NULL`);
ALTER TABLE pools console.log('🆕 Added matrix_instance_id column to existing user_tree_closure');
MODIFY COLUMN state ENUM('active','inactive','archived') DEFAULT 'active'
`);
console.log('🆙 Pools.state updated to include archived');
} catch (e) { } catch (e) {
console.log(' Pools.state alteration skipped:', e.message); // already integrated new definition
} }
if (firstInstanceId) {
try { try {
await connection.query(`ALTER TABLE pools ADD COLUMN created_by INT NOT NULL`); await connection.query(`UPDATE user_tree_closure SET matrix_instance_id = ? WHERE matrix_instance_id IS NULL`, [firstInstanceId]);
await connection.query(`CREATE INDEX idx_pool_created_by ON pools (created_by)`); console.log('🧩 Backfilled closure matrix_instance_id');
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');
} catch (e) { } catch (e) {
console.log(' Pools.created_by add skipped:', e.message); 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 { try {
await connection.query(`ALTER TABLE pools ADD COLUMN updated_by INT NULL`); await connection.query(`ALTER TABLE matrix_config DROP CHECK chk_matrix_singleton`);
await connection.query(`CREATE INDEX idx_pool_updated_by ON pools (updated_by)`); console.log('🧹 Dropped chk_matrix_singleton');
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');
} catch (e) { } catch (e) {
console.log(' Pools.updated_by add skipped:', e.message); console.log(' chk_matrix_singleton drop skipped:', e.message);
} }
console.log('🎉 Normalized database schema created/updated successfully!'); console.log('🎉 Normalized database schema created/updated successfully!');

View File

@ -9,7 +9,8 @@ class Matrix {
egoActivatedAt, egoActivatedAt,
lastBfsFillAt, lastBfsFillAt,
immediateChildrenCount, immediateChildrenCount,
firstFreePosition firstFreePosition,
matrixInstanceId // ADDED (was missing → ReferenceError)
}) { }) {
this.name = typeof name === 'string' ? name : null; this.name = typeof name === 'string' ? name : null;
this.masterTopUserId = masterTopUserId !== undefined ? Number(masterTopUserId) : null; this.masterTopUserId = masterTopUserId !== undefined ? Number(masterTopUserId) : null;
@ -22,6 +23,7 @@ class Matrix {
this.firstFreePosition = firstFreePosition !== undefined this.firstFreePosition = firstFreePosition !== undefined
? (firstFreePosition === null ? null : Number(firstFreePosition)) ? (firstFreePosition === null ? null : Number(firstFreePosition))
: null; : null;
this.matrixInstanceId = matrixInstanceId !== undefined ? Number(matrixInstanceId) : null;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -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/users', authMiddleware, adminOnly, MatrixController.getMatrixUserforAdmin);
router.get('/admin/matrix/user-candidates', authMiddleware, adminOnly, MatrixController.searchCandidates); 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) // NEW: Matrix POST (admin)
router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser); router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser);

View File

@ -8,6 +8,7 @@ const CoffeeController = require('../controller/admin/CoffeeController');
const AdminUserController = require('../controller/admin/AdminUserController'); const AdminUserController = require('../controller/admin/AdminUserController');
const PersonalProfileController = require('../controller/profile/PersonalProfileController'); // <-- add const PersonalProfileController = require('../controller/profile/PersonalProfileController'); // <-- add
const PoolController = require('../controller/pool/PoolController'); // <-- new const PoolController = require('../controller/pool/PoolController'); // <-- new
const MatrixController = require('../controller/matrix/MatrixController'); // <-- new
// Helper middlewares for company-stamp // Helper middlewares for company-stamp
function adminOnly(req, res, next) { 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); router.patch('/admin/coffee/:id/state', authMiddleware, adminOnly, CoffeeController.setState);
// NEW: Admin pool state update // NEW: Admin pool state update
router.patch('/admin/pools/:id/state', authMiddleware, adminOnly, PoolController.updateState); 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 // Personal profile (self-service) - no admin guard
router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic); router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic);

View File

@ -19,7 +19,7 @@ function toBool(value, defaultVal = false) {
return defaultVal; return defaultVal;
} }
async function create({ name, topNodeEmail, force = false, actorUser }) { async function create({ name, topNodeEmail, actorUser }) { // force removed (new instance each time)
if (!actorUser) { if (!actorUser) {
const err = new Error('Unauthorized'); const err = new Error('Unauthorized');
err.status = 401; err.status = 401;
@ -48,14 +48,13 @@ async function create({ name, topNodeEmail, force = false, actorUser }) {
throw err; throw err;
} }
const res = await MatrixRepository.createMatrix({ name: trimmedName, topNodeEmail: email, force }); const res = await MatrixRepository.createMatrix({ name: trimmedName, topNodeEmail: email });
return new Matrix({ return new Matrix({
name: res.name, name: res.name,
masterTopUserId: res.masterTopUserId, masterTopUserId: res.masterTopUserId,
masterTopUserEmail: res.masterTopUserEmail, masterTopUserEmail: res.masterTopUserEmail,
// also expose root user id for frontend rootUserId: res.rootUserId,
rootUserId: res.masterTopUserId matrixInstanceId: res.matrixInstanceId
}); });
} }
@ -78,29 +77,22 @@ async function getStats({ actorUser }) {
activeMatrices: stats.activeMatrices, activeMatrices: stats.activeMatrices,
totalMatrices: stats.totalMatrices, totalMatrices: stats.totalMatrices,
totalUsersSubscribed: stats.totalUsersSubscribed, totalUsersSubscribed: stats.totalUsersSubscribed,
matrices: stats.matrices.map(m => { matrices: stats.matrices.map(m => ({
const rootId = Number(m.rootUserId); matrixInstanceId: Number(m.matrixInstanceId),
const matrixConfigId = m.matrixConfigId != null ? Number(m.matrixConfigId) : null; rootUserId: Number(m.rootUserId),
const matrixId = matrixConfigId != null ? matrixConfigId : rootId; // prefer config id when available
return {
// primary fields
rootUserId: rootId,
name: m.name, name: m.name,
isActive: !!m.isActive, isActive: !!m.isActive,
usersCount: m.usersCount, usersCount: m.usersCount,
createdAt: m.createdAt, // equals ego_activated_at rogueUsersCount: m.rogueUsersCount,
createdAt: m.createdAt,
topNodeEmail: m.topNodeEmail, topNodeEmail: m.topNodeEmail,
// compatibility and routing aliases for frontend matrixId: Number(m.matrixInstanceId), // backward compatibility
root_user_id: rootId, id: Number(m.matrixInstanceId)
matrixConfigId, }))
matrixId,
id: matrixId
};
})
}; };
} }
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) { if (!actorUser) {
const err = new Error('Unauthorized'); const err = new Error('Unauthorized');
err.status = 401; err.status = 401;
@ -112,11 +104,9 @@ async function getUsers({ rootUserId, maxDepth, limit, offset, includeRoot, acto
throw err; throw err;
} }
// resolve root id from any of the accepted identifiers const resolved = await MatrixRepository.resolveRootUserId({ rootUserId, matrixId, matrixInstanceId, topNodeEmail });
let rid = Number.parseInt(rootUserId, 10); const rid = resolved.rootUserId;
if (!Number.isFinite(rid) || rid <= 0) { const mid = resolved.matrixInstanceId;
rid = await MatrixRepository.resolveRootUserId({ rootUserId, matrixId, topNodeEmail });
}
// NEW: do not clamp to 5 globally; repository will enforce per-root policy // NEW: do not clamp to 5 globally; repository will enforce per-root policy
let depth = Number.parseInt(maxDepth ?? 5, 10); 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({ const users = await MatrixRepository.getMatrixUsers({
rootUserId: rid, rootUserId: rid,
matrixInstanceId: mid,
maxDepth: depth, maxDepth: depth,
limit: lim, limit: lim,
offset: off, offset: off,
includeRoot: incRoot includeRoot: incRoot,
rogueOnly: !!rogueOnly
}); });
return { return {
matrixInstanceId: mid,
rootUserId: rid, rootUserId: rid,
maxDepth: depth, maxDepth: depth,
limit: lim, limit: lim,
offset: off, offset: off,
includeRoot: incRoot, includeRoot: incRoot,
rogueOnly: !!rogueOnly,
users users
}; };
} }
// NEW: search user candidates to add into a matrix // 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) { if (!actorUser) {
const err = new Error('Unauthorized'); const err = new Error('Unauthorized');
err.status = 401; err.status = 401;
@ -181,23 +175,26 @@ async function getUserCandidates({ q, type, limit, offset, rootUserId, matrixId,
q: query, q: query,
type: normType, type: normType,
rootUserId: null, rootUserId: null,
matrixInstanceId: null, // ADDED for consistency
limit: lim, limit: lim,
offset: off, offset: off,
total: 0, total: 0,
items: [] items: []
}; };
} }
// Always resolve (covers legacy rootUserId only case)
// Resolve root id if not a valid positive number const { rootUserId: rid, matrixInstanceId: mid } = await MatrixRepository.resolveRootUserId({
let rid = Number.parseInt(rootUserId, 10); rootUserId,
if (!Number.isFinite(rid) || rid <= 0) { matrixId,
rid = await MatrixRepository.resolveRootUserId({ rootUserId, matrixId, topNodeEmail }); matrixInstanceId,
} topNodeEmail
});
const { total, items } = await MatrixRepository.getUserSearchCandidates({ const { total, items } = await MatrixRepository.getUserSearchCandidates({
q: query, q: query,
type: normType, type: normType,
rootUserId: rid, rootUserId: rid,
matrixInstanceId: mid,
limit: lim, limit: lim,
offset: off offset: off
}); });
@ -206,6 +203,7 @@ async function getUserCandidates({ q, type, limit, offset, rootUserId, matrixId,
q: query, q: query,
type: normType, type: normType,
rootUserId: rid, rootUserId: rid,
matrixInstanceId: mid,
limit: lim, limit: lim,
offset: off, offset: off,
total, total,
@ -216,6 +214,7 @@ async function getUserCandidates({ q, type, limit, offset, rootUserId, matrixId,
async function addUserToMatrix({ async function addUserToMatrix({
rootUserId, rootUserId,
matrixId, matrixId,
matrixInstanceId,
topNodeEmail, topNodeEmail,
childUserId, childUserId,
forceParentFallback, forceParentFallback,
@ -244,16 +243,17 @@ async function addUserToMatrix({
const summary = await MatrixRepository.addUserToMatrix({ const summary = await MatrixRepository.addUserToMatrix({
rootUserId, rootUserId,
matrixId, matrixId,
matrixInstanceId,
topNodeEmail, topNodeEmail,
childUserId: cId, childUserId: cId,
forceParentFallback: fallback, forceParentFallback: fallback, // respected by repository for root fallback
parentUserId: parentOverride, parentUserId: parentOverride,
actorUserId: actorUser.id actorUserId: actorUser.id
}); });
// fetch a small updated list (depth 2, limit 25)
const users = await MatrixRepository.getMatrixUsers({ const users = await MatrixRepository.getMatrixUsers({
rootUserId: summary.rootUserId, rootUserId: summary.rootUserId,
matrixInstanceId: summary.matrixInstanceId,
maxDepth: 2, maxDepth: 2,
limit: 25, limit: 25,
offset: 0, 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 = { module.exports = {
create, create,
getStats, getStats,
getUsers, getUsers,
getUserCandidates, getUserCandidates,
addUserToMatrix // NEW addUserToMatrix,
getMyOverview, // NEW
deactivate, // NEW
activate // NEW
}; };