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