feat: add personal matrix

This commit is contained in:
DeathKaioken 2025-11-30 13:24:22 +01:00
parent 9f5458f0a8
commit 39458dd556
4 changed files with 356 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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