@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 +10 -0
- package/package.json +2 -1
- 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 +381 -0
- package/src/client-profile/side-filter.js +96 -0
- package/src/client-profile/sides.js +204 -0
- package/src/index.js +4 -0
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.
|
|
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));
|