feat: matrix management backend #1

This commit is contained in:
DeathKaioken 2025-11-17 22:11:28 +01:00
parent 77e34af8e2
commit b424e90e08
6 changed files with 835 additions and 31 deletions

View File

@ -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
}; };

View File

@ -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) {

View File

@ -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
}; };

View File

@ -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;

View File

@ -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) => {

View File

@ -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
}; };