CentralBackend/services/matrix/MatrixService.js
2025-11-30 13:24:22 +01:00

735 lines
20 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: 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');
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
listMyMatrices, // NEW (enhanced)
getMyOverviewByInstance, // NEW
getMyMatrixSummary, // NEW
deactivate, // NEW
activate // NEW
};