945 lines
29 KiB
JavaScript
945 lines
29 KiB
JavaScript
const mysql = require('mysql2/promise');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||
|
||
function getSSLConfig() {
|
||
const useSSL = String(process.env.DB_SSL || '').toLowerCase() === 'true';
|
||
const caPath = process.env.DB_SSL_CA_PATH;
|
||
if (!useSSL) return undefined;
|
||
try {
|
||
if (caPath) {
|
||
const resolved = path.resolve(caPath);
|
||
if (fs.existsSync(resolved)) {
|
||
return { ca: fs.readFileSync(resolved), rejectUnauthorized: false };
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
return { rejectUnauthorized: false };
|
||
}
|
||
|
||
let dbConfig;
|
||
if (NODE_ENV === 'development') {
|
||
dbConfig = {
|
||
host: process.env.DEV_DB_HOST || 'localhost',
|
||
port: Number(process.env.DEV_DB_PORT) || 3306,
|
||
user: process.env.DEV_DB_USER || 'root',
|
||
password: process.env.DEV_DB_PASSWORD || '',
|
||
database: process.env.DEV_DB_NAME || 'profitplanet_centralserver',
|
||
ssl: undefined
|
||
};
|
||
} else {
|
||
dbConfig = {
|
||
host: process.env.DB_HOST,
|
||
port: Number(process.env.DB_PORT) || 3306,
|
||
user: process.env.DB_USER,
|
||
password: process.env.DB_PASSWORD,
|
||
database: process.env.DB_NAME,
|
||
ssl: getSSLConfig()
|
||
};
|
||
}
|
||
|
||
const pool = mysql.createPool({
|
||
...dbConfig,
|
||
waitForConnections: true,
|
||
connectionLimit: 10,
|
||
queueLimit: 0
|
||
});
|
||
|
||
async function ensureUserExistsByEmail(conn, email) {
|
||
const [rows] = await conn.query('SELECT id, email FROM users WHERE LOWER(email) = LOWER(?) LIMIT 1', [email]);
|
||
if (!rows.length) {
|
||
const err = new Error('Top node user not found');
|
||
err.status = 404;
|
||
throw err;
|
||
}
|
||
return rows[0];
|
||
}
|
||
|
||
async function createMatrix({ name, topNodeEmail }) {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
await conn.beginTransaction();
|
||
|
||
// Ensure columns for multi-instance (idempotent)
|
||
try { await conn.query(`ALTER TABLE user_tree_edges ADD COLUMN matrix_instance_id INT NULL`); } catch (_) {}
|
||
try { await conn.query(`ALTER TABLE user_tree_edges ADD COLUMN rogue_user BOOLEAN DEFAULT FALSE`); } catch (_) {}
|
||
try { await conn.query(`ALTER TABLE user_tree_closure ADD COLUMN matrix_instance_id INT NOT NULL`); } catch (_) {}
|
||
try { await conn.query(`ALTER TABLE user_matrix_metadata ADD COLUMN matrix_instance_id INT NOT NULL`); } catch (_) {}
|
||
|
||
const topUser = await ensureUserExistsByEmail(conn, topNodeEmail);
|
||
|
||
// Create new matrix instance
|
||
const [instRes] = await conn.query(
|
||
`INSERT INTO matrix_instances
|
||
(root_user_id, name, is_active, max_depth, ego_activated_at, immediate_children_count, first_free_position)
|
||
VALUES (?, ?, TRUE, NULL, NOW(), 0, NULL)`, // CHANGED: first_free_position NULL at root
|
||
[topUser.id, name]
|
||
);
|
||
const matrixInstanceId = instRes.insertId;
|
||
|
||
// Insert metadata row (primary keyed by matrix_instance_id)
|
||
await conn.query(
|
||
`INSERT INTO user_matrix_metadata
|
||
(matrix_instance_id, 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, NULL)`,
|
||
[matrixInstanceId, topUser.id, name]
|
||
);
|
||
|
||
// Self closure scoped to instance
|
||
await conn.query(
|
||
`INSERT IGNORE INTO user_tree_closure
|
||
(matrix_instance_id, ancestor_user_id, descendant_user_id, depth)
|
||
VALUES (?, ?, ?, 0)`,
|
||
[matrixInstanceId, topUser.id, topUser.id]
|
||
);
|
||
|
||
await conn.commit();
|
||
return {
|
||
name,
|
||
matrixInstanceId,
|
||
masterTopUserId: topUser.id,
|
||
masterTopUserEmail: topUser.email,
|
||
rootUserId: topUser.id
|
||
};
|
||
} catch (e) {
|
||
await conn.rollback();
|
||
throw e;
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
async function ensureUserExists(conn, userId) {
|
||
const [rows] = await conn.query('SELECT id FROM users WHERE id = ?', [userId]);
|
||
if (!rows.length) {
|
||
const err = new Error('User not found');
|
||
err.status = 404;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function activateEgoMatrix(rootUserId) {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
await conn.beginTransaction();
|
||
await ensureUserExists(conn, rootUserId);
|
||
await conn.query(
|
||
'INSERT IGNORE INTO user_tree_closure (ancestor_user_id, descendant_user_id, depth) VALUES (?, ?, 0)',
|
||
[rootUserId, rootUserId]
|
||
);
|
||
const [childRows] = await conn.query(
|
||
'SELECT position FROM user_tree_edges WHERE parent_user_id = ? ORDER BY position ASC',
|
||
[rootUserId]
|
||
);
|
||
const positions = new Set(childRows.map(r => r.position));
|
||
let firstFreePosition = null;
|
||
for (let i = 1; i <= 5; i++) {
|
||
if (!positions.has(i)) { firstFreePosition = i; break; }
|
||
}
|
||
const immediateChildrenCount = childRows.length;
|
||
await conn.query(
|
||
`
|
||
INSERT INTO user_matrix_metadata
|
||
(root_user_id, ego_activated_at, last_bfs_fill_at, immediate_children_count, first_free_position, max_depth)
|
||
VALUES
|
||
(?, 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]
|
||
);
|
||
const [metaRows] = await conn.query(
|
||
'SELECT ego_activated_at, last_bfs_fill_at, immediate_children_count, first_free_position FROM user_matrix_metadata WHERE root_user_id = ?',
|
||
[rootUserId]
|
||
);
|
||
const meta = metaRows[0] || {
|
||
ego_activated_at: null,
|
||
last_bfs_fill_at: null,
|
||
immediate_children_count: immediateChildrenCount,
|
||
first_free_position: firstFreePosition
|
||
};
|
||
await conn.commit();
|
||
return {
|
||
rootUserId,
|
||
egoActivatedAt: meta.ego_activated_at,
|
||
lastBfsFillAt: meta.last_bfs_fill_at,
|
||
immediateChildrenCount: meta.immediate_children_count,
|
||
firstFreePosition: meta.first_free_position
|
||
};
|
||
} catch (err) {
|
||
await conn.rollback();
|
||
throw err;
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
async function getMatrixStats() {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
const [matRows] = await conn.query(`
|
||
SELECT mi.id AS matrixInstanceId,
|
||
mi.root_user_id,
|
||
mi.name,
|
||
mi.is_active,
|
||
mi.ego_activated_at,
|
||
mi.max_depth,
|
||
u.email
|
||
FROM matrix_instances mi
|
||
JOIN users u ON u.id = mi.root_user_id
|
||
ORDER BY mi.id ASC
|
||
`);
|
||
if (!matRows.length) return {
|
||
activeMatrices: 0,
|
||
totalMatrices: 0,
|
||
totalUsersSubscribed: 0,
|
||
matrices: []
|
||
};
|
||
|
||
const instanceIds = matRows.map(r => r.matrixInstanceId);
|
||
|
||
// Count users per instance honoring max_depth
|
||
const [cntRows] = await conn.query(`
|
||
SELECT c.matrix_instance_id AS mid, COUNT(*) AS cnt
|
||
FROM user_tree_closure c
|
||
JOIN matrix_instances mi ON mi.id = c.matrix_instance_id
|
||
WHERE c.matrix_instance_id IN (?)
|
||
AND c.depth BETWEEN 0 AND COALESCE(mi.max_depth, 2147483647)
|
||
GROUP BY c.matrix_instance_id
|
||
`, [instanceIds]);
|
||
|
||
const countsMap = new Map(cntRows.map(r => [Number(r.mid), Number(r.cnt)]));
|
||
|
||
// Rogue counts
|
||
const [rogueRows] = await conn.query(`
|
||
SELECT matrix_instance_id AS mid, COUNT(*) AS rogueCnt
|
||
FROM user_tree_edges
|
||
WHERE matrix_instance_id IN (?)
|
||
AND rogue_user = TRUE
|
||
`, [instanceIds]);
|
||
const rogueMap = new Map(rogueRows.map(r => [Number(r.mid), Number(r.rogueCnt)]));
|
||
|
||
// Distinct subscribed users across active matrices
|
||
const [distinctRows] = await conn.query(`
|
||
SELECT COUNT(DISTINCT c.descendant_user_id) AS total
|
||
FROM user_tree_closure c
|
||
JOIN matrix_instances mi ON mi.id = c.matrix_instance_id
|
||
WHERE mi.is_active = TRUE
|
||
AND c.matrix_instance_id IN (?)
|
||
AND c.depth BETWEEN 0 AND COALESCE(mi.max_depth, 2147483647)
|
||
`, [instanceIds]);
|
||
|
||
const totalDistinct = Number(distinctRows[0]?.total || 0);
|
||
const activeMatrices = matRows.filter(r => !!r.is_active).length;
|
||
|
||
const matrices = matRows.map(r => ({
|
||
matrixInstanceId: Number(r.matrixInstanceId),
|
||
rootUserId: Number(r.root_user_id),
|
||
name: r.name || null,
|
||
isActive: !!r.is_active,
|
||
usersCount: countsMap.get(Number(r.matrixInstanceId)) || 0,
|
||
rogueUsersCount: rogueMap.get(Number(r.matrixInstanceId)) || 0,
|
||
createdAt: r.ego_activated_at || null,
|
||
topNodeEmail: r.email
|
||
}));
|
||
|
||
return {
|
||
activeMatrices,
|
||
totalMatrices: matRows.length,
|
||
totalUsersSubscribed: totalDistinct,
|
||
matrices
|
||
};
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
async function resolveRootUserId({ rootUserId, matrixId, matrixInstanceId, topNodeEmail }) {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
// Prefer explicit matrixInstanceId (matrixId backward compatible)
|
||
const midRaw = matrixInstanceId || matrixId;
|
||
const mid = Number.parseInt(midRaw, 10);
|
||
if (Number.isFinite(mid) && mid > 0) {
|
||
const [rows] = await conn.query(
|
||
`SELECT id, root_user_id FROM matrix_instances WHERE id = ? LIMIT 1`,
|
||
[mid]
|
||
);
|
||
if (!rows.length) {
|
||
const err = new Error('Matrix instance not found');
|
||
err.status = 404;
|
||
throw err;
|
||
}
|
||
return { rootUserId: Number(rows[0].root_user_id), matrixInstanceId: Number(rows[0].id) };
|
||
}
|
||
|
||
// Direct root user id (find first instance with that root)
|
||
const rid = Number.parseInt(rootUserId, 10);
|
||
if (Number.isFinite(rid) && rid > 0) {
|
||
const [rows] = await conn.query(
|
||
`SELECT id FROM matrix_instances WHERE root_user_id = ? ORDER BY id ASC LIMIT 1`,
|
||
[rid]
|
||
);
|
||
if (rows.length) {
|
||
return { rootUserId: rid, matrixInstanceId: Number(rows[0].id) };
|
||
}
|
||
}
|
||
|
||
// Resolve by email
|
||
const email = (topNodeEmail || '').trim();
|
||
if (email) {
|
||
const [uRows] = await conn.query(
|
||
`SELECT id FROM users WHERE LOWER(email)=LOWER(?) LIMIT 1`,
|
||
[email]
|
||
);
|
||
if (!uRows.length) {
|
||
const err = new Error('Top node user not found');
|
||
err.status = 404;
|
||
throw err;
|
||
}
|
||
const uid = Number(uRows[0].id);
|
||
const [instRows] = await conn.query(
|
||
`SELECT id FROM matrix_instances WHERE root_user_id = ? ORDER BY id ASC LIMIT 1`,
|
||
[uid]
|
||
);
|
||
if (instRows.length) {
|
||
return { rootUserId: uid, matrixInstanceId: Number(instRows[0].id) };
|
||
}
|
||
}
|
||
|
||
// Fallback: pick first existing instance
|
||
const [firstInst] = await conn.query(`SELECT id, root_user_id FROM matrix_instances ORDER BY id ASC LIMIT 1`);
|
||
if (firstInst.length) {
|
||
return { rootUserId: Number(firstInst[0].root_user_id), matrixInstanceId: Number(firstInst[0].id) };
|
||
}
|
||
|
||
const err = new Error('Matrix resolver parameters required');
|
||
err.status = 400;
|
||
throw err;
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
// NEW: search user candidates to add to a matrix
|
||
async function getUserSearchCandidates({ q, type = 'all', rootUserId, matrixInstanceId, limit = 20, offset = 0 }) {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
const whereParts = [];
|
||
const params = [];
|
||
|
||
// Exclude users already in selected matrix instance
|
||
whereParts.push(`u.id NOT IN (
|
||
SELECT descendant_user_id
|
||
FROM user_tree_closure
|
||
WHERE matrix_instance_id = ?
|
||
AND ancestor_user_id = ?
|
||
)`);
|
||
params.push(Number(matrixInstanceId), Number(rootUserId));
|
||
|
||
whereParts.push(`u.role = 'user'`);
|
||
|
||
if (type === 'personal' || type === 'company') {
|
||
whereParts.push(`u.user_type = ?`);
|
||
params.push(type);
|
||
}
|
||
|
||
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 = `WHERE ${whereParts.join(' AND ')}`;
|
||
|
||
const [countRows] = await conn.query(`
|
||
SELECT COUNT(*) AS total
|
||
FROM users u
|
||
LEFT JOIN personal_profiles p ON p.user_id = u.id
|
||
LEFT JOIN company_profiles c ON c.user_id = u.id
|
||
${whereSql}
|
||
`, params);
|
||
const total = Number(countRows[0]?.total || 0);
|
||
|
||
const [rows] = await conn.query(`
|
||
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
|
||
LEFT JOIN personal_profiles p ON p.user_id = u.id
|
||
LEFT JOIN company_profiles c ON c.user_id = u.id
|
||
${whereSql}
|
||
ORDER BY u.created_at DESC, u.email ASC
|
||
LIMIT ? OFFSET ?
|
||
`, params.concat([Number(limit), Number(offset)]));
|
||
|
||
return {
|
||
total,
|
||
items: rows.map(r => ({
|
||
userId: Number(r.userId),
|
||
email: r.email,
|
||
userType: r.userType,
|
||
name: r.name || ''
|
||
}))
|
||
};
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
// NEW: fetch matrix users (descendants) under a root
|
||
async function getMatrixUsers({ rootUserId, matrixInstanceId, maxDepth = 5, limit = 100, offset = 0, includeRoot = false, rogueOnly = false }) {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
let policyDepth = null;
|
||
try {
|
||
const [pRows] = await conn.query(
|
||
`SELECT max_depth FROM matrix_instances WHERE id = ? LIMIT 1`,
|
||
[matrixInstanceId]
|
||
);
|
||
if (pRows.length) policyDepth = pRows[0].max_depth == null ? null : Number(pRows[0].max_depth);
|
||
} catch (_) {}
|
||
|
||
let requestedDepth = Number(maxDepth);
|
||
if (!Number.isFinite(requestedDepth) || requestedDepth < 0) requestedDepth = 0;
|
||
const depthLimit = policyDepth == null ? requestedDepth : Math.min(requestedDepth, policyDepth);
|
||
const startDepth = includeRoot ? 0 : 1;
|
||
if (startDepth > depthLimit) return [];
|
||
|
||
const rogueClause = rogueOnly ? 'AND (e.rogue_user = TRUE)' : '';
|
||
|
||
const [rows] = await conn.query(`
|
||
SELECT
|
||
c.descendant_user_id AS userId,
|
||
c.depth,
|
||
u.email,
|
||
u.user_type AS userType,
|
||
u.role,
|
||
u.created_at AS createdAt,
|
||
e.parent_user_id AS parentUserId,
|
||
e.position,
|
||
e.rogue_user AS rogueUser,
|
||
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.matrix_instance_id = c.matrix_instance_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.matrix_instance_id = ?
|
||
AND c.ancestor_user_id = ?
|
||
AND c.depth BETWEEN ? AND ?
|
||
${rogueClause}
|
||
ORDER BY c.depth ASC, u.created_at ASC, u.id ASC
|
||
LIMIT ? OFFSET ?
|
||
`, [matrixInstanceId, rootUserId, startDepth, depthLimit, Number(limit), Number(offset)]);
|
||
|
||
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,
|
||
rogueUser: !!r.rogueUser,
|
||
name: r.name || r.email,
|
||
createdAt: r.createdAt
|
||
}));
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
async function addUserToMatrix({
|
||
rootUserId,
|
||
matrixInstanceId,
|
||
matrixId,
|
||
topNodeEmail,
|
||
childUserId,
|
||
forceParentFallback = false,
|
||
parentUserId,
|
||
actorUserId
|
||
}) {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
await conn.beginTransaction();
|
||
|
||
// Resolve (matrixId backward compatible)
|
||
const resolved = await resolveRootUserId({
|
||
rootUserId,
|
||
matrixId: matrixId || matrixInstanceId,
|
||
matrixInstanceId,
|
||
topNodeEmail
|
||
});
|
||
const rid = resolved.rootUserId;
|
||
const mid = resolved.matrixInstanceId;
|
||
|
||
// PREVENT additions to inactive instances
|
||
const [miRows] = await conn.query(
|
||
`SELECT is_active FROM matrix_instances WHERE id = ? LIMIT 1`,
|
||
[mid]
|
||
);
|
||
if (!miRows.length) {
|
||
const err = new Error('Matrix instance not found');
|
||
err.status = 404;
|
||
throw err;
|
||
}
|
||
if (!miRows[0].is_active) {
|
||
const err = new Error('Matrix instance is inactive');
|
||
err.status = 409;
|
||
throw err;
|
||
}
|
||
|
||
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;
|
||
let explicitRootChoice = false;
|
||
if (parentId === rid) {
|
||
explicitRootChoice = true; // admin explicitly chose root
|
||
}
|
||
|
||
if (!parentId) {
|
||
// referral parent discovery
|
||
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
|
||
WHERE u.used_by_user_id = ?
|
||
ORDER BY u.id ASC
|
||
LIMIT 1
|
||
`,
|
||
[cId]
|
||
);
|
||
if (refRows.length) parentId = Number(refRows[0].parent_user_id);
|
||
}
|
||
|
||
let fallback_reason = null;
|
||
let rogueFlag = false;
|
||
|
||
// Check parent within matrix instance (when parentId exists and is not root)
|
||
if (parentId && parentId !== rid) {
|
||
const [inMatrixRows] = await conn.query(
|
||
`
|
||
SELECT depth FROM user_tree_closure
|
||
WHERE matrix_instance_id = ? AND ancestor_user_id = ? AND descendant_user_id = ?
|
||
LIMIT 1
|
||
`,
|
||
[mid, rid, parentId]
|
||
);
|
||
if (!inMatrixRows.length) {
|
||
// Referrer not in matrix -> fallback decision
|
||
if (forceParentFallback) {
|
||
fallback_reason = 'referrer_not_in_matrix';
|
||
parentId = rid;
|
||
rogueFlag = true;
|
||
} else {
|
||
const err = new Error('Referrer is not part of this matrix');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!parentId) {
|
||
// No referral parent found
|
||
fallback_reason = 'referrer_not_in_matrix';
|
||
parentId = rid;
|
||
rogueFlag = true;
|
||
}
|
||
|
||
// Duplicate check (already in matrix)
|
||
const [dupRows] = await conn.query(
|
||
`
|
||
SELECT 1 FROM user_tree_closure
|
||
WHERE matrix_instance_id = ? AND ancestor_user_id = ? AND descendant_user_id = ?
|
||
LIMIT 1
|
||
`,
|
||
[mid, rid, cId]
|
||
);
|
||
if (dupRows.length) {
|
||
const err = new Error('User already in matrix');
|
||
err.status = 409;
|
||
throw err;
|
||
}
|
||
|
||
// Determine policy depth
|
||
let maxDepthPolicy = null;
|
||
try {
|
||
const [pRows] = await conn.query(`SELECT max_depth FROM matrix_instances WHERE id = ? LIMIT 1`, [mid]);
|
||
if (pRows.length) maxDepthPolicy = pRows[0].max_depth == null ? null : Number(pRows[0].max_depth);
|
||
} catch (_) {}
|
||
|
||
let assignPos = null;
|
||
|
||
if (parentId === rid) {
|
||
// ROOT: unlimited children, sequential position
|
||
// Do not mark rogue if admin explicitly chose root
|
||
if (explicitRootChoice) {
|
||
rogueFlag = false;
|
||
fallback_reason = null;
|
||
}
|
||
const [rootPosRows] = await conn.query(
|
||
`SELECT MAX(position) AS maxPos FROM user_tree_edges WHERE matrix_instance_id = ? AND parent_user_id = ?`,
|
||
[mid, rid]
|
||
);
|
||
const maxPos = Number(rootPosRows[0]?.maxPos || 0);
|
||
assignPos = maxPos + 1; // sequential position under root
|
||
} else {
|
||
// NON-ROOT: enforce 1–5
|
||
const [childPosRows] = await conn.query(
|
||
`SELECT position FROM user_tree_edges WHERE matrix_instance_id = ? AND parent_user_id = ? ORDER BY position ASC`,
|
||
[mid, parentId]
|
||
);
|
||
const used = new Set(childPosRows.map(r => Number(r.position)));
|
||
for (let i = 1; i <= 5; i++) if (!used.has(i)) { assignPos = i; break; }
|
||
|
||
if (!assignPos) {
|
||
// BFS within the chosen parent subtree only
|
||
const [candRows] = await conn.query(
|
||
`
|
||
SELECT d.descendant_user_id AS candidate_id
|
||
FROM user_tree_closure AS d
|
||
WHERE d.matrix_instance_id = ?
|
||
AND d.ancestor_user_id = ?
|
||
AND d.descendant_user_id != ?
|
||
ORDER BY d.depth ASC, d.descendant_user_id ASC
|
||
`,
|
||
[mid, parentId, parentId]
|
||
);
|
||
|
||
for (const row of candRows) {
|
||
const candidateParentId = Number(row.candidate_id);
|
||
// skip root in BFS
|
||
if (candidateParentId === rid) continue;
|
||
// ensure candidate within max depth policy (parent depth + 1)
|
||
if (maxDepthPolicy != null) {
|
||
const [depthRows] = await conn.query(
|
||
`SELECT depth FROM user_tree_closure WHERE matrix_instance_id = ? AND ancestor_user_id = ? AND descendant_user_id = ? LIMIT 1`,
|
||
[mid, rid, candidateParentId]
|
||
);
|
||
const candDepthFromRoot = Number(depthRows[0]?.depth ?? 0);
|
||
if (candDepthFromRoot + 1 > maxDepthPolicy) continue;
|
||
}
|
||
const [candPosRows] = await conn.query(
|
||
`SELECT position FROM user_tree_edges WHERE matrix_instance_id = ? AND parent_user_id = ? ORDER BY position ASC`,
|
||
[mid, candidateParentId]
|
||
);
|
||
const candUsed = new Set(candPosRows.map(r => Number(r.position)));
|
||
for (let i = 1; i <= 5; i++) if (!candUsed.has(i)) { assignPos = i; break; }
|
||
if (assignPos) {
|
||
parentId = candidateParentId;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!assignPos) {
|
||
// no slot in subtree -> decide fallback
|
||
if (forceParentFallback) {
|
||
fallback_reason = 'referrer_full';
|
||
parentId = rid;
|
||
rogueFlag = true;
|
||
// root sequential
|
||
const [rootPosRows] = await conn.query(
|
||
`SELECT MAX(position) AS maxPos FROM user_tree_edges WHERE matrix_instance_id = ? AND parent_user_id = ?`,
|
||
[mid, rid]
|
||
);
|
||
const maxPos = Number(rootPosRows[0]?.maxPos || 0);
|
||
assignPos = maxPos + 1;
|
||
} else {
|
||
const err = new Error('Parent subtree is full (5-ary); no available position');
|
||
err.status = 409;
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Insert edge
|
||
await conn.query(
|
||
`INSERT INTO user_tree_edges
|
||
(matrix_instance_id, parent_user_id, child_user_id, position, rogue_user)
|
||
VALUES (?,?,?,?,?)`,
|
||
[mid, parentId, cId, assignPos, rogueFlag]
|
||
);
|
||
|
||
// Self closure for child
|
||
await conn.query(
|
||
`INSERT IGNORE INTO user_tree_closure
|
||
(matrix_instance_id, ancestor_user_id, descendant_user_id, depth)
|
||
VALUES (?,?,?,0)`,
|
||
[mid, cId, cId]
|
||
);
|
||
|
||
// Ancestor closure rows
|
||
const [ancestorRows] = await conn.query(
|
||
`SELECT ancestor_user_id, depth
|
||
FROM user_tree_closure
|
||
WHERE matrix_instance_id = ? AND descendant_user_id = ?
|
||
ORDER BY depth ASC`,
|
||
[mid, parentId]
|
||
);
|
||
if (ancestorRows.length) {
|
||
const values = ancestorRows
|
||
.map(r => `(${mid}, ${Number(r.ancestor_user_id)}, ${cId}, ${Number(r.depth) + 1})`)
|
||
.join(',');
|
||
await conn.query(
|
||
`INSERT IGNORE INTO user_tree_closure
|
||
(matrix_instance_id, ancestor_user_id, descendant_user_id, depth)
|
||
VALUES ${values}`
|
||
);
|
||
}
|
||
|
||
// Update instance metadata: immediate children count for root; do not compute first_free_position for root
|
||
if (parentId === rid) {
|
||
const [rootChildrenRows] = await conn.query(
|
||
`SELECT COUNT(*) AS cnt FROM user_tree_edges WHERE matrix_instance_id = ? AND parent_user_id = ?`,
|
||
[mid, rid]
|
||
);
|
||
await conn.query(
|
||
`UPDATE matrix_instances
|
||
SET immediate_children_count = ?
|
||
WHERE id = ?`,
|
||
[Number(rootChildrenRows[0]?.cnt || 0), mid]
|
||
);
|
||
}
|
||
|
||
// Log
|
||
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({
|
||
matrixInstanceId: mid,
|
||
rootUserId: rid,
|
||
parentUserId: parentId,
|
||
position: assignPos,
|
||
rogue_user: rogueFlag,
|
||
fallback_reason
|
||
})
|
||
]
|
||
);
|
||
} catch (_) {}
|
||
|
||
await conn.commit();
|
||
return {
|
||
matrixInstanceId: mid,
|
||
rootUserId: rid,
|
||
parentUserId: parentId,
|
||
childUserId: cId,
|
||
position: assignPos,
|
||
rogue_user: rogueFlag,
|
||
fallback_reason
|
||
};
|
||
} catch (e) {
|
||
try { await conn.rollback(); } catch (_) {}
|
||
throw e;
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
// NEW: resolve instance containing a given user via closure
|
||
async function resolveInstanceForUser(userId) {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
const [rows] = await conn.query(
|
||
`SELECT matrix_instance_id AS mid
|
||
FROM user_tree_closure
|
||
WHERE descendant_user_id = ?
|
||
ORDER BY matrix_instance_id ASC
|
||
LIMIT 1`,
|
||
[Number(userId)]
|
||
);
|
||
if (!rows.length) return null;
|
||
return { matrixInstanceId: Number(rows[0].mid) };
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
// NEW: fetch instance basic info
|
||
async function getInstanceInfo(instanceId) {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
const [rows] = await conn.query(
|
||
`SELECT id, root_user_id, max_depth
|
||
FROM matrix_instances
|
||
WHERE id = ? LIMIT 1`,
|
||
[Number(instanceId)]
|
||
);
|
||
return rows.length ? rows[0] : null;
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
// NEW: deactivate a matrix instance by id (idempotent)
|
||
async function deactivateInstance(instanceId) {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
const id = Number(instanceId);
|
||
if (!Number.isFinite(id) || id <= 0) {
|
||
const err = new Error('Invalid matrix instance id');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
|
||
const [rows] = await conn.query(
|
||
`SELECT id, is_active FROM matrix_instances WHERE id = ? LIMIT 1`,
|
||
[id]
|
||
);
|
||
if (!rows.length) {
|
||
const err = new Error('Matrix instance not found');
|
||
err.status = 404;
|
||
throw err;
|
||
}
|
||
const wasActive = !!rows[0].is_active;
|
||
|
||
// Try to set deactivated_at if column exists; fallback otherwise
|
||
try {
|
||
await conn.query(
|
||
`UPDATE matrix_instances
|
||
SET is_active = FALSE,
|
||
deactivated_at = COALESCE(deactivated_at, NOW()),
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?`,
|
||
[id]
|
||
);
|
||
} catch (e) {
|
||
if (e && e.code === 'ER_BAD_FIELD_ERROR') {
|
||
await conn.query(
|
||
`UPDATE matrix_instances
|
||
SET is_active = FALSE,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?`,
|
||
[id]
|
||
);
|
||
} else {
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
return {
|
||
matrixInstanceId: id,
|
||
wasActive,
|
||
isActive: false,
|
||
status: wasActive ? 'deactivated' : 'already_inactive'
|
||
};
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
// NEW: activate a matrix instance by id (idempotent)
|
||
async function activateInstance(instanceId) {
|
||
const conn = await pool.getConnection();
|
||
try {
|
||
const id = Number(instanceId);
|
||
if (!Number.isFinite(id) || id <= 0) {
|
||
const err = new Error('Invalid matrix instance id');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
|
||
const [rows] = await conn.query(
|
||
`SELECT id, is_active FROM matrix_instances WHERE id = ? LIMIT 1`,
|
||
[id]
|
||
);
|
||
if (!rows.length) {
|
||
const err = new Error('Matrix instance not found');
|
||
err.status = 404;
|
||
throw err;
|
||
}
|
||
const wasActive = !!rows[0].is_active;
|
||
|
||
if (!wasActive) {
|
||
// Try to clear deactivated_at if column exists
|
||
try {
|
||
await conn.query(
|
||
`UPDATE matrix_instances
|
||
SET is_active = TRUE,
|
||
deactivated_at = NULL,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?`,
|
||
[id]
|
||
);
|
||
} catch (e) {
|
||
if (e && e.code === 'ER_BAD_FIELD_ERROR') {
|
||
await conn.query(
|
||
`UPDATE matrix_instances
|
||
SET is_active = TRUE,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?`,
|
||
[id]
|
||
);
|
||
} else {
|
||
throw e;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
matrixInstanceId: id,
|
||
wasActive,
|
||
isActive: true,
|
||
status: wasActive ? 'already_active' : 'activated'
|
||
};
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
createMatrix,
|
||
ensureUserExistsByEmail,
|
||
activateEgoMatrix,
|
||
getMatrixStats,
|
||
resolveRootUserId,
|
||
getUserSearchCandidates,
|
||
getMatrixUsers,
|
||
addUserToMatrix,
|
||
resolveInstanceForUser, // NEW
|
||
getInstanceInfo, // NEW
|
||
deactivateInstance, // NEW
|
||
activateInstance // NEW
|
||
};
|