@i4e/invest4edu-access-core 0.37.0 → 0.38.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.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Personal-details field policy — who may change which client field, per profile type.
3
+ *
4
+ * ── Data ─────────────────────────────────────────────────────────────────────────────────────
5
+ * One row per field in the `clientfieldpolicies` collection:
6
+ *
7
+ * { collection: "distributorclients", field: "dob", section: "identity", class: "personal",
8
+ * policy: { main: { client: "edit", partner: "lock", employee: "lock", ops: "edit" },
9
+ * corporate: { client: "edit", partner: "verify", employee: "lock", ops: "edit" } },
10
+ * open_during_onboarding: true, label: "Date of birth", is_active: true }
11
+ *
12
+ * `field` is a top-level path; a row covers everything beneath it (`bank` covers `bank.0.ifsc`).
13
+ * `field: "*"` covers a whole collection (the satellite tables).
14
+ *
15
+ * ── Decisions ────────────────────────────────────────────────────────────────────────────────
16
+ * edit — applies now
17
+ * verify — held; the client confirms before it applies (corporate partner edits)
18
+ * lock — refused
19
+ * A field with no row takes `field_lock.unclassified_field_action` for every caller, so a new
20
+ * schema field is safe by default. The client always edits their own PERSONAL fields; for
21
+ * relationship and system fields the row's `client` entry applies (lock when absent) — the app
22
+ * must never be able to change its own RM, distributor or KYC flags.
23
+ *
24
+ * Only CHANGED fields are judged: forms re-send whole records, and an unchanged value is never a
25
+ * violation. `changedFields` does that comparison.
26
+ */
27
+ import { CALLER_CLASSES } from "./access.js";
28
+ import { PROFILE_TYPES } from "./settings.js";
29
+
30
+ export const FIELD_ACTIONS = Object.freeze({ EDIT: "edit", VERIFY: "verify", LOCK: "lock" });
31
+ export const FIELD_CLASSES = Object.freeze({ PERSONAL: "personal", RELATIONSHIP: "relationship", SYSTEM: "system" });
32
+ export const WHOLE_COLLECTION = "*";
33
+
34
+ const ACTION_VALUES = new Set(Object.values(FIELD_ACTIONS));
35
+ const CALLER_VALUES = Object.values(CALLER_CLASSES);
36
+ const PROFILE_VALUES = Object.values(PROFILE_TYPES);
37
+
38
+ /**
39
+ * Validate and index policy rows. Invalid rows are skipped and reported, never thrown.
40
+ *
41
+ * @returns {{ index: Map<string, object>, problems: string[] }} keyed `collection|field`
42
+ */
43
+ export function buildFieldPolicyIndex(rows = []) {
44
+ const index = new Map();
45
+ const problems = [];
46
+ for (const row of rows) {
47
+ if (!row || row.is_active === false) continue;
48
+ const where = `${row.collection}|${row.field}`;
49
+ if (!row.collection || !row.field) {
50
+ problems.push(`${where}: collection and field are required`);
51
+ continue;
52
+ }
53
+ if (!Object.values(FIELD_CLASSES).includes(row.class)) {
54
+ problems.push(`${where}: class must be one of ${Object.values(FIELD_CLASSES).join("|")}`);
55
+ continue;
56
+ }
57
+ const bad = policyProblems(row.policy);
58
+ if (bad.length) {
59
+ problems.push(`${where}: ${bad.join("; ")}`);
60
+ continue;
61
+ }
62
+ if (index.has(where)) problems.push(`${where}: duplicate row, the later one wins`);
63
+ index.set(where, row);
64
+ }
65
+ return { index, problems };
66
+ }
67
+
68
+ /** The row governing one field (exact, then its top-level parent, then the whole collection). */
69
+ export function policyRowFor(index, collection, field) {
70
+ const top = String(field).split(".")[0];
71
+ return index.get(`${collection}|${field}`) ?? index.get(`${collection}|${top}`) ?? index.get(`${collection}|${WHOLE_COLLECTION}`) ?? null;
72
+ }
73
+
74
+ /**
75
+ * Judge a set of changed fields.
76
+ *
77
+ * @param {{ index: Map, collection: string, fields: string[], callerClass: string,
78
+ * profileType: string, personalLocked: boolean, settings: object }} input
79
+ * @returns {{ edit: string[], verify: string[], lock: string[], unclassified: string[],
80
+ * outcome: "edit"|"verify"|"lock" }}
81
+ * `outcome` is the strictest action across the fields (lock > verify > edit).
82
+ */
83
+ export function evaluateFieldChanges({ index, collection, fields = [], callerClass, profileType, personalLocked, settings }) {
84
+ const result = { edit: [], verify: [], lock: [], unclassified: [] };
85
+ for (const field of fields) {
86
+ const row = policyRowFor(index, collection, field);
87
+ let action;
88
+ if (!row) {
89
+ result.unclassified.push(field);
90
+ action = settings?.field_lock?.unclassified_field_action ?? FIELD_ACTIONS.LOCK;
91
+ } else action = actionFor(row, { callerClass, profileType, personalLocked });
92
+ result[action].push(field);
93
+ }
94
+ result.outcome = result.lock.length ? FIELD_ACTIONS.LOCK : result.verify.length ? FIELD_ACTIONS.VERIFY : FIELD_ACTIONS.EDIT;
95
+ return result;
96
+ }
97
+
98
+ /**
99
+ * Has the personal lock started for this profile? Stamped (`personal_locked_at`) once KYC and the
100
+ * UCC are complete, per settings; the stamp is the source of truth once present.
101
+ *
102
+ * @param {object} profile
103
+ * @param {{ kycVerified?: boolean, hasUcc?: boolean }} facts what the backend knows today
104
+ */
105
+ export function isPersonalLocked(profile, facts = {}, settings) {
106
+ if (profile?.personal_locked_at) return true;
107
+ const needKyc = settings?.field_lock?.lock_requires_kyc_verified ?? true;
108
+ const needUcc = settings?.field_lock?.lock_requires_ucc ?? true;
109
+ if (!needKyc && !needUcc) return true;
110
+ return (!needKyc || Boolean(facts.kycVerified)) && (!needUcc || Boolean(facts.hasUcc));
111
+ }
112
+
113
+ /**
114
+ * The effective action per policy row for one caller on one profile — what the UI renders.
115
+ * Keys are `field` for rows of `collection` (or `*` for a whole-collection row).
116
+ */
117
+ export function effectivePolicyFor({ index, collection, callerClass, profileType, personalLocked }) {
118
+ const out = {};
119
+ for (const [key, row] of index) {
120
+ const [rowCollection, field] = key.split("|");
121
+ if (collection && rowCollection !== collection) continue;
122
+ const name = collection ? field : key;
123
+ out[name] = actionFor(row, { callerClass, profileType, personalLocked });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * Top-level fields whose value changes when `incoming` is applied to `stored`. Only keys present in
130
+ * `incoming` are considered. Values are compared structurally, with ObjectIds and Dates compared
131
+ * by their string form so a re-sent id or date is not seen as a change.
132
+ */
133
+ export function changedFields(stored = {}, incoming = {}, { ignore = [] } = {}) {
134
+ const skip = new Set(ignore);
135
+ const changed = [];
136
+ for (const key of Object.keys(incoming ?? {})) {
137
+ if (skip.has(key)) continue;
138
+ if (!sameValue(stored?.[key], incoming[key])) changed.push(key);
139
+ }
140
+ return changed;
141
+ }
142
+
143
+ // ── internals ─────────────────────────────────────────────────────────────────────────────────
144
+
145
+ /** One row's action for one caller: the client owns personal fields; onboarding is open. */
146
+ function actionFor(row, { callerClass, profileType, personalLocked }) {
147
+ if (callerClass === CALLER_CLASSES.CLIENT && row.class === FIELD_CLASSES.PERSONAL) return FIELD_ACTIONS.EDIT;
148
+ if (row.class === FIELD_CLASSES.PERSONAL && !personalLocked && row.open_during_onboarding) return FIELD_ACTIONS.EDIT;
149
+ return row.policy?.[profileType]?.[callerClass] ?? FIELD_ACTIONS.LOCK;
150
+ }
151
+
152
+ function policyProblems(policy) {
153
+ const problems = [];
154
+ if (!policy || typeof policy !== "object") return ["policy is required"];
155
+ for (const type of PROFILE_VALUES) {
156
+ const byCaller = policy[type];
157
+ if (!byCaller || typeof byCaller !== "object") {
158
+ problems.push(`policy.${type} is required`);
159
+ continue;
160
+ }
161
+ for (const caller of CALLER_VALUES) {
162
+ if (caller === CALLER_CLASSES.CLIENT && !(caller in byCaller)) continue;
163
+ if (!ACTION_VALUES.has(byCaller[caller])) problems.push(`policy.${type}.${caller} must be edit|verify|lock`);
164
+ }
165
+ }
166
+ return problems;
167
+ }
168
+
169
+ function normalise(v) {
170
+ if (v === undefined || v === "") return null;
171
+ if (v instanceof Date) return v.toISOString();
172
+ if (v && typeof v === "object" && typeof v.toHexString === "function") return v.toHexString();
173
+ if (Array.isArray(v)) return v.map(normalise);
174
+ if (v && typeof v === "object") {
175
+ const out = {};
176
+ for (const k of Object.keys(v).sort()) out[k] = normalise(v[k]);
177
+ return out;
178
+ }
179
+ return v;
180
+ }
181
+
182
+ function sameValue(a, b) {
183
+ return JSON.stringify(normalise(a)) === JSON.stringify(normalise(b));
184
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Goals on the two-profile model (D6) — who a goal belongs to, who sees it, and what counts.
3
+ *
4
+ * - A goal the CLIENT creates is theirs and approved at once.
5
+ * - A goal a PARTNER or EMPLOYEE adds waits for the client (`pending_client`) — visible only to
6
+ * its creator and the client, and excluded from totals, projections and prefill until approved.
7
+ * - Goals on a CORPORATE profile stay with that profile's corporate distributor(s); corporate
8
+ * clients have no app, so nothing waits for approval there.
9
+ * - Another distributor sees a goal only once the client shares it (`shared_with`).
10
+ *
11
+ * Pure: the backend supplies the caller (class, firm ids, own user ids) and applies the filters.
12
+ */
13
+ import { CALLER_CLASSES, ACCESS_LEVELS } from "./access.js";
14
+ import { PROFILE_TYPES, isEnforced, isEvaluated } from "./settings.js";
15
+
16
+ export const GOAL_APPROVAL = Object.freeze({ PENDING: "pending_client", APPROVED: "approved", REJECTED: "rejected" });
17
+ export const GOAL_SHARE = Object.freeze({ REQUESTED: "requested", APPROVED: "approved", REVOKED: "revoked" });
18
+
19
+ /** Statuses that must never count toward totals, projections or a calculator prefill. */
20
+ export const GOAL_STATUSES_EXCLUDED_FROM_TOTALS = Object.freeze([GOAL_APPROVAL.PENDING, GOAL_APPROVAL.REJECTED]);
21
+
22
+ /** A query fragment keeping only goals that count (missing status = an older, approved goal). */
23
+ export function countingGoalsFilter() {
24
+ return { approval_status: { $nin: [...GOAL_STATUSES_EXCLUDED_FROM_TOTALS] } };
25
+ }
26
+
27
+ /**
28
+ * The fields a NEW goal is stamped with.
29
+ * @param {{ actor: { callerClass: string, distributorId?: * }, profileType: string, settings: object }} input
30
+ * @returns {{ distributor_id: *, approval_status: string }}
31
+ */
32
+ export function goalCreationStamp({ actor, profileType, settings }) {
33
+ const distributorId = actor?.callerClass === CALLER_CLASSES.PARTNER ? actor.distributorId ?? null : null;
34
+ const needsApproval = isEnforced(settings, "goal_approval")
35
+ && settings?.goals?.partner_goal_requires_approval
36
+ && actor?.callerClass !== CALLER_CLASSES.CLIENT
37
+ && !(profileType === PROFILE_TYPES.CORPORATE && settings?.goals?.corporate_goals_stay_with_corporate);
38
+ return { distributor_id: distributorId, approval_status: needsApproval ? GOAL_APPROVAL.PENDING : GOAL_APPROVAL.APPROVED };
39
+ }
40
+
41
+ /**
42
+ * Which of a client's goals this caller may see. `{}` = all (mode off, or the client).
43
+ *
44
+ * @param {{ callerClass: string, accessLevel: string, firmDistributorIds?: Array, userIds?: Array,
45
+ * profileType?: string, settings: object }} input
46
+ */
47
+ export function goalVisibilityFilter({ callerClass, accessLevel, firmDistributorIds = [], userIds = [], profileType, settings }) {
48
+ if (!isEvaluated(settings, "goal_approval")) return {};
49
+ const notRejected = { approval_status: { $ne: GOAL_APPROVAL.REJECTED } };
50
+ if (callerClass === CALLER_CLASSES.CLIENT) return notRejected;
51
+ const ownPending = { approval_status: GOAL_APPROVAL.PENDING, created_by: { $in: [...userIds] } };
52
+ const approved = { approval_status: { $nin: [...GOAL_STATUSES_EXCLUDED_FROM_TOTALS] } };
53
+
54
+ if (callerClass === CALLER_CLASSES.PARTNER) {
55
+ const firm = [...firmDistributorIds];
56
+ const arms = [
57
+ { ...approved, distributor_id: { $in: firm } },
58
+ { shared_with: { $elemMatch: { distributor_id: { $in: firm }, status: GOAL_SHARE.APPROVED } } },
59
+ ownPending,
60
+ ];
61
+ // Corporate goals stay with the corporate distributor(s) mapped to that profile: the firm arm
62
+ // above already limits them; nothing else is added for a corporate profile.
63
+ if (accessLevel === ACCESS_LEVELS.LINKED) return { $or: arms.slice(1).concat({ ...approved, created_by: { $in: [...userIds] } }) };
64
+ return { $or: arms };
65
+ }
66
+
67
+ // Internal staff: owners see every approved goal plus what they themselves added; a linked
68
+ // employee sees only what they added.
69
+ if (accessLevel === ACCESS_LEVELS.FULL) return { $or: [approved, ownPending] };
70
+ return { created_by: { $in: [...userIds] }, ...notRejected };
71
+ }
72
+
73
+ /** A share entry the client grants (or a partner requests). */
74
+ export function goalShareEntry({ distributorId, status, by = null, at = new Date() }) {
75
+ return { distributor_id: distributorId, status, requested_by: by, decided_at: status === GOAL_SHARE.REQUESTED ? null : at };
76
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Client-profile rules — the two-profile model shared by v1 and v2.
3
+ *
4
+ * Design and tracker: https://claude.ai/artifact/JrCTutgQkp32r3NeU2uvmk
5
+ */
6
+ export { CLIENT_PROFILE_ERRORS, CLIENT_PROFILE_ERROR_CODES } from "./errors.js";
7
+ export {
8
+ CLIENT_PROFILE_COLLECTIONS,
9
+ CLIENT_PROFILE_SETTINGS_DOC_ID,
10
+ MODES,
11
+ PROFILE_TYPES,
12
+ ATTACH_TRIGGERS,
13
+ DEFAULT_CLIENT_PROFILE_SETTINGS,
14
+ CLIENT_PROFILE_SETTING_DESCRIPTIONS,
15
+ resolveClientProfileSettings,
16
+ describeClientProfileSettings,
17
+ modeOf,
18
+ isEvaluated,
19
+ isEnforced,
20
+ messageFor,
21
+ clientEventSubject,
22
+ } from "./settings.js";
23
+ export {
24
+ DIRECT_SIDE,
25
+ TAG_REASONS,
26
+ sideKeyOf,
27
+ profileTypeFor,
28
+ primaryMappingOf,
29
+ primarySideOf,
30
+ sidesOf,
31
+ mappingForSide,
32
+ isMfProduct,
33
+ orderSideFor,
34
+ primaryLockFor,
35
+ primaryFromLead,
36
+ withPrimaryLocked,
37
+ } from "./sides.js";
38
+ export {
39
+ ACCESS_LEVELS,
40
+ CALLER_CLASSES,
41
+ MATCH_REASONS,
42
+ callerClassOf,
43
+ matchReasonsFor,
44
+ accessLevelFor,
45
+ clientAccessFor,
46
+ linkedViewOf,
47
+ maskValue,
48
+ } from "./access.js";
49
+ export { MATCH_NOTHING, buildSideFilter, recordMatchesFilter } from "./side-filter.js";
50
+ export { CLIENT_PROFILE_KILL_SWITCH_ENV, createClientProfileConfigStore } from "./config-store.js";
51
+ export {
52
+ normalisePan,
53
+ panMatchFilter,
54
+ newProfileFields,
55
+ profileTypeForCaller,
56
+ pickProfileOfType,
57
+ attachDecision,
58
+ } from "./profiles.js";
59
+ export { createClientProfileService } from "./profile-service.js";
60
+ export { createAttachPlugin } from "./attach-plugin.js";
61
+ export { CHANGE_REQUEST_STATUS, CHANGE_REQUEST_FAILURES, createChangeRequestService } from "./change-requests.js";
62
+ export {
63
+ GOAL_APPROVAL,
64
+ GOAL_SHARE,
65
+ GOAL_STATUSES_EXCLUDED_FROM_TOTALS,
66
+ countingGoalsFilter,
67
+ goalCreationStamp,
68
+ goalVisibilityFilter,
69
+ goalShareEntry,
70
+ } from "./goals.js";
71
+ export {
72
+ FIELD_ACTIONS,
73
+ FIELD_CLASSES,
74
+ WHOLE_COLLECTION,
75
+ buildFieldPolicyIndex,
76
+ policyRowFor,
77
+ evaluateFieldChanges,
78
+ isPersonalLocked,
79
+ effectivePolicyFor,
80
+ changedFields,
81
+ } from "./field-policy.js";
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Profile service — the I/O half of the attach flow, shared by v1 and v2.
3
+ *
4
+ * Like the config store, it owns no connection and imports no driver: each backend passes a
5
+ * `getDb()` returning its native `Db`, plus its config getters. Every write is a single atomic
6
+ * `updateOne` guarded so that two concurrent actions by the same side or person can never add
7
+ * the same mapping or link twice.
8
+ *
9
+ * Modes (`modes.attach`, `modes.resolve_by_pan`):
10
+ * off — `attach` does nothing; `findExisting` returns null
11
+ * audit — decisions are computed and reported through `onAudit`, nothing is written or changed
12
+ * enforce — decisions are applied
13
+ */
14
+ import { clientEventSubject, isEnforced, isEvaluated } from "./settings.js";
15
+ import { attachDecision, panMatchFilter, pickProfileOfType } from "./profiles.js";
16
+ import { ACCESS_LEVELS, CALLER_CLASSES, callerClassOf, clientAccessFor } from "./access.js";
17
+ import { buildSideFilter } from "./side-filter.js";
18
+ import { FIELD_ACTIONS, evaluateFieldChanges, isPersonalLocked } from "./field-policy.js";
19
+ import { PROFILE_TYPES } from "./settings.js";
20
+ import { goalCreationStamp, goalVisibilityFilter } from "./goals.js";
21
+ import { buildCapabilityMap, resolveCapabilities } from "../role-capabilities.js";
22
+ import { CLIENT_APP_ROLE_NAMES } from "../portal-roles.js";
23
+
24
+ const CLIENTS = "distributorclients";
25
+
26
+ /**
27
+ * @param {{ getDb: Function, getSettings: Function, getSideContext?: Function,
28
+ * getVisibilitySets?: (userId) => Promise<{ userIds: Array, sourceDistributorIds: Array }>,
29
+ * toId?: (value) => *, onAudit?: (event: object) => void, now?: () => Date }} deps
30
+ * `getVisibilitySets` is the backend's own visibility code (self + reportees, source
31
+ * distributors); without it an employee is never treated as already seeing a client.
32
+ * `toId` converts ids to the stored type (ObjectId); identity by default.
33
+ */
34
+ export function createClientProfileService({ getDb, getSettings, getSideContext = () => ({}), getVisibilitySets = null, getFieldPolicyIndex = () => new Map(), toId = (v) => v, onAudit = () => {}, emit = null, now = () => new Date() }) {
35
+ const db = () => {
36
+ const handle = getDb?.();
37
+ if (!handle) throw new Error("client-profile service: no database handle");
38
+ return handle;
39
+ };
40
+
41
+ /** Every ACTIVE profile of a person, by PAN (global: the whole book is one person space). */
42
+ async function findActiveProfilesByPan(pan, { session, projection } = {}) {
43
+ const filter = panMatchFilter(pan);
44
+ if (!filter) return [];
45
+ return db().collection(CLIENTS).find({ ...filter, status: 1 }, { session, projection }).toArray();
46
+ }
47
+
48
+ /**
49
+ * The person's existing profile of the type the caller would create, or null.
50
+ * `mode` tells the caller whether to act on it: "enforce" → show the existing client instead
51
+ * of creating; "audit" → carry on as before, the match was only reported.
52
+ */
53
+ async function findExisting({ pan, profileType, session }) {
54
+ const settings = getSettings();
55
+ if (!isEvaluated(settings, "resolve_by_pan")) return { profile: null, mode: "off" };
56
+ const profile = pickProfileOfType(await findActiveProfilesByPan(pan, { session }), profileType);
57
+ const mode = isEnforced(settings, "resolve_by_pan") ? "enforce" : "audit";
58
+ if (profile && mode === "audit") onAudit({ kind: "resolve_by_pan", client_id: profile._id, profile_type: profileType });
59
+ return { profile, mode };
60
+ }
61
+
62
+ /**
63
+ * Record the actor on the client after a saved action. Never throws for business reasons —
64
+ * an attach failure must not fail the order, proposal or upload that triggered it.
65
+ *
66
+ * @returns {{ attached: boolean, kind: string, reason: string }}
67
+ */
68
+ async function attach({ clientId, actor, via, stamps, session }) {
69
+ const settings = getSettings();
70
+ if (!isEvaluated(settings, "attach")) return { attached: false, kind: "none", reason: "mode_off" };
71
+ const profile = await db().collection(CLIENTS).findOne(
72
+ { _id: clientId },
73
+ { session, projection: { distributor_id: 1, distributor_mappings: 1, linked_users: 1 } },
74
+ );
75
+ const decision = attachDecision({ profile, actor, via, at: now(), settings, ctx: getSideContext(), stamps });
76
+ if (decision.kind === "none") return { attached: false, ...decision };
77
+ if (!isEnforced(settings, "attach")) {
78
+ onAudit({ kind: "attach", client_id: clientId, decision: decision.kind, reason: decision.reason, via });
79
+ return { attached: false, kind: decision.kind, reason: "audit" };
80
+ }
81
+ const update = decision.kind === "mapping"
82
+ ? { filter: { _id: clientId, "distributor_mappings.distributor_id": { $ne: decision.entry.distributor_id } }, $push: { distributor_mappings: decision.entry } }
83
+ : { filter: { _id: clientId, "linked_users.user_id": { $ne: decision.entry.user_id } }, $push: { linked_users: decision.entry } };
84
+ const res = await db().collection(CLIENTS).updateOne(update.filter, { $push: update.$push }, { session });
85
+ const attached = res.modifiedCount === 1;
86
+ if (attached) announceAttach({ clientId, decision, via, settings });
87
+ return { attached, kind: decision.kind, reason: attached ? decision.reason : "raced" };
88
+ }
89
+
90
+ /**
91
+ * "A new adviser is linked to you" — to the client, and the primary's side. Fire and forget:
92
+ * a notification must never fail the order that caused the link.
93
+ */
94
+ function announceAttach({ clientId, decision, via, settings }) {
95
+ if (!emit) return;
96
+ const entry = decision.entry ?? {};
97
+ const payload = {
98
+ client_id: String(clientId),
99
+ kind: decision.kind,
100
+ via: via ?? null,
101
+ distributor_id: entry.distributor_id != null ? String(entry.distributor_id) : null,
102
+ user_id: entry.user_id != null ? String(entry.user_id) : null,
103
+ };
104
+ Promise.resolve()
105
+ .then(() => emit(settings.events.relationship_attached, payload, clientEventSubject(settings, clientId)))
106
+ .catch((err) => onAudit({ kind: "attach_event_error", client_id: clientId, message: err?.message }));
107
+ }
108
+
109
+ /**
110
+ * A partner's firm ids: their own distributor, always, plus whatever the backend's visibility
111
+ * sets add (its sub-distributors). A backend whose sets omit the caller's own firm must not
112
+ * leave a partner seeing nothing of their own clients.
113
+ */
114
+ function firmIdsOf(actor, sourceDistributorIds = []) {
115
+ if (!actor?.distributorId) return [];
116
+ const ids = [actor.distributorId, ...sourceDistributorIds];
117
+ const seen = new Set();
118
+ return ids.filter((id) => id !== null && id !== undefined && !seen.has(String(id)) && seen.add(String(id)));
119
+ }
120
+
121
+ /** Role names for a user: userentitymappings.role_id → roles.role_name (users carry none). */
122
+ async function roleNamesOf(userId, session) {
123
+ const maps = await db().collection("userentitymappings").find({ user_id: userId, status: { $ne: 0 }, role_id: { $ne: null } }, { session, projection: { role_id: 1 } }).toArray();
124
+ if (!maps.length) return [];
125
+ const roles = await db().collection("roles").find({ _id: { $in: maps.map((m) => m.role_id) } }, { session, projection: { role_name: 1 } }).toArray();
126
+ return roles.map((r) => r.role_name).filter(Boolean);
127
+ }
128
+
129
+ /**
130
+ * Who is behind a user id, as the rules need it: class (client / partner / ops / employee),
131
+ * firm, and whether their roles or flag give whole-book access.
132
+ */
133
+ async function resolveActor(userId, { session } = {}) {
134
+ const id = toId(userId);
135
+ if (!id) return null;
136
+ const user = await db().collection("users").findOne({ _id: id }, { session, projection: { distributor_id: 1, client_id: 1, employee_id: 1, full_record_access: 1 } });
137
+ if (!user) return null;
138
+ const roles = await roleNamesOf(id, session);
139
+ const capabilityMap = buildCapabilityMap(await db().collection("rolecapabilities").find({}, { session }).toArray());
140
+ const isClientApp = Boolean(user.client_id) && roles.length > 0 && roles.every((r) => CLIENT_APP_ROLE_NAMES.includes(r));
141
+ return {
142
+ userId: id,
143
+ employeeId: user.employee_id ?? null,
144
+ distributorId: user.distributor_id ?? null,
145
+ roles,
146
+ fullRecordAccess: Boolean(user.full_record_access) || resolveCapabilities(roles, capabilityMap).isFullAccess,
147
+ callerClass: callerClassOf({ roles, distributorId: user.distributor_id, isClientApp }, getSettings()),
148
+ };
149
+ }
150
+
151
+ /** Can this internal actor already see the client with FULL access? */
152
+ async function isAlreadyVisible(actor, clientId, session) {
153
+ if (actor.fullRecordAccess) return true;
154
+ if (!getVisibilitySets) return false;
155
+ const client = await db().collection(CLIENTS).findOne({ _id: clientId }, { session });
156
+ if (!client) return false;
157
+ const { userIds = [], sourceDistributorIds = [] } = (await getVisibilitySets(actor.userId)) ?? {};
158
+ const { level } = clientAccessFor(client, { userIds, sourceDistributorIds, houseDistributorIds: getSideContext().houseDistributorIds }, getSettings());
159
+ return level === ACCESS_LEVELS.FULL;
160
+ }
161
+
162
+ /**
163
+ * Record the person behind `userId` on the client after a saved action. Resolves the actor,
164
+ * checks existing visibility for employees, then `attach`. Never throws.
165
+ */
166
+ async function attachUser({ clientId, userId, via, stamps, session } = {}) {
167
+ try {
168
+ if (!isEvaluated(getSettings(), "attach")) return { attached: false, kind: "none", reason: "mode_off" };
169
+ const cid = toId(clientId);
170
+ if (!cid || !userId) return { attached: false, kind: "none", reason: "missing_ids" };
171
+ const actor = await resolveActor(userId, { session });
172
+ if (!actor) return { attached: false, kind: "none", reason: "unknown_user" };
173
+ if (actor.callerClass !== CALLER_CLASSES.PARTNER && actor.callerClass !== CALLER_CLASSES.CLIENT) {
174
+ actor.alreadyVisible = await isAlreadyVisible(actor, cid, session);
175
+ }
176
+ return await attach({ clientId: cid, actor, via, stamps, session });
177
+ } catch (err) {
178
+ onAudit({ kind: "attach_error", client_id: clientId, message: err.message });
179
+ return { attached: false, kind: "none", reason: "error" };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * What this caller may see of the records filed on one client: the side filter for their access
185
+ * to it (`{}` for owners, whole-book staff and the client; the firm's side for a partner; only
186
+ * their own placements for a linked user). `{}` when the mode is off or on any failure (today's
187
+ * behaviour); in audit, `{}` plus the decision reported through onAudit.
188
+ *
189
+ * @returns {Promise<{ filter: object, applied: boolean, level: string|null }>}
190
+ */
191
+ async function sideFilterFor({ userId, clientId, fields } = {}) {
192
+ const none = { filter: {}, applied: false, level: null };
193
+ try {
194
+ const settings = getSettings();
195
+ if (!isEvaluated(settings, "side_filter")) return none;
196
+ const cid = toId(clientId);
197
+ if (!userId || !cid) return none;
198
+ const client = await db().collection(CLIENTS).findOne({ _id: cid });
199
+ const actor = await resolveActor(userId);
200
+ if (!client || !actor) return none;
201
+ const { userIds = [], sourceDistributorIds = [] } = (getVisibilitySets ? await getVisibilitySets(actor.userId) : null) ?? {};
202
+ const firm = firmIdsOf(actor, sourceDistributorIds);
203
+ const { level } = clientAccessFor(client, {
204
+ userIds,
205
+ sourceDistributorIds,
206
+ firmDistributorIds: firm,
207
+ fullRecordAccess: actor.fullRecordAccess,
208
+ houseDistributorIds: getSideContext().houseDistributorIds,
209
+ }, settings);
210
+ const result = buildSideFilter({ callerClass: actor.callerClass, accessLevel: level, firmDistributorIds: firm.length ? firm : sourceDistributorIds, userIds, fields, settings });
211
+ if (!result.applied && Object.keys(result.decided).length) onAudit({ kind: "side_filter", client_id: clientId, decided: result.decided });
212
+ return { filter: result.filter, applied: result.applied, level };
213
+ } catch (err) {
214
+ onAudit({ kind: "side_filter_error", client_id: clientId, message: err.message });
215
+ return none;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Personal-details lock (D5): may this caller change these fields of this client?
221
+ *
222
+ * outcome "edit" — go ahead
223
+ * outcome "verify" — the `verify` fields must be confirmed by the client (corporate partner);
224
+ * any `edit` fields may still be applied now
225
+ * outcome "lock" — refuse; `lock` names the fields
226
+ *
227
+ * `modes.field_lock` off → always edit. audit → the real decision is reported through onAudit
228
+ * and the outcome returned is edit. The lock starts at `personal_locked_at`, or — before the
229
+ * stamp — once KYC and a UCC exist on the client or its active investor profiles.
230
+ *
231
+ * @returns {Promise<{ outcome: string, edit: string[], verify: string[], lock: string[],
232
+ * unclassified: string[], applied: boolean, actor: object|null,
233
+ * profile: object|null }>}
234
+ */
235
+ async function evaluatePersonalEdit({ userId, clientId, collection = CLIENTS, fields = [], session } = {}) {
236
+ const allowAll = (extra = {}) => ({ outcome: FIELD_ACTIONS.EDIT, edit: fields, verify: [], lock: [], unclassified: [], applied: false, actor: null, profile: null, ...extra });
237
+ const settings = getSettings();
238
+ if (!isEvaluated(settings, "field_lock") || !fields.length) return allowAll();
239
+ const cid = toId(clientId);
240
+ const profile = cid ? await db().collection(CLIENTS).findOne({ _id: cid }, { session, projection: { profile_type: 1, personal_locked_at: 1, is_kyc_verified: 1, mf_ucc_code: 1, distributor_id: 1 } }) : null;
241
+ const actor = userId ? await resolveActor(userId, { session }) : null;
242
+ if (!profile || !actor) return allowAll({ actor, profile });
243
+ let personalLocked = Boolean(profile.personal_locked_at);
244
+ if (!personalLocked) {
245
+ const investor = await db().collection("clientinvestorprofiles").findOne(
246
+ { client_id: cid, is_active: { $ne: false }, is_read_only_copy: { $ne: true }, "mf_details.ucc_code": { $nin: [null, ""] } },
247
+ { session, projection: { "mf_details.mf_kyced": 1 } },
248
+ );
249
+ personalLocked = isPersonalLocked(profile, {
250
+ kycVerified: Boolean(profile.is_kyc_verified) || investor?.mf_details?.mf_kyced === true,
251
+ hasUcc: Boolean(profile.mf_ucc_code) || Boolean(investor),
252
+ }, settings);
253
+ }
254
+ const result = evaluateFieldChanges({
255
+ index: getFieldPolicyIndex(),
256
+ collection,
257
+ fields,
258
+ callerClass: actor.callerClass,
259
+ profileType: profile.profile_type ?? PROFILE_TYPES.MAIN,
260
+ personalLocked,
261
+ settings,
262
+ });
263
+ if (!isEnforced(settings, "field_lock")) {
264
+ if (result.outcome !== FIELD_ACTIONS.EDIT || result.unclassified.length) {
265
+ onAudit({ kind: "field_lock", client_id: clientId, caller: actor.callerClass, outcome: result.outcome, lock: result.lock, verify: result.verify, unclassified: result.unclassified });
266
+ }
267
+ return allowAll({ actor, profile });
268
+ }
269
+ return { ...result, applied: true, actor, profile };
270
+ }
271
+
272
+ /**
273
+ * Goals (D6): for this caller on this client, the filter over the client's goals they may see
274
+ * and the stamp a goal they create gets. Mode off → `{}` and an approved, untagged stamp.
275
+ */
276
+ async function goalContextFor({ userId, clientId } = {}) {
277
+ const settings = getSettings();
278
+ const open = { visibility: {}, stamp: { distributor_id: null, approval_status: "approved" }, actor: null };
279
+ try {
280
+ const actor = userId ? await resolveActor(userId) : null;
281
+ const cid = toId(clientId);
282
+ const client = cid ? await db().collection(CLIENTS).findOne({ _id: cid }) : null;
283
+ if (!actor || !client) return open;
284
+ const profileType = client.profile_type ?? PROFILE_TYPES.MAIN;
285
+ const { userIds = [], sourceDistributorIds = [] } = (getVisibilitySets ? await getVisibilitySets(actor.userId) : null) ?? {};
286
+ const firm = firmIdsOf(actor, sourceDistributorIds);
287
+ const { level } = clientAccessFor(client, {
288
+ userIds,
289
+ sourceDistributorIds,
290
+ firmDistributorIds: firm,
291
+ fullRecordAccess: actor.fullRecordAccess,
292
+ houseDistributorIds: getSideContext().houseDistributorIds,
293
+ }, settings);
294
+ return {
295
+ actor,
296
+ visibility: goalVisibilityFilter({ callerClass: actor.callerClass, accessLevel: level, firmDistributorIds: firm.length ? firm : sourceDistributorIds, userIds: [...userIds, actor.userId], profileType, settings }),
297
+ stamp: goalCreationStamp({ actor, profileType, settings }),
298
+ };
299
+ } catch (err) {
300
+ onAudit({ kind: "goal_context_error", client_id: clientId, message: err.message });
301
+ return open;
302
+ }
303
+ }
304
+
305
+ return Object.freeze({ findActiveProfilesByPan, findExisting, attach, attachUser, resolveActor, sideFilterFor, evaluatePersonalEdit, goalContextFor });
306
+ }