@i4e/invest4edu-access-core 0.32.0 → 0.33.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 +128 -126
- package/package.json +57 -56
- package/src/access-config.js +68 -68
- package/src/access-resolver.js +269 -269
- package/src/access-schema.js +174 -174
- package/src/credits.js +128 -128
- package/src/entitlement-schema.js +194 -194
- package/src/entitlement-store.d.ts +99 -99
- package/src/entitlement-store.js +610 -610
- package/src/entitlement.js +300 -300
- package/src/grid-schema.js +231 -231
- package/src/index.js +75 -74
- package/src/proration.js +155 -155
- package/src/reportee-tree.js +129 -129
- package/src/role-capabilities.js +93 -93
- package/src/route-features.js +292 -292
- package/src/route-screen.js +45 -0
- package/src/subscription-lifecycle.js +106 -106
- package/src/tenant-context.js +26 -26
- package/src/tenant-plugin.js +177 -177
- package/src/visible-when.js +108 -108
package/src/proration.js
CHANGED
|
@@ -1,155 +1,155 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Proration — what the unused part of a running term is worth when someone upgrades.
|
|
3
|
-
*
|
|
4
|
-
* Without it, an upgrade STACKS: the new term is appended to the current end date, so the days
|
|
5
|
-
* already paid for on the smaller plan sit behind the new one, unusable at the new tier. The
|
|
6
|
-
* customer quietly loses that value and nothing on screen says so.
|
|
7
|
-
*
|
|
8
|
-
* The POLICY is configuration, not code — mode, basis, rounding, cap and scope all arrive from
|
|
9
|
-
* the caller (platform default < plan < service), so changing how proration works is an edit
|
|
10
|
-
* rather than a deploy. This module is only the arithmetic, kept pure so it can be tested
|
|
11
|
-
* without a database, a clock or a subscription.
|
|
12
|
-
*
|
|
13
|
-
* ── The rules that are NOT configurable, because getting them wrong invents money ────────────
|
|
14
|
-
* · credit can never exceed what was actually paid for the current term
|
|
15
|
-
* · credit can never exceed the new plan's price (a purchase must not become negative)
|
|
16
|
-
* · rounding is toward the CUSTOMER paying more, never less — we do not credit value that was
|
|
17
|
-
* not paid for
|
|
18
|
-
* · a term that has not started, or has already ended, is worth nothing
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/** Every knob, with the conservative answer as the default. */
|
|
22
|
-
export const PRORATION_DEFAULTS = {
|
|
23
|
-
/**
|
|
24
|
-
* none — today's behaviour: no credit, the new term stacks on the old end date
|
|
25
|
-
* daily — unused days ÷ total days
|
|
26
|
-
* monthly — whole unused months only; a part-month counts for nothing
|
|
27
|
-
* points — no cash credit; the unused value is returned as reward points instead
|
|
28
|
-
*/
|
|
29
|
-
mode: "none",
|
|
30
|
-
/** Ceiling on the credit as a percentage of the new plan's price. 100 = up to the full price. */
|
|
31
|
-
cap_percent: 100,
|
|
32
|
-
/** Apply when the PLAN changes, and/or when only the billing cycle changes. */
|
|
33
|
-
on_plan_change: true,
|
|
34
|
-
on_cycle_change: false,
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
38
|
-
|
|
39
|
-
/** Whole days between two instants, never negative. */
|
|
40
|
-
function daysBetween(from, to) {
|
|
41
|
-
const ms = new Date(to).getTime() - new Date(from).getTime();
|
|
42
|
-
return ms <= 0 ? 0 : Math.floor(ms / MS_PER_DAY);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Whole months remaining — a part-month is worth nothing under `monthly`. */
|
|
46
|
-
function wholeMonthsBetween(from, to) {
|
|
47
|
-
const a = new Date(from);
|
|
48
|
-
const b = new Date(to);
|
|
49
|
-
if (b <= a) return 0;
|
|
50
|
-
let months = (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
|
|
51
|
-
if (b.getDate() < a.getDate()) months -= 1;
|
|
52
|
-
return months > 0 ? months : 0;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Merge policy layers. Later wins, and only for keys it actually specifies — a plan that sets
|
|
57
|
-
* only `mode` must not silently reset the cap someone configured globally.
|
|
58
|
-
*/
|
|
59
|
-
export function resolveProrationPolicy(...layers) {
|
|
60
|
-
const out = { ...PRORATION_DEFAULTS };
|
|
61
|
-
for (const layer of layers) {
|
|
62
|
-
if (!layer || typeof layer !== "object") continue;
|
|
63
|
-
for (const k of Object.keys(PRORATION_DEFAULTS)) {
|
|
64
|
-
if (layer[k] !== undefined && layer[k] !== null) out[k] = layer[k];
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return out;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* What the unused remainder of the current term is worth, in PAISE.
|
|
72
|
-
*
|
|
73
|
-
* @param {object} a
|
|
74
|
-
* @param {number} a.paidAmount what was paid for the CURRENT term (paise, ex-tax)
|
|
75
|
-
* @param {Date} a.periodStart when the current term began
|
|
76
|
-
* @param {Date} a.periodEnd when it ends (null/absent ⇒ open-ended ⇒ nothing to prorate)
|
|
77
|
-
* @param {Date} a.now the moment of the change
|
|
78
|
-
* @param {number} a.newPlanPrice price of what they are moving to (paise, ex-tax) — the cap
|
|
79
|
-
* @param {boolean} a.isCycleChange true when the plan is unchanged and only the cycle differs
|
|
80
|
-
* @param {object} a.policy resolved policy (see resolveProrationPolicy)
|
|
81
|
-
* @returns {{credit:number, as_points:boolean, reason:string, days_remaining:number,
|
|
82
|
-
* days_total:number, mode:string}}
|
|
83
|
-
*/
|
|
84
|
-
export function prorate({
|
|
85
|
-
paidAmount = 0,
|
|
86
|
-
periodStart,
|
|
87
|
-
periodEnd,
|
|
88
|
-
now = new Date(),
|
|
89
|
-
newPlanPrice = 0,
|
|
90
|
-
isCycleChange = false,
|
|
91
|
-
policy = PRORATION_DEFAULTS,
|
|
92
|
-
} = {}) {
|
|
93
|
-
const p = resolveProrationPolicy(policy);
|
|
94
|
-
const nil = (reason) => ({
|
|
95
|
-
credit: 0, as_points: false, reason, mode: p.mode, days_remaining: 0, days_total: 0,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
if (p.mode === "none") return nil("proration is off");
|
|
99
|
-
if (isCycleChange && !p.on_cycle_change) return nil("cycle changes are not prorated");
|
|
100
|
-
if (!isCycleChange && !p.on_plan_change) return nil("plan changes are not prorated");
|
|
101
|
-
|
|
102
|
-
// An open-ended term (the provisioned default) has no remainder to value: nothing was paid for
|
|
103
|
-
// a period that does not end.
|
|
104
|
-
if (!periodEnd) return nil("term has no end date");
|
|
105
|
-
if (!(paidAmount > 0)) return nil("nothing was paid for the current term");
|
|
106
|
-
|
|
107
|
-
const daysTotal = daysBetween(periodStart, periodEnd);
|
|
108
|
-
const daysRemaining = daysBetween(now, periodEnd);
|
|
109
|
-
if (daysTotal <= 0) return nil("term length is zero");
|
|
110
|
-
if (daysRemaining <= 0) return nil("term has already ended");
|
|
111
|
-
|
|
112
|
-
let credit;
|
|
113
|
-
if (p.mode === "monthly") {
|
|
114
|
-
const monthsRemaining = wholeMonthsBetween(now, periodEnd);
|
|
115
|
-
const monthsTotal = wholeMonthsBetween(periodStart, periodEnd);
|
|
116
|
-
if (monthsTotal <= 0 || monthsRemaining <= 0) {
|
|
117
|
-
return { ...nil("less than a whole month remains"), days_remaining: daysRemaining, days_total: daysTotal };
|
|
118
|
-
}
|
|
119
|
-
credit = Math.floor((paidAmount * monthsRemaining) / monthsTotal);
|
|
120
|
-
} else {
|
|
121
|
-
// daily, and the basis for `points` too — the difference is only in how it is returned.
|
|
122
|
-
credit = Math.floor((paidAmount * daysRemaining) / daysTotal);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Never more than was paid. Floating-point and clock skew both make this reachable.
|
|
126
|
-
credit = Math.min(credit, paidAmount);
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* The cap. Without it, a long unused term can exceed the new plan's price and the purchase
|
|
130
|
-
* goes to zero or negative — the customer upgrades for free, or we owe them money for taking
|
|
131
|
-
* a bigger plan. Applies to the cash credit only; points are a separate liability that does
|
|
132
|
-
* not have to fit inside this purchase.
|
|
133
|
-
*/
|
|
134
|
-
if (p.mode !== "points") {
|
|
135
|
-
const ceiling = Math.floor((newPlanPrice * Math.max(0, Math.min(100, p.cap_percent))) / 100);
|
|
136
|
-
if (credit > ceiling) {
|
|
137
|
-
credit = ceiling;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (credit <= 0) return { ...nil("nothing to credit"), days_remaining: daysRemaining, days_total: daysTotal };
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
credit,
|
|
145
|
-
as_points: p.mode === "points",
|
|
146
|
-
reason: p.mode === "points"
|
|
147
|
-
? `${daysRemaining} unused day(s) returned as points`
|
|
148
|
-
: `${daysRemaining} of ${daysTotal} day(s) unused`,
|
|
149
|
-
mode: p.mode,
|
|
150
|
-
days_remaining: daysRemaining,
|
|
151
|
-
days_total: daysTotal,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export default { prorate, resolveProrationPolicy, PRORATION_DEFAULTS };
|
|
1
|
+
/**
|
|
2
|
+
* Proration — what the unused part of a running term is worth when someone upgrades.
|
|
3
|
+
*
|
|
4
|
+
* Without it, an upgrade STACKS: the new term is appended to the current end date, so the days
|
|
5
|
+
* already paid for on the smaller plan sit behind the new one, unusable at the new tier. The
|
|
6
|
+
* customer quietly loses that value and nothing on screen says so.
|
|
7
|
+
*
|
|
8
|
+
* The POLICY is configuration, not code — mode, basis, rounding, cap and scope all arrive from
|
|
9
|
+
* the caller (platform default < plan < service), so changing how proration works is an edit
|
|
10
|
+
* rather than a deploy. This module is only the arithmetic, kept pure so it can be tested
|
|
11
|
+
* without a database, a clock or a subscription.
|
|
12
|
+
*
|
|
13
|
+
* ── The rules that are NOT configurable, because getting them wrong invents money ────────────
|
|
14
|
+
* · credit can never exceed what was actually paid for the current term
|
|
15
|
+
* · credit can never exceed the new plan's price (a purchase must not become negative)
|
|
16
|
+
* · rounding is toward the CUSTOMER paying more, never less — we do not credit value that was
|
|
17
|
+
* not paid for
|
|
18
|
+
* · a term that has not started, or has already ended, is worth nothing
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Every knob, with the conservative answer as the default. */
|
|
22
|
+
export const PRORATION_DEFAULTS = {
|
|
23
|
+
/**
|
|
24
|
+
* none — today's behaviour: no credit, the new term stacks on the old end date
|
|
25
|
+
* daily — unused days ÷ total days
|
|
26
|
+
* monthly — whole unused months only; a part-month counts for nothing
|
|
27
|
+
* points — no cash credit; the unused value is returned as reward points instead
|
|
28
|
+
*/
|
|
29
|
+
mode: "none",
|
|
30
|
+
/** Ceiling on the credit as a percentage of the new plan's price. 100 = up to the full price. */
|
|
31
|
+
cap_percent: 100,
|
|
32
|
+
/** Apply when the PLAN changes, and/or when only the billing cycle changes. */
|
|
33
|
+
on_plan_change: true,
|
|
34
|
+
on_cycle_change: false,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
/** Whole days between two instants, never negative. */
|
|
40
|
+
function daysBetween(from, to) {
|
|
41
|
+
const ms = new Date(to).getTime() - new Date(from).getTime();
|
|
42
|
+
return ms <= 0 ? 0 : Math.floor(ms / MS_PER_DAY);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whole months remaining — a part-month is worth nothing under `monthly`. */
|
|
46
|
+
function wholeMonthsBetween(from, to) {
|
|
47
|
+
const a = new Date(from);
|
|
48
|
+
const b = new Date(to);
|
|
49
|
+
if (b <= a) return 0;
|
|
50
|
+
let months = (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
|
|
51
|
+
if (b.getDate() < a.getDate()) months -= 1;
|
|
52
|
+
return months > 0 ? months : 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Merge policy layers. Later wins, and only for keys it actually specifies — a plan that sets
|
|
57
|
+
* only `mode` must not silently reset the cap someone configured globally.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveProrationPolicy(...layers) {
|
|
60
|
+
const out = { ...PRORATION_DEFAULTS };
|
|
61
|
+
for (const layer of layers) {
|
|
62
|
+
if (!layer || typeof layer !== "object") continue;
|
|
63
|
+
for (const k of Object.keys(PRORATION_DEFAULTS)) {
|
|
64
|
+
if (layer[k] !== undefined && layer[k] !== null) out[k] = layer[k];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What the unused remainder of the current term is worth, in PAISE.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} a
|
|
74
|
+
* @param {number} a.paidAmount what was paid for the CURRENT term (paise, ex-tax)
|
|
75
|
+
* @param {Date} a.periodStart when the current term began
|
|
76
|
+
* @param {Date} a.periodEnd when it ends (null/absent ⇒ open-ended ⇒ nothing to prorate)
|
|
77
|
+
* @param {Date} a.now the moment of the change
|
|
78
|
+
* @param {number} a.newPlanPrice price of what they are moving to (paise, ex-tax) — the cap
|
|
79
|
+
* @param {boolean} a.isCycleChange true when the plan is unchanged and only the cycle differs
|
|
80
|
+
* @param {object} a.policy resolved policy (see resolveProrationPolicy)
|
|
81
|
+
* @returns {{credit:number, as_points:boolean, reason:string, days_remaining:number,
|
|
82
|
+
* days_total:number, mode:string}}
|
|
83
|
+
*/
|
|
84
|
+
export function prorate({
|
|
85
|
+
paidAmount = 0,
|
|
86
|
+
periodStart,
|
|
87
|
+
periodEnd,
|
|
88
|
+
now = new Date(),
|
|
89
|
+
newPlanPrice = 0,
|
|
90
|
+
isCycleChange = false,
|
|
91
|
+
policy = PRORATION_DEFAULTS,
|
|
92
|
+
} = {}) {
|
|
93
|
+
const p = resolveProrationPolicy(policy);
|
|
94
|
+
const nil = (reason) => ({
|
|
95
|
+
credit: 0, as_points: false, reason, mode: p.mode, days_remaining: 0, days_total: 0,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (p.mode === "none") return nil("proration is off");
|
|
99
|
+
if (isCycleChange && !p.on_cycle_change) return nil("cycle changes are not prorated");
|
|
100
|
+
if (!isCycleChange && !p.on_plan_change) return nil("plan changes are not prorated");
|
|
101
|
+
|
|
102
|
+
// An open-ended term (the provisioned default) has no remainder to value: nothing was paid for
|
|
103
|
+
// a period that does not end.
|
|
104
|
+
if (!periodEnd) return nil("term has no end date");
|
|
105
|
+
if (!(paidAmount > 0)) return nil("nothing was paid for the current term");
|
|
106
|
+
|
|
107
|
+
const daysTotal = daysBetween(periodStart, periodEnd);
|
|
108
|
+
const daysRemaining = daysBetween(now, periodEnd);
|
|
109
|
+
if (daysTotal <= 0) return nil("term length is zero");
|
|
110
|
+
if (daysRemaining <= 0) return nil("term has already ended");
|
|
111
|
+
|
|
112
|
+
let credit;
|
|
113
|
+
if (p.mode === "monthly") {
|
|
114
|
+
const monthsRemaining = wholeMonthsBetween(now, periodEnd);
|
|
115
|
+
const monthsTotal = wholeMonthsBetween(periodStart, periodEnd);
|
|
116
|
+
if (monthsTotal <= 0 || monthsRemaining <= 0) {
|
|
117
|
+
return { ...nil("less than a whole month remains"), days_remaining: daysRemaining, days_total: daysTotal };
|
|
118
|
+
}
|
|
119
|
+
credit = Math.floor((paidAmount * monthsRemaining) / monthsTotal);
|
|
120
|
+
} else {
|
|
121
|
+
// daily, and the basis for `points` too — the difference is only in how it is returned.
|
|
122
|
+
credit = Math.floor((paidAmount * daysRemaining) / daysTotal);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Never more than was paid. Floating-point and clock skew both make this reachable.
|
|
126
|
+
credit = Math.min(credit, paidAmount);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The cap. Without it, a long unused term can exceed the new plan's price and the purchase
|
|
130
|
+
* goes to zero or negative — the customer upgrades for free, or we owe them money for taking
|
|
131
|
+
* a bigger plan. Applies to the cash credit only; points are a separate liability that does
|
|
132
|
+
* not have to fit inside this purchase.
|
|
133
|
+
*/
|
|
134
|
+
if (p.mode !== "points") {
|
|
135
|
+
const ceiling = Math.floor((newPlanPrice * Math.max(0, Math.min(100, p.cap_percent))) / 100);
|
|
136
|
+
if (credit > ceiling) {
|
|
137
|
+
credit = ceiling;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (credit <= 0) return { ...nil("nothing to credit"), days_remaining: daysRemaining, days_total: daysTotal };
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
credit,
|
|
145
|
+
as_points: p.mode === "points",
|
|
146
|
+
reason: p.mode === "points"
|
|
147
|
+
? `${daysRemaining} unused day(s) returned as points`
|
|
148
|
+
: `${daysRemaining} of ${daysTotal} day(s) unused`,
|
|
149
|
+
mode: p.mode,
|
|
150
|
+
days_remaining: daysRemaining,
|
|
151
|
+
days_total: daysTotal,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default { prorate, resolveProrationPolicy, PRORATION_DEFAULTS };
|
package/src/reportee-tree.js
CHANGED
|
@@ -1,129 +1,129 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reportee-tree DFS — @i4e/invest4edu-access-core (Track 3 G3 shared primitive).
|
|
3
|
-
*
|
|
4
|
-
* The one canonical "self + all reportees" traversal, shared by both backends so v1 and v2
|
|
5
|
-
* compose the SAME hierarchy scope. Pure + data-source agnostic: the caller passes a flat
|
|
6
|
-
* employees list `[{ _id, user_id, reporting_manager }]` (v1 feeds the Redis snapshot, v2 the
|
|
7
|
-
* `employees` collection) and one-or-more root user ids (delegation passes `[self, ...givers]`).
|
|
8
|
-
*
|
|
9
|
-
* ── `reporting_manager` is an employee `_id` (BRI-973) ────────────────────────────────
|
|
10
|
-
* It used to hold the manager's **user id**. It now holds the manager's **employee `_id`**,
|
|
11
|
-
* so the hierarchy no longer depends on `employees.user_id` and that field can be dropped.
|
|
12
|
-
*
|
|
13
|
-
* Through the rollout this accepted both shapes; that widening is gone. Only an `_id` that is
|
|
14
|
-
* present in the list resolves — an unrecognised reference detaches that manager's downline
|
|
15
|
-
* rather than being read as a user id, which is the correct reading now that no row should
|
|
16
|
-
* hold one.
|
|
17
|
-
*
|
|
18
|
-
* ── DEPLOY ORDER: migration first, then this version ──────────────────────────────────
|
|
19
|
-
* This is a BREAKING change to the data this function can read, on a 0.x minor. Against
|
|
20
|
-
* un-migrated rows it returns ZERO reportees — and since it feeds record visibility and the
|
|
21
|
-
* lead access scope, that is a silent narrowing of what people can see rather than an error
|
|
22
|
-
* anyone would notice.
|
|
23
|
-
*
|
|
24
|
-
* So: run nfd-api-node's migrations/verify-reporting-manager-to-employee-id.js and get a
|
|
25
|
-
* clean verdict FIRST, then ship this version. The window that matters is a rolling deploy,
|
|
26
|
-
* where an upgraded instance and un-migrated rows can coexist for real minutes — the
|
|
27
|
-
* migration has to be complete before the first instance carrying this code serves traffic,
|
|
28
|
-
* not merely started.
|
|
29
|
-
*
|
|
30
|
-
* Roots and the returned set stay **user ids** throughout, because that is what the callers
|
|
31
|
-
* compare against (`assigned_rm_id`, `rm_id`). Only the manager reference is translated.
|
|
32
|
-
*
|
|
33
|
-
* ── CALLER CONTRACT: pass the manager's own row ───────────────────────────────────────
|
|
34
|
-
* An employee-id reference can only be resolved if the row carrying that `_id` is in the
|
|
35
|
-
* list. A user-id reference never needed a lookup, so under the old shape an absent manager
|
|
36
|
-
* row cost nothing — under the new shape it silently detaches that manager's entire
|
|
37
|
-
* downline.
|
|
38
|
-
*
|
|
39
|
-
* So a caller must feed EVERY employee for linking, and filter unwanted people out of the
|
|
40
|
-
* RESULT. Feeding a pre-filtered list (`{ status: 1 }`, an account scope, anything) drops
|
|
41
|
-
* link rows, and what you lose is not the filtered person but everyone beneath them.
|
|
42
|
-
*
|
|
43
|
-
* The caller this bites is nfd-api-node's `src/cache/employeeCache.js`, which builds the
|
|
44
|
-
* hierarchy snapshot. It filtered `{ status: 1 }` and argued the filter was safe because it
|
|
45
|
-
* "already breaks the chain at a deactivated manager" — true under the old shape, where the
|
|
46
|
-
* manager's user id was the map key whether or not their row was present, and false under
|
|
47
|
-
* this one. It now selects every employee and filters the RESULT instead. Any new caller
|
|
48
|
-
* has to do the same.
|
|
49
|
-
*
|
|
50
|
-
* @param {string|string[]} roots root user id(s)
|
|
51
|
-
* @param {Array<{_id?:*, user_id:*, reporting_manager:*}>} employees flat employee list
|
|
52
|
-
* @param {Object} [options]
|
|
53
|
-
* @param {Function} [options.onUnresolvable] called once when the list carries no `_id` at
|
|
54
|
-
* all — see below. Defaults to `console.warn`; pass the app logger to route it properly.
|
|
55
|
-
* @returns {string[]} de-duplicated reachable set (roots + all reportees) as STRING ids
|
|
56
|
-
*/
|
|
57
|
-
export function resolveReporteeUserIds(roots, employees, options = {}) {
|
|
58
|
-
const rootIds = (Array.isArray(roots) ? roots : [roots]).filter((id) => id != null).map(String);
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* employee `_id` -> that employee's user id.
|
|
62
|
-
*
|
|
63
|
-
* Built first and over the whole list, because a manager may appear anywhere in it — a
|
|
64
|
-
* single pass that resolved as it went would miss any manager listed after their reportee.
|
|
65
|
-
*
|
|
66
|
-
* Employees with no `user_id` are still indexed: such a row cannot be a REPORTEE (nothing
|
|
67
|
-
* to return for them) but it can perfectly well be a MANAGER, and dropping it here would
|
|
68
|
-
* silently orphan their whole downline. `null` marks "known employee, no portal user".
|
|
69
|
-
*/
|
|
70
|
-
const userIdByEmployeeId = new Map();
|
|
71
|
-
for (const emp of employees || []) {
|
|
72
|
-
if (!emp || emp._id == null) continue;
|
|
73
|
-
userIdByEmployeeId.set(String(emp._id), emp.user_id == null ? null : String(emp.user_id));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* A list with no `_id` on any row is the pre-BRI-973 shape, and nothing in it can ever
|
|
78
|
-
* resolve — every caller gets `[roots]` back, which reads as "this manager has no
|
|
79
|
-
* reportees" rather than as "this list cannot be understood".
|
|
80
|
-
*
|
|
81
|
-
* That is the one failure this cannot leave silent. It is not a degraded answer, it is a
|
|
82
|
-
* confident wrong one, and it is cheap to tell apart: an employee list of any size with
|
|
83
|
-
* an empty index means the caller passed the old shape or dropped `_id` from a projection.
|
|
84
|
-
* A warn here costs nothing on a correct call and turns the worst failure mode into an
|
|
85
|
-
* obvious one. It does not throw — this sits on the login path, and refusing to resolve a
|
|
86
|
-
* scope is a harder failure than reporting one.
|
|
87
|
-
*/
|
|
88
|
-
if ((employees?.length || 0) > 0 && userIdByEmployeeId.size === 0) {
|
|
89
|
-
const warn = options.onUnresolvable || ((message) => console.warn(message));
|
|
90
|
-
warn(
|
|
91
|
-
`[reportee-tree] ${employees.length} employees but not one carries an \`_id\`, so no `
|
|
92
|
-
+ 'reporting_manager reference can resolve and every caller sees zero reportees. The list '
|
|
93
|
-
+ 'is in the pre-BRI-973 shape, or `_id` was dropped from the projection.',
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const byManager = new Map();
|
|
98
|
-
for (const emp of employees || []) {
|
|
99
|
-
if (!emp || !emp.user_id) continue;
|
|
100
|
-
if (emp.reporting_manager == null) continue;
|
|
101
|
-
|
|
102
|
-
const raw = String(emp.reporting_manager);
|
|
103
|
-
/**
|
|
104
|
-
* The reference is an employee `_id`; it is translated to that manager's user id.
|
|
105
|
-
*
|
|
106
|
-
* `get` rather than the old "fall back to reading it as a user id": an unknown `_id` now
|
|
107
|
-
* means either a dangling reference or a row the caller filtered out of the list, and
|
|
108
|
-
* both are conditions to drop the edge on, not to guess about. See the caller contract.
|
|
109
|
-
*/
|
|
110
|
-
const managerId = userIdByEmployeeId.get(raw);
|
|
111
|
-
// Unknown reference, or a manager with no portal user — no key to hang reportees on.
|
|
112
|
-
if (managerId == null) continue;
|
|
113
|
-
|
|
114
|
-
if (!byManager.has(managerId)) byManager.set(managerId, []);
|
|
115
|
-
byManager.get(managerId).push(String(emp.user_id));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const visited = new Set();
|
|
119
|
-
const result = new Set();
|
|
120
|
-
const stack = [...rootIds];
|
|
121
|
-
while (stack.length) {
|
|
122
|
-
const id = stack.pop();
|
|
123
|
-
if (!id || visited.has(id)) continue;
|
|
124
|
-
visited.add(id);
|
|
125
|
-
result.add(id);
|
|
126
|
-
for (const rep of byManager.get(id) || []) stack.push(rep);
|
|
127
|
-
}
|
|
128
|
-
return [...result];
|
|
129
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reportee-tree DFS — @i4e/invest4edu-access-core (Track 3 G3 shared primitive).
|
|
3
|
+
*
|
|
4
|
+
* The one canonical "self + all reportees" traversal, shared by both backends so v1 and v2
|
|
5
|
+
* compose the SAME hierarchy scope. Pure + data-source agnostic: the caller passes a flat
|
|
6
|
+
* employees list `[{ _id, user_id, reporting_manager }]` (v1 feeds the Redis snapshot, v2 the
|
|
7
|
+
* `employees` collection) and one-or-more root user ids (delegation passes `[self, ...givers]`).
|
|
8
|
+
*
|
|
9
|
+
* ── `reporting_manager` is an employee `_id` (BRI-973) ────────────────────────────────
|
|
10
|
+
* It used to hold the manager's **user id**. It now holds the manager's **employee `_id`**,
|
|
11
|
+
* so the hierarchy no longer depends on `employees.user_id` and that field can be dropped.
|
|
12
|
+
*
|
|
13
|
+
* Through the rollout this accepted both shapes; that widening is gone. Only an `_id` that is
|
|
14
|
+
* present in the list resolves — an unrecognised reference detaches that manager's downline
|
|
15
|
+
* rather than being read as a user id, which is the correct reading now that no row should
|
|
16
|
+
* hold one.
|
|
17
|
+
*
|
|
18
|
+
* ── DEPLOY ORDER: migration first, then this version ──────────────────────────────────
|
|
19
|
+
* This is a BREAKING change to the data this function can read, on a 0.x minor. Against
|
|
20
|
+
* un-migrated rows it returns ZERO reportees — and since it feeds record visibility and the
|
|
21
|
+
* lead access scope, that is a silent narrowing of what people can see rather than an error
|
|
22
|
+
* anyone would notice.
|
|
23
|
+
*
|
|
24
|
+
* So: run nfd-api-node's migrations/verify-reporting-manager-to-employee-id.js and get a
|
|
25
|
+
* clean verdict FIRST, then ship this version. The window that matters is a rolling deploy,
|
|
26
|
+
* where an upgraded instance and un-migrated rows can coexist for real minutes — the
|
|
27
|
+
* migration has to be complete before the first instance carrying this code serves traffic,
|
|
28
|
+
* not merely started.
|
|
29
|
+
*
|
|
30
|
+
* Roots and the returned set stay **user ids** throughout, because that is what the callers
|
|
31
|
+
* compare against (`assigned_rm_id`, `rm_id`). Only the manager reference is translated.
|
|
32
|
+
*
|
|
33
|
+
* ── CALLER CONTRACT: pass the manager's own row ───────────────────────────────────────
|
|
34
|
+
* An employee-id reference can only be resolved if the row carrying that `_id` is in the
|
|
35
|
+
* list. A user-id reference never needed a lookup, so under the old shape an absent manager
|
|
36
|
+
* row cost nothing — under the new shape it silently detaches that manager's entire
|
|
37
|
+
* downline.
|
|
38
|
+
*
|
|
39
|
+
* So a caller must feed EVERY employee for linking, and filter unwanted people out of the
|
|
40
|
+
* RESULT. Feeding a pre-filtered list (`{ status: 1 }`, an account scope, anything) drops
|
|
41
|
+
* link rows, and what you lose is not the filtered person but everyone beneath them.
|
|
42
|
+
*
|
|
43
|
+
* The caller this bites is nfd-api-node's `src/cache/employeeCache.js`, which builds the
|
|
44
|
+
* hierarchy snapshot. It filtered `{ status: 1 }` and argued the filter was safe because it
|
|
45
|
+
* "already breaks the chain at a deactivated manager" — true under the old shape, where the
|
|
46
|
+
* manager's user id was the map key whether or not their row was present, and false under
|
|
47
|
+
* this one. It now selects every employee and filters the RESULT instead. Any new caller
|
|
48
|
+
* has to do the same.
|
|
49
|
+
*
|
|
50
|
+
* @param {string|string[]} roots root user id(s)
|
|
51
|
+
* @param {Array<{_id?:*, user_id:*, reporting_manager:*}>} employees flat employee list
|
|
52
|
+
* @param {Object} [options]
|
|
53
|
+
* @param {Function} [options.onUnresolvable] called once when the list carries no `_id` at
|
|
54
|
+
* all — see below. Defaults to `console.warn`; pass the app logger to route it properly.
|
|
55
|
+
* @returns {string[]} de-duplicated reachable set (roots + all reportees) as STRING ids
|
|
56
|
+
*/
|
|
57
|
+
export function resolveReporteeUserIds(roots, employees, options = {}) {
|
|
58
|
+
const rootIds = (Array.isArray(roots) ? roots : [roots]).filter((id) => id != null).map(String);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* employee `_id` -> that employee's user id.
|
|
62
|
+
*
|
|
63
|
+
* Built first and over the whole list, because a manager may appear anywhere in it — a
|
|
64
|
+
* single pass that resolved as it went would miss any manager listed after their reportee.
|
|
65
|
+
*
|
|
66
|
+
* Employees with no `user_id` are still indexed: such a row cannot be a REPORTEE (nothing
|
|
67
|
+
* to return for them) but it can perfectly well be a MANAGER, and dropping it here would
|
|
68
|
+
* silently orphan their whole downline. `null` marks "known employee, no portal user".
|
|
69
|
+
*/
|
|
70
|
+
const userIdByEmployeeId = new Map();
|
|
71
|
+
for (const emp of employees || []) {
|
|
72
|
+
if (!emp || emp._id == null) continue;
|
|
73
|
+
userIdByEmployeeId.set(String(emp._id), emp.user_id == null ? null : String(emp.user_id));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A list with no `_id` on any row is the pre-BRI-973 shape, and nothing in it can ever
|
|
78
|
+
* resolve — every caller gets `[roots]` back, which reads as "this manager has no
|
|
79
|
+
* reportees" rather than as "this list cannot be understood".
|
|
80
|
+
*
|
|
81
|
+
* That is the one failure this cannot leave silent. It is not a degraded answer, it is a
|
|
82
|
+
* confident wrong one, and it is cheap to tell apart: an employee list of any size with
|
|
83
|
+
* an empty index means the caller passed the old shape or dropped `_id` from a projection.
|
|
84
|
+
* A warn here costs nothing on a correct call and turns the worst failure mode into an
|
|
85
|
+
* obvious one. It does not throw — this sits on the login path, and refusing to resolve a
|
|
86
|
+
* scope is a harder failure than reporting one.
|
|
87
|
+
*/
|
|
88
|
+
if ((employees?.length || 0) > 0 && userIdByEmployeeId.size === 0) {
|
|
89
|
+
const warn = options.onUnresolvable || ((message) => console.warn(message));
|
|
90
|
+
warn(
|
|
91
|
+
`[reportee-tree] ${employees.length} employees but not one carries an \`_id\`, so no `
|
|
92
|
+
+ 'reporting_manager reference can resolve and every caller sees zero reportees. The list '
|
|
93
|
+
+ 'is in the pre-BRI-973 shape, or `_id` was dropped from the projection.',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const byManager = new Map();
|
|
98
|
+
for (const emp of employees || []) {
|
|
99
|
+
if (!emp || !emp.user_id) continue;
|
|
100
|
+
if (emp.reporting_manager == null) continue;
|
|
101
|
+
|
|
102
|
+
const raw = String(emp.reporting_manager);
|
|
103
|
+
/**
|
|
104
|
+
* The reference is an employee `_id`; it is translated to that manager's user id.
|
|
105
|
+
*
|
|
106
|
+
* `get` rather than the old "fall back to reading it as a user id": an unknown `_id` now
|
|
107
|
+
* means either a dangling reference or a row the caller filtered out of the list, and
|
|
108
|
+
* both are conditions to drop the edge on, not to guess about. See the caller contract.
|
|
109
|
+
*/
|
|
110
|
+
const managerId = userIdByEmployeeId.get(raw);
|
|
111
|
+
// Unknown reference, or a manager with no portal user — no key to hang reportees on.
|
|
112
|
+
if (managerId == null) continue;
|
|
113
|
+
|
|
114
|
+
if (!byManager.has(managerId)) byManager.set(managerId, []);
|
|
115
|
+
byManager.get(managerId).push(String(emp.user_id));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const visited = new Set();
|
|
119
|
+
const result = new Set();
|
|
120
|
+
const stack = [...rootIds];
|
|
121
|
+
while (stack.length) {
|
|
122
|
+
const id = stack.pop();
|
|
123
|
+
if (!id || visited.has(id)) continue;
|
|
124
|
+
visited.add(id);
|
|
125
|
+
result.add(id);
|
|
126
|
+
for (const rep of byManager.get(id) || []) stack.push(rep);
|
|
127
|
+
}
|
|
128
|
+
return [...result];
|
|
129
|
+
}
|