@i4e/invest4edu-access-core 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,35 +1,129 @@
1
- /**
2
- * Reportee-tree DFS — @i4e/invest4edu-access-core (Track 3 G3 shared primitive).
3
- *
4
- * The one canonical "self + all reportees" traversal, shared by both backends so v1 and v2
5
- * compose the SAME hierarchy scope. Pure + data-source agnostic: the caller passes a flat
6
- * employees list `[{ user_id, reporting_manager }]` (v1 feeds the Redis snapshot, v2 the
7
- * `employees` collection) and one-or-more root user ids (delegation passes `[self, ...givers]`).
8
- *
9
- * @param {string|string[]} roots root user id(s)
10
- * @param {Array<{user_id:*, reporting_manager:*}>} employees flat employee list
11
- * @returns {string[]} de-duplicated reachable set (roots + all reportees) as STRING ids
12
- */
13
- export function resolveReporteeUserIds(roots, employees) {
14
- const rootIds = (Array.isArray(roots) ? roots : [roots]).filter((id) => id != null).map(String);
15
-
16
- const byManager = new Map();
17
- for (const emp of employees || []) {
18
- if (!emp || !emp.user_id) continue;
19
- const managerId = String(emp.reporting_manager);
20
- if (!byManager.has(managerId)) byManager.set(managerId, []);
21
- byManager.get(managerId).push(String(emp.user_id));
22
- }
23
-
24
- const visited = new Set();
25
- const result = new Set();
26
- const stack = [...rootIds];
27
- while (stack.length) {
28
- const id = stack.pop();
29
- if (!id || visited.has(id)) continue;
30
- visited.add(id);
31
- result.add(id);
32
- for (const rep of byManager.get(id) || []) stack.push(rep);
33
- }
34
- return [...result];
35
- }
1
+ /**
2
+ * Reportee-tree DFS — @i4e/invest4edu-access-core (Track 3 G3 shared primitive).
3
+ *
4
+ * The one canonical "self + all reportees" traversal, shared by both backends so v1 and v2
5
+ * compose the SAME hierarchy scope. Pure + data-source agnostic: the caller passes a flat
6
+ * employees list `[{ _id, user_id, reporting_manager }]` (v1 feeds the Redis snapshot, v2 the
7
+ * `employees` collection) and one-or-more root user ids (delegation passes `[self, ...givers]`).
8
+ *
9
+ * ── `reporting_manager` is an employee `_id` (BRI-973) ────────────────────────────────
10
+ * It used to hold the manager's **user id**. It now holds the manager's **employee `_id`**,
11
+ * so the hierarchy no longer depends on `employees.user_id` and that field can be dropped.
12
+ *
13
+ * Through the rollout this accepted both shapes; that widening is gone. Only an `_id` that is
14
+ * present in the list resolves — an unrecognised reference detaches that manager's downline
15
+ * rather than being read as a user id, which is the correct reading now that no row should
16
+ * hold one.
17
+ *
18
+ * ── DEPLOY ORDER: migration first, then this version ──────────────────────────────────
19
+ * This is a BREAKING change to the data this function can read, on a 0.x minor. Against
20
+ * un-migrated rows it returns ZERO reportees — and since it feeds record visibility and the
21
+ * lead access scope, that is a silent narrowing of what people can see rather than an error
22
+ * anyone would notice.
23
+ *
24
+ * So: run nfd-api-node's migrations/verify-reporting-manager-to-employee-id.js and get a
25
+ * clean verdict FIRST, then ship this version. The window that matters is a rolling deploy,
26
+ * where an upgraded instance and un-migrated rows can coexist for real minutes — the
27
+ * migration has to be complete before the first instance carrying this code serves traffic,
28
+ * not merely started.
29
+ *
30
+ * Roots and the returned set stay **user ids** throughout, because that is what the callers
31
+ * compare against (`assigned_rm_id`, `rm_id`). Only the manager reference is translated.
32
+ *
33
+ * ── CALLER CONTRACT: pass the manager's own row ───────────────────────────────────────
34
+ * An employee-id reference can only be resolved if the row carrying that `_id` is in the
35
+ * list. A user-id reference never needed a lookup, so under the old shape an absent manager
36
+ * row cost nothing — under the new shape it silently detaches that manager's entire
37
+ * downline.
38
+ *
39
+ * So a caller must feed EVERY employee for linking, and filter unwanted people out of the
40
+ * RESULT. Feeding a pre-filtered list (`{ status: 1 }`, an account scope, anything) drops
41
+ * link rows, and what you lose is not the filtered person but everyone beneath them.
42
+ *
43
+ * The caller this bites is nfd-api-node's `src/cache/employeeCache.js`, which builds the
44
+ * hierarchy snapshot. It filtered `{ status: 1 }` and argued the filter was safe because it
45
+ * "already breaks the chain at a deactivated manager" — true under the old shape, where the
46
+ * manager's user id was the map key whether or not their row was present, and false under
47
+ * this one. It now selects every employee and filters the RESULT instead. Any new caller
48
+ * has to do the same.
49
+ *
50
+ * @param {string|string[]} roots root user id(s)
51
+ * @param {Array<{_id?:*, user_id:*, reporting_manager:*}>} employees flat employee list
52
+ * @param {Object} [options]
53
+ * @param {Function} [options.onUnresolvable] called once when the list carries no `_id` at
54
+ * all — see below. Defaults to `console.warn`; pass the app logger to route it properly.
55
+ * @returns {string[]} de-duplicated reachable set (roots + all reportees) as STRING ids
56
+ */
57
+ export function resolveReporteeUserIds(roots, employees, options = {}) {
58
+ const rootIds = (Array.isArray(roots) ? roots : [roots]).filter((id) => id != null).map(String);
59
+
60
+ /**
61
+ * employee `_id` -> that employee's user id.
62
+ *
63
+ * Built first and over the whole list, because a manager may appear anywhere in it — a
64
+ * single pass that resolved as it went would miss any manager listed after their reportee.
65
+ *
66
+ * Employees with no `user_id` are still indexed: such a row cannot be a REPORTEE (nothing
67
+ * to return for them) but it can perfectly well be a MANAGER, and dropping it here would
68
+ * silently orphan their whole downline. `null` marks "known employee, no portal user".
69
+ */
70
+ const userIdByEmployeeId = new Map();
71
+ for (const emp of employees || []) {
72
+ if (!emp || emp._id == null) continue;
73
+ userIdByEmployeeId.set(String(emp._id), emp.user_id == null ? null : String(emp.user_id));
74
+ }
75
+
76
+ /**
77
+ * A list with no `_id` on any row is the pre-BRI-973 shape, and nothing in it can ever
78
+ * resolve — every caller gets `[roots]` back, which reads as "this manager has no
79
+ * reportees" rather than as "this list cannot be understood".
80
+ *
81
+ * That is the one failure this cannot leave silent. It is not a degraded answer, it is a
82
+ * confident wrong one, and it is cheap to tell apart: an employee list of any size with
83
+ * an empty index means the caller passed the old shape or dropped `_id` from a projection.
84
+ * A warn here costs nothing on a correct call and turns the worst failure mode into an
85
+ * obvious one. It does not throw — this sits on the login path, and refusing to resolve a
86
+ * scope is a harder failure than reporting one.
87
+ */
88
+ if ((employees?.length || 0) > 0 && userIdByEmployeeId.size === 0) {
89
+ const warn = options.onUnresolvable || ((message) => console.warn(message));
90
+ warn(
91
+ `[reportee-tree] ${employees.length} employees but not one carries an \`_id\`, so no `
92
+ + 'reporting_manager reference can resolve and every caller sees zero reportees. The list '
93
+ + 'is in the pre-BRI-973 shape, or `_id` was dropped from the projection.',
94
+ );
95
+ }
96
+
97
+ const byManager = new Map();
98
+ for (const emp of employees || []) {
99
+ if (!emp || !emp.user_id) continue;
100
+ if (emp.reporting_manager == null) continue;
101
+
102
+ const raw = String(emp.reporting_manager);
103
+ /**
104
+ * The reference is an employee `_id`; it is translated to that manager's user id.
105
+ *
106
+ * `get` rather than the old "fall back to reading it as a user id": an unknown `_id` now
107
+ * means either a dangling reference or a row the caller filtered out of the list, and
108
+ * both are conditions to drop the edge on, not to guess about. See the caller contract.
109
+ */
110
+ const managerId = userIdByEmployeeId.get(raw);
111
+ // Unknown reference, or a manager with no portal user — no key to hang reportees on.
112
+ if (managerId == null) continue;
113
+
114
+ if (!byManager.has(managerId)) byManager.set(managerId, []);
115
+ byManager.get(managerId).push(String(emp.user_id));
116
+ }
117
+
118
+ const visited = new Set();
119
+ const result = new Set();
120
+ const stack = [...rootIds];
121
+ while (stack.length) {
122
+ const id = stack.pop();
123
+ if (!id || visited.has(id)) continue;
124
+ visited.add(id);
125
+ result.add(id);
126
+ for (const rep of byManager.get(id) || []) stack.push(rep);
127
+ }
128
+ return [...result];
129
+ }
@@ -1,93 +1,93 @@
1
- /**
2
- * Role capabilities — @i4e/invest4edu-access-core (Track 3 C1 · G7).
3
- *
4
- * Turns role classification from hardcoded per-repo Sets into DATA. A `roleCapabilities`
5
- * collection (one row per role) overrides these defaults; with no rows, `resolveCapabilities`
6
- * reproduces the exact legacy behaviour, so wiring this in is behaviour-neutral until config
7
- * is populated.
8
- *
9
- * Flags:
10
- * isFullAccess — sees all data within the account (BROKER_ADMIN-style super-role)
11
- * isEmployee — eligible for the reportee-tree / 5-field visibility scope
12
- * canGovernDelegation — may grant / edit / reactivate delegations
13
- * canCorrectE1 — may run the audited E1 correction
14
- * canCrossAccount — audited sysadmin cross-account path (default: none)
15
- * productScoped — pages + records restricted to the user's mapped products
16
- * bypassAllGates — system-admin break-glass (D27): opens EVERY gate WITHIN the caller's
17
- * own account (RBAC, nav, entitlement, product scope, grid visibleWhen,
18
- * kill switches). Deliberately NOT cross-tenant — crossing accounts is
19
- * the separate, stricter `canCrossAccount` path (D3/BRI-582: config data
20
- * only, header-driven, logged before bypass). bypassAllGates ≠
21
- * canCrossAccount. Exists so a broken access config can never lock
22
- * everyone out of the screens needed to repair it.
23
- */
24
-
25
- export const CAPABILITY_FLAGS = Object.freeze([
26
- "isFullAccess",
27
- "isEmployee",
28
- "canGovernDelegation",
29
- "canCorrectE1",
30
- "canCrossAccount",
31
- "productScoped",
32
- "bypassAllGates",
33
- // Edit the GLOBAL feature registry (modules/screens/actions + their flags). Deliberately
34
- // narrower than canGovernDelegation: that includes per-account BROKER_ADMINs, and the registry
35
- // is cross-account — one edit changes what every tenant sees.
36
- "canManageRegistry",
37
- ]);
38
-
39
- /**
40
- * Legacy-parity defaults. MUST mirror the hardcoded Sets they replace:
41
- * FULL_ACCESS_ROLES = SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN, BACKOFFICE_USER, HR_USER
42
- * EMPLOYEE_ROLES = SALES_USER, PAT_SALES
43
- * GRANT_ROLES (deleg)= SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN
44
- * ALLOWED_ROLES (e1) = SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN, BACKOFFICE_USER
45
- */
46
- export const DEFAULT_ROLE_CAPABILITIES = Object.freeze({
47
- // bypassAllGates (D27) defaults to the two platform-admin roles. Inert until the access
48
- // engine reads it, and revocable from the `rolecapabilities` collection with no deploy.
49
- SUPER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true, canManageRegistry: true },
50
- SYSTEM_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true, canManageRegistry: true },
51
- BROKER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true },
52
- BACKOFFICE_USER: { isFullAccess: true, canCorrectE1: true },
53
- HR_USER: { isFullAccess: true },
54
- SALES_USER: { isEmployee: true },
55
- PAT_SALES: { isEmployee: true },
56
- });
57
-
58
- function pickFlags(row) {
59
- const out = {};
60
- for (const f of CAPABILITY_FLAGS) if (row[f] != null) out[f] = !!row[f];
61
- return out;
62
- }
63
-
64
- /**
65
- * Merge DB rows over the parity defaults → a role→capabilities map.
66
- * @param {Array<{role:string} & Record<string, boolean>>} dbRows
67
- */
68
- export function buildCapabilityMap(dbRows = []) {
69
- const map = {};
70
- for (const [role, caps] of Object.entries(DEFAULT_ROLE_CAPABILITIES)) map[role] = { ...caps };
71
- for (const row of dbRows) {
72
- if (!row || !row.role) continue;
73
- map[row.role] = { ...(map[row.role] || {}), ...pickFlags(row) };
74
- }
75
- return map;
76
- }
77
-
78
- /**
79
- * OR-combine the capability flags across all of a caller's roles (multi-role = union).
80
- * @param {string[]} roles
81
- * @param {Record<string, Record<string, boolean>>} [capabilityMap] defaults if omitted
82
- * @returns {Record<string, boolean>} every CAPABILITY_FLAG resolved to a boolean
83
- */
84
- export function resolveCapabilities(roles, capabilityMap = DEFAULT_ROLE_CAPABILITIES) {
85
- const out = {};
86
- for (const f of CAPABILITY_FLAGS) out[f] = false;
87
- for (const role of Array.isArray(roles) ? roles : [roles].filter(Boolean)) {
88
- const caps = capabilityMap[role];
89
- if (!caps) continue;
90
- for (const f of CAPABILITY_FLAGS) if (caps[f]) out[f] = true;
91
- }
92
- return out;
93
- }
1
+ /**
2
+ * Role capabilities — @i4e/invest4edu-access-core (Track 3 C1 · G7).
3
+ *
4
+ * Turns role classification from hardcoded per-repo Sets into DATA. A `roleCapabilities`
5
+ * collection (one row per role) overrides these defaults; with no rows, `resolveCapabilities`
6
+ * reproduces the exact legacy behaviour, so wiring this in is behaviour-neutral until config
7
+ * is populated.
8
+ *
9
+ * Flags:
10
+ * isFullAccess — sees all data within the account (BROKER_ADMIN-style super-role)
11
+ * isEmployee — eligible for the reportee-tree / 5-field visibility scope
12
+ * canGovernDelegation — may grant / edit / reactivate delegations
13
+ * canCorrectE1 — may run the audited E1 correction
14
+ * canCrossAccount — audited sysadmin cross-account path (default: none)
15
+ * productScoped — pages + records restricted to the user's mapped products
16
+ * bypassAllGates — system-admin break-glass (D27): opens EVERY gate WITHIN the caller's
17
+ * own account (RBAC, nav, entitlement, product scope, grid visibleWhen,
18
+ * kill switches). Deliberately NOT cross-tenant — crossing accounts is
19
+ * the separate, stricter `canCrossAccount` path (D3/BRI-582: config data
20
+ * only, header-driven, logged before bypass). bypassAllGates ≠
21
+ * canCrossAccount. Exists so a broken access config can never lock
22
+ * everyone out of the screens needed to repair it.
23
+ */
24
+
25
+ export const CAPABILITY_FLAGS = Object.freeze([
26
+ "isFullAccess",
27
+ "isEmployee",
28
+ "canGovernDelegation",
29
+ "canCorrectE1",
30
+ "canCrossAccount",
31
+ "productScoped",
32
+ "bypassAllGates",
33
+ // Edit the GLOBAL feature registry (modules/screens/actions + their flags). Deliberately
34
+ // narrower than canGovernDelegation: that includes per-account BROKER_ADMINs, and the registry
35
+ // is cross-account — one edit changes what every tenant sees.
36
+ "canManageRegistry",
37
+ ]);
38
+
39
+ /**
40
+ * Legacy-parity defaults. MUST mirror the hardcoded Sets they replace:
41
+ * FULL_ACCESS_ROLES = SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN, BACKOFFICE_USER, HR_USER
42
+ * EMPLOYEE_ROLES = SALES_USER, PAT_SALES
43
+ * GRANT_ROLES (deleg)= SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN
44
+ * ALLOWED_ROLES (e1) = SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN, BACKOFFICE_USER
45
+ */
46
+ export const DEFAULT_ROLE_CAPABILITIES = Object.freeze({
47
+ // bypassAllGates (D27) defaults to the two platform-admin roles. Inert until the access
48
+ // engine reads it, and revocable from the `rolecapabilities` collection with no deploy.
49
+ SUPER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true, canManageRegistry: true },
50
+ SYSTEM_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true, canManageRegistry: true },
51
+ BROKER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true },
52
+ BACKOFFICE_USER: { isFullAccess: true, canCorrectE1: true },
53
+ HR_USER: { isFullAccess: true },
54
+ SALES_USER: { isEmployee: true },
55
+ PAT_SALES: { isEmployee: true },
56
+ });
57
+
58
+ function pickFlags(row) {
59
+ const out = {};
60
+ for (const f of CAPABILITY_FLAGS) if (row[f] != null) out[f] = !!row[f];
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Merge DB rows over the parity defaults → a role→capabilities map.
66
+ * @param {Array<{role:string} & Record<string, boolean>>} dbRows
67
+ */
68
+ export function buildCapabilityMap(dbRows = []) {
69
+ const map = {};
70
+ for (const [role, caps] of Object.entries(DEFAULT_ROLE_CAPABILITIES)) map[role] = { ...caps };
71
+ for (const row of dbRows) {
72
+ if (!row || !row.role) continue;
73
+ map[row.role] = { ...(map[row.role] || {}), ...pickFlags(row) };
74
+ }
75
+ return map;
76
+ }
77
+
78
+ /**
79
+ * OR-combine the capability flags across all of a caller's roles (multi-role = union).
80
+ * @param {string[]} roles
81
+ * @param {Record<string, Record<string, boolean>>} [capabilityMap] defaults if omitted
82
+ * @returns {Record<string, boolean>} every CAPABILITY_FLAG resolved to a boolean
83
+ */
84
+ export function resolveCapabilities(roles, capabilityMap = DEFAULT_ROLE_CAPABILITIES) {
85
+ const out = {};
86
+ for (const f of CAPABILITY_FLAGS) out[f] = false;
87
+ for (const role of Array.isArray(roles) ? roles : [roles].filter(Boolean)) {
88
+ const caps = capabilityMap[role];
89
+ if (!caps) continue;
90
+ for (const f of CAPABILITY_FLAGS) if (caps[f]) out[f] = true;
91
+ }
92
+ return out;
93
+ }