@i4e/invest4edu-access-core 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Which hats may coexist on one person — @i4e/invest4edu-access-core.
3
+ *
4
+ * Historically every entity flow minted its own `users` row, so the same person signing
5
+ * up as a partner after already being a client was refused outright. One person is now
6
+ * one row carrying a link per entity, which makes "may this person also be X" a real
7
+ * question with a real answer — and it has to be the SAME answer in every flow, or the
8
+ * employee path enforces something the partner path does not.
9
+ *
10
+ * Only the rule table lives here. Resolving which hats a person currently wears is each
11
+ * backend's job: v1 reads `users` columns plus `userentitymappings`, v2 reads its own
12
+ * narrower projection, and neither shape belongs in a pure package. What must not differ
13
+ * is the verdict, so the verdict is what is shared.
14
+ */
15
+ import { ENTITY } from './entity-status.js';
16
+
17
+ /**
18
+ * Entities that may not coexist on one person.
19
+ *
20
+ * Deliberately minimal: it states the one prohibition the business asked for rather than
21
+ * enumerating every pair. Employee and partner are mutually exclusive; everything else
22
+ * that already happens keeps happening — a client links onto an employee today, and
23
+ * refusing that here would break a live flow.
24
+ *
25
+ * Extending this is a line in the table, not a new check somewhere else.
26
+ */
27
+ export const CONFLICTING_ENTITIES = {
28
+ [ENTITY.DISTRIBUTOR]: [ENTITY.EMPLOYEE],
29
+ [ENTITY.EMPLOYEE]: [ENTITY.DISTRIBUTOR],
30
+ [ENTITY.CLIENT]: [],
31
+ [ENTITY.MANUFACTURER]: [],
32
+ };
33
+
34
+ /**
35
+ * Which already-worn hat, if any, forbids adding this one.
36
+ *
37
+ * Takes a plain set of entity types so it stays independent of how they were discovered —
38
+ * the mapping collection, the legacy columns, or a mix of both all reach the same table.
39
+ *
40
+ * @param {string} targetEntity the hat being added, one of ENTITY
41
+ * @param {Set<string>|Array} existing the hats already worn
42
+ * @returns {string|null} the conflicting entity type, or null when the pairing is allowed
43
+ */
44
+ export function conflictingEntityFor(targetEntity, existing) {
45
+ const worn = existing instanceof Set ? existing : new Set(existing || []);
46
+ return (CONFLICTING_ENTITIES[targetEntity] || []).find((e) => worn.has(e)) || null;
47
+ }
48
+
49
+ /** The refusal message for a pairing the table forbids. Shared so both backends say the same thing. */
50
+ export function entityConflictMessage(targetEntity, conflictingEntity) {
51
+ if (targetEntity === ENTITY.DISTRIBUTOR && conflictingEntity === ENTITY.EMPLOYEE) {
52
+ return 'These contact details belong to an employee. An employee cannot also be registered as a partner.';
53
+ }
54
+ if (targetEntity === ENTITY.EMPLOYEE && conflictingEntity === ENTITY.DISTRIBUTOR) {
55
+ return 'These contact details belong to a partner. A partner cannot also be registered as an employee.';
56
+ }
57
+ return `These contact details are already registered as a ${conflictingEntity}, which cannot be combined with a ${targetEntity}.`;
58
+ }
@@ -1,108 +1,108 @@
1
- /**
2
- * `visibleWhen` — ONE predicate language, ONE evaluator.
3
- * @i4e/invest4edu-access-core (unified access framework, P1)
4
- *
5
- * The same function gates nav items, screen actions, grid columns, filters, row/bulk actions,
6
- * exports and summary metrics. That is what makes "driven by product and roles" true across the
7
- * whole app instead of per-screen — and it means a rule is written once, in data, not in 139
8
- * hardcoded predicates.
9
- *
10
- * Client-side evaluation is UX ONLY. Servers MUST re-evaluate before returning data, projecting
11
- * columns, or building an export/summary — never trust a client-supplied column list.
12
- *
13
- * Design: TDD-unified-access-and-subscription.md §8.3
14
- */
15
-
16
- import { hasFeature, isUsable } from "./access-resolver.js";
17
-
18
- /**
19
- * Shape (every key optional; all present keys must pass — AND):
20
- * {
21
- * features: ["ORDERS.LIST"] // caller must hold all of these
22
- * actions: ["ORDERS.LIST.CANCEL"] // alias of features, reads better on actions
23
- * entitled: "TOOLS.AI_REPORT" // must be allowed AND entitlement-unlocked
24
- * products: ["IVP003"] // caller's product scope must intersect
25
- * distributorLevel: ["D1","D2"]
26
- * screenType: "openorders" | ["a","b"]
27
- * roles: ["BROKER_ADMIN"] // LEGACY escape hatch — lint-flagged, being removed
28
- * anyOf: [ {...}, {...} ] // OR of nested predicates
29
- * not: { ... } // negation
30
- * }
31
- *
32
- * @param {Object|null|undefined} rule absent/empty ⇒ visible (opt-in gating, not opt-out)
33
- * @param {Object} ctx { snapshot, productScope?, distributorLevel?, screenType?, roles? }
34
- */
35
- export function evaluateVisibleWhen(rule, ctx = {}) {
36
- if (!rule || typeof rule !== "object") return true;
37
-
38
- const snapshot = ctx.snapshot || null;
39
- // A system admin (D27) resolves a fully-open snapshot, so feature/entitlement checks below
40
- // pass naturally. Product/level predicates still evaluate — an admin seeing a D1-only column
41
- // labelled for D1 would be misleading, not permissive.
42
- const productScope = new Set(
43
- (ctx.productScope || snapshot?.productScope || []).map(String),
44
- );
45
-
46
- const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
47
-
48
- if (rule.features && !asArray(rule.features).every((c) => hasFeature(snapshot, c))) return false;
49
- if (rule.actions && !asArray(rule.actions).every((c) => hasFeature(snapshot, c))) return false;
50
- if (rule.entitled && !asArray(rule.entitled).every((c) => isUsable(snapshot, c))) return false;
51
-
52
- if (rule.products) {
53
- const want = asArray(rule.products).map(String);
54
- if (want.length && !want.some((p) => productScope.has(p))) return false;
55
- }
56
-
57
- if (rule.distributorLevel) {
58
- const lvl = ctx.distributorLevel ? String(ctx.distributorLevel) : null;
59
- if (!lvl || !asArray(rule.distributorLevel).map(String).includes(lvl)) return false;
60
- }
61
-
62
- if (rule.screenType) {
63
- const st = ctx.screenType ? String(ctx.screenType) : null;
64
- if (!st || !asArray(rule.screenType).map(String).includes(st)) return false;
65
- }
66
-
67
- // LEGACY: kept only so migration can proceed screen-by-screen. New configs must not use it.
68
- if (rule.roles) {
69
- const held = new Set(asArray(ctx.roles || snapshot?.resolved_for?.roles).map(String));
70
- if (!asArray(rule.roles).map(String).some((r) => held.has(r))) return false;
71
- }
72
-
73
- if (rule.anyOf) {
74
- const branches = asArray(rule.anyOf);
75
- if (branches.length && !branches.some((b) => evaluateVisibleWhen(b, ctx))) return false;
76
- }
77
-
78
- if (rule.not && evaluateVisibleWhen(rule.not, ctx)) return false;
79
-
80
- return true;
81
- }
82
-
83
- /** Filter a list of `{visibleWhen}`-bearing config items (columns, metrics, actions, filters). */
84
- export function filterVisible(items = [], ctx = {}) {
85
- return items.filter((i) => evaluateVisibleWhen(i?.visibleWhen, ctx));
86
- }
87
-
88
- /**
89
- * Apply `relabelWhen` overrides to a grid column — absorbs the imperative relabelling in
90
- * order-constants (e.g. d1/d2/d3_payout → "Net Income" for the matching distributor level).
91
- * First matching rule wins; the column is returned unchanged when nothing matches.
92
- */
93
- export function applyRelabel(column, ctx = {}) {
94
- if (!column?.relabelWhen?.length) return column;
95
- for (const r of column.relabelWhen) {
96
- if (evaluateVisibleWhen(r?.when, ctx)) {
97
- return { ...column, header: r.header ?? column.header, label: r.label ?? column.label };
98
- }
99
- }
100
- return column;
101
- }
102
-
103
- /** Resolve a full column list: drop hidden ones, apply relabelling, preserve order. */
104
- export function resolveColumns(columns = [], ctx = {}) {
105
- return filterVisible(columns, ctx).map((c) => applyRelabel(c, ctx));
106
- }
107
-
108
- export default { evaluateVisibleWhen, filterVisible, applyRelabel, resolveColumns };
1
+ /**
2
+ * `visibleWhen` — ONE predicate language, ONE evaluator.
3
+ * @i4e/invest4edu-access-core (unified access framework, P1)
4
+ *
5
+ * The same function gates nav items, screen actions, grid columns, filters, row/bulk actions,
6
+ * exports and summary metrics. That is what makes "driven by product and roles" true across the
7
+ * whole app instead of per-screen — and it means a rule is written once, in data, not in 139
8
+ * hardcoded predicates.
9
+ *
10
+ * Client-side evaluation is UX ONLY. Servers MUST re-evaluate before returning data, projecting
11
+ * columns, or building an export/summary — never trust a client-supplied column list.
12
+ *
13
+ * Design: TDD-unified-access-and-subscription.md §8.3
14
+ */
15
+
16
+ import { hasFeature, isUsable } from "./access-resolver.js";
17
+
18
+ /**
19
+ * Shape (every key optional; all present keys must pass — AND):
20
+ * {
21
+ * features: ["ORDERS.LIST"] // caller must hold all of these
22
+ * actions: ["ORDERS.LIST.CANCEL"] // alias of features, reads better on actions
23
+ * entitled: "TOOLS.AI_REPORT" // must be allowed AND entitlement-unlocked
24
+ * products: ["IVP003"] // caller's product scope must intersect
25
+ * distributorLevel: ["D1","D2"]
26
+ * screenType: "openorders" | ["a","b"]
27
+ * roles: ["BROKER_ADMIN"] // LEGACY escape hatch — lint-flagged, being removed
28
+ * anyOf: [ {...}, {...} ] // OR of nested predicates
29
+ * not: { ... } // negation
30
+ * }
31
+ *
32
+ * @param {Object|null|undefined} rule absent/empty ⇒ visible (opt-in gating, not opt-out)
33
+ * @param {Object} ctx { snapshot, productScope?, distributorLevel?, screenType?, roles? }
34
+ */
35
+ export function evaluateVisibleWhen(rule, ctx = {}) {
36
+ if (!rule || typeof rule !== "object") return true;
37
+
38
+ const snapshot = ctx.snapshot || null;
39
+ // A system admin (D27) resolves a fully-open snapshot, so feature/entitlement checks below
40
+ // pass naturally. Product/level predicates still evaluate — an admin seeing a D1-only column
41
+ // labelled for D1 would be misleading, not permissive.
42
+ const productScope = new Set(
43
+ (ctx.productScope || snapshot?.productScope || []).map(String),
44
+ );
45
+
46
+ const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
47
+
48
+ if (rule.features && !asArray(rule.features).every((c) => hasFeature(snapshot, c))) return false;
49
+ if (rule.actions && !asArray(rule.actions).every((c) => hasFeature(snapshot, c))) return false;
50
+ if (rule.entitled && !asArray(rule.entitled).every((c) => isUsable(snapshot, c))) return false;
51
+
52
+ if (rule.products) {
53
+ const want = asArray(rule.products).map(String);
54
+ if (want.length && !want.some((p) => productScope.has(p))) return false;
55
+ }
56
+
57
+ if (rule.distributorLevel) {
58
+ const lvl = ctx.distributorLevel ? String(ctx.distributorLevel) : null;
59
+ if (!lvl || !asArray(rule.distributorLevel).map(String).includes(lvl)) return false;
60
+ }
61
+
62
+ if (rule.screenType) {
63
+ const st = ctx.screenType ? String(ctx.screenType) : null;
64
+ if (!st || !asArray(rule.screenType).map(String).includes(st)) return false;
65
+ }
66
+
67
+ // LEGACY: kept only so migration can proceed screen-by-screen. New configs must not use it.
68
+ if (rule.roles) {
69
+ const held = new Set(asArray(ctx.roles || snapshot?.resolved_for?.roles).map(String));
70
+ if (!asArray(rule.roles).map(String).some((r) => held.has(r))) return false;
71
+ }
72
+
73
+ if (rule.anyOf) {
74
+ const branches = asArray(rule.anyOf);
75
+ if (branches.length && !branches.some((b) => evaluateVisibleWhen(b, ctx))) return false;
76
+ }
77
+
78
+ if (rule.not && evaluateVisibleWhen(rule.not, ctx)) return false;
79
+
80
+ return true;
81
+ }
82
+
83
+ /** Filter a list of `{visibleWhen}`-bearing config items (columns, metrics, actions, filters). */
84
+ export function filterVisible(items = [], ctx = {}) {
85
+ return items.filter((i) => evaluateVisibleWhen(i?.visibleWhen, ctx));
86
+ }
87
+
88
+ /**
89
+ * Apply `relabelWhen` overrides to a grid column — absorbs the imperative relabelling in
90
+ * order-constants (e.g. d1/d2/d3_payout → "Net Income" for the matching distributor level).
91
+ * First matching rule wins; the column is returned unchanged when nothing matches.
92
+ */
93
+ export function applyRelabel(column, ctx = {}) {
94
+ if (!column?.relabelWhen?.length) return column;
95
+ for (const r of column.relabelWhen) {
96
+ if (evaluateVisibleWhen(r?.when, ctx)) {
97
+ return { ...column, header: r.header ?? column.header, label: r.label ?? column.label };
98
+ }
99
+ }
100
+ return column;
101
+ }
102
+
103
+ /** Resolve a full column list: drop hidden ones, apply relabelling, preserve order. */
104
+ export function resolveColumns(columns = [], ctx = {}) {
105
+ return filterVisible(columns, ctx).map((c) => applyRelabel(c, ctx));
106
+ }
107
+
108
+ export default { evaluateVisibleWhen, filterVisible, applyRelabel, resolveColumns };