@i4e/invest4edu-access-core 0.6.1 → 0.8.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 +5 -2
- package/src/entitlement-schema.js +183 -0
- package/src/entitlement.js +166 -0
- package/src/grid-schema.js +231 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
"./access-config": "./src/access-config.js",
|
|
13
13
|
"./access-schema": "./src/access-schema.js",
|
|
14
14
|
"./access-resolver": "./src/access-resolver.js",
|
|
15
|
-
"./visible-when": "./src/visible-when.js"
|
|
15
|
+
"./visible-when": "./src/visible-when.js",
|
|
16
|
+
"./entitlement": "./src/entitlement.js",
|
|
17
|
+
"./entitlement-schema": "./src/entitlement-schema.js",
|
|
18
|
+
"./grid-schema": "./src/grid-schema.js"
|
|
16
19
|
},
|
|
17
20
|
"scripts": {
|
|
18
21
|
"test": "node --test test/"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entitlement / subscription schema DEFINITIONS — @i4e/invest4edu-access-core (P6).
|
|
3
|
+
*
|
|
4
|
+
* Plain objects like access-schema.js (D4: no Mongoose in the package). Each backend registers its
|
|
5
|
+
* own models from these, so field names cannot drift between v1 and v2.
|
|
6
|
+
*
|
|
7
|
+
* RBAC answers "may they?"; entitlement answers "is it unlocked, and are they in quota?". Both must
|
|
8
|
+
* pass. See TDD §6, and §6.2a for the 2026-07-29 amendment that generalised quota period + subject
|
|
9
|
+
* and added the service-facing meter.
|
|
10
|
+
*
|
|
11
|
+
* Design: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TDD-unified-access-and-subscription.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** How often a quota allowance resets. Carried in the period key, so no cron is needed. */
|
|
15
|
+
export const PERIOD_GRANULARITIES = Object.freeze(["day", "month", "year", "term", "lifetime"]);
|
|
16
|
+
|
|
17
|
+
/** Whose allowance is being spent. `account` = per firm (the legacy assumption); `user` = per seat. */
|
|
18
|
+
export const QUOTA_SCOPES = Object.freeze(["account", "user"]);
|
|
19
|
+
|
|
20
|
+
/** What happens when the allowance runs out. */
|
|
21
|
+
export const OVERAGE_POLICIES = Object.freeze(["block", "single_usage", "unlimited"]);
|
|
22
|
+
|
|
23
|
+
export const SUBSCRIPTION_STATUSES = Object.freeze([
|
|
24
|
+
"trialing", "active", "past_due", "blocked", "cancelled", "expired",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The timezone every calendar boundary is evaluated in.
|
|
29
|
+
*
|
|
30
|
+
* A daily quota is meaningless without one: in UTC an Indian partner's "10 a day" would reset at
|
|
31
|
+
* 05:30 local, so they would lose the tail of every working day. Matches the `tz` the MIS pipelines
|
|
32
|
+
* already use.
|
|
33
|
+
*/
|
|
34
|
+
export const QUOTA_TIMEZONE = "Asia/Kolkata";
|
|
35
|
+
|
|
36
|
+
/** Reasons an entitlement decision can carry. Stable strings — they surface in APIs and logs. */
|
|
37
|
+
export const ENTITLEMENT_REASONS = Object.freeze({
|
|
38
|
+
OK: "ok",
|
|
39
|
+
NO_SUBSCRIPTION: "no_subscription",
|
|
40
|
+
NOT_IN_PLAN: "not_in_plan",
|
|
41
|
+
LOCKED: "locked",
|
|
42
|
+
QUOTA_EXCEEDED: "quota_exceeded",
|
|
43
|
+
SUBSCRIPTION_BLOCKED: "subscription_blocked",
|
|
44
|
+
ADMIN_BYPASS: "admin_bypass",
|
|
45
|
+
NOT_METERED: "not_metered",
|
|
46
|
+
ENGINE_OFF: "engine_off",
|
|
47
|
+
RESOLVE_FAILED: "resolve_failed",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Global: the plans on sale. */
|
|
51
|
+
export const PLAN_CATALOG_DEF = Object.freeze({
|
|
52
|
+
collection: "plan_catalog",
|
|
53
|
+
fields: Object.freeze({
|
|
54
|
+
plan_code: { type: "String", required: true, unique: true, index: true, immutable: true },
|
|
55
|
+
name: { type: "String", required: true },
|
|
56
|
+
description: { type: "String" },
|
|
57
|
+
billing_cycle: { type: "String", enum: ["monthly", "quarterly", "annual"], default: "annual" },
|
|
58
|
+
price: { type: "Number", default: 0 },
|
|
59
|
+
currency: { type: "String", default: "INR" },
|
|
60
|
+
position: { type: "Number", default: 0 },
|
|
61
|
+
status: { type: "String", enum: ["active", "dormant", "retired"], default: "active" },
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Global: `(plan, cycle, feature)` → what that plan grants for that feature.
|
|
67
|
+
*
|
|
68
|
+
* VERSIONED by `effective_date`: resolve the row with the greatest `effective_date <= now`. Prices
|
|
69
|
+
* and quotas change, and a past invoice must remain explicable — so rows are superseded, never
|
|
70
|
+
* edited in place.
|
|
71
|
+
*/
|
|
72
|
+
export const PLAN_ENTITLEMENT_DEF = Object.freeze({
|
|
73
|
+
collection: "plan_entitlements",
|
|
74
|
+
fields: Object.freeze({
|
|
75
|
+
plan_code: { type: "String", required: true, index: true },
|
|
76
|
+
billing_cycle: { type: "String", required: true },
|
|
77
|
+
feature_code: { type: "String", required: true, index: true },
|
|
78
|
+
|
|
79
|
+
unlocked: { type: "Boolean", default: true },
|
|
80
|
+
// null = unlimited. 0 is a real value: "in the plan, but no allowance".
|
|
81
|
+
quota: { type: "Number", default: null },
|
|
82
|
+
period_granularity: { type: "String", enum: PERIOD_GRANULARITIES, default: "month" },
|
|
83
|
+
quota_scope: { type: "String", enum: QUOTA_SCOPES, default: "account" },
|
|
84
|
+
overage_policy: { type: "String", enum: OVERAGE_POLICIES, default: "block" },
|
|
85
|
+
single_usage_price: { type: "Number", default: null },
|
|
86
|
+
|
|
87
|
+
effective_date: { type: "Date", required: true, index: true },
|
|
88
|
+
superseded_date: { type: "Date", default: null },
|
|
89
|
+
}),
|
|
90
|
+
indexes: Object.freeze([
|
|
91
|
+
{ keys: { plan_code: 1, billing_cycle: 1, feature_code: 1, effective_date: -1 } },
|
|
92
|
+
]),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Tenant: one subscription per firm. Replaces AccountPlan + distributor.selected_plan. */
|
|
96
|
+
export const ACCOUNT_SUBSCRIPTION_DEF = Object.freeze({
|
|
97
|
+
collection: "account_subscriptions",
|
|
98
|
+
fields: Object.freeze({
|
|
99
|
+
account_id: { type: "ObjectId", required: true, index: true },
|
|
100
|
+
plan_code: { type: "String", required: true },
|
|
101
|
+
billing_cycle: { type: "String", required: true },
|
|
102
|
+
status: { type: "String", enum: SUBSCRIPTION_STATUSES, default: "trialing", index: true },
|
|
103
|
+
start_date: { type: "Date", required: true },
|
|
104
|
+
end_date: { type: "Date", default: null },
|
|
105
|
+
is_trial: { type: "Boolean", default: false },
|
|
106
|
+
trial_ends_at: { type: "Date", default: null },
|
|
107
|
+
// Per-firm overrides of the plan row: [{ feature_code, quota, unlocked, … }]. Sales concessions
|
|
108
|
+
// belong here, NOT as an edit to the shared plan row.
|
|
109
|
+
entitlement_overrides: { type: "[Mixed]", default: [] },
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Tenant: the quota counters. MUST live in the app DB — `consume` has to be atomic with the read,
|
|
115
|
+
* so this cannot be a side/analytics store.
|
|
116
|
+
*
|
|
117
|
+
* `subject_key` is the account id or the user id depending on `quota_scope`, so one collection
|
|
118
|
+
* serves both without a second shape.
|
|
119
|
+
*/
|
|
120
|
+
export const SUBSCRIPTION_USAGE_DEF = Object.freeze({
|
|
121
|
+
collection: "subscription_usage",
|
|
122
|
+
fields: Object.freeze({
|
|
123
|
+
subscription_id: { type: "ObjectId", required: true, index: true },
|
|
124
|
+
subject_key: { type: "String", required: true },
|
|
125
|
+
feature_code: { type: "String", required: true },
|
|
126
|
+
period: { type: "String", required: true },
|
|
127
|
+
used: { type: "Number", default: 0 },
|
|
128
|
+
last_consumed_at: { type: "Date", default: null },
|
|
129
|
+
}),
|
|
130
|
+
indexes: Object.freeze([
|
|
131
|
+
{ keys: { subscription_id: 1, subject_key: 1, feature_code: 1, period: 1 }, options: { unique: true } },
|
|
132
|
+
]),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Tenant: the exactly-once ledger for `consume`.
|
|
137
|
+
*
|
|
138
|
+
* A tool that times out and retries must not charge the partner twice, so every consume carries an
|
|
139
|
+
* idempotency key and the unique index — not application logic — is what enforces it.
|
|
140
|
+
*/
|
|
141
|
+
export const USAGE_IDEMPOTENCY_DEF = Object.freeze({
|
|
142
|
+
collection: "usage_idempotency",
|
|
143
|
+
fields: Object.freeze({
|
|
144
|
+
idempotency_key: { type: "String", required: true, unique: true, index: true },
|
|
145
|
+
account_id: { type: "ObjectId", index: true },
|
|
146
|
+
feature_code: { type: "String", required: true },
|
|
147
|
+
subject_key: { type: "String" },
|
|
148
|
+
period: { type: "String" },
|
|
149
|
+
n: { type: "Number", default: 1 },
|
|
150
|
+
// The decision returned the first time, replayed verbatim on a duplicate key.
|
|
151
|
+
result: { type: "Mixed", default: null },
|
|
152
|
+
created_date: { type: "Date", default: "now" },
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/** Global: the service callers allowed to meter, and which features each may meter. */
|
|
157
|
+
export const METER_CLIENT_DEF = Object.freeze({
|
|
158
|
+
collection: "meter_clients",
|
|
159
|
+
fields: Object.freeze({
|
|
160
|
+
client_code: { type: "String", required: true, unique: true, index: true },
|
|
161
|
+
name: { type: "String", required: true },
|
|
162
|
+
// Key Vault SECRET NAME, never the secret. An admin API must never be able to read it back.
|
|
163
|
+
key_vault_ref: { type: "String", required: true },
|
|
164
|
+
// A tool may only meter what it is registered for — a leaked NFD-AI key cannot spend PFA quota.
|
|
165
|
+
allowed_features: { type: "[String]", default: [] },
|
|
166
|
+
status: { type: "String", enum: ["active", "disabled"], default: "active" },
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export default {
|
|
171
|
+
PERIOD_GRANULARITIES,
|
|
172
|
+
QUOTA_SCOPES,
|
|
173
|
+
OVERAGE_POLICIES,
|
|
174
|
+
SUBSCRIPTION_STATUSES,
|
|
175
|
+
QUOTA_TIMEZONE,
|
|
176
|
+
ENTITLEMENT_REASONS,
|
|
177
|
+
PLAN_CATALOG_DEF,
|
|
178
|
+
PLAN_ENTITLEMENT_DEF,
|
|
179
|
+
ACCOUNT_SUBSCRIPTION_DEF,
|
|
180
|
+
SUBSCRIPTION_USAGE_DEF,
|
|
181
|
+
USAGE_IDEMPOTENCY_DEF,
|
|
182
|
+
METER_CLIENT_DEF,
|
|
183
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entitlement resolution — @i4e/invest4edu-access-core (P6). Pure: no DB, no Mongoose.
|
|
3
|
+
*
|
|
4
|
+
* RBAC says whether a user MAY do a thing. This says whether the FIRM's plan has it unlocked and
|
|
5
|
+
* whether the allowance is spent. Both must pass (TDD §4.2).
|
|
6
|
+
*
|
|
7
|
+
* The one hard rule (§4.3): entitlement **fails open**. An outage in the entitlement path must
|
|
8
|
+
* never block a feature a customer has paid for — the worst acceptable outcome is that we
|
|
9
|
+
* occasionally fail to charge. `overage_policy: 'block'` is the only branch that denies, and only
|
|
10
|
+
* on a definite quota-exceeded answer.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
ENTITLEMENT_REASONS as R,
|
|
14
|
+
QUOTA_TIMEZONE,
|
|
15
|
+
PERIOD_GRANULARITIES,
|
|
16
|
+
} from "./entitlement-schema.js";
|
|
17
|
+
|
|
18
|
+
export { ENTITLEMENT_REASONS } from "./entitlement-schema.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Calendar parts of `date` in QUOTA_TIMEZONE.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately via Intl rather than date arithmetic: an offset constant would be wrong the moment
|
|
24
|
+
* anything changes, and a naive `toISOString()` would put an Indian partner's daily reset at 05:30
|
|
25
|
+
* local — silently costing them the tail of every working day.
|
|
26
|
+
*/
|
|
27
|
+
function partsInZone(date, timeZone = QUOTA_TIMEZONE) {
|
|
28
|
+
const fmt = new Intl.DateTimeFormat("en-CA", {
|
|
29
|
+
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
|
|
30
|
+
});
|
|
31
|
+
const [{ value: y }, , { value: m }, , { value: d }] = fmt.formatToParts(date);
|
|
32
|
+
return { y, m, d };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The bucket key an allowance is counted in. The granularity lives IN the key, so a reset needs no
|
|
37
|
+
* cron: at midnight the key simply changes and the new bucket starts at zero.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} granularity day | month | year | term | lifetime
|
|
40
|
+
* @param {Object} [opts]
|
|
41
|
+
* @param {Date} [opts.now]
|
|
42
|
+
* @param {string} [opts.subscriptionId] required for `term`
|
|
43
|
+
* @param {string} [opts.timeZone]
|
|
44
|
+
*/
|
|
45
|
+
export function periodKey(granularity, { now = new Date(), subscriptionId = null, timeZone } = {}) {
|
|
46
|
+
const g = PERIOD_GRANULARITIES.includes(granularity) ? granularity : "month";
|
|
47
|
+
if (g === "lifetime") return "lifetime";
|
|
48
|
+
// A term quota with no subscription would silently collapse every firm into one shared bucket.
|
|
49
|
+
if (g === "term") return subscriptionId ? `term:${String(subscriptionId)}` : "lifetime";
|
|
50
|
+
|
|
51
|
+
const { y, m, d } = partsInZone(now, timeZone);
|
|
52
|
+
if (g === "year") return y;
|
|
53
|
+
if (g === "month") return `${y}-${m}`;
|
|
54
|
+
return `${y}-${m}-${d}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whose allowance this is: the firm, or the individual seat. */
|
|
58
|
+
export function subjectKeyFor(quotaScope, { accountId, userId } = {}) {
|
|
59
|
+
return String((quotaScope === "user" ? userId : accountId) ?? "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pick the live entitlement row for a feature: greatest `effective_date <= now`, not superseded.
|
|
64
|
+
* Rows are versioned rather than edited so a past invoice stays explicable.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveEntitlementRow(rows = [], featureCode, now = new Date()) {
|
|
67
|
+
return rows
|
|
68
|
+
.filter((r) => r
|
|
69
|
+
&& r.feature_code === featureCode
|
|
70
|
+
&& new Date(r.effective_date) <= now
|
|
71
|
+
&& (!r.superseded_date || new Date(r.superseded_date) > now))
|
|
72
|
+
.sort((a, b) => new Date(b.effective_date) - new Date(a.effective_date))[0] || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Apply a per-firm override on top of the plan row. Concessions live on the subscription. */
|
|
76
|
+
function withOverride(row, overrides = [], featureCode) {
|
|
77
|
+
const o = (overrides || []).find((x) => x && x.feature_code === featureCode);
|
|
78
|
+
if (!o) return row;
|
|
79
|
+
const merged = { ...row };
|
|
80
|
+
for (const k of ["unlocked", "quota", "period_granularity", "quota_scope", "overage_policy", "single_usage_price"]) {
|
|
81
|
+
if (o[k] !== undefined) merged[k] = o[k];
|
|
82
|
+
}
|
|
83
|
+
return merged;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decide entitlement for one feature.
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} p
|
|
90
|
+
* @param {Object|null} p.subscription account_subscriptions row
|
|
91
|
+
* @param {Array} p.entitlementRows plan_entitlements rows for this plan+cycle
|
|
92
|
+
* @param {string} p.featureCode
|
|
93
|
+
* @param {Object} p.feature registry row — `is_meterable` decides if quota applies
|
|
94
|
+
* @param {number} p.used already consumed in this period (caller supplies)
|
|
95
|
+
* @param {Object} p.identity { accountId, userId }
|
|
96
|
+
* @param {boolean} [p.bypass] D27 admin break-glass
|
|
97
|
+
* @param {Date} [p.now]
|
|
98
|
+
* @returns {Object} { unlocked, quota, used, remaining, allowed, overage_policy, reason,
|
|
99
|
+
* period, subject_key, metered }
|
|
100
|
+
*/
|
|
101
|
+
export function resolveEntitlement({
|
|
102
|
+
subscription, entitlementRows = [], featureCode, feature = {},
|
|
103
|
+
used = 0, identity = {}, bypass = false, now = new Date(),
|
|
104
|
+
} = {}) {
|
|
105
|
+
const base = {
|
|
106
|
+
quota: null, used, remaining: null, period: null, subject_key: null,
|
|
107
|
+
overage_policy: "unlimited", metered: false,
|
|
108
|
+
};
|
|
109
|
+
const open = (reason) => ({ ...base, unlocked: true, allowed: true, reason });
|
|
110
|
+
|
|
111
|
+
// D27: the break-glass resolves every gate open, including this one.
|
|
112
|
+
if (bypass) return open(R.ADMIN_BYPASS);
|
|
113
|
+
|
|
114
|
+
// `always_available` exists so login/billing/profile survive a blocked subscription — otherwise a
|
|
115
|
+
// past-due firm could not reach the screen that takes their money.
|
|
116
|
+
if (feature.always_available) return open(R.OK);
|
|
117
|
+
|
|
118
|
+
// No subscription: fail OPEN. Absence of a row is far more likely to be our gap than a customer
|
|
119
|
+
// genuinely owning nothing, and denying would break every un-migrated firm on day one.
|
|
120
|
+
if (!subscription) return open(R.NO_SUBSCRIPTION);
|
|
121
|
+
|
|
122
|
+
if (subscription.status === "blocked" || subscription.status === "expired") {
|
|
123
|
+
return { ...base, unlocked: false, allowed: false, reason: R.SUBSCRIPTION_BLOCKED };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const row = withOverride(
|
|
127
|
+
resolveEntitlementRow(entitlementRows, featureCode, now),
|
|
128
|
+
subscription.entitlement_overrides,
|
|
129
|
+
featureCode,
|
|
130
|
+
);
|
|
131
|
+
// Not named in the plan: OPEN. A plan that forgot to list a feature is a config gap, and P6 must
|
|
132
|
+
// not silently switch features off for everyone the moment it ships.
|
|
133
|
+
if (!row) return open(R.NOT_IN_PLAN);
|
|
134
|
+
|
|
135
|
+
if (row.unlocked === false) {
|
|
136
|
+
return { ...base, unlocked: false, allowed: false, overage_policy: row.overage_policy, reason: R.LOCKED };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Unlocked but not metered → nothing to count.
|
|
140
|
+
if (!feature.is_meterable || row.quota === null || row.quota === undefined) {
|
|
141
|
+
return { ...open(feature.is_meterable ? R.OK : R.NOT_METERED), overage_policy: row.overage_policy || "unlimited" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const granularity = row.period_granularity || "month";
|
|
145
|
+
const period = periodKey(granularity, { now, subscriptionId: subscription._id });
|
|
146
|
+
const subject_key = subjectKeyFor(row.quota_scope, identity);
|
|
147
|
+
const remaining = Math.max(0, Number(row.quota) - Number(used || 0));
|
|
148
|
+
const withinQuota = Number(used || 0) < Number(row.quota);
|
|
149
|
+
const policy = row.overage_policy || "block";
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
unlocked: true,
|
|
153
|
+
quota: Number(row.quota),
|
|
154
|
+
used: Number(used || 0),
|
|
155
|
+
remaining,
|
|
156
|
+
// Only `block` denies. single_usage/unlimited let the call through — billing catches up after.
|
|
157
|
+
allowed: withinQuota || policy !== "block",
|
|
158
|
+
overage_policy: policy,
|
|
159
|
+
reason: withinQuota ? R.OK : R.QUOTA_EXCEEDED,
|
|
160
|
+
period,
|
|
161
|
+
subject_key,
|
|
162
|
+
metered: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default { resolveEntitlement, resolveEntitlementRow, periodKey, subjectKeyFor };
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataGrid v2 config — @i4e/invest4edu-access-core (P4). Definitions + validator. No React, no DB.
|
|
3
|
+
*
|
|
4
|
+
* ONE config defines a listing end to end: where the rows come from, which columns exist and who
|
|
5
|
+
* sees each, the filters, the row/bulk actions, the export, and the summary cards. It lives in the
|
|
6
|
+
* shared package because three consumers must agree on it exactly — the UI renders it, v1 serves
|
|
7
|
+
* and re-evaluates it, and the admin screen edits it.
|
|
8
|
+
*
|
|
9
|
+
* ── Why it is strictly serialisable ──────────────────────────────────────────────────────────
|
|
10
|
+
* No functions anywhere. A column says `type: "currency"`, never `cell: (row) => …`. That is the
|
|
11
|
+
* single property that makes a grid storable, diffable, admin-editable and server-re-evaluable.
|
|
12
|
+
* The moment one config carries a callback, it can no longer be saved — and the whole model
|
|
13
|
+
* collapses back into per-screen code.
|
|
14
|
+
*
|
|
15
|
+
* ── Why access binding lives IN the config ───────────────────────────────────────────────────
|
|
16
|
+
* `feature_code` gates the grid, every action carries an `action_code`, and `visibleWhen` gates
|
|
17
|
+
* columns/filters/metrics. So "who may see this column" and "who may press this button" are
|
|
18
|
+
* answered by the same engine that answers "who may open this screen" — not by a second, parallel
|
|
19
|
+
* set of role checks inside a component.
|
|
20
|
+
*
|
|
21
|
+
* `source.collection` is what lets the SERVER apply tenant + product + record scope to the query
|
|
22
|
+
* (scopedMatch). Without it a grid could be pointed at a collection nobody has scoped.
|
|
23
|
+
*
|
|
24
|
+
* Design: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TDD-unified-access-and-subscription.md §8
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const COLUMN_TYPES = Object.freeze([
|
|
28
|
+
"text", "number", "currency", "percent", "date", "datetime",
|
|
29
|
+
"boolean", "status-pill", "link", "actions", "inline-edit",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export const FILTER_TYPES = Object.freeze([
|
|
33
|
+
"text", "select", "multiselect", "reference", "boolean", "exists", "daterange", "numberrange",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export const AGGREGATIONS = Object.freeze(["sum", "avg", "count", "countWhere", "distinct", "raw"]);
|
|
37
|
+
|
|
38
|
+
export const GRID_SCOPES = Object.freeze(["global", "account"]);
|
|
39
|
+
|
|
40
|
+
/** The filter payload dialect the table and its summary endpoint MUST both speak. */
|
|
41
|
+
export const FILTER_DIALECT = "grid-ast-v1";
|
|
42
|
+
|
|
43
|
+
export const GRID_CONFIG_DEF = Object.freeze({
|
|
44
|
+
collection: "grid_configs",
|
|
45
|
+
fields: Object.freeze({
|
|
46
|
+
grid_code: { type: "String", required: true, index: true },
|
|
47
|
+
// Binds the whole grid to the access engine — no grid without a feature.
|
|
48
|
+
feature_code: { type: "String", required: true, index: true },
|
|
49
|
+
name: { type: "String" },
|
|
50
|
+
scope: { type: "String", enum: GRID_SCOPES, default: "global" },
|
|
51
|
+
// Set only on scope:'account' rows; the per-tenant override of a global config.
|
|
52
|
+
account_id: { type: "ObjectId", default: null, index: true },
|
|
53
|
+
config: { type: "Mixed", required: true },
|
|
54
|
+
// Bumped on every save. A saved user layout records the version it was made against, so the
|
|
55
|
+
// renderer can reconcile rather than break.
|
|
56
|
+
version: { type: "Number", default: 1 },
|
|
57
|
+
status: { type: "String", enum: ["active", "draft", "retired"], default: "draft" },
|
|
58
|
+
updated_by: { type: "ObjectId" },
|
|
59
|
+
updated_at: { type: "Date" },
|
|
60
|
+
}),
|
|
61
|
+
indexes: Object.freeze([
|
|
62
|
+
{ keys: { grid_code: 1, scope: 1, account_id: 1 }, options: { unique: true } },
|
|
63
|
+
]),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
67
|
+
const FEATURE_CODE = /^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*){1,2}$/;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate a grid config. Returns `{ valid, errors[], warnings[] }`.
|
|
71
|
+
*
|
|
72
|
+
* Errors are things that would render a broken or unsafe grid; warnings are things that are legal
|
|
73
|
+
* but almost certainly a mistake. The admin screen must block on errors and show warnings — a
|
|
74
|
+
* config is edited by a human and saved straight into what everyone sees, so "it saved fine and
|
|
75
|
+
* then the screen was empty" is the failure mode this exists to prevent.
|
|
76
|
+
*/
|
|
77
|
+
export function validateGridConfig(config) {
|
|
78
|
+
const errors = [];
|
|
79
|
+
const warnings = [];
|
|
80
|
+
const err = (m) => errors.push(m);
|
|
81
|
+
const warn = (m) => warnings.push(m);
|
|
82
|
+
|
|
83
|
+
if (!isObj(config)) return { valid: false, errors: ["config must be an object"], warnings };
|
|
84
|
+
|
|
85
|
+
if (!config.grid_code) err("grid_code is required");
|
|
86
|
+
if (!config.feature_code) err("feature_code is required — a grid must bind to the access engine");
|
|
87
|
+
else if (!FEATURE_CODE.test(config.feature_code)) err(`feature_code "${config.feature_code}" must be MODULE.SCREEN[.ACTION]`);
|
|
88
|
+
|
|
89
|
+
// ── source ──────────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
const src = config.source;
|
|
91
|
+
if (!isObj(src)) err("source is required");
|
|
92
|
+
else {
|
|
93
|
+
if (!src.endpoint) err("source.endpoint is required");
|
|
94
|
+
// Without a collection the server cannot apply tenant/product/record scope to this grid.
|
|
95
|
+
if (!src.collection) err("source.collection is required — the server needs it to scope the query");
|
|
96
|
+
if (src.filter_dialect && src.filter_dialect !== FILTER_DIALECT) {
|
|
97
|
+
err(`source.filter_dialect must be "${FILTER_DIALECT}"`);
|
|
98
|
+
}
|
|
99
|
+
if (!src.id_field) warn("source.id_field not set — defaulting to _id");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── columns ─────────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
const cols = Array.isArray(config.columns) ? config.columns : [];
|
|
104
|
+
if (!cols.length) err("at least one column is required");
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
cols.forEach((c, i) => {
|
|
107
|
+
const at = `columns[${i}]`;
|
|
108
|
+
if (!isObj(c)) return err(`${at} must be an object`);
|
|
109
|
+
if (!c.key) err(`${at}.key is required`);
|
|
110
|
+
else if (seen.has(c.key)) err(`${at}.key "${c.key}" is duplicated — keys address saved layouts`);
|
|
111
|
+
else seen.add(c.key);
|
|
112
|
+
if (!c.header) warn(`${at} ("${c.key}") has no header`);
|
|
113
|
+
if (!c.path && c.type !== "actions") err(`${at} ("${c.key}") needs a path`);
|
|
114
|
+
if (c.type && !COLUMN_TYPES.includes(c.type)) err(`${at}.type "${c.type}" is not a known type`);
|
|
115
|
+
// A callback here is the one thing that would make the config unstorable.
|
|
116
|
+
for (const k of Object.keys(c)) {
|
|
117
|
+
if (typeof c[k] === "function") err(`${at}.${k} is a function — configs must be serialisable`);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
if (cols.length && !cols.some((c) => c.defaultVisible !== false)) {
|
|
121
|
+
err("every column is hidden by default — the grid would render empty");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── filters ─────────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
(config.filters || []).forEach((f, i) => {
|
|
126
|
+
const at = `filters[${i}]`;
|
|
127
|
+
if (!f?.key) err(`${at}.key is required`);
|
|
128
|
+
if (f?.type && !FILTER_TYPES.includes(f.type)) err(`${at}.type "${f.type}" is not a known type`);
|
|
129
|
+
if (f?.type === "select" && !f.options && !f.ref) {
|
|
130
|
+
warn(`${at} ("${f.key}") is a select with neither options nor ref — it will render empty`);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── actions: each is a FEATURE, so it must carry a code ─────────────────────────────────────
|
|
135
|
+
const checkActions = (list, label) => (list || []).forEach((a, i) => {
|
|
136
|
+
const at = `${label}[${i}]`;
|
|
137
|
+
if (!a?.action_code) {
|
|
138
|
+
err(`${at} has no action_code — actions are gated features, an ungated action is a hole`);
|
|
139
|
+
} else if (!FEATURE_CODE.test(a.action_code)) {
|
|
140
|
+
err(`${at}.action_code "${a.action_code}" must be MODULE.SCREEN.ACTION`);
|
|
141
|
+
}
|
|
142
|
+
if (!a?.label) warn(`${at} has no label`);
|
|
143
|
+
// Destructive things must ask. Cheap to require, expensive to forget.
|
|
144
|
+
if (a?.kind === "mutation" && /delete|cancel|remove|revoke/i.test(a.action_code || "") && !a.confirm) {
|
|
145
|
+
warn(`${at} ("${a.action_code}") looks destructive but has no confirm`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
checkActions(config.rowActions, "rowActions");
|
|
149
|
+
checkActions(config.bulkActions, "bulkActions");
|
|
150
|
+
|
|
151
|
+
if (config.export && !config.export.action_code) {
|
|
152
|
+
err("export needs an action_code — exports are gated and meterable like any other action");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── summary ─────────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
if (config.summary) {
|
|
157
|
+
const s = config.summary;
|
|
158
|
+
if (!Array.isArray(s.metrics) || !s.metrics.length) warn("summary has no metrics");
|
|
159
|
+
// The rule the MIS rollups taught us: cards and the table must answer the same question.
|
|
160
|
+
if (s.source === "endpoint" && !s.endpoint) {
|
|
161
|
+
err("summary.source is 'endpoint' but no summary.endpoint is set");
|
|
162
|
+
}
|
|
163
|
+
(s.metrics || []).forEach((m, i) => {
|
|
164
|
+
const at = `summary.metrics[${i}]`;
|
|
165
|
+
if (!m?.key) err(`${at}.key is required`);
|
|
166
|
+
if (m?.agg && !AGGREGATIONS.includes(m.agg)) err(`${at}.agg "${m.agg}" is not a known aggregation`);
|
|
167
|
+
if (m?.agg && m.agg !== "count" && !m.path) err(`${at} ("${m.key}") needs a path for agg "${m.agg}"`);
|
|
168
|
+
if (m?.agg === "countWhere" && !m.where) err(`${at} ("${m.key}") is countWhere with no where clause`);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── pagination / sort ───────────────────────────────────────────────────────────────────────
|
|
173
|
+
if (config.pagination) {
|
|
174
|
+
const p = config.pagination;
|
|
175
|
+
if (p.defaultSize && Array.isArray(p.pageSizes) && !p.pageSizes.includes(p.defaultSize)) {
|
|
176
|
+
err(`pagination.defaultSize ${p.defaultSize} is not one of pageSizes`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (config.defaultSort?.key && !seen.has(config.defaultSort.key)) {
|
|
180
|
+
err(`defaultSort.key "${config.defaultSort.key}" is not a column key`);
|
|
181
|
+
}
|
|
182
|
+
if (config.totalsRow?.perColumn) {
|
|
183
|
+
config.totalsRow.perColumn.forEach((t, i) => {
|
|
184
|
+
if (!seen.has(t.key)) err(`totalsRow.perColumn[${i}].key "${t.key}" is not a column key`);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Reconcile a user's saved layout against the current config.
|
|
193
|
+
*
|
|
194
|
+
* A layout is preference, never permission — so this drops anything the user may no longer see and
|
|
195
|
+
* fills gaps from the config. It cannot fail: the worst case is that the layout contributes nothing
|
|
196
|
+
* and the user gets the default grid.
|
|
197
|
+
*
|
|
198
|
+
* @param {Object} config the grid config
|
|
199
|
+
* @param {Object|null} layout the user's saved layout
|
|
200
|
+
* @param {string[]|null} permittedKeys column keys the access engine allows (null = all)
|
|
201
|
+
*/
|
|
202
|
+
export function applyLayout(config, layout, permittedKeys = null) {
|
|
203
|
+
const cols = Array.isArray(config?.columns) ? config.columns : [];
|
|
204
|
+
const allowed = permittedKeys ? new Set(permittedKeys) : null;
|
|
205
|
+
// Permission first, preference second. Reversing these would let a stale layout pin access.
|
|
206
|
+
const visible = cols.filter((c) => !allowed || allowed.has(c.key));
|
|
207
|
+
|
|
208
|
+
const saved = Array.isArray(layout?.columns) ? layout.columns.filter((k) => visible.some((c) => c.key === k)) : null;
|
|
209
|
+
const ordered = saved && saved.length
|
|
210
|
+
? [
|
|
211
|
+
...saved.map((k) => visible.find((c) => c.key === k)),
|
|
212
|
+
// Columns added to the config since the layout was saved stay available, just not pinned.
|
|
213
|
+
...visible.filter((c) => !saved.includes(c.key)).map((c) => ({ ...c, defaultVisible: false })),
|
|
214
|
+
]
|
|
215
|
+
: visible;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
columns: ordered.filter(Boolean),
|
|
219
|
+
filters: layout?.filters ?? {},
|
|
220
|
+
sort: layout?.sort ?? config?.defaultSort ?? null,
|
|
221
|
+
pageSize: layout?.pageSize ?? config?.pagination?.defaultSize ?? 25,
|
|
222
|
+
// True when the layout referenced something that no longer exists — the UI can offer a reset
|
|
223
|
+
// instead of leaving someone confused by a view that silently lost columns.
|
|
224
|
+
stale: !!(layout?.columns && saved && saved.length !== layout.columns.length),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export default {
|
|
229
|
+
COLUMN_TYPES, FILTER_TYPES, AGGREGATIONS, GRID_SCOPES, FILTER_DIALECT,
|
|
230
|
+
GRID_CONFIG_DEF, validateGridConfig, applyLayout,
|
|
231
|
+
};
|