@i4e/invest4edu-access-core 0.31.0 → 0.33.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.
package/README.md CHANGED
@@ -6,6 +6,39 @@ central, unforgettable `account_id` scoping.
6
6
 
7
7
  Both backends are ESM, so this package ships plain ESM source — no build step.
8
8
 
9
+ ## 0.32.0 — BREAKING, and it fails silently
10
+
11
+ `resolveReporteeUserIds` **no longer reads `employees.reporting_manager` as a user id.** It
12
+ reads it only as the manager's employee `_id`, which is the shape BRI-973 migrates the field
13
+ to. This is a breaking change on a 0.x minor, so read the two notes below before upgrading a
14
+ consumer.
15
+
16
+ **1. Against un-migrated data it returns zero reportees, not an error.** The dual-shape
17
+ widening that carried the rollout is gone. A manager whose rows have not been migrated
18
+ resolves to a self-only scope, and because this function feeds record visibility in
19
+ nfd-api-node and the lead access scope in nfd-api-node-v2, that reads as *"this manager has
20
+ no reportees"* — nothing thrown, nothing logged, just a smaller answer.
21
+
22
+ So the deploy order is **migrate → verify → bump**, not the other way round. Run
23
+ nfd-api-node's `migrations/verify-reporting-manager-to-employee-id.js` and get a clean
24
+ verdict before the first instance carrying 0.32.0 serves traffic. In a rolling deploy that
25
+ window is real, so the migration must be complete rather than merely started.
26
+
27
+ Under npm's 0.x caret rules `^0.31.0` will not resolve to 0.32.0, so no consumer is dragged
28
+ across by an install — the ordering risk is entirely a human one.
29
+
30
+ **2. Callers must pass the FULL employee list.** An `_id` reference only resolves if the row
31
+ carrying it is present, so a pre-filtered list (`{ status: 1 }`, an account scope, anything)
32
+ drops link rows — and what you lose is not the filtered person but everyone beneath them.
33
+ Filter the result instead. `resolveReporteeUserIds` warns once when handed a list in which no
34
+ row carries an `_id` at all, which is the one case where the answer is confidently wrong
35
+ rather than merely narrow.
36
+
37
+ 0.32.0 also moves the entity/role vocabulary — `ENTITY`, `ENTITY_STATUS`,
38
+ `isEntityLoginAllowed`, `conflictingEntityFor`, `ROLE_TYPE_PRIORITY`,
39
+ `CLIENT_APP_ROLE_NAMES` — into this package. It was previously written out three times
40
+ (nfd-api-node, nfd-api-node-v2, nfdui-nextjs) and held together by parity tests.
41
+
9
42
  ## Install
10
43
 
11
44
  ```
@@ -72,6 +105,15 @@ Model.find(q).setOptions({ skipTenant: true }); // one query
72
105
  - `tenantPlugin` (default of `/tenant-plugin`) — the Mongoose plugin
73
106
  - `setTenantMode(m)` / `getTenantMode()` — the read ladder
74
107
  - `setTenantWriteMode(m)` / `getTenantWriteMode()` — the write ladder
108
+ - `resolveReporteeUserIds(roots, employees, opts)` (`/reportee-tree`) — self + all reportees
109
+ - `ENTITY`, `ENTITY_STATUS`, `isEntityLoginAllowed`, `entityInactiveMessage`,
110
+ `checkPortalEntityAccess` (`/entity-status`) — which entities permit a login
111
+ - `conflictingEntityFor`, `entityConflictMessage`, `CONFLICTING_ENTITIES`
112
+ (`/user-entity-link`) — which hats may coexist on one person
113
+ - `ROLE_TYPE_PRIORITY`, `CLIENT_APP_ROLE_NAMES`, `pickPortalRoleName` (`/portal-roles`) —
114
+ which role names a portal session
115
+ - `normalizeRoute(route)`, `screenForRoute(features, route)` (`/route-screen`) — the screen row
116
+ a route resolves to, so a `MODULE.SCREEN.ACTION` code is read off the registry, never spelled
75
117
 
76
118
  ## Not covered
77
119
  **Aggregation pipelines** — add an explicit `{ $match: { account_id } }` stage.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.31.0",
4
- "description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
3
+ "version": "0.33.0",
4
+ "description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, entity/role vocabulary, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
@@ -9,6 +9,9 @@
9
9
  "./tenant-plugin": "./src/tenant-plugin.js",
10
10
  "./role-capabilities": "./src/role-capabilities.js",
11
11
  "./reportee-tree": "./src/reportee-tree.js",
12
+ "./entity-status": "./src/entity-status.js",
13
+ "./user-entity-link": "./src/user-entity-link.js",
14
+ "./portal-roles": "./src/portal-roles.js",
12
15
  "./access-config": "./src/access-config.js",
13
16
  "./access-schema": "./src/access-schema.js",
14
17
  "./access-resolver": "./src/access-resolver.js",
@@ -17,6 +20,7 @@
17
20
  "./entitlement-schema": "./src/entitlement-schema.js",
18
21
  "./grid-schema": "./src/grid-schema.js",
19
22
  "./route-features": "./src/route-features.js",
23
+ "./route-screen": "./src/route-screen.js",
20
24
  "./subscription-lifecycle": "./src/subscription-lifecycle.js",
21
25
  "./entitlement-store": "./src/entitlement-store.js",
22
26
  "./proration": "./src/proration.js",
@@ -0,0 +1,165 @@
1
+ /**
2
+ * One definition of "may this entity be used to log in" — @i4e/invest4edu-access-core.
3
+ *
4
+ * A person is one `users` row that may link to several entities (partner, client,
5
+ * employee, manufacturer). Each entity carries its own activation, so switching one off
6
+ * must not disturb the others: a partner whose client record is disabled still logs into
7
+ * the portal, and a client whose partner record is disabled still uses the app.
8
+ * `users.status` stays what it always was — the platform-level block on the person.
9
+ *
10
+ * ── Why this lives in the package rather than in each backend ─────────────────────────
11
+ * It was written twice, in nfd-api-node/src/utils/entity-status.js and its
12
+ * nfd-api-node-v2 twin, and pinned together by a parity test. That test earned its keep:
13
+ * it caught two `adminRoles` lists that had already diverged, so the same person resolved
14
+ * to a different `role_type` depending on which backend served the login. A parity test
15
+ * fails AFTER someone edits one side and only if it runs, so the copies are the defect and
16
+ * the test was the symptom. Both backends and the frontend now import from here.
17
+ *
18
+ * ── Why the entities disagree with each other ─────────────────────────────────────────
19
+ * Each collection grew its own convention and none can be normalised without rewriting
20
+ * live rows:
21
+ *
22
+ * distributors 1 active · 2 inactive · 3 not-yet-verified · 4 rejected
23
+ * distributorclients 1 active · 0 inactive, and legacy rows hold the boolean `true`
24
+ * because the schema defaults a Number field to it
25
+ * employees 1 active · 2 inactive, no default — legacy rows are undefined
26
+ * manufacturers 1 active · 2 inactive, no default, never written after create
27
+ *
28
+ * So the vocabulary is per-entity by necessity. What this module guarantees is that every
29
+ * caller asks the question the same way.
30
+ */
31
+
32
+ /** The entity kinds one person can be linked to. These are wire codes. */
33
+ export const ENTITY = {
34
+ DISTRIBUTOR: 'distributor',
35
+ CLIENT: 'client',
36
+ EMPLOYEE: 'employee',
37
+ MANUFACTURER: 'manufacturer',
38
+ };
39
+
40
+ /**
41
+ * The numbers each collection actually stores — see the table above for why they differ.
42
+ *
43
+ * `MAPPING` is the odd one out in kind rather than in value: it is a `userentitymappings`
44
+ * row's own status, saying whether this PERSON may still act through that entity. The
45
+ * entity statuses say whether the business record is live; `USER` says whether the person
46
+ * is. Three different questions, and conflating any two of them switches off more than
47
+ * the caller meant to.
48
+ */
49
+ export const ENTITY_STATUS = {
50
+ USER: { ACTIVE: 1, INACTIVE: 2 },
51
+ DISTRIBUTOR: { ACTIVE: 1, INACTIVE: 2, NOT_YET_VERIFIED: 3, REJECTED: 4 },
52
+ CLIENT: { ACTIVE: 1, INACTIVE: 0 },
53
+ EMPLOYEE: { ACTIVE: 1, INACTIVE: 2 },
54
+ MANUFACTURER: { ACTIVE: 1, INACTIVE: 2 },
55
+ MAPPING: { ACTIVE: 1, INACTIVE: 2 },
56
+ };
57
+
58
+ /**
59
+ * Statuses that still permit a partner login.
60
+ *
61
+ * `not_yet_verified` is deliberately included: a self-signed-up partner sits at 3 for the
62
+ * whole of onboarding and has to reach the complete-profile screens. Treating 3 as "cannot
63
+ * log in" would lock every draft partner out of the flow they just started. `rejected` is
64
+ * excluded — before this, rejected partners were stopped only because rejection also
65
+ * flipped their user row inactive, and that side effect is gone, so the gate states it
66
+ * directly.
67
+ */
68
+ const DISTRIBUTOR_LOGIN_STATUSES = [
69
+ ENTITY_STATUS.DISTRIBUTOR.ACTIVE,
70
+ ENTITY_STATUS.DISTRIBUTOR.NOT_YET_VERIFIED,
71
+ ];
72
+
73
+ /**
74
+ * @param {string} entityType one of ENTITY
75
+ * @param {Object|null} doc the entity document (needs `status` projected)
76
+ * @returns {boolean} false only when the entity is explicitly switched off
77
+ */
78
+ export function isEntityLoginAllowed(entityType, doc) {
79
+ if (!doc) return false;
80
+ const { status } = doc;
81
+
82
+ switch (entityType) {
83
+ case ENTITY.DISTRIBUTOR:
84
+ return DISTRIBUTOR_LOGIN_STATUSES.includes(Number(status));
85
+
86
+ case ENTITY.CLIENT:
87
+ // `true` is not a stand-in for "unknown" here — it is the literal default the
88
+ // schema wrote onto real rows, and it means active.
89
+ return status === true || Number(status) === ENTITY_STATUS.CLIENT.ACTIVE;
90
+
91
+ case ENTITY.EMPLOYEE:
92
+ // No schema default, so undefined is the normal shape of an older row rather than
93
+ // a signal. Only an explicit inactive blocks the login; reading undefined as "off"
94
+ // would shut out every employee predating the field.
95
+ return status === undefined || status === null
96
+ ? true
97
+ : Number(status) !== ENTITY_STATUS.EMPLOYEE.INACTIVE;
98
+
99
+ case ENTITY.MANUFACTURER:
100
+ return status === undefined || status === null
101
+ ? true
102
+ : Number(status) !== ENTITY_STATUS.MANUFACTURER.INACTIVE;
103
+
104
+ default:
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /** Message shown when an entity is switched off. Entity-specific so support can tell them apart. */
110
+ export function entityInactiveMessage(entityType) {
111
+ switch (entityType) {
112
+ case ENTITY.DISTRIBUTOR:
113
+ return 'Associated Partnership account is not active. Please contact support for assistance.';
114
+ case ENTITY.CLIENT:
115
+ return 'Your client account is inactive. Please contact support.';
116
+ case ENTITY.EMPLOYEE:
117
+ return 'Your employee account is inactive. Please contact support for assistance.';
118
+ case ENTITY.MANUFACTURER:
119
+ return 'Your manufacturer account is inactive. Please contact support for assistance.';
120
+ default:
121
+ return 'Your account is inactive. Please contact support for assistance.';
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Whether a portal session's entities all permit the login, and which one refuses.
127
+ *
128
+ * The portal is every hat except client — the client app is a separate surface with its
129
+ * own gate. A user holding none of them (an admin, a plain business user) has nothing
130
+ * entity-level to fail on and passes.
131
+ *
132
+ * EVERY hat present is examined, not just the first. Partner and employee cannot coexist
133
+ * on one person, so that pair is safe either way — but manufacturer conflicts with
134
+ * neither, so a person holding an active partner hat and a switched-off manufacturer hat
135
+ * would pass a first-hat-only check without the manufacturer ever being looked at.
136
+ *
137
+ * Order still matters for the ANSWER rather than for the verdict: partner is reported
138
+ * ahead of employee and manufacturer, so the message names the hat the person is most
139
+ * likely on the portal for.
140
+ *
141
+ * @param {Object} p
142
+ * @param {*} [p.distributorId] the partner this user IS, from ownDistributorIdOf
143
+ * @param {Object} [p.distributor] partner doc, `status` projected
144
+ * @param {Object} [p.employee] employee doc, `status` projected
145
+ * @param {Object} [p.manufacturer] manufacturer doc, `status` projected
146
+ * @returns {{allowed: boolean, entityType?: string, message?: string}}
147
+ */
148
+ export function checkPortalEntityAccess({ distributorId, distributor, employee, manufacturer }) {
149
+ const hats = [
150
+ distributorId ? [ENTITY.DISTRIBUTOR, distributor] : null,
151
+ employee ? [ENTITY.EMPLOYEE, employee] : null,
152
+ manufacturer ? [ENTITY.MANUFACTURER, manufacturer] : null,
153
+ ].filter(Boolean);
154
+
155
+ if (!hats.length) return { allowed: true };
156
+
157
+ const refused = hats.find(([entityType, doc]) => !isEntityLoginAllowed(entityType, doc));
158
+ if (refused) {
159
+ const [entityType] = refused;
160
+ return { allowed: false, entityType, message: entityInactiveMessage(entityType) };
161
+ }
162
+
163
+ // The hat this session is best described by — the same precedence the list is built in.
164
+ return { allowed: true, entityType: hats[0][0] };
165
+ }
package/src/index.js CHANGED
@@ -18,6 +18,23 @@ export {
18
18
  resolveCapabilities,
19
19
  } from "./role-capabilities.js";
20
20
  export { resolveReporteeUserIds } from "./reportee-tree.js";
21
+ export {
22
+ ENTITY,
23
+ ENTITY_STATUS,
24
+ isEntityLoginAllowed,
25
+ entityInactiveMessage,
26
+ checkPortalEntityAccess,
27
+ } from "./entity-status.js";
28
+ export {
29
+ CONFLICTING_ENTITIES,
30
+ conflictingEntityFor,
31
+ entityConflictMessage,
32
+ } from "./user-entity-link.js";
33
+ export {
34
+ CLIENT_APP_ROLE_NAMES,
35
+ ROLE_TYPE_PRIORITY,
36
+ pickPortalRoleName,
37
+ } from "./portal-roles.js";
21
38
  export {
22
39
  DEFAULT_ACCESS_FLAGS,
23
40
  ACCESS_FLAG_ENV_NAMES,
@@ -41,6 +58,7 @@ export {
41
58
  ACCESS_FEATURE_DEF,
42
59
  ACCESS_GRANT_DEF,
43
60
  } from "./access-schema.js";
61
+ export { normalizeRoute, screenForRoute } from "./route-screen.js";
44
62
  export {
45
63
  resolveAccessSnapshot,
46
64
  mergeGrants,
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Which roles belong to which surface, and which one names a portal session.
3
+ *
4
+ * One person is one `users` row that may hold a partner role and a client role at the
5
+ * same time. Permissions resolve as a union across roles, and the frontend collapses the
6
+ * array to a single `role_type` string — so both "which roles apply here" and "which one
7
+ * wins" have to be answered deliberately rather than by array order.
8
+ *
9
+ * Lives here because it was written three times: nfd-api-node, nfd-api-node-v2 and the
10
+ * frontend each held the same sixteen names in the same order. The frontend's copy was the
11
+ * one nothing pinned, and it is also the one where drift is quietest — a wrong `role_type`
12
+ * renders as missing screens, not as an error.
13
+ */
14
+
15
+ /** Roles that exist only for the client app. A portal session never carries these. */
16
+ export const CLIENT_APP_ROLE_NAMES = ['CLIENT_USER'];
17
+
18
+ /**
19
+ * Priority order for resolving the single `role_type` a portal session reports.
20
+ *
21
+ * Most-privileged first, so a person wearing several hats is described by the strongest.
22
+ * Partner roles sit ahead of CLIENT_ADMIN deliberately: on the portal, someone who is
23
+ * both a partner and a client-facing admin is there as the partner.
24
+ *
25
+ * Adding a role here is what makes it visible to the frontend's role predicates — a role
26
+ * missing from this list resolves to an empty `role_type`.
27
+ */
28
+ export const ROLE_TYPE_PRIORITY = [
29
+ 'SUPER_ADMIN',
30
+ 'BROKER_ADMIN',
31
+ 'MANUFACTURER_ADMIN',
32
+ 'DISTRIBUTOR_ADMIN',
33
+ 'PARTNER_ADMIN',
34
+ 'BASIC_PLAN_PARTNERS',
35
+ // The default role of every self-signed-up partner. Its absence from the old list is
36
+ // why they resolved to an empty role_type and the client had to guess positionally.
37
+ 'DRAFT_DISTRIBUTOR_USER',
38
+ 'INTERN_USER',
39
+ 'DISTRIBUTOR_TRIAL',
40
+ 'BROKER_PRODUCT_ADMINS',
41
+ 'BACKOFFICE_USER',
42
+ 'HR_USER',
43
+ 'HR',
44
+ 'PAT_SALES',
45
+ 'SALES_USER',
46
+ 'CLIENT_ADMIN',
47
+ ];
48
+
49
+ /**
50
+ * The role that names a session, out of every role name held.
51
+ *
52
+ * Client-app roles are dropped first: they match no portal predicate, so landing on one
53
+ * reads as "neither partner nor client" and hides every partner screen. A role outside
54
+ * the priority list is still better than nothing — it simply cannot be ranked, so the
55
+ * first remaining one wins.
56
+ *
57
+ * @param {string[]} roleNames
58
+ * @returns {string} the winning role name, or '' when none applies to a portal session
59
+ */
60
+ export function pickPortalRoleName(roleNames) {
61
+ const portalNames = (roleNames || [])
62
+ .filter(Boolean)
63
+ .filter((name) => !CLIENT_APP_ROLE_NAMES.includes(name));
64
+
65
+ return ROLE_TYPE_PRIORITY.find((r) => portalNames.includes(r)) || portalNames[0] || '';
66
+ }
@@ -3,20 +3,114 @@
3
3
  *
4
4
  * The one canonical "self + all reportees" traversal, shared by both backends so v1 and v2
5
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
6
+ * employees list `[{ _id, user_id, reporting_manager }]` (v1 feeds the Redis snapshot, v2 the
7
7
  * `employees` collection) and one-or-more root user ids (delegation passes `[self, ...givers]`).
8
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
+ *
9
50
  * @param {string|string[]} roots root user id(s)
10
- * @param {Array<{user_id:*, reporting_manager:*}>} employees flat employee list
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.
11
55
  * @returns {string[]} de-duplicated reachable set (roots + all reportees) as STRING ids
12
56
  */
13
- export function resolveReporteeUserIds(roots, employees) {
57
+ export function resolveReporteeUserIds(roots, employees, options = {}) {
14
58
  const rootIds = (Array.isArray(roots) ? roots : [roots]).filter((id) => id != null).map(String);
15
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
+
16
97
  const byManager = new Map();
17
98
  for (const emp of employees || []) {
18
99
  if (!emp || !emp.user_id) continue;
19
- const managerId = String(emp.reporting_manager);
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
+
20
114
  if (!byManager.has(managerId)) byManager.set(managerId, []);
21
115
  byManager.get(managerId).push(String(emp.user_id));
22
116
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Route → screen: the join both backends and the action seeder make.
3
+ *
4
+ * A screen's `feature_code` is a property of its `access_features` row, and the row is found by
5
+ * the screen's route. Anything composing `MODULE.SCREEN.ACTION` for a screen must therefore start
6
+ * from the route and READ the prefix, never spell it. The activity log learned this the hard way:
7
+ * a prefix guessed as `DISTRIBUTOR_LEADS.DISTRIBUTOR_LEAD` matched no row, and because an unknown
8
+ * code resolves to rollout "off", every gate composed from it silently never fired.
9
+ *
10
+ * Pure, like the rest of this package: the caller hands in the feature rows it already holds
11
+ * (each backend's in-memory registry, or a raw collection scan in a script) and gets the matching
12
+ * screen back. One normaliser serves seeding and runtime, so the two cannot disagree on what "the
13
+ * same route" means.
14
+ */
15
+
16
+ /**
17
+ * Canonical form of a route for comparison: no leading or trailing slashes, lowercase.
18
+ * `/home/customer/leads/` and `home/Customer/Leads` name the same screen.
19
+ */
20
+ export function normalizeRoute(route) {
21
+ return String(route || "")
22
+ .replace(/^\/+|\/+$/g, "")
23
+ .toLowerCase();
24
+ }
25
+
26
+ /**
27
+ * The `feature_type: 'screen'` row whose route equals `route`, or null.
28
+ *
29
+ * Null, never a fabricated code: a route with no screen row means the screen is not registered in
30
+ * this environment, and the caller must treat that as "no gate can be composed" rather than compose
31
+ * one no grant will ever match. An empty `features` list (a registry not yet loaded) answers null
32
+ * too — a caller that must tell "not loaded" from "not registered" checks the registry itself.
33
+ *
34
+ * @param {Array<{feature_type?: string, route?: string|null, feature_code: string}>} features
35
+ * @param {string} route
36
+ */
37
+ export function screenForRoute(features, route) {
38
+ const wanted = normalizeRoute(route);
39
+ if (!wanted || !Array.isArray(features)) return null;
40
+ return (
41
+ features.find(
42
+ (f) => f && f.feature_type === "screen" && f.route && normalizeRoute(f.route) === wanted,
43
+ ) || null
44
+ );
45
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Which hats may coexist on one person — @i4e/invest4edu-access-core.
3
+ *
4
+ * Historically every entity flow minted its own `users` row, so the same person signing
5
+ * up as a partner after already being a client was refused outright. One person is now
6
+ * one row carrying a link per entity, which makes "may this person also be X" a real
7
+ * question with a real answer — and it has to be the SAME answer in every flow, or the
8
+ * employee path enforces something the partner path does not.
9
+ *
10
+ * Only the rule table lives here. Resolving which hats a person currently wears is each
11
+ * backend's job: v1 reads `users` columns plus `userentitymappings`, v2 reads its own
12
+ * narrower projection, and neither shape belongs in a pure package. What must not differ
13
+ * is the verdict, so the verdict is what is shared.
14
+ */
15
+ import { ENTITY } from './entity-status.js';
16
+
17
+ /**
18
+ * Entities that may not coexist on one person.
19
+ *
20
+ * Deliberately minimal: it states the one prohibition the business asked for rather than
21
+ * enumerating every pair. Employee and partner are mutually exclusive; everything else
22
+ * that already happens keeps happening — a client links onto an employee today, and
23
+ * refusing that here would break a live flow.
24
+ *
25
+ * Extending this is a line in the table, not a new check somewhere else.
26
+ */
27
+ export const CONFLICTING_ENTITIES = {
28
+ [ENTITY.DISTRIBUTOR]: [ENTITY.EMPLOYEE],
29
+ [ENTITY.EMPLOYEE]: [ENTITY.DISTRIBUTOR],
30
+ [ENTITY.CLIENT]: [],
31
+ [ENTITY.MANUFACTURER]: [],
32
+ };
33
+
34
+ /**
35
+ * Which already-worn hat, if any, forbids adding this one.
36
+ *
37
+ * Takes a plain set of entity types so it stays independent of how they were discovered —
38
+ * the mapping collection, the legacy columns, or a mix of both all reach the same table.
39
+ *
40
+ * @param {string} targetEntity the hat being added, one of ENTITY
41
+ * @param {Set<string>|Array} existing the hats already worn
42
+ * @returns {string|null} the conflicting entity type, or null when the pairing is allowed
43
+ */
44
+ export function conflictingEntityFor(targetEntity, existing) {
45
+ const worn = existing instanceof Set ? existing : new Set(existing || []);
46
+ return (CONFLICTING_ENTITIES[targetEntity] || []).find((e) => worn.has(e)) || null;
47
+ }
48
+
49
+ /** The refusal message for a pairing the table forbids. Shared so both backends say the same thing. */
50
+ export function entityConflictMessage(targetEntity, conflictingEntity) {
51
+ if (targetEntity === ENTITY.DISTRIBUTOR && conflictingEntity === ENTITY.EMPLOYEE) {
52
+ return 'These contact details belong to an employee. An employee cannot also be registered as a partner.';
53
+ }
54
+ if (targetEntity === ENTITY.EMPLOYEE && conflictingEntity === ENTITY.DISTRIBUTOR) {
55
+ return 'These contact details belong to a partner. A partner cannot also be registered as an employee.';
56
+ }
57
+ return `These contact details are already registered as a ${conflictingEntity}, which cannot be combined with a ${targetEntity}.`;
58
+ }