diff --git a/controller/matrix/MatrixController.js b/controller/matrix/MatrixController.js index 2525f6a..b07bfd3 100644 --- a/controller/matrix/MatrixController.js +++ b/controller/matrix/MatrixController.js @@ -159,6 +159,44 @@ async function activate(req, res) { } } +// NEW: list all matrices current user belongs to +async function listMyMatrices(req, res) { + try { + const userId = req.user?.id ?? req.user?.userId; + const data = await MatrixService.listMyMatrices({ userId, 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 load matrices' }); + } +} + +// NEW: personal overview for a specific matrix instance +async function getMyOverviewByInstance(req, res) { + try { + const userId = req.user?.id ?? req.user?.userId; + const matrixInstanceId = req.params.id || req.query?.matrixInstanceId || req.query?.matrixId; + const data = await MatrixService.getMyOverviewByInstance({ userId, matrixInstanceId, 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 load matrix overview' }); + } +} + +// NEW: personal matrix summary (totals and fill) for a specific instance +async function getMyMatrixSummary(req, res) { + try { + const userId = req.user?.id ?? req.user?.userId; + const matrixInstanceId = req.params.id || req.query?.matrixInstanceId || req.query?.matrixId; + const data = await MatrixService.getMyMatrixSummary({ userId, matrixInstanceId, 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 load matrix summary' }); + } +} + module.exports = { create, stats, @@ -167,5 +205,8 @@ module.exports = { addUser, // NEW getMyOverview, // NEW deactivate, // NEW - activate // NEW + activate, // NEW + listMyMatrices, // NEW + getMyOverviewByInstance, // NEW + getMyMatrixSummary // NEW }; diff --git a/repositories/matrix/MatrixRepository.js b/repositories/matrix/MatrixRepository.js index 22fbfd4..4eea9c3 100644 --- a/repositories/matrix/MatrixRepository.js +++ b/repositories/matrix/MatrixRepository.js @@ -812,6 +812,46 @@ async function getInstanceInfo(instanceId) { } } +// NEW: list distinct matrix instances a user belongs to +async function listInstancesForUser(userId) { + const conn = await pool.getConnection(); + try { + const [rows] = await conn.query( + ` + SELECT DISTINCT mi.id AS id, mi.name + FROM matrix_instances mi + JOIN user_tree_closure c + ON c.matrix_instance_id = mi.id + WHERE c.descendant_user_id = ? + ORDER BY mi.id ASC + `, + [Number(userId)] + ); + return rows.map(r => ({ id: Number(r.id), name: r.name || null })); + } finally { + conn.release(); + } +} + +// NEW: check if user belongs to a specific instance +async function userBelongsToInstance(userId, instanceId) { + const conn = await pool.getConnection(); + try { + const [rows] = await conn.query( + ` + SELECT 1 + FROM user_tree_closure + WHERE matrix_instance_id = ? AND descendant_user_id = ? + LIMIT 1 + `, + [Number(instanceId), Number(userId)] + ); + return rows.length > 0; + } finally { + conn.release(); + } +} + // NEW: deactivate a matrix instance by id (idempotent) async function deactivateInstance(instanceId) { const conn = await pool.getConnection(); @@ -940,5 +980,7 @@ module.exports = { resolveInstanceForUser, // NEW getInstanceInfo, // NEW deactivateInstance, // NEW - activateInstance // NEW + activateInstance, // NEW + listInstancesForUser, // NEW + userBelongsToInstance // NEW }; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index a58b7e8..b127397 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -125,5 +125,11 @@ router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixControlle // NEW: Admin list pools router.get('/admin/pools', authMiddleware, adminOnly, PoolController.list); +// NEW: User matrices list and per-instance overview +router.get('/matrix/me/list', authMiddleware, MatrixController.listMyMatrices); +router.get('/matrix/:id/overview', authMiddleware, MatrixController.getMyOverviewByInstance); +// NEW: User matrix summary (totals and fill) +router.get('/matrix/:id/summary', authMiddleware, MatrixController.getMyMatrixSummary); + // export module.exports = router; \ No newline at end of file diff --git a/services/matrix/MatrixService.js b/services/matrix/MatrixService.js index 39ada8f..961e001 100644 --- a/services/matrix/MatrixService.js +++ b/services/matrix/MatrixService.js @@ -391,7 +391,268 @@ async function getMyOverview({ userId, actorUser }) { }; } -// NEW: deactivate a matrix instance (admin-only) +// 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'); @@ -465,6 +726,9 @@ module.exports = { getUserCandidates, addUserToMatrix, getMyOverview, // NEW + listMyMatrices, // NEW (enhanced) + getMyOverviewByInstance, // NEW + getMyMatrixSummary, // NEW deactivate, // NEW activate // NEW };