471 lines
13 KiB
JavaScript
471 lines
13 KiB
JavaScript
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: deactivate a matrix instance (admin-only)
|
|
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
|
|
deactivate, // NEW
|
|
activate // NEW
|
|
};
|