@i4e/invest4edu-access-core 0.36.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.
- package/README.md +13 -0
- package/package.json +4 -2
- package/src/client-profile/access.js +149 -0
- package/src/client-profile/attach-plugin.js +53 -0
- package/src/client-profile/change-requests.js +155 -0
- package/src/client-profile/config-store.js +93 -0
- package/src/client-profile/errors.js +25 -0
- package/src/client-profile/field-policy.js +184 -0
- package/src/client-profile/goals.js +76 -0
- package/src/client-profile/index.js +81 -0
- package/src/client-profile/profile-service.js +306 -0
- package/src/client-profile/profiles.js +126 -0
- package/src/client-profile/settings.js +377 -0
- package/src/client-profile/side-filter.js +96 -0
- package/src/client-profile/sides.js +204 -0
- package/src/index.js +11 -0
- package/src/mobile-app-identity.js +52 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profiles — finding a person's existing profile, stamping a new one, and deciding what a saved
|
|
3
|
+
* action attaches.
|
|
4
|
+
*
|
|
5
|
+
* ── Finding a person ─────────────────────────────────────────────────────────────────────────
|
|
6
|
+
* A person is a PAN. Stored PANs vary in case and stray whitespace depending on the path that
|
|
7
|
+
* wrote them, so lookups match the normalised value case-insensitively and anchored — an exact
|
|
8
|
+
* match, never a prefix, so a lookup can never be used to enumerate clients.
|
|
9
|
+
*
|
|
10
|
+
* ── Attaching ────────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
* The first saved action by someone who does not own a client records them on it (D4):
|
|
12
|
+
* partner / partner staff → a secondary distributor mapping for their firm
|
|
13
|
+
* internal employee → a linked_users entry, unless they already see the client
|
|
14
|
+
* client (app) → nothing
|
|
15
|
+
* Searching alone attaches nothing; only a saved action does.
|
|
16
|
+
*/
|
|
17
|
+
import { ATTACH_TRIGGERS, PROFILE_TYPES, isEvaluated } from "./settings.js";
|
|
18
|
+
import { CALLER_CLASSES } from "./access.js";
|
|
19
|
+
import { DIRECT_SIDE, profileTypeFor, sideKeyOf } from "./sides.js";
|
|
20
|
+
|
|
21
|
+
/** Uppercase and strip every whitespace character. Empty → null. */
|
|
22
|
+
export function normalisePan(pan) {
|
|
23
|
+
if (pan === null || pan === undefined) return null;
|
|
24
|
+
const value = String(pan).replace(/\s+/g, "").toUpperCase();
|
|
25
|
+
return value || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A Mongo filter matching this PAN exactly, whatever the stored case or padding.
|
|
30
|
+
* Returns null for an empty PAN (never match "every client without a PAN").
|
|
31
|
+
*/
|
|
32
|
+
export function panMatchFilter(pan, field = "pan_card_no") {
|
|
33
|
+
const normal = normalisePan(pan);
|
|
34
|
+
if (!normal) return null;
|
|
35
|
+
const escaped = normal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
return { [field]: { $regex: `^\\s*${escaped}\\s*$`, $options: "i" } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The two-profile fields a brand-new client is created with: its type and a provisional primary
|
|
41
|
+
* mapping for the creator's side. Always additive — safe with every mode off.
|
|
42
|
+
*/
|
|
43
|
+
export function newProfileFields({ distributor, distributorId = null, at = new Date(), by = null, rmId = null, distEmpRmId = null, departmentId = null, via = ATTACH_TRIGGERS.CREATE, primaryLocked = false, settings }) {
|
|
44
|
+
return {
|
|
45
|
+
profile_type: profileTypeFor(distributor, settings),
|
|
46
|
+
distributor_mappings: [{
|
|
47
|
+
distributor_id: distributorId ?? null,
|
|
48
|
+
is_primary: true,
|
|
49
|
+
primary_locked: Boolean(primaryLocked),
|
|
50
|
+
via,
|
|
51
|
+
mapped_at: at,
|
|
52
|
+
mapped_by: by,
|
|
53
|
+
first_business_at: null,
|
|
54
|
+
rm_id: rmId,
|
|
55
|
+
dist_emp_rm_id: distEmpRmId,
|
|
56
|
+
department_id: departmentId,
|
|
57
|
+
merged_from_client_id: null,
|
|
58
|
+
status: "active",
|
|
59
|
+
}],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The profile type a caller's new client would get — the key for "does this person exist?". */
|
|
64
|
+
export function profileTypeForCaller(callerDistributor, settings) {
|
|
65
|
+
return profileTypeFor(callerDistributor, settings);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 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
|
+
const ofType = profiles.filter((p) => (p.profile_type ?? PROFILE_TYPES.MAIN) === profileType);
|
|
71
|
+
ofType.sort((a, b) => new Date(a.created_date ?? 0) - new Date(b.created_date ?? 0));
|
|
72
|
+
return ofType[0] ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What a saved action should attach.
|
|
77
|
+
*
|
|
78
|
+
* @param {{ profile: object, actor: { callerClass: string, distributorId?: *, userId?: *,
|
|
79
|
+
* employeeId?: *, alreadyVisible?: boolean }, via: string, at?: Date,
|
|
80
|
+
* settings: object, ctx?: object, stamps?: { rmId?, distEmpRmId?, departmentId? } }} input
|
|
81
|
+
* @returns {{ kind: "none"|"mapping"|"linked_user", reason: string, entry?: object }}
|
|
82
|
+
*/
|
|
83
|
+
export function attachDecision({ profile, actor = {}, via, at = new Date(), settings, ctx, stamps = {} }) {
|
|
84
|
+
if (!isEvaluated(settings, "attach")) return { kind: "none", reason: "mode_off" };
|
|
85
|
+
if (!(settings.attach?.triggers ?? []).includes(via)) return { kind: "none", reason: "trigger_not_configured" };
|
|
86
|
+
if (!profile) return { kind: "none", reason: "no_profile" };
|
|
87
|
+
|
|
88
|
+
if (actor.callerClass === CALLER_CLASSES.CLIENT) return { kind: "none", reason: "client" };
|
|
89
|
+
|
|
90
|
+
if (actor.callerClass === CALLER_CLASSES.PARTNER) {
|
|
91
|
+
const side = sideKeyOf(actor.distributorId, ctx);
|
|
92
|
+
if (side === DIRECT_SIDE) return { kind: "none", reason: "partner_without_firm" };
|
|
93
|
+
const primarySide = sideKeyOf((profile.distributor_mappings ?? []).find((m) => m?.is_primary)?.distributor_id ?? profile.distributor_id, ctx);
|
|
94
|
+
const mapped = side === primarySide || (profile.distributor_mappings ?? []).some((m) => m && m.status !== "inactive" && sideKeyOf(m.distributor_id, ctx) === side);
|
|
95
|
+
if (mapped) return { kind: "none", reason: "already_mapped" };
|
|
96
|
+
return {
|
|
97
|
+
kind: "mapping",
|
|
98
|
+
reason: "new_side",
|
|
99
|
+
entry: {
|
|
100
|
+
distributor_id: actor.distributorId,
|
|
101
|
+
is_primary: false,
|
|
102
|
+
primary_locked: false,
|
|
103
|
+
via,
|
|
104
|
+
mapped_at: at,
|
|
105
|
+
mapped_by: actor.userId ?? null,
|
|
106
|
+
first_business_at: at,
|
|
107
|
+
rm_id: stamps.rmId ?? null,
|
|
108
|
+
dist_emp_rm_id: stamps.distEmpRmId ?? null,
|
|
109
|
+
department_id: stamps.departmentId ?? null,
|
|
110
|
+
merged_from_client_id: null,
|
|
111
|
+
status: "active",
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Internal employee (ops or sales).
|
|
117
|
+
if (!actor.userId) return { kind: "none", reason: "no_user" };
|
|
118
|
+
if (actor.alreadyVisible && settings.attach?.skip_if_already_visible) return { kind: "none", reason: "already_visible" };
|
|
119
|
+
const linked = (profile.linked_users ?? []).some((l) => l && String(l.user_id) === String(actor.userId));
|
|
120
|
+
if (linked) return { kind: "none", reason: "already_linked" };
|
|
121
|
+
return {
|
|
122
|
+
kind: "linked_user",
|
|
123
|
+
reason: "new_user",
|
|
124
|
+
entry: { user_id: actor.userId, employee_id: actor.employeeId ?? null, via, first_business_at: at },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-profile settings — the ONE place every behaviour of the two-profile model is configured.
|
|
3
|
+
*
|
|
4
|
+
* ── How configuration flows ──────────────────────────────────────────────────────────────────
|
|
5
|
+
* Defaults live here. Each environment keeps one document in the `clientprofilesettings`
|
|
6
|
+
* collection that overrides any subset of them. Both backends load that document through their
|
|
7
|
+
* config refresher and call `resolveClientProfileSettings(doc)`; neither re-states a default.
|
|
8
|
+
*
|
|
9
|
+
* ── What is NOT a default ────────────────────────────────────────────────────────────────────
|
|
10
|
+
* Business identifiers — distributor codes, role names, product codes, distributor-type labels —
|
|
11
|
+
* are data about one environment, so their defaults here are EMPTY. They are supplied by the
|
|
12
|
+
* environment's settings document (seeded per environment by the backends). An empty list simply
|
|
13
|
+
* means "nothing matches", and every feature that depends on one is also behind a mode that
|
|
14
|
+
* defaults to `off`.
|
|
15
|
+
*
|
|
16
|
+
* ── Modes ────────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
* Every behaviour that changes a request's outcome has a mode:
|
|
18
|
+
* off — behave exactly as before this module existed
|
|
19
|
+
* audit — compute the decision, log it, change nothing
|
|
20
|
+
* enforce — apply the decision
|
|
21
|
+
* All modes default to `off`, so shipping the code changes nothing until an environment opts in.
|
|
22
|
+
*
|
|
23
|
+
* ── Validation ───────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
* The defaults double as the schema: a supplied value is accepted only when it has the same shape
|
|
25
|
+
* as the default it replaces (a string list for a list, a mode for a mode, …). Anything else keeps
|
|
26
|
+
* the default and is reported in `problems`, never thrown — a bad config document must not take a
|
|
27
|
+
* request path down.
|
|
28
|
+
*/
|
|
29
|
+
import { CLIENT_PROFILE_ERRORS } from "./errors.js";
|
|
30
|
+
|
|
31
|
+
/** Collections the backends read and write for this feature. Named once, here. */
|
|
32
|
+
export const CLIENT_PROFILE_COLLECTIONS = Object.freeze({
|
|
33
|
+
SETTINGS: "clientprofilesettings",
|
|
34
|
+
FIELD_POLICIES: "clientfieldpolicies",
|
|
35
|
+
CHANGE_REQUESTS: "client_profile_change_requests",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** The id of the settings document inside `clientprofilesettings`. */
|
|
39
|
+
export const CLIENT_PROFILE_SETTINGS_DOC_ID = "default";
|
|
40
|
+
|
|
41
|
+
export const MODES = Object.freeze({ OFF: "off", AUDIT: "audit", ENFORCE: "enforce" });
|
|
42
|
+
const MODE_VALUES = Object.freeze(Object.values(MODES));
|
|
43
|
+
|
|
44
|
+
export const PROFILE_TYPES = Object.freeze({ CORPORATE: "corporate", MAIN: "main" });
|
|
45
|
+
|
|
46
|
+
/** What caused a relationship to be recorded on a client. */
|
|
47
|
+
export const ATTACH_TRIGGERS = Object.freeze({
|
|
48
|
+
CREATE: "create",
|
|
49
|
+
ORDER: "order",
|
|
50
|
+
PROPOSAL: "proposal",
|
|
51
|
+
PFA: "pfa",
|
|
52
|
+
GOAL: "goal",
|
|
53
|
+
ACTIVITY: "activity",
|
|
54
|
+
REFERRAL: "referral",
|
|
55
|
+
LEAD_CONVERSION: "lead_conversion",
|
|
56
|
+
MERGE: "merge",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The defaults, which are also the schema. Frozen deeply so no caller can mutate the shared copy.
|
|
61
|
+
*/
|
|
62
|
+
export const DEFAULT_CLIENT_PROFILE_SETTINGS = deepFreeze({
|
|
63
|
+
modes: {
|
|
64
|
+
attach: MODES.OFF,
|
|
65
|
+
side_filter: MODES.OFF,
|
|
66
|
+
field_lock: MODES.OFF,
|
|
67
|
+
primary_lock: MODES.OFF,
|
|
68
|
+
mf_primary_stamp: MODES.OFF,
|
|
69
|
+
app_orders_primary: MODES.OFF,
|
|
70
|
+
goal_approval: MODES.OFF,
|
|
71
|
+
resolve_by_pan: MODES.OFF,
|
|
72
|
+
linked_visibility: MODES.OFF,
|
|
73
|
+
mf_one_investor: MODES.OFF,
|
|
74
|
+
},
|
|
75
|
+
corporate_distributor_types: [],
|
|
76
|
+
house_distributor_codes: [],
|
|
77
|
+
primary: {
|
|
78
|
+
lock_on_first_order: true,
|
|
79
|
+
ignore_order_statuses: [],
|
|
80
|
+
referral_sets_primary: true,
|
|
81
|
+
referral_types: [],
|
|
82
|
+
},
|
|
83
|
+
mf: {
|
|
84
|
+
product_codes: [],
|
|
85
|
+
exclusive_to_primary: true,
|
|
86
|
+
one_investor_per_person: true,
|
|
87
|
+
},
|
|
88
|
+
app_orders: {
|
|
89
|
+
tag_to_primary: true,
|
|
90
|
+
},
|
|
91
|
+
attach: {
|
|
92
|
+
triggers: [
|
|
93
|
+
ATTACH_TRIGGERS.ORDER,
|
|
94
|
+
ATTACH_TRIGGERS.PROPOSAL,
|
|
95
|
+
ATTACH_TRIGGERS.PFA,
|
|
96
|
+
ATTACH_TRIGGERS.GOAL,
|
|
97
|
+
ATTACH_TRIGGERS.ACTIVITY,
|
|
98
|
+
ATTACH_TRIGGERS.LEAD_CONVERSION,
|
|
99
|
+
],
|
|
100
|
+
skip_if_already_visible: true,
|
|
101
|
+
},
|
|
102
|
+
access: {
|
|
103
|
+
owner_user_fields: ["e1", "rm_id", "dist_emp_rm_id", "created_by"],
|
|
104
|
+
linked_user_field: "linked_users.user_id",
|
|
105
|
+
mapping_distributor_field: "distributor_mappings.distributor_id",
|
|
106
|
+
},
|
|
107
|
+
side_filter: {
|
|
108
|
+
distributor_fields: ["distributor_id", "ma1_id", "ma2_id"],
|
|
109
|
+
placer_fields: ["created_by", "e1"],
|
|
110
|
+
},
|
|
111
|
+
linked_view: {
|
|
112
|
+
fields: ["_id", "client_code", "client_name", "first_name", "middle_name", "last_name", "pan_card_no", "phone_list", "status"],
|
|
113
|
+
masked_fields: ["pan_card_no", "phone_list"],
|
|
114
|
+
masked_contact_keys: ["mobile", "phone", "phone_no", "number", "email"],
|
|
115
|
+
},
|
|
116
|
+
field_lock: {
|
|
117
|
+
ops_roles: [],
|
|
118
|
+
require_reason_for_ops: true,
|
|
119
|
+
unclassified_field_action: "lock",
|
|
120
|
+
lock_requires_kyc_verified: true,
|
|
121
|
+
lock_requires_ucc: true,
|
|
122
|
+
},
|
|
123
|
+
change_request: {
|
|
124
|
+
ttl_minutes: 10,
|
|
125
|
+
},
|
|
126
|
+
goals: {
|
|
127
|
+
partner_goal_requires_approval: true,
|
|
128
|
+
corporate_goals_stay_with_corporate: true,
|
|
129
|
+
},
|
|
130
|
+
events: {
|
|
131
|
+
profile_change_requested: "client.profile_change.requested",
|
|
132
|
+
profile_change_applied: "client.profile_change.applied",
|
|
133
|
+
profile_change_rejected: "client.profile_change.rejected",
|
|
134
|
+
profile_change_expired: "client.profile_change.expired",
|
|
135
|
+
goal_approval_requested: "client.goal.approval_requested",
|
|
136
|
+
goal_approved: "client.goal.approved",
|
|
137
|
+
goal_rejected: "client.goal.rejected",
|
|
138
|
+
goal_share_requested: "client.goal.share_requested",
|
|
139
|
+
goal_shared: "client.goal.shared",
|
|
140
|
+
goal_share_revoked: "client.goal.revoked",
|
|
141
|
+
relationship_attached: "client.relationship.attached",
|
|
142
|
+
},
|
|
143
|
+
event_subject: {
|
|
144
|
+
// The Events Framework entity type a client event is filed under. It decides what the
|
|
145
|
+
// engine loads as `client` and which related parties (distributor, RM) and derived fields
|
|
146
|
+
// (the app user for nudges) a template can use. `client` there means a LEAD — not this.
|
|
147
|
+
client_entity_type: "distributorclient",
|
|
148
|
+
},
|
|
149
|
+
messages: {
|
|
150
|
+
[CLIENT_PROFILE_ERRORS.PERSONAL_FIELD_LOCKED]: "These details are updated by the client in the InvestValue app: {fields}.",
|
|
151
|
+
[CLIENT_PROFILE_ERRORS.CONFIRMATION_REQUIRED]: "{count} change(s) were sent to the client for confirmation.",
|
|
152
|
+
[CLIENT_PROFILE_ERRORS.MF_INVESTOR_EXISTS]: "This client already has a mutual fund account. Place orders on it.",
|
|
153
|
+
[CLIENT_PROFILE_ERRORS.CLIENT_NOT_IN_SCOPE]: "You can't act on this client.",
|
|
154
|
+
[CLIENT_PROFILE_ERRORS.PROFILE_TYPE_EXISTS]: "This person already has a {profile_type} profile. Use the existing one.",
|
|
155
|
+
[CLIENT_PROFILE_ERRORS.EXISTING_CLIENT_FOUND]: "This client already exists. You can place an order, create a proposal or run a portfolio analysis for them.",
|
|
156
|
+
[CLIENT_PROFILE_ERRORS.SETTINGS_INVALID]: "Client-profile settings were invalid; defaults were used for: {keys}.",
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* One line per setting, for documentation and the tracker's configuration registry. Kept next to
|
|
162
|
+
* the defaults so a new setting cannot ship undescribed — `describeClientProfileSettings` throws
|
|
163
|
+
* in tests when a leaf has no description.
|
|
164
|
+
*/
|
|
165
|
+
export const CLIENT_PROFILE_SETTING_DESCRIPTIONS = Object.freeze({
|
|
166
|
+
"modes.attach": "Record a distributor or employee on a client at their first saved action.",
|
|
167
|
+
"modes.side_filter": "Narrow client-keyed reads to the caller's own side.",
|
|
168
|
+
"modes.field_lock": "Enforce the personal-details field policy.",
|
|
169
|
+
"modes.primary_lock": "Lock the primary distributor on the first order.",
|
|
170
|
+
"modes.mf_primary_stamp": "Tag every MF order to the primary distributor.",
|
|
171
|
+
"modes.app_orders_primary": "Tag every client-app order to the primary distributor, without asking.",
|
|
172
|
+
"modes.goal_approval": "Hold partner-added goals until the client approves them.",
|
|
173
|
+
"modes.resolve_by_pan": "Show an existing client instead of a duplicate error when a PAN is found.",
|
|
174
|
+
"modes.mf_one_investor": "Refuse a second MF investor for a person who already has one (D9).",
|
|
175
|
+
"modes.linked_visibility": "Clients reached only through a secondary mapping or a linked user appear in lists, as the limited card.",
|
|
176
|
+
corporate_distributor_types: "distributors.distributor_type values whose clients get a corporate profile.",
|
|
177
|
+
house_distributor_codes: "Distributor codes treated as direct (InvestValue's own house distributors).",
|
|
178
|
+
"primary.lock_on_first_order": "The first non-ignored order locks the primary distributor.",
|
|
179
|
+
"primary.ignore_order_statuses": "Order statuses that do not count as first business (e.g. cancelled).",
|
|
180
|
+
"primary.referral_sets_primary": "A lead referred by a distributor makes that distributor primary at conversion.",
|
|
181
|
+
"primary.referral_types": "clientleads.referred_by_type values that count as a distributor referral.",
|
|
182
|
+
"mf.product_codes": "Product codes treated as mutual funds for exclusivity.",
|
|
183
|
+
"mf.exclusive_to_primary": "MF orders are always tagged to the primary distributor.",
|
|
184
|
+
"mf.one_investor_per_person": "Never create a second MF investor for a person.",
|
|
185
|
+
"app_orders.tag_to_primary": "Client-app orders land on the main profile's primary.",
|
|
186
|
+
"attach.triggers": "Which saved actions link the actor to the client.",
|
|
187
|
+
"attach.skip_if_already_visible": "Write no link when the actor can already see the client.",
|
|
188
|
+
"access.owner_user_fields": "Client fields naming an owning person; matching one gives full access.",
|
|
189
|
+
"access.linked_user_field": "Client field holding linked users (the linked-visibility arm).",
|
|
190
|
+
"access.mapping_distributor_field": "Client field holding mapped distributors (the secondary-distributor arm).",
|
|
191
|
+
"side_filter.distributor_fields": "Record fields that name a record's distributor side.",
|
|
192
|
+
"side_filter.placer_fields": "Record fields that name the person who created a record.",
|
|
193
|
+
"linked_view.fields": "Client fields returned to a caller with linked access.",
|
|
194
|
+
"linked_view.masked_fields": "Linked-view fields that are masked before returning.",
|
|
195
|
+
"linked_view.masked_contact_keys": "Keys masked inside contact entries of a masked list field.",
|
|
196
|
+
"field_lock.ops_roles": "Roles allowed to correct personal details (audited).",
|
|
197
|
+
"field_lock.require_reason_for_ops": "Ops corrections must carry a reason.",
|
|
198
|
+
"field_lock.unclassified_field_action": "What happens to a field with no policy row (edit, verify or lock).",
|
|
199
|
+
"field_lock.lock_requires_kyc_verified": "The personal lock starts only once KYC is verified.",
|
|
200
|
+
"field_lock.lock_requires_ucc": "The personal lock starts only once a UCC exists.",
|
|
201
|
+
"change_request.ttl_minutes": "Minutes a corporate change request waits for the client's OTP.",
|
|
202
|
+
"goals.partner_goal_requires_approval": "Goals added by partners or employees wait for the client's approval.",
|
|
203
|
+
"goals.corporate_goals_stay_with_corporate": "Corporate-profile goals are never shown to main-profile partners.",
|
|
204
|
+
"events.profile_change_requested": "Event name: a corporate change was sent to the client.",
|
|
205
|
+
"events.profile_change_applied": "Event name: the client confirmed a change.",
|
|
206
|
+
"events.profile_change_rejected": "Event name: the client rejected a change.",
|
|
207
|
+
"events.profile_change_expired": "Event name: a change request expired.",
|
|
208
|
+
"events.goal_approval_requested": "Event name: a goal waits for the client's approval.",
|
|
209
|
+
"events.goal_approved": "Event name: the client approved a goal.",
|
|
210
|
+
"events.goal_rejected": "Event name: the client rejected a goal.",
|
|
211
|
+
"events.goal_share_requested": "Event name: a partner asked to see a goal.",
|
|
212
|
+
"events.goal_shared": "Event name: the client shared a goal with a partner.",
|
|
213
|
+
"events.goal_share_revoked": "Event name: the client stopped sharing a goal.",
|
|
214
|
+
"events.relationship_attached": "Event name: a new distributor or employee was linked to a client.",
|
|
215
|
+
"event_subject.client_entity_type": "Events Framework entity type client events are filed under (distributorclients).",
|
|
216
|
+
messages: "Human text for each error code; {placeholders} are filled by the caller.",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/** The allowed values for leaves that are enums rather than free strings. */
|
|
220
|
+
const ENUM_LEAVES = Object.freeze({
|
|
221
|
+
"field_lock.unclassified_field_action": ["edit", "verify", "lock"],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Merge an environment's settings document over the defaults, validating as it goes.
|
|
226
|
+
*
|
|
227
|
+
* @param {object|null|undefined} doc the `clientprofilesettings` document (its `_id` and audit
|
|
228
|
+
* fields are ignored)
|
|
229
|
+
* @returns {{ settings: object, problems: string[] }} `settings` is deeply frozen
|
|
230
|
+
*/
|
|
231
|
+
export function resolveClientProfileSettings(doc) {
|
|
232
|
+
const problems = [];
|
|
233
|
+
const source = isPlainObject(doc) ? stripMeta(doc) : {};
|
|
234
|
+
const settings = mergeLevel(DEFAULT_CLIENT_PROFILE_SETTINGS, source, "", problems);
|
|
235
|
+
return { settings: deepFreeze(settings), problems };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Read a mode with a safe answer for anything unexpected (`off`). */
|
|
239
|
+
export function modeOf(settings, name) {
|
|
240
|
+
const value = settings?.modes?.[name];
|
|
241
|
+
return MODE_VALUES.includes(value) ? value : MODES.OFF;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** True when the mode means "compute the decision" (audit or enforce). */
|
|
245
|
+
export function isEvaluated(settings, name) {
|
|
246
|
+
return modeOf(settings, name) !== MODES.OFF;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** True only when the decision should be applied. */
|
|
250
|
+
export function isEnforced(settings, name) {
|
|
251
|
+
return modeOf(settings, name) === MODES.ENFORCE;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Fill a message template from settings. Unknown placeholders are left as-is so a missing value
|
|
256
|
+
* is visible rather than silently blank.
|
|
257
|
+
*/
|
|
258
|
+
export function messageFor(settings, code, values = {}) {
|
|
259
|
+
const template = settings?.messages?.[code] ?? DEFAULT_CLIENT_PROFILE_SETTINGS.messages[code] ?? code;
|
|
260
|
+
return String(template).replace(/\{(\w+)\}/g, (whole, key) => (key in values ? String(values[key]) : whole));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Flatten the defaults into registry rows: `{ key, default, description }`. Every leaf must be
|
|
265
|
+
* described; an undescribed leaf throws so it is caught in tests, not in production docs.
|
|
266
|
+
*/
|
|
267
|
+
/**
|
|
268
|
+
* The Events Framework subject for an event about a client. One place, so every emitter files
|
|
269
|
+
* client events under the same entity type.
|
|
270
|
+
*/
|
|
271
|
+
export function clientEventSubject(settings, clientId, extra = {}) {
|
|
272
|
+
return { entityType: settings.event_subject.client_entity_type, entityId: String(clientId), ...extra };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function describeClientProfileSettings() {
|
|
276
|
+
const rows = [];
|
|
277
|
+
walkLeaves(DEFAULT_CLIENT_PROFILE_SETTINGS, "", (key, value) => {
|
|
278
|
+
const describedKey = key.startsWith("messages.") ? "messages" : key;
|
|
279
|
+
if (describedKey === "messages" && rows.some((r) => r.key === "messages")) return;
|
|
280
|
+
const description = CLIENT_PROFILE_SETTING_DESCRIPTIONS[describedKey];
|
|
281
|
+
if (!description) throw new Error(`client-profile setting "${key}" has no description`);
|
|
282
|
+
rows.push({
|
|
283
|
+
key: describedKey,
|
|
284
|
+
default: describedKey === "messages" ? DEFAULT_CLIENT_PROFILE_SETTINGS.messages : value,
|
|
285
|
+
description,
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
return rows;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── internals ─────────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
const META_KEYS = new Set(["_id", "created_at", "updated_at", "created_by", "updated_by", "__v"]);
|
|
294
|
+
|
|
295
|
+
function stripMeta(doc) {
|
|
296
|
+
const out = {};
|
|
297
|
+
for (const [k, v] of Object.entries(doc)) if (!META_KEYS.has(k)) out[k] = v;
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function mergeLevel(defaults, source, prefix, problems) {
|
|
302
|
+
const out = {};
|
|
303
|
+
for (const key of Object.keys(source)) {
|
|
304
|
+
if (!(key in defaults) && prefix !== "messages.") problems.push(`${prefix}${key}: unknown setting, ignored`);
|
|
305
|
+
}
|
|
306
|
+
for (const [key, def] of Object.entries(defaults)) {
|
|
307
|
+
const path = `${prefix}${key}`;
|
|
308
|
+
const has = Object.prototype.hasOwnProperty.call(source, key);
|
|
309
|
+
const value = has ? source[key] : undefined;
|
|
310
|
+
if (isPlainObject(def)) {
|
|
311
|
+
if (has && !isPlainObject(value)) {
|
|
312
|
+
problems.push(`${path}: expected an object, kept the default`);
|
|
313
|
+
out[key] = clone(def);
|
|
314
|
+
} else {
|
|
315
|
+
out[key] = mergeLevel(def, has ? value : {}, `${path}.`, problems);
|
|
316
|
+
}
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (!has) {
|
|
320
|
+
out[key] = clone(def);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (acceptsValue(path, def, value)) out[key] = clone(value);
|
|
324
|
+
else {
|
|
325
|
+
problems.push(`${path}: ${describeExpected(path, def)}, kept the default`);
|
|
326
|
+
out[key] = clone(def);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Messages may carry codes the defaults do not know yet (a newer backend); keep them.
|
|
330
|
+
if (prefix === "messages.") {
|
|
331
|
+
for (const [key, value] of Object.entries(source)) {
|
|
332
|
+
if (!(key in out) && typeof value === "string") out[key] = value;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function acceptsValue(path, def, value) {
|
|
339
|
+
if (path.startsWith("modes.")) return MODE_VALUES.includes(value);
|
|
340
|
+
if (ENUM_LEAVES[path]) return ENUM_LEAVES[path].includes(value);
|
|
341
|
+
if (Array.isArray(def)) return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
342
|
+
if (typeof def === "boolean") return typeof value === "boolean";
|
|
343
|
+
if (typeof def === "number") return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
344
|
+
if (typeof def === "string") return typeof value === "string" && value.length > 0;
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function describeExpected(path, def) {
|
|
349
|
+
if (path.startsWith("modes.")) return `expected one of ${MODE_VALUES.join("|")}`;
|
|
350
|
+
if (ENUM_LEAVES[path]) return `expected one of ${ENUM_LEAVES[path].join("|")}`;
|
|
351
|
+
if (Array.isArray(def)) return "expected a list of strings";
|
|
352
|
+
if (typeof def === "number") return "expected a non-negative number";
|
|
353
|
+
return `expected a ${typeof def}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function walkLeaves(obj, prefix, visit) {
|
|
357
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
358
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
359
|
+
if (isPlainObject(value)) walkLeaves(value, path, visit);
|
|
360
|
+
else visit(path, value);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function isPlainObject(v) {
|
|
365
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function clone(v) {
|
|
369
|
+
if (Array.isArray(v)) return v.map(clone);
|
|
370
|
+
if (isPlainObject(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, clone(x)]));
|
|
371
|
+
return v;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function deepFreeze(obj) {
|
|
375
|
+
for (const value of Object.values(obj)) if (value && typeof value === "object") deepFreeze(value);
|
|
376
|
+
return Object.freeze(obj);
|
|
377
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The side filter — what a caller may see of records filed on a client they can see.
|
|
3
|
+
*
|
|
4
|
+
* A client can now carry business from several sides. Every read keyed by `client_id` (orders,
|
|
5
|
+
* proposals, PFA uploads, goals, activities, MF batches) is narrowed with this filter, ANDed next
|
|
6
|
+
* to the `client_id` match. It only ever narrows: the tenant and record scopes still apply.
|
|
7
|
+
*
|
|
8
|
+
* client (app) → everything ({})
|
|
9
|
+
* full internal / ops → everything ({})
|
|
10
|
+
* partner, full or linked → their firm's side (distributor fields ∈ firm)
|
|
11
|
+
* linked internal employee → what they placed (placer fields ∈ self + reportees)
|
|
12
|
+
* linked partner staff → firm side AND what they placed
|
|
13
|
+
* none → nothing (an impossible match)
|
|
14
|
+
*
|
|
15
|
+
* The field names come from settings (`side_filter.*`) because records name their distributor
|
|
16
|
+
* and creator differently across collections; the backend passes per-collection overrides when a
|
|
17
|
+
* collection differs (e.g. proposals use `distributorId` / `createdBy`).
|
|
18
|
+
*
|
|
19
|
+
* Returns a plain Mongo filter fragment. Ids are passed through untouched — the backend decides
|
|
20
|
+
* whether its collection stores ObjectIds or strings and passes values of that type.
|
|
21
|
+
*/
|
|
22
|
+
import { ACCESS_LEVELS, CALLER_CLASSES } from "./access.js";
|
|
23
|
+
import { isEnforced, isEvaluated } from "./settings.js";
|
|
24
|
+
|
|
25
|
+
/** A filter that matches nothing, for callers with no access. Stable so it can be asserted on. */
|
|
26
|
+
export const MATCH_NOTHING = Object.freeze({ _id: { $exists: false } });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ callerClass: string, accessLevel: string, firmDistributorIds?: Array,
|
|
30
|
+
* userIds?: Array, fields?: { distributor?: string[], placer?: string[] },
|
|
31
|
+
* settings: object }} input
|
|
32
|
+
* @returns {{ filter: object, applied: boolean, decided: object }}
|
|
33
|
+
* `decided` is what enforce would apply; `filter` is what to apply now (`{}` unless enforced).
|
|
34
|
+
*/
|
|
35
|
+
export function buildSideFilter({ callerClass, accessLevel, firmDistributorIds = [], userIds = [], fields = {}, settings }) {
|
|
36
|
+
const decided = decideSideFilter({ callerClass, accessLevel, firmDistributorIds, userIds, fields, settings });
|
|
37
|
+
if (!isEvaluated(settings, "side_filter")) return { filter: {}, applied: false, decided: {} };
|
|
38
|
+
const enforce = isEnforced(settings, "side_filter");
|
|
39
|
+
return { filter: enforce ? decided : {}, applied: enforce, decided };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function decideSideFilter({ callerClass, accessLevel, firmDistributorIds, userIds, fields, settings }) {
|
|
43
|
+
if (callerClass === CALLER_CLASSES.CLIENT) return {};
|
|
44
|
+
if (accessLevel === ACCESS_LEVELS.NONE) return MATCH_NOTHING;
|
|
45
|
+
|
|
46
|
+
const distributorFields = fields.distributor ?? settings?.side_filter?.distributor_fields ?? [];
|
|
47
|
+
const placerFields = fields.placer ?? settings?.side_filter?.placer_fields ?? [];
|
|
48
|
+
|
|
49
|
+
if (callerClass === CALLER_CLASSES.PARTNER) {
|
|
50
|
+
const firm = anyOf(distributorFields, firmDistributorIds);
|
|
51
|
+
if (accessLevel === ACCESS_LEVELS.LINKED) return and(firm, anyOf(placerFields, userIds));
|
|
52
|
+
return firm;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Internal callers (ops, employees).
|
|
56
|
+
if (accessLevel === ACCESS_LEVELS.FULL) return {};
|
|
57
|
+
return anyOf(placerFields, userIds);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function anyOf(fieldNames, values) {
|
|
61
|
+
if (!fieldNames.length || !values.length) return MATCH_NOTHING;
|
|
62
|
+
const arms = fieldNames.map((f) => ({ [f]: { $in: [...values] } }));
|
|
63
|
+
return arms.length === 1 ? arms[0] : { $or: arms };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function and(a, b) {
|
|
67
|
+
if (a === MATCH_NOTHING || b === MATCH_NOTHING) return MATCH_NOTHING;
|
|
68
|
+
return { $and: [a, b] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Evaluate a side filter against an already-fetched record — for reads that filter in memory or
|
|
73
|
+
* aggregate in ways a query fragment cannot be spliced into. Supports exactly the shapes
|
|
74
|
+
* `buildSideFilter` produces: `{}`, MATCH_NOTHING, `$or`, `$and`, and `{ field: { $in: [...] } }`
|
|
75
|
+
* (dotted paths; array values match when any element matches). Ids compare by string.
|
|
76
|
+
*/
|
|
77
|
+
export function recordMatchesFilter(record, filter) {
|
|
78
|
+
if (!filter || Object.keys(filter).length === 0) return true;
|
|
79
|
+
if (filter === MATCH_NOTHING || (filter._id && filter._id.$exists === false)) return false;
|
|
80
|
+
return Object.entries(filter).every(([key, cond]) => {
|
|
81
|
+
if (key === "$or") return cond.some((f) => recordMatchesFilter(record, f));
|
|
82
|
+
if (key === "$and") return cond.every((f) => recordMatchesFilter(record, f));
|
|
83
|
+
const wanted = new Set((cond?.$in ?? []).map(String));
|
|
84
|
+
const value = valueAt(record, key);
|
|
85
|
+
const values = Array.isArray(value) ? value : [value];
|
|
86
|
+
return values.some((v) => v !== null && v !== undefined && wanted.has(String(v?._id ?? v)));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function valueAt(record, path) {
|
|
91
|
+
return path.split(".").reduce((acc, part) => {
|
|
92
|
+
if (acc === null || acc === undefined) return undefined;
|
|
93
|
+
if (Array.isArray(acc)) return acc.map((x) => x?.[part]);
|
|
94
|
+
return acc[part];
|
|
95
|
+
}, record);
|
|
96
|
+
}
|