@i4e/invest4edu-access-core 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -115,6 +115,16 @@ Model.find(q).setOptions({ skipTenant: true }); // one query
115
115
  - `MOBILE_LOGIN_SOURCES`, `CLIENT_APP_LOGIN_SOURCES`, `PARTNER_APP_HATS`,
116
116
  `APP_BUNDLE_ID_PATTERN`, `appBundleIdOf` (`/mobile-app-identity`) — which mobile app a login
117
117
  came from, which hats it flags, and the bundle id its session keeps
118
+ - `clientProfile` namespace (`/client-profile`) — the two-profile client model. Settings
119
+ (`resolveClientProfileSettings`, `MODES`, collection names), sides and tagging (`sideKeyOf`,
120
+ `profileTypeFor`, `orderSideFor`, `primaryLockFor`, `primaryFromLead`), access level
121
+ (`clientAccessFor`, `callerClassOf`, `linkedViewOf`), the side filter (`buildSideFilter`) and
122
+ the personal-details field policy (`buildFieldPolicyIndex`, `evaluateFieldChanges`,
123
+ `effectivePolicyFor`, `changedFields`). Every behaviour is read from a `clientprofilesettings`
124
+ document and each has an `off | audit | enforce` mode, defaulting to `off`. Business
125
+ identifiers (distributor codes, role names, product codes) have EMPTY defaults and come from
126
+ each environment's settings document. Design and tracker:
127
+ https://claude.ai/artifact/JrCTutgQkp32r3NeU2uvmk
118
128
 
119
129
  ## Not covered
120
130
  **Aggregation pipelines** — add an explicit `{ $match: { account_id } }` stage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.37.0",
3
+ "version": "0.39.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": {
@@ -22,6 +22,7 @@
22
22
  "./route-features": "./src/route-features.js",
23
23
  "./route-screen": "./src/route-screen.js",
24
24
  "./report-audience": "./src/report-audience.js",
25
+ "./client-profile": "./src/client-profile/index.js",
25
26
  "./mobile-app-identity": "./src/mobile-app-identity.js",
26
27
  "./subscription-lifecycle": "./src/subscription-lifecycle.js",
27
28
  "./entitlement-store": "./src/entitlement-store.js",
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Access level on a client — full, linked or none — and who the caller is.
3
+ *
4
+ * ── The rule ─────────────────────────────────────────────────────────────────────────────────
5
+ * A caller's view of a client depends on HOW they reach it, not only whether they can:
6
+ *
7
+ * full — they own it: the client's E1, RM, partner RM or creator is them or a reportee; its
8
+ * distributor is one whose RM they are (the source arm); they hold whole-book access;
9
+ * or their firm is the client's PRIMARY distributor.
10
+ * linked — they reach it only because they did business on it: a secondary distributor
11
+ * mapping for their firm, or a `linked_users` entry for them or a reportee. They get
12
+ * the limited card and their own records.
13
+ * none — neither.
14
+ *
15
+ * Full always wins over linked, so an owner who also transacted is never downgraded.
16
+ *
17
+ * Pure: the caller's visibility sets (user ids incl. reportees, firm distributor ids, source
18
+ * distributor ids) are computed by the backend's existing visibility code and passed in.
19
+ */
20
+ import { primaryMappingOf, sideKeyOf } from "./sides.js";
21
+
22
+ export const ACCESS_LEVELS = Object.freeze({ FULL: "full", LINKED: "linked", NONE: "none" });
23
+
24
+ export const CALLER_CLASSES = Object.freeze({
25
+ CLIENT: "client",
26
+ PARTNER: "partner",
27
+ OPS: "ops",
28
+ EMPLOYEE: "employee",
29
+ });
30
+
31
+ /** The reasons a caller can reach a client. Owner reasons give full access; link reasons linked. */
32
+ export const MATCH_REASONS = Object.freeze({
33
+ OWNER_USER: "owner_user",
34
+ SOURCE_DISTRIBUTOR: "source_distributor",
35
+ PRIMARY_DISTRIBUTOR: "primary_distributor",
36
+ FULL_RECORD_ACCESS: "full_record_access",
37
+ SECONDARY_DISTRIBUTOR: "secondary_distributor",
38
+ LINKED_USER: "linked_user",
39
+ });
40
+
41
+ const OWNER_REASONS = new Set([
42
+ MATCH_REASONS.OWNER_USER,
43
+ MATCH_REASONS.SOURCE_DISTRIBUTOR,
44
+ MATCH_REASONS.PRIMARY_DISTRIBUTOR,
45
+ MATCH_REASONS.FULL_RECORD_ACCESS,
46
+ ]);
47
+
48
+ /**
49
+ * Who is calling.
50
+ *
51
+ * @param {{ roles?: string[], distributorId?: *, isClientApp?: boolean }} session
52
+ * @param {object} settings resolved client-profile settings (for `field_lock.ops_roles`)
53
+ */
54
+ export function callerClassOf({ roles = [], distributorId = null, isClientApp = false } = {}, settings) {
55
+ if (isClientApp) return CALLER_CLASSES.CLIENT;
56
+ if (distributorId) return CALLER_CLASSES.PARTNER;
57
+ const opsRoles = settings?.field_lock?.ops_roles ?? [];
58
+ if (roles.some((r) => opsRoles.includes(r))) return CALLER_CLASSES.OPS;
59
+ return CALLER_CLASSES.EMPLOYEE;
60
+ }
61
+
62
+ /**
63
+ * Every reason the caller reaches this client.
64
+ *
65
+ * @param {object} client a distributorclients document (needs the owner fields, distributor_id,
66
+ * distributor_mappings, linked_users)
67
+ * @param {{ userIds?: Iterable, firmDistributorIds?: Iterable, sourceDistributorIds?: Iterable,
68
+ * fullRecordAccess?: boolean, houseDistributorIds?: Iterable }} caller
69
+ * @param {object} settings resolved settings (`access.owner_user_fields`)
70
+ * @returns {string[]} MATCH_REASONS values
71
+ */
72
+ export function matchReasonsFor(client, caller = {}, settings) {
73
+ const reasons = [];
74
+ if (!client) return reasons;
75
+ const users = toKeySet(caller.userIds);
76
+ const firm = toKeySet(caller.firmDistributorIds);
77
+ const source = toKeySet(caller.sourceDistributorIds);
78
+ const ctx = { houseDistributorIds: caller.houseDistributorIds };
79
+
80
+ if (caller.fullRecordAccess) reasons.push(MATCH_REASONS.FULL_RECORD_ACCESS);
81
+ const ownerFields = settings?.access?.owner_user_fields ?? [];
82
+ if (ownerFields.some((f) => client[f] && users.has(String(client[f])))) reasons.push(MATCH_REASONS.OWNER_USER);
83
+
84
+ const primary = primaryMappingOf(client);
85
+ if (primary.distributor_id && source.has(String(primary.distributor_id))) reasons.push(MATCH_REASONS.SOURCE_DISTRIBUTOR);
86
+ if (primary.distributor_id && firm.has(String(primary.distributor_id))) reasons.push(MATCH_REASONS.PRIMARY_DISTRIBUTOR);
87
+
88
+ const primaryKey = sideKeyOf(primary.distributor_id, ctx);
89
+ for (const m of client.distributor_mappings ?? []) {
90
+ if (!m || m.is_primary || m.status === "inactive" || !m.distributor_id) continue;
91
+ if (sideKeyOf(m.distributor_id, ctx) === primaryKey) continue;
92
+ if (firm.has(String(m.distributor_id))) {
93
+ reasons.push(MATCH_REASONS.SECONDARY_DISTRIBUTOR);
94
+ break;
95
+ }
96
+ }
97
+ if ((client.linked_users ?? []).some((l) => l && users.has(String(l.user_id)))) reasons.push(MATCH_REASONS.LINKED_USER);
98
+ return [...new Set(reasons)];
99
+ }
100
+
101
+ /** Collapse match reasons to an access level. */
102
+ export function accessLevelFor(reasons = []) {
103
+ if (reasons.some((r) => OWNER_REASONS.has(r))) return ACCESS_LEVELS.FULL;
104
+ if (reasons.length) return ACCESS_LEVELS.LINKED;
105
+ return ACCESS_LEVELS.NONE;
106
+ }
107
+
108
+ /** Convenience: reasons and level in one call. */
109
+ export function clientAccessFor(client, caller, settings) {
110
+ const reasons = matchReasonsFor(client, caller, settings);
111
+ return { level: accessLevelFor(reasons), reasons };
112
+ }
113
+
114
+ /**
115
+ * Reduce a client document to what a linked caller may see, masking the configured fields.
116
+ * Arrays of contacts keep only a masked number per entry.
117
+ */
118
+ export function linkedViewOf(client, settings) {
119
+ const fields = settings?.linked_view?.fields ?? [];
120
+ const masked = new Set(settings?.linked_view?.masked_fields ?? []);
121
+ const out = {};
122
+ for (const f of fields) {
123
+ if (!(f in (client ?? {}))) continue;
124
+ out[f] = masked.has(f) ? maskValue(client[f], settings?.linked_view?.masked_contact_keys) : client[f];
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /** Keep the last 4 characters of a value, or of each listed key inside contact entries. */
130
+ export function maskValue(value, contactKeys = []) {
131
+ if (value === null || value === undefined) return value;
132
+ if (Array.isArray(value)) {
133
+ return value.map((entry) => {
134
+ if (entry && typeof entry === "object") {
135
+ const copy = { ...entry };
136
+ for (const k of contactKeys) if (k in copy) copy[k] = maskValue(copy[k]);
137
+ return copy;
138
+ }
139
+ return maskValue(entry, contactKeys);
140
+ });
141
+ }
142
+ const s = String(value);
143
+ if (s.length <= 4) return "•".repeat(s.length);
144
+ return "•".repeat(s.length - 4) + s.slice(-4);
145
+ }
146
+
147
+ function toKeySet(values) {
148
+ return new Set([...(values ?? [])].filter((v) => v !== null && v !== undefined).map(String));
149
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Mongoose plugin factory: record the person who created a document on the client it names.
3
+ *
4
+ * Both backends add it to the models whose documents are "business done on a client" (v1 orders,
5
+ * v2 goals, …). The backend injects its own `attach` (its profile service's `attachUser`), so the
6
+ * package still imports no driver and no models.
7
+ *
8
+ * schema.plugin(createAttachPlugin({ attach }), { via: ATTACH_TRIGGERS.ORDER })
9
+ *
10
+ * After a NEW document is saved — or inserted in bulk — `attach` runs with the document's client
11
+ * and creator, in the save's session (a rolled-back transaction rolls the attach back too). It
12
+ * never throws: an attach failure must not fail the save that triggered it.
13
+ *
14
+ * @param {{ attach: (args: { clientId, userId, via, stamps, session, doc }) => Promise<unknown> }} deps
15
+ * `doc` is the saved document, for backends that also act on it (v1 locks the primary on the
16
+ * first order).
17
+ */
18
+ export function createAttachPlugin({ attach }) {
19
+ if (typeof attach !== "function") throw new Error("createAttachPlugin: `attach` is required");
20
+
21
+ return function clientProfileAttachPlugin(schema, { via, clientField = "client_id", creatorField = "created_by" } = {}) {
22
+ if (!via) throw new Error("clientProfileAttachPlugin: `via` is required");
23
+
24
+ const run = async (doc, session) => {
25
+ const clientId = doc?.[clientField];
26
+ const userId = doc?.[creatorField];
27
+ if (!clientId || !userId) return;
28
+ try {
29
+ await attach({
30
+ clientId,
31
+ userId,
32
+ via,
33
+ stamps: { rmId: doc.rm_id ?? null, distEmpRmId: doc.dist_emp_rm_id ?? null, departmentId: doc.department_id ?? null },
34
+ session,
35
+ doc,
36
+ });
37
+ } catch {
38
+ // never fail the save
39
+ }
40
+ };
41
+
42
+ schema.pre("save", function markNew() {
43
+ this.$locals.clientProfileWasNew = this.isNew;
44
+ });
45
+ schema.post("save", async (doc) => {
46
+ if (!doc.$locals?.clientProfileWasNew) return;
47
+ await run(doc, doc.$session?.() ?? undefined);
48
+ });
49
+ schema.post("insertMany", async (docs) => {
50
+ for (const doc of docs ?? []) await run(doc, undefined); // eslint-disable-line no-await-in-loop
51
+ });
52
+ };
53
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Corporate change requests (D5) — a partner's edit to a corporate client's personal details is
3
+ * HELD until the client confirms it with a one-time code sent to their stored mobile.
4
+ *
5
+ * Storage: `client_profile_change_requests`, one row per request:
6
+ * { client_id, requested_by, distributor_id, source, payload, fields, status, otp_hash,
7
+ * otp_salt, attempts, expires_at, created_at, decided_at, decided_by }
8
+ * status: pending → applied | rejected | expired
9
+ *
10
+ * The request stores the ORIGINAL write (`source` names the backend endpoint, `payload` its
11
+ * body). On confirmation the backend replays that write through its own handler, so validation,
12
+ * FundCore sync and audit behave exactly as for a direct edit. This service never writes the
13
+ * client itself.
14
+ *
15
+ * Messages go out as events (`settings.events.*`) through the injected `emit` — the OTP is in the
16
+ * requested event's payload for the Events Framework to deliver; it is never logged or returned.
17
+ */
18
+ import crypto from "crypto";
19
+ import { CLIENT_PROFILE_COLLECTIONS, clientEventSubject } from "./settings.js";
20
+
21
+ export const CHANGE_REQUEST_STATUS = Object.freeze({
22
+ PENDING: "pending",
23
+ APPLIED: "applied",
24
+ REJECTED: "rejected",
25
+ EXPIRED: "expired",
26
+ });
27
+
28
+ /** Why a confirmation did not go through. */
29
+ export const CHANGE_REQUEST_FAILURES = Object.freeze({
30
+ NOT_FOUND: "not_found",
31
+ NOT_PENDING: "not_pending",
32
+ EXPIRED: "expired",
33
+ WRONG_CODE: "wrong_code",
34
+ TOO_MANY_ATTEMPTS: "too_many_attempts",
35
+ });
36
+
37
+ const hashOtp = (otp, salt) => crypto.createHash("sha256").update(`${salt}:${otp}`).digest("hex");
38
+ const defaultOtp = () => String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
39
+
40
+ /**
41
+ * @param {{ getDb: Function, getSettings: Function, emit?: Function, toId?: Function,
42
+ * now?: () => Date, generateOtp?: () => string, maxAttempts?: number }} deps
43
+ */
44
+ export function createChangeRequestService({ getDb, getSettings, emit = async () => ({}), toId = (v) => v, now = () => new Date(), generateOtp = defaultOtp, maxAttempts = 5 }) {
45
+ const coll = () => getDb().collection(CLIENT_PROFILE_COLLECTIONS.CHANGE_REQUESTS);
46
+
47
+ /** Who hears about an outcome: the partner (and firm) that asked. Ids only, as strings. */
48
+ const outcomeParties = (request) => ({
49
+ requested_by: request?.requested_by ? String(request.requested_by) : null,
50
+ distributor_id: request?.distributor_id ? String(request.distributor_id) : null,
51
+ });
52
+
53
+ /**
54
+ * Hold a write for the client's confirmation and send them the code.
55
+ * @returns {Promise<{ requestId: *, expiresAt: Date }>}
56
+ */
57
+ async function create({ clientId, requestedBy, distributorId = null, source, payload, fields = [], mobile = null }) {
58
+ const settings = getSettings();
59
+ const otp = generateOtp();
60
+ const salt = crypto.randomBytes(8).toString("hex");
61
+ const created = now();
62
+ const expiresAt = new Date(created.getTime() + settings.change_request.ttl_minutes * 60 * 1000);
63
+ const row = {
64
+ client_id: toId(clientId),
65
+ requested_by: toId(requestedBy),
66
+ distributor_id: distributorId ? toId(distributorId) : null,
67
+ source,
68
+ payload,
69
+ fields,
70
+ status: CHANGE_REQUEST_STATUS.PENDING,
71
+ otp_hash: hashOtp(otp, salt),
72
+ otp_salt: salt,
73
+ attempts: 0,
74
+ expires_at: expiresAt,
75
+ created_at: created,
76
+ };
77
+ const { insertedId } = await coll().insertOne(row);
78
+ await emit(settings.events.profile_change_requested, {
79
+ change_request_id: String(insertedId),
80
+ client_id: String(clientId),
81
+ fields,
82
+ otp,
83
+ expires_at: expiresAt.toISOString(),
84
+ valid_minutes: String(settings.change_request.ttl_minutes),
85
+ }, clientEventSubject(settings, clientId, { mobile }));
86
+ return { requestId: insertedId, expiresAt };
87
+ }
88
+
89
+ /**
90
+ * Check the code. On success the request is marked applied and returned so the backend can
91
+ * replay it; the backend reports a replay failure through `markFailed`.
92
+ * @returns {Promise<{ ok: true, request: object } | { ok: false, reason: string }>}
93
+ */
94
+ async function confirm({ requestId, otp, decidedBy = null }) {
95
+ const settings = getSettings();
96
+ const id = toId(requestId);
97
+ const request = await coll().findOne({ _id: id });
98
+ if (!request) return { ok: false, reason: CHANGE_REQUEST_FAILURES.NOT_FOUND };
99
+ if (request.status !== CHANGE_REQUEST_STATUS.PENDING) return { ok: false, reason: CHANGE_REQUEST_FAILURES.NOT_PENDING };
100
+ if (now() > new Date(request.expires_at)) {
101
+ await coll().updateOne({ _id: id, status: CHANGE_REQUEST_STATUS.PENDING }, { $set: { status: CHANGE_REQUEST_STATUS.EXPIRED, decided_at: now() } });
102
+ await emit(settings.events.profile_change_expired, { change_request_id: String(id), client_id: String(request.client_id), ...outcomeParties(request) }, clientEventSubject(settings, request.client_id));
103
+ return { ok: false, reason: CHANGE_REQUEST_FAILURES.EXPIRED };
104
+ }
105
+ if (request.attempts >= maxAttempts) return { ok: false, reason: CHANGE_REQUEST_FAILURES.TOO_MANY_ATTEMPTS };
106
+ if (hashOtp(String(otp ?? ""), request.otp_salt) !== request.otp_hash) {
107
+ await coll().updateOne({ _id: id }, { $inc: { attempts: 1 } });
108
+ return { ok: false, reason: CHANGE_REQUEST_FAILURES.WRONG_CODE };
109
+ }
110
+ // Atomic claim: two confirmations of one request cannot both apply it.
111
+ const claimed = await coll().updateOne(
112
+ { _id: id, status: CHANGE_REQUEST_STATUS.PENDING },
113
+ { $set: { status: CHANGE_REQUEST_STATUS.APPLIED, decided_at: now(), decided_by: toId(decidedBy) } },
114
+ );
115
+ if (claimed.modifiedCount !== 1) return { ok: false, reason: CHANGE_REQUEST_FAILURES.NOT_PENDING };
116
+ await emit(settings.events.profile_change_applied, { change_request_id: String(id), client_id: String(request.client_id), fields: request.fields, ...outcomeParties(request) }, clientEventSubject(settings, request.client_id));
117
+ return { ok: true, request };
118
+ }
119
+
120
+ /** The client (or ops) declines the change. */
121
+ async function reject({ requestId, decidedBy = null }) {
122
+ const settings = getSettings();
123
+ const id = toId(requestId);
124
+ const res = await coll().updateOne(
125
+ { _id: id, status: CHANGE_REQUEST_STATUS.PENDING },
126
+ { $set: { status: CHANGE_REQUEST_STATUS.REJECTED, decided_at: now(), decided_by: toId(decidedBy) } },
127
+ );
128
+ if (res.modifiedCount === 1) {
129
+ const request = await coll().findOne({ _id: id }, { projection: { client_id: 1, requested_by: 1, distributor_id: 1 } });
130
+ await emit(settings.events.profile_change_rejected, { change_request_id: String(id), client_id: String(request?.client_id), ...outcomeParties(request) }, clientEventSubject(settings, request?.client_id));
131
+ return { ok: true };
132
+ }
133
+ return { ok: false, reason: CHANGE_REQUEST_FAILURES.NOT_PENDING };
134
+ }
135
+
136
+ /** A confirmed request whose replay failed: recorded, never silently lost. */
137
+ async function markFailed({ requestId, error }) {
138
+ await coll().updateOne({ _id: toId(requestId) }, { $set: { apply_error: String(error).slice(0, 500) } });
139
+ }
140
+
141
+ /** Pending requests for a client (for the "change pending" badge). OTP fields are never returned. */
142
+ async function listPending(clientId) {
143
+ return coll().find(
144
+ { client_id: toId(clientId), status: CHANGE_REQUEST_STATUS.PENDING, expires_at: { $gt: now() } },
145
+ { projection: { otp_hash: 0, otp_salt: 0, payload: 0 } },
146
+ ).toArray();
147
+ }
148
+
149
+ /** One request without its code fields — e.g. to route confirmation to the backend that owns it. */
150
+ async function get(requestId) {
151
+ return coll().findOne({ _id: toId(requestId) }, { projection: { otp_hash: 0, otp_salt: 0 } });
152
+ }
153
+
154
+ return Object.freeze({ create, confirm, reject, markFailed, listPending, get });
155
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The configuration store both backends use — load, cache, kill switch.
3
+ *
4
+ * v1 and v2 read the same three things (the settings document, the field-policy rows, and the ids
5
+ * of the configured house distributors). Rather than two copies of that loader, each backend
6
+ * creates one store here and hands it its database handle, logger and environment. The store
7
+ * owns no connection and imports no driver: `getDb()` returns the backend's native `Db` (or
8
+ * undefined before it connects).
9
+ *
10
+ * Behaviour:
11
+ * - before the first load, and on any failed load, the last good configuration is served; the
12
+ * very first configuration is the package defaults, where every mode is `off`
13
+ * - problems in the documents are logged once per distinct set, never thrown
14
+ * - `env.CLIENT_PROFILE_MODE = "off"` forces every mode off whatever the document says
15
+ */
16
+ import {
17
+ CLIENT_PROFILE_COLLECTIONS,
18
+ CLIENT_PROFILE_SETTINGS_DOC_ID,
19
+ MODES,
20
+ resolveClientProfileSettings,
21
+ } from "./settings.js";
22
+ import { buildFieldPolicyIndex } from "./field-policy.js";
23
+
24
+ /** The environment variable that forces every mode off. */
25
+ export const CLIENT_PROFILE_KILL_SWITCH_ENV = "CLIENT_PROFILE_MODE";
26
+
27
+ /**
28
+ * @param {{ getDb: () => (object|undefined), logger?: { warn: Function }, env?: object }} deps
29
+ */
30
+ export function createClientProfileConfigStore({ getDb, logger, env = {} } = {}) {
31
+ let settings = resolveClientProfileSettings(null).settings;
32
+ let fieldPolicyIndex = new Map();
33
+ let houseDistributorIds = [];
34
+ let lastProblemsKey = "";
35
+
36
+ const warn = (message) => {
37
+ try { logger?.warn?.(message); } catch { /* logging must never break a load */ }
38
+ };
39
+
40
+ const isKilled = () => {
41
+ const value = String(env[CLIENT_PROFILE_KILL_SWITCH_ENV] || "").toLowerCase();
42
+ return value === MODES.OFF || value === "false";
43
+ };
44
+
45
+ const reportProblems = (what, problems) => {
46
+ const key = `${what}:${problems.join("|")}`;
47
+ if (!problems.length || key === lastProblemsKey) return;
48
+ lastProblemsKey = key;
49
+ warn(`client-profile config: ${what} problems (defaults used for these): ${problems.join("; ")}`);
50
+ };
51
+
52
+ const getSettings = () => {
53
+ if (!isKilled()) return settings;
54
+ const modes = Object.fromEntries(Object.keys(settings.modes).map((m) => [m, MODES.OFF]));
55
+ return Object.freeze({ ...settings, modes: Object.freeze(modes) });
56
+ };
57
+
58
+ const load = async () => {
59
+ const db = getDb?.();
60
+ if (!db) return getSettings();
61
+ try {
62
+ const doc = await db.collection(CLIENT_PROFILE_COLLECTIONS.SETTINGS).findOne({ _id: CLIENT_PROFILE_SETTINGS_DOC_ID });
63
+ const { settings: resolved, problems } = resolveClientProfileSettings(doc);
64
+
65
+ const rows = await db.collection(CLIENT_PROFILE_COLLECTIONS.FIELD_POLICIES).find({ is_active: { $ne: false } }).toArray();
66
+ const { index, problems: policyProblems } = buildFieldPolicyIndex(rows);
67
+
68
+ const codes = resolved.house_distributor_codes;
69
+ const house = codes.length
70
+ ? (await db.collection("distributors").find({ distributor_code: { $in: codes } }, { projection: { _id: 1 } }).toArray())
71
+ .map(({ _id: id }) => String(id))
72
+ : [];
73
+
74
+ // Swap all three together so no reader ever sees settings from one load and policies from another.
75
+ settings = resolved;
76
+ fieldPolicyIndex = index;
77
+ houseDistributorIds = house;
78
+ reportProblems("settings", problems);
79
+ reportProblems("field policies", policyProblems);
80
+ } catch (err) {
81
+ warn(`client-profile config: load failed, keeping the last good configuration: ${err.message}`);
82
+ }
83
+ return getSettings();
84
+ };
85
+
86
+ return Object.freeze({
87
+ load,
88
+ getSettings,
89
+ getFieldPolicyIndex: () => fieldPolicyIndex,
90
+ getSideContext: () => ({ houseDistributorIds }),
91
+ isKilled,
92
+ });
93
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Stable result and error codes for the client-profile rules.
3
+ *
4
+ * Callers branch on the CODE, never on text. The human text lives in settings (`messages`), so a
5
+ * wording change is a config edit and every backend says the same thing.
6
+ */
7
+ export const CLIENT_PROFILE_ERRORS = Object.freeze({
8
+ /** A personal field was changed by a caller who may not change it. */
9
+ PERSONAL_FIELD_LOCKED: "CLIENT_PROFILE.PERSONAL_FIELD_LOCKED",
10
+ /** A personal field was changed and must be confirmed by the client before it applies. */
11
+ CONFIRMATION_REQUIRED: "CLIENT_PROFILE.CONFIRMATION_REQUIRED",
12
+ /** The person already has an MF investor; a second is never created. */
13
+ MF_INVESTOR_EXISTS: "CLIENT_PROFILE.MF_INVESTOR_EXISTS",
14
+ /** The caller has no way to act on this client. */
15
+ CLIENT_NOT_IN_SCOPE: "CLIENT_PROFILE.CLIENT_NOT_IN_SCOPE",
16
+ /** A second profile of a type the person already holds was attempted. */
17
+ PROFILE_TYPE_EXISTS: "CLIENT_PROFILE.PROFILE_TYPE_EXISTS",
18
+ /** Not an error: a client with this PAN already exists; the caller may act on it (D4). */
19
+ EXISTING_CLIENT_FOUND: "CLIENT_PROFILE.EXISTING_CLIENT_FOUND",
20
+ /** The settings document failed validation; defaults were used for the listed keys. */
21
+ SETTINGS_INVALID: "CLIENT_PROFILE.SETTINGS_INVALID",
22
+ });
23
+
24
+ /** Every code, for exhaustiveness checks (the messages table must cover each one). */
25
+ export const CLIENT_PROFILE_ERROR_CODES = Object.freeze(Object.values(CLIENT_PROFILE_ERRORS));