@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 +42 -0
- package/package.json +6 -2
- package/src/entity-status.js +165 -0
- package/src/index.js +18 -0
- package/src/portal-roles.js +66 -0
- package/src/reportee-tree.js +98 -4
- package/src/route-screen.js +45 -0
- package/src/user-entity-link.js +58 -0
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.
|
|
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
|
+
}
|
package/src/reportee-tree.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|