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