const MatrixRepository = require('../../repositories/matrix/MatrixRepository'); const Matrix = require('../../models/Matrix'); function isAdmin(user) { return !!user && ['admin', 'super_admin'].includes(user.role); } 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, actorUser }) { // force removed (new instance each time) 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 trimmedName = (name || '').trim(); if (!trimmedName) { const err = new Error('Matrix name is required'); err.status = 400; throw err; } if (trimmedName.length > 255) { const err = new Error('Matrix name is too long'); err.status = 400; throw err; } const email = (topNodeEmail || '').trim().toLowerCase(); if (!isValidEmail(email)) { const err = new Error('Valid top node email is required'); err.status = 400; throw err; } const res = await MatrixRepository.createMatrix({ name: trimmedName, topNodeEmail: email }); return new Matrix({ name: res.name, masterTopUserId: res.masterTopUserId, masterTopUserEmail: res.masterTopUserEmail, rootUserId: res.rootUserId, matrixInstanceId: res.matrixInstanceId }); } async function getStats({ 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 stats = await MatrixRepository.getMatrixStats(); // Keep the response shape straightforward for the dashboard return { activeMatrices: stats.activeMatrices, totalMatrices: stats.totalMatrices, totalUsersSubscribed: stats.totalUsersSubscribed, matrices: stats.matrices.map(m => ({ matrixInstanceId: Number(m.matrixInstanceId), rootUserId: Number(m.rootUserId), name: m.name, isActive: !!m.isActive, usersCount: m.usersCount, rogueUsersCount: m.rogueUsersCount, createdAt: m.createdAt, topNodeEmail: m.topNodeEmail, matrixId: Number(m.matrixInstanceId), // backward compatibility id: Number(m.matrixInstanceId) })) }; } async function getUsers({ rootUserId, maxDepth, limit, offset, includeRoot, actorUser, matrixId, matrixInstanceId, topNodeEmail, rogueOnly }) { 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 resolved = await MatrixRepository.resolveRootUserId({ rootUserId, matrixId, matrixInstanceId, topNodeEmail }); const rid = resolved.rootUserId; const mid = resolved.matrixInstanceId; // 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, matrixInstanceId: mid, maxDepth: depth, limit: lim, offset: off, includeRoot: incRoot, rogueOnly: !!rogueOnly }); return { matrixInstanceId: mid, rootUserId: rid, maxDepth: depth, limit: lim, offset: off, includeRoot: incRoot, rogueOnly: !!rogueOnly, users }; } // NEW: search user candidates to add into a matrix async function getUserCandidates({ q, type, limit, offset, rootUserId, matrixId, matrixInstanceId, 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, matrixInstanceId: null, // ADDED for consistency limit: lim, offset: off, total: 0, items: [] }; } // Always resolve (covers legacy rootUserId only case) const { rootUserId: rid, matrixInstanceId: mid } = await MatrixRepository.resolveRootUserId({ rootUserId, matrixId, matrixInstanceId, topNodeEmail }); const { total, items } = await MatrixRepository.getUserSearchCandidates({ q: query, type: normType, rootUserId: rid, matrixInstanceId: mid, limit: lim, offset: off }); return { q: query, type: normType, rootUserId: rid, matrixInstanceId: mid, limit: lim, offset: off, total, items }; } async function addUserToMatrix({ rootUserId, matrixId, matrixInstanceId, 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, matrixInstanceId, topNodeEmail, childUserId: cId, forceParentFallback: fallback, // respected by repository for root fallback parentUserId: parentOverride, actorUserId: actorUser.id }); const users = await MatrixRepository.getMatrixUsers({ rootUserId: summary.rootUserId, matrixInstanceId: summary.matrixInstanceId, maxDepth: 2, limit: 25, offset: 0, includeRoot: true }); return { ...summary, usersPreview: users }; } function maskName(name, email) { const safe = (s) => (typeof s === 'string' ? s.trim() : ''); const n = safe(name); if (n) { const parts = n.split(/\s+/).filter(Boolean); if (parts.length === 1) { const fi = parts[0][0]?.toLowerCase() || ''; return fi ? `${fi}…` : '…'; } const first = parts[0][0]?.toLowerCase() || ''; const last = parts[parts.length - 1][0]?.toLowerCase() || ''; return `${first}… ${last}…`.trim(); } const em = safe(email); if (em && em.includes('@')) { const [local, domain] = em.split('@'); const fi = local?.[0]?.toLowerCase() || ''; const li = domain?.[0]?.toLowerCase() || ''; return `${fi}… ${li}…`.trim(); } return '…'; } // NEW: user-facing overview anchored at requester async function getMyOverview({ userId, actorUser }) { if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; throw err; } const uid = Number(userId); if (!Number.isFinite(uid) || uid <= 0) { const err = new Error('Invalid user'); err.status = 400; throw err; } // Resolve the matrix instance that includes this user const resolved = await MatrixRepository.resolveInstanceForUser(uid); if (!resolved) { return { matrixInstanceId: null, totalUsersUnderMe: 0, levelsFilled: 0, immediateChildrenCount: 0, rootSlotsRemaining: null, level1: [], level2Plus: [] }; } const mid = resolved.matrixInstanceId; // Load instance policy and root user const instanceInfo = await MatrixRepository.getInstanceInfo(mid); // helper added below const policyDepth = instanceInfo?.max_depth == null ? 5 : Number(instanceInfo.max_depth); const rootUserId = Number(instanceInfo?.root_user_id || 0); const isRoot = rootUserId === uid; // Fetch descendants anchored at requester const users = await MatrixRepository.getMatrixUsers({ rootUserId: uid, matrixInstanceId: mid, maxDepth: policyDepth, limit: 1000, offset: 0, includeRoot: false }); // Bucket by depth const depthBuckets = new Map(); for (const u of users) { const d = Number(u.depth); if (!depthBuckets.has(d)) depthBuckets.set(d, []); depthBuckets.get(d).push(u); } const immediateChildren = depthBuckets.get(1) || []; // Order level1 by position asc, then createdAt immediateChildren.sort((a, b) => { const pa = (a.position ?? 999999); const pb = (b.position ?? 999999); if (pa !== pb) return pa - pb; return (new Date(a.createdAt) - new Date(b.createdAt)); }); const level1 = immediateChildren.slice(0, 5).map(u => ({ userId: u.userId, email: u.email, name: u.name || u.email, position: u.position ?? null })); const level2PlusRaw = users.filter(u => u.depth >= 2); const level2Plus = level2PlusRaw.map(u => ({ userId: u.userId, depth: u.depth, name: maskName(u.name, u.email) })); // levelsFilled = highest depth having at least one user (bounded by policyDepth) let levelsFilled = 0; for (let d = 1; d <= policyDepth; d++) { if ((depthBuckets.get(d) || []).length > 0) levelsFilled = d; } // rootSlotsRemaining: only meaningful for root; compute among positions 1..5 under root let rootSlotsRemaining = null; if (isRoot) { const firstFiveUsed = immediateChildren .map(u => Number(u.position)) .filter(p => Number.isFinite(p) && p >= 1 && p <= 5); const unique = new Set(firstFiveUsed); rootSlotsRemaining = Math.max(0, 5 - unique.size); } return { matrixInstanceId: mid, totalUsersUnderMe: users.length, levelsFilled, immediateChildrenCount: immediateChildren.length, rootSlotsRemaining, level1, level2Plus }; } // NEW: list all matrices the user belongs to async function listMyMatrices({ userId, actorUser }) { if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; throw err; } const uid = Number(userId); if (!Number.isFinite(uid) || uid <= 0) { const err = new Error('Invalid user'); err.status = 400; throw err; } const items = await MatrixRepository.listInstancesForUser(uid); // Enrich each item with summary (total users under requester, highest full level, fill percent) const enriched = []; for (const m of items) { const mid = Number(m.id ?? m.matrixInstanceId); // Load instance policy and root user const instanceInfo = await MatrixRepository.getInstanceInfo(mid); const policyDepth = instanceInfo?.max_depth == null ? 5 : Number(instanceInfo.max_depth); const users = await MatrixRepository.getMatrixUsers({ rootUserId: uid, matrixInstanceId: mid, maxDepth: policyDepth, limit: 10000, offset: 0, includeRoot: false }); const depthCounts = new Map(); for (const u of users) { const d = Number(u.depth); depthCounts.set(d, (depthCounts.get(d) || 0) + 1); } const totalUsersUnderMe = users.length; let highestFullLevel = 0; for (let k = 1; k <= policyDepth; k++) { const theoretical = Math.pow(5, k); const actual = Number(depthCounts.get(k) || 0); if (actual === theoretical) { highestFullLevel = k; } else { break; } } let theoreticalTotal = 0; let occupiedTotal = 0; for (let k = 1; k <= policyDepth; k++) { const slots = Math.pow(5, k); theoreticalTotal += slots; occupiedTotal += Number(depthCounts.get(k) || 0); } const matrixFillPercent = theoreticalTotal > 0 ? Math.round((occupiedTotal / theoreticalTotal) * 10000) / 100 : 0; enriched.push({ id: mid, matrixInstanceId: mid, name: String(m.name || `Matrix ${mid}`).trim(), totalUsersUnderMe, highestFullLevel, matrixFillPercent }); } return enriched; } // NEW: personal overview for a specific matrix instance async function getMyOverviewByInstance({ userId, matrixInstanceId, actorUser }) { if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; throw err; } const uid = Number(userId); const mid = Number(matrixInstanceId); if (!Number.isFinite(uid) || uid <= 0) { const err = new Error('Invalid user'); err.status = 400; throw err; } if (!Number.isFinite(mid) || mid <= 0) { const err = new Error('Invalid matrix instance id'); err.status = 400; throw err; } // Ensure the user belongs to the requested instance const belongs = await MatrixRepository.userBelongsToInstance(uid, mid); if (!belongs) { const err = new Error('User does not belong to this matrix'); err.status = 403; throw err; } // Load instance policy and root user const instanceInfo = await MatrixRepository.getInstanceInfo(mid); if (!instanceInfo) { const err = new Error('Matrix instance not found'); err.status = 404; throw err; } const policyDepth = instanceInfo?.max_depth == null ? 5 : Number(instanceInfo.max_depth); const rootUserId = Number(instanceInfo?.root_user_id || 0); const isRoot = rootUserId === uid; // Fetch descendants anchored at requester within this instance const users = await MatrixRepository.getMatrixUsers({ rootUserId: uid, matrixInstanceId: mid, maxDepth: policyDepth, limit: 1000, offset: 0, includeRoot: false }); const depthBuckets = new Map(); for (const u of users) { const d = Number(u.depth); if (!depthBuckets.has(d)) depthBuckets.set(d, []); depthBuckets.get(d).push(u); } const immediateChildren = depthBuckets.get(1) || []; immediateChildren.sort((a, b) => { const pa = (a.position ?? 999999); const pb = (b.position ?? 999999); if (pa !== pb) return pa - pb; return (new Date(a.createdAt) - new Date(b.createdAt)); }); const level1 = immediateChildren.slice(0, 5).map(u => ({ userId: u.userId, email: u.email, name: u.name || u.email, position: u.position ?? null })); const level2PlusRaw = users.filter(u => u.depth >= 2); const level2Plus = level2PlusRaw.map(u => ({ userId: u.userId, depth: u.depth, name: maskName(u.name, u.email) })); let levelsFilled = 0; for (let d = 1; d <= policyDepth; d++) { if ((depthBuckets.get(d) || []).length > 0) levelsFilled = d; } let rootSlotsRemaining = null; if (isRoot) { const firstFiveUsed = immediateChildren .map(u => Number(u.position)) .filter(p => Number.isFinite(p) && p >= 1 && p <= 5); const unique = new Set(firstFiveUsed); rootSlotsRemaining = Math.max(0, 5 - unique.size); } return { matrixInstanceId: mid, totalUsersUnderMe: users.length, levelsFilled, immediateChildrenCount: immediateChildren.length, rootSlotsRemaining, level1, level2Plus }; } // NEW: personal matrix summary (totals and fill) for a specific instance async function getMyMatrixSummary({ userId, matrixInstanceId, actorUser }) { if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; throw err; } const uid = Number(userId); const mid = Number(matrixInstanceId); if (!Number.isFinite(uid) || uid <= 0) { const err = new Error('Invalid user'); err.status = 400; throw err; } if (!Number.isFinite(mid) || mid <= 0) { const err = new Error('Invalid matrix instance id'); err.status = 400; throw err; } // Ensure membership const belongs = await MatrixRepository.userBelongsToInstance(uid, mid); if (!belongs) { const err = new Error('User does not belong to this matrix'); err.status = 403; throw err; } // Policy info const instanceInfo = await MatrixRepository.getInstanceInfo(mid); if (!instanceInfo) { const err = new Error('Matrix instance not found'); err.status = 404; throw err; } const policyDepth = instanceInfo?.max_depth == null ? 5 : Number(instanceInfo.max_depth); // Fetch all descendants anchored at requester within this instance const users = await MatrixRepository.getMatrixUsers({ rootUserId: uid, matrixInstanceId: mid, maxDepth: policyDepth, limit: 10000, offset: 0, includeRoot: false }); // Bucket counts by depth const depthCounts = new Map(); // depth -> count for (const u of users) { const d = Number(u.depth); depthCounts.set(d, (depthCounts.get(d) || 0) + 1); } const totalUsersUnderMe = users.length; // highestFullLevel: largest k where count at depth k equals 5^k (bounded by policyDepth) let highestFullLevel = 0; for (let k = 1; k <= policyDepth; k++) { const theoretical = Math.pow(5, k); const actual = Number(depthCounts.get(k) || 0); if (actual === theoretical) { highestFullLevel = k; } else { break; // stop at first non-full level } } // matrixFillPercent: occupied slots / theoretical slots across depths 1..policyDepth let theoreticalTotal = 0; let occupiedTotal = 0; for (let k = 1; k <= policyDepth; k++) { const slots = Math.pow(5, k); theoreticalTotal += slots; occupiedTotal += Number(depthCounts.get(k) || 0); } const matrixFillPercent = theoreticalTotal > 0 ? Math.round((occupiedTotal / theoreticalTotal) * 10000) / 100 : 0; return { matrixInstanceId: mid, totalUsersUnderMe, highestFullLevel, matrixFillPercent }; } async function deactivate({ matrixInstanceId, matrixId, rootUserId, topNodeEmail, actorUser }) { if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; throw err; } if (!isAdmin(actorUser)) { const err = new Error('Forbidden: Admins only'); err.status = 403; throw err; } let instanceId = Number.parseInt(matrixInstanceId ?? matrixId, 10); if (!Number.isFinite(instanceId) || instanceId <= 0) { // Try to resolve via other hints const resolved = await MatrixRepository.resolveRootUserId({ rootUserId, matrixId, matrixInstanceId, topNodeEmail }); instanceId = resolved.matrixInstanceId; } const res = await MatrixRepository.deactivateInstance(instanceId); return { matrixInstanceId: res.matrixInstanceId, wasActive: res.wasActive, isActive: res.isActive, status: res.status }; } // NEW: activate a matrix instance (admin-only) async function activate({ matrixInstanceId, matrixId, rootUserId, topNodeEmail, actorUser }) { if (!actorUser) { const err = new Error('Unauthorized'); err.status = 401; throw err; } if (!isAdmin(actorUser)) { const err = new Error('Forbidden: Admins only'); err.status = 403; throw err; } let instanceId = Number.parseInt(matrixInstanceId ?? matrixId, 10); if (!Number.isFinite(instanceId) || instanceId <= 0) { const resolved = await MatrixRepository.resolveRootUserId({ rootUserId, matrixId, matrixInstanceId, topNodeEmail }); instanceId = resolved.matrixInstanceId; } const res = await MatrixRepository.activateInstance(instanceId); return { matrixInstanceId: res.matrixInstanceId, wasActive: res.wasActive, isActive: res.isActive, status: res.status }; } module.exports = { create, getStats, getUsers, getUserCandidates, addUserToMatrix, getMyOverview, // NEW listMyMatrices, // NEW (enhanced) getMyOverviewByInstance, // NEW getMyMatrixSummary, // NEW deactivate, // NEW activate // NEW };