@i4e/invest4edu-access-core 0.42.0 → 0.43.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, entity/role vocabulary, mobile app identity, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -56,10 +56,10 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
|
|
|
56
56
|
* `mode` tells the caller whether to act on it: "enforce" → show the existing client instead
|
|
57
57
|
* of creating; "audit" → carry on as before, the match was only reported.
|
|
58
58
|
*/
|
|
59
|
-
async function findExisting({ pan, profileType, session }) {
|
|
59
|
+
async function findExisting({ pan, profileType, distributorId = null, session }) {
|
|
60
60
|
const settings = getSettings();
|
|
61
61
|
if (!isEvaluated(settings, "resolve_by_pan")) return { profile: null, mode: "off" };
|
|
62
|
-
const profile = pickProfileOfType(await findActiveProfilesByPan(pan, { session }), profileType);
|
|
62
|
+
const profile = pickProfileOfType(await findActiveProfilesByPan(pan, { session }), profileType, { distributorId, settings, ctx: getSideContext() });
|
|
63
63
|
const mode = isEnforced(settings, "resolve_by_pan") ? "enforce" : "audit";
|
|
64
64
|
if (profile && mode === "audit") onAudit({ kind: "resolve_by_pan", client_id: profile._id, profile_type: profileType });
|
|
65
65
|
return { profile, mode };
|
|
@@ -128,7 +128,10 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
|
|
|
128
128
|
async function roleNamesOf(userId, session) {
|
|
129
129
|
const maps = await db().collection("userentitymappings").find({ user_id: userId, status: { $ne: 0 }, role_id: { $ne: null } }, { session, projection: { role_id: 1 } }).toArray();
|
|
130
130
|
if (!maps.length) return [];
|
|
131
|
-
|
|
131
|
+
// role_id is an ARRAY on real mappings; `$in` must get the role ids themselves, not the arrays
|
|
132
|
+
// (an array inside `$in` only matches an equal array, so every caller read as role-less).
|
|
133
|
+
const roleIds = maps.flatMap((m) => (Array.isArray(m.role_id) ? m.role_id : [m.role_id])).filter(Boolean);
|
|
134
|
+
const roles = await db().collection("roles").find({ _id: { $in: roleIds } }, { session, projection: { role_name: 1 } }).toArray();
|
|
132
135
|
return roles.map((r) => r.role_name).filter(Boolean);
|
|
133
136
|
}
|
|
134
137
|
|
|
@@ -65,9 +65,25 @@ export function profileTypeForCaller(callerDistributor, settings) {
|
|
|
65
65
|
return profileTypeFor(callerDistributor, settings);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** Corporate profiles are one per corporate firm and never shared (settings.corporate). */
|
|
69
|
+
function corporateSiloed(settings) {
|
|
70
|
+
return settings?.corporate?.one_profile_per_distributor !== false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The side that owns a profile: its primary mapping, else its distributor_id. */
|
|
74
|
+
function primarySideOfProfile(profile, ctx) {
|
|
75
|
+
return sideKeyOf((profile?.distributor_mappings ?? []).find((m) => m?.is_primary)?.distributor_id ?? profile?.distributor_id, ctx);
|
|
76
|
+
}
|
|
77
|
+
|
|
68
78
|
/** Of a person's active profiles, the one of this type (oldest first if the data has duplicates). */
|
|
69
|
-
export function pickProfileOfType(profiles = [], profileType) {
|
|
70
|
-
|
|
79
|
+
export function pickProfileOfType(profiles = [], profileType, { distributorId = null, settings, ctx } = {}) {
|
|
80
|
+
let ofType = profiles.filter((p) => (p.profile_type ?? PROFILE_TYPES.MAIN) === profileType);
|
|
81
|
+
// A corporate profile belongs to ONE corporate firm: only that firm's own profile is "the existing
|
|
82
|
+
// one". Another firm (or a caller with no firm) finds nothing and creates its own.
|
|
83
|
+
if (profileType === PROFILE_TYPES.CORPORATE && corporateSiloed(settings)) {
|
|
84
|
+
const side = distributorId ? sideKeyOf(distributorId, ctx) : null;
|
|
85
|
+
ofType = side ? ofType.filter((p) => primarySideOfProfile(p, ctx) === side) : [];
|
|
86
|
+
}
|
|
71
87
|
ofType.sort((a, b) => new Date(a.created_date ?? 0) - new Date(b.created_date ?? 0));
|
|
72
88
|
return ofType[0] ?? null;
|
|
73
89
|
}
|
|
@@ -90,9 +106,13 @@ export function attachDecision({ profile, actor = {}, via, at = new Date(), sett
|
|
|
90
106
|
if (actor.callerClass === CALLER_CLASSES.PARTNER) {
|
|
91
107
|
const side = sideKeyOf(actor.distributorId, ctx);
|
|
92
108
|
if (side === DIRECT_SIDE) return { kind: "none", reason: "partner_without_firm" };
|
|
93
|
-
const primarySide =
|
|
109
|
+
const primarySide = primarySideOfProfile(profile, ctx);
|
|
94
110
|
const mapped = side === primarySide || (profile.distributor_mappings ?? []).some((m) => m && m.status !== "inactive" && sideKeyOf(m.distributor_id, ctx) === side);
|
|
95
111
|
if (mapped) return { kind: "none", reason: "already_mapped" };
|
|
112
|
+
// A corporate profile is its firm's alone: no other firm is ever added to it.
|
|
113
|
+
if ((profile.profile_type ?? PROFILE_TYPES.MAIN) === PROFILE_TYPES.CORPORATE && corporateSiloed(settings)) {
|
|
114
|
+
return { kind: "none", reason: "corporate_silo" };
|
|
115
|
+
}
|
|
96
116
|
return {
|
|
97
117
|
kind: "mapping",
|
|
98
118
|
reason: "new_side",
|
|
@@ -77,6 +77,12 @@ export const DEFAULT_CLIENT_PROFILE_SETTINGS = deepFreeze({
|
|
|
77
77
|
},
|
|
78
78
|
corporate_distributor_types: [],
|
|
79
79
|
house_distributor_codes: [],
|
|
80
|
+
corporate: {
|
|
81
|
+
// Each corporate distributor holds its OWN profile of a person (one per corporate firm), and
|
|
82
|
+
// works on it alone: a PAN lookup never surfaces another firm's corporate profile, and no
|
|
83
|
+
// other firm is ever attached to one.
|
|
84
|
+
one_profile_per_distributor: true,
|
|
85
|
+
},
|
|
80
86
|
primary: {
|
|
81
87
|
lock_on_first_order: true,
|
|
82
88
|
ignore_order_statuses: [],
|
|
@@ -192,6 +198,7 @@ export const CLIENT_PROFILE_SETTING_DESCRIPTIONS = Object.freeze({
|
|
|
192
198
|
"modes.detail_scope": "A client's full record by id only for callers whose client list includes them; everyone else gets the limited card (R15).",
|
|
193
199
|
"modes.linked_visibility": "Clients reached only through a secondary mapping or a linked user appear in lists, as the limited card.",
|
|
194
200
|
corporate_distributor_types: "distributors.distributor_type values whose clients get a corporate profile.",
|
|
201
|
+
"corporate.one_profile_per_distributor": "Each corporate firm has its own, siloed profile of a person: no cross-firm lookup or attach.",
|
|
195
202
|
house_distributor_codes: "Distributor codes treated as direct (InvestValue's own house distributors).",
|
|
196
203
|
"primary.lock_on_first_order": "The first non-ignored order locks the primary distributor.",
|
|
197
204
|
"primary.ignore_order_statuses": "Order statuses that do not count as first business (e.g. cancelled).",
|