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 = {
|
||||
create,
|
||||
stats,
|
||||
@ -167,5 +205,8 @@ module.exports = {
|
||||
addUser, // NEW
|
||||
getMyOverview, // 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)
|
||||
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
|
||||
};
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user