feat: add personal matrix
This commit is contained in:
parent
9f5458f0a8
commit
39458dd556
@ -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 = {
|
module.exports = {
|
||||||
create,
|
create,
|
||||||
stats,
|
stats,
|
||||||
@ -167,5 +205,8 @@ module.exports = {
|
|||||||
addUser, // NEW
|
addUser, // NEW
|
||||||
getMyOverview, // NEW
|
getMyOverview, // NEW
|
||||||
deactivate, // NEW
|
deactivate, // NEW
|
||||||
activate // NEW
|
activate, // NEW
|
||||||
|
listMyMatrices, // NEW
|
||||||
|
getMyOverviewByInstance, // NEW
|
||||||
|
getMyMatrixSummary // NEW
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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)
|
// NEW: deactivate a matrix instance by id (idempotent)
|
||||||
async function deactivateInstance(instanceId) {
|
async function deactivateInstance(instanceId) {
|
||||||
const conn = await pool.getConnection();
|
const conn = await pool.getConnection();
|
||||||
@ -940,5 +980,7 @@ module.exports = {
|
|||||||
resolveInstanceForUser, // NEW
|
resolveInstanceForUser, // NEW
|
||||||
getInstanceInfo, // NEW
|
getInstanceInfo, // NEW
|
||||||
deactivateInstance, // NEW
|
deactivateInstance, // NEW
|
||||||
activateInstance // NEW
|
activateInstance, // NEW
|
||||||
|
listInstancesForUser, // NEW
|
||||||
|
userBelongsToInstance // NEW
|
||||||
};
|
};
|
||||||
|
|||||||
@ -125,5 +125,11 @@ router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixControlle
|
|||||||
// NEW: Admin list pools
|
// NEW: Admin list pools
|
||||||
router.get('/admin/pools', authMiddleware, adminOnly, PoolController.list);
|
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
|
// export
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@ -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 }) {
|
async function deactivate({ matrixInstanceId, matrixId, rootUserId, topNodeEmail, actorUser }) {
|
||||||
if (!actorUser) {
|
if (!actorUser) {
|
||||||
const err = new Error('Unauthorized');
|
const err = new Error('Unauthorized');
|
||||||
@ -465,6 +726,9 @@ module.exports = {
|
|||||||
getUserCandidates,
|
getUserCandidates,
|
||||||
addUserToMatrix,
|
addUserToMatrix,
|
||||||
getMyOverview, // NEW
|
getMyOverview, // NEW
|
||||||
|
listMyMatrices, // NEW (enhanced)
|
||||||
|
getMyOverviewByInstance, // NEW
|
||||||
|
getMyMatrixSummary, // NEW
|
||||||
deactivate, // NEW
|
deactivate, // NEW
|
||||||
activate // NEW
|
activate // NEW
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user