@i4e/invest4edu-access-core 0.40.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.40.1",
3
+ "version": "0.41.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": {
@@ -30,7 +30,8 @@
30
30
  "./credits": "./src/credits.js"
31
31
  },
32
32
  "scripts": {
33
- "test": "node --test test/*.test.mjs test/*.test.js"
33
+ "test": "node --test test/*.test.mjs test/*.test.js",
34
+ "prepublishOnly": "npm test"
34
35
  },
35
36
  "files": [
36
37
  "src",
@@ -18,10 +18,16 @@ import { buildSideFilter } from "./side-filter.js";
18
18
  import { FIELD_ACTIONS, evaluateFieldChanges, isPersonalLocked } from "./field-policy.js";
19
19
  import { PROFILE_TYPES } from "./settings.js";
20
20
  import { goalCreationStamp, goalVisibilityFilter } from "./goals.js";
21
+ import { uccFieldsTouched } from "./ucc-followup.js";
21
22
  import { buildCapabilityMap, resolveCapabilities } from "../role-capabilities.js";
22
23
  import { CLIENT_APP_ROLE_NAMES } from "../portal-roles.js";
23
24
 
24
25
  const CLIENTS = "distributorclients";
26
+ const UCC_RULES = "uccfieldrules";
27
+ const INVESTOR_PROFILES = "clientinvestorprofiles";
28
+ /** A stored UCC is an alphanumeric code; a failed call leaves BSE's error text there instead. */
29
+ const REAL_UCC = /^[A-Za-z0-9]+$/;
30
+ const UCC_RULES_TTL_MS = 60_000;
25
31
 
26
32
  /**
27
33
  * @param {{ getDb: Function, getSettings: Function, getSideContext?: Function,
@@ -302,5 +308,71 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
302
308
  }
303
309
  }
304
310
 
305
- return Object.freeze({ findActiveProfilesByPan, findExisting, attach, attachUser, resolveActor, sideFilterFor, evaluatePersonalEdit, goalContextFor });
311
+ /* ── UCC follow-up ──────────────────────────────────────────────────────────────────────────
312
+ * A client never pushes to BSE from the app. When their own successful edit changes a detail the
313
+ * product's UCC field rules sync to the UCC, the profile is flagged
314
+ * (`ucc_update_pending { fields, since, last_at, by }`) and the RM is told; backoffice or the RM
315
+ * runs the UCC update from the web, which clears it. Gated by modes.ucc_followup.
316
+ */
317
+ let uccRulesCache = { at: 0, product: null, doc: null };
318
+ async function uccRulesFor(productCode) {
319
+ if (uccRulesCache.product === productCode && now().getTime() - uccRulesCache.at < UCC_RULES_TTL_MS) return uccRulesCache.doc;
320
+ const doc = await db().collection(UCC_RULES).findOne({ product_code: productCode, is_active: { $ne: false } });
321
+ uccRulesCache = { at: now().getTime(), product: productCode, doc };
322
+ return doc;
323
+ }
324
+
325
+ /** After a SUCCESSFUL write by `userId` that changed `fields` of `clientId`. Never throws. */
326
+ async function noteClientUccEdit({ userId, clientId, fields } = {}) {
327
+ try {
328
+ const settings = getSettings();
329
+ if (!isEvaluated(settings, "ucc_followup") || !userId) return { flagged: false, reason: "mode_off" };
330
+ const cid = toId(clientId);
331
+ if (!cid) return { flagged: false, reason: "no_client" };
332
+ const actor = await resolveActor(userId);
333
+ if (actor?.callerClass !== CALLER_CLASSES.CLIENT) return { flagged: false, reason: "not_client" };
334
+ const touched = uccFieldsTouched(fields, await uccRulesFor(settings.ucc_followup.product_code));
335
+ if (!touched.length) return { flagged: false, reason: "no_ucc_fields" };
336
+ const hasUcc = await db().collection(INVESTOR_PROFILES).findOne(
337
+ { client_id: cid, "mf_details.ucc_code": { $regex: REAL_UCC } },
338
+ { projection: { _id: 1 } },
339
+ );
340
+ if (!hasUcc) return { flagged: false, reason: "no_ucc" };
341
+ if (!isEnforced(settings, "ucc_followup")) {
342
+ onAudit({ kind: "ucc_followup", client_id: clientId, fields: touched });
343
+ return { flagged: false, reason: "audit" };
344
+ }
345
+ const at = now();
346
+ await db().collection(CLIENTS).updateOne(
347
+ { _id: cid },
348
+ {
349
+ $addToSet: { "ucc_update_pending.fields": { $each: touched } },
350
+ $min: { "ucc_update_pending.since": at },
351
+ $set: { "ucc_update_pending.last_at": at, "ucc_update_pending.by": toId(userId) },
352
+ },
353
+ );
354
+ if (emit) {
355
+ Promise.resolve()
356
+ .then(() => emit(settings.events.ucc_update_pending, { client_id: String(clientId), fields: touched }, clientEventSubject(settings, clientId)))
357
+ .catch((err) => onAudit({ kind: "ucc_followup_event_error", client_id: clientId, message: err?.message }));
358
+ }
359
+ return { flagged: true, fields: touched };
360
+ } catch (err) {
361
+ onAudit({ kind: "ucc_followup_error", client_id: clientId, message: err?.message });
362
+ return { flagged: false, reason: "error" };
363
+ }
364
+ }
365
+
366
+ /** The UCC was updated: nothing is pending any more. Never throws. */
367
+ async function clearUccPending(clientId) {
368
+ try {
369
+ const cid = toId(clientId);
370
+ if (!cid) return;
371
+ await db().collection(CLIENTS).updateOne({ _id: cid, ucc_update_pending: { $exists: true } }, { $unset: { ucc_update_pending: "" } });
372
+ } catch (err) {
373
+ onAudit({ kind: "ucc_clear_error", client_id: clientId, message: err?.message });
374
+ }
375
+ }
376
+
377
+ return Object.freeze({ findActiveProfilesByPan, findExisting, attach, attachUser, resolveActor, sideFilterFor, evaluatePersonalEdit, goalContextFor, noteClientUccEdit, clearUccPending });
306
378
  }
@@ -187,7 +187,7 @@ export const CLIENT_PROFILE_SETTING_DESCRIPTIONS = Object.freeze({
187
187
  "modes.resolve_by_pan": "Show an existing client instead of a duplicate error when a PAN is found.",
188
188
  "modes.mf_one_investor": "Refuse a second MF investor for a person who already has one (D9).",
189
189
  "modes.ucc_followup": "Flag a profile for a UCC update when its client changes UCC details in the app (never pushed by the client).",
190
- "modes.detail_scope": ""A client's full record by id only for callers whose client list includes them; everyone else gets the limited card (R15).",
190
+ "modes.detail_scope": "A client's full record by id only for callers whose client list includes them; everyone else gets the limited card (R15).",
191
191
  "modes.linked_visibility": "Clients reached only through a secondary mapping or a linked user appear in lists, as the limited card.",
192
192
  corporate_distributor_types: "distributors.distributor_type values whose clients get a corporate profile.",
193
193
  house_distributor_codes: "Distributor codes treated as direct (InvestValue's own house distributors).",