@i4e/invest4edu-access-core 0.30.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.
- package/README.md +126 -86
- package/package.json +56 -53
- 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 -589
- package/src/entitlement.js +300 -300
- package/src/entity-status.js +165 -0
- package/src/grid-schema.js +231 -231
- package/src/index.js +74 -57
- package/src/portal-roles.js +66 -0
- package/src/proration.js +155 -155
- package/src/reportee-tree.js +129 -35
- package/src/role-capabilities.js +93 -93
- package/src/route-features.js +292 -292
- package/src/subscription-lifecycle.js +106 -106
- package/src/tenant-context.js +26 -26
- package/src/tenant-plugin.js +177 -177
- package/src/user-entity-link.js +58 -0
- package/src/visible-when.js +108 -108
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One definition of "may this entity be used to log in" — @i4e/invest4edu-access-core.
|
|
3
|
+
*
|
|
4
|
+
* A person is one `users` row that may link to several entities (partner, client,
|
|
5
|
+
* employee, manufacturer). Each entity carries its own activation, so switching one off
|
|
6
|
+
* must not disturb the others: a partner whose client record is disabled still logs into
|
|
7
|
+
* the portal, and a client whose partner record is disabled still uses the app.
|
|
8
|
+
* `users.status` stays what it always was — the platform-level block on the person.
|
|
9
|
+
*
|
|
10
|
+
* ── Why this lives in the package rather than in each backend ─────────────────────────
|
|
11
|
+
* It was written twice, in nfd-api-node/src/utils/entity-status.js and its
|
|
12
|
+
* nfd-api-node-v2 twin, and pinned together by a parity test. That test earned its keep:
|
|
13
|
+
* it caught two `adminRoles` lists that had already diverged, so the same person resolved
|
|
14
|
+
* to a different `role_type` depending on which backend served the login. A parity test
|
|
15
|
+
* fails AFTER someone edits one side and only if it runs, so the copies are the defect and
|
|
16
|
+
* the test was the symptom. Both backends and the frontend now import from here.
|
|
17
|
+
*
|
|
18
|
+
* ── Why the entities disagree with each other ─────────────────────────────────────────
|
|
19
|
+
* Each collection grew its own convention and none can be normalised without rewriting
|
|
20
|
+
* live rows:
|
|
21
|
+
*
|
|
22
|
+
* distributors 1 active · 2 inactive · 3 not-yet-verified · 4 rejected
|
|
23
|
+
* distributorclients 1 active · 0 inactive, and legacy rows hold the boolean `true`
|
|
24
|
+
* because the schema defaults a Number field to it
|
|
25
|
+
* employees 1 active · 2 inactive, no default — legacy rows are undefined
|
|
26
|
+
* manufacturers 1 active · 2 inactive, no default, never written after create
|
|
27
|
+
*
|
|
28
|
+
* So the vocabulary is per-entity by necessity. What this module guarantees is that every
|
|
29
|
+
* caller asks the question the same way.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The entity kinds one person can be linked to. These are wire codes. */
|
|
33
|
+
export const ENTITY = {
|
|
34
|
+
DISTRIBUTOR: 'distributor',
|
|
35
|
+
CLIENT: 'client',
|
|
36
|
+
EMPLOYEE: 'employee',
|
|
37
|
+
MANUFACTURER: 'manufacturer',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The numbers each collection actually stores — see the table above for why they differ.
|
|
42
|
+
*
|
|
43
|
+
* `MAPPING` is the odd one out in kind rather than in value: it is a `userentitymappings`
|
|
44
|
+
* row's own status, saying whether this PERSON may still act through that entity. The
|
|
45
|
+
* entity statuses say whether the business record is live; `USER` says whether the person
|
|
46
|
+
* is. Three different questions, and conflating any two of them switches off more than
|
|
47
|
+
* the caller meant to.
|
|
48
|
+
*/
|
|
49
|
+
export const ENTITY_STATUS = {
|
|
50
|
+
USER: { ACTIVE: 1, INACTIVE: 2 },
|
|
51
|
+
DISTRIBUTOR: { ACTIVE: 1, INACTIVE: 2, NOT_YET_VERIFIED: 3, REJECTED: 4 },
|
|
52
|
+
CLIENT: { ACTIVE: 1, INACTIVE: 0 },
|
|
53
|
+
EMPLOYEE: { ACTIVE: 1, INACTIVE: 2 },
|
|
54
|
+
MANUFACTURER: { ACTIVE: 1, INACTIVE: 2 },
|
|
55
|
+
MAPPING: { ACTIVE: 1, INACTIVE: 2 },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Statuses that still permit a partner login.
|
|
60
|
+
*
|
|
61
|
+
* `not_yet_verified` is deliberately included: a self-signed-up partner sits at 3 for the
|
|
62
|
+
* whole of onboarding and has to reach the complete-profile screens. Treating 3 as "cannot
|
|
63
|
+
* log in" would lock every draft partner out of the flow they just started. `rejected` is
|
|
64
|
+
* excluded — before this, rejected partners were stopped only because rejection also
|
|
65
|
+
* flipped their user row inactive, and that side effect is gone, so the gate states it
|
|
66
|
+
* directly.
|
|
67
|
+
*/
|
|
68
|
+
const DISTRIBUTOR_LOGIN_STATUSES = [
|
|
69
|
+
ENTITY_STATUS.DISTRIBUTOR.ACTIVE,
|
|
70
|
+
ENTITY_STATUS.DISTRIBUTOR.NOT_YET_VERIFIED,
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} entityType one of ENTITY
|
|
75
|
+
* @param {Object|null} doc the entity document (needs `status` projected)
|
|
76
|
+
* @returns {boolean} false only when the entity is explicitly switched off
|
|
77
|
+
*/
|
|
78
|
+
export function isEntityLoginAllowed(entityType, doc) {
|
|
79
|
+
if (!doc) return false;
|
|
80
|
+
const { status } = doc;
|
|
81
|
+
|
|
82
|
+
switch (entityType) {
|
|
83
|
+
case ENTITY.DISTRIBUTOR:
|
|
84
|
+
return DISTRIBUTOR_LOGIN_STATUSES.includes(Number(status));
|
|
85
|
+
|
|
86
|
+
case ENTITY.CLIENT:
|
|
87
|
+
// `true` is not a stand-in for "unknown" here — it is the literal default the
|
|
88
|
+
// schema wrote onto real rows, and it means active.
|
|
89
|
+
return status === true || Number(status) === ENTITY_STATUS.CLIENT.ACTIVE;
|
|
90
|
+
|
|
91
|
+
case ENTITY.EMPLOYEE:
|
|
92
|
+
// No schema default, so undefined is the normal shape of an older row rather than
|
|
93
|
+
// a signal. Only an explicit inactive blocks the login; reading undefined as "off"
|
|
94
|
+
// would shut out every employee predating the field.
|
|
95
|
+
return status === undefined || status === null
|
|
96
|
+
? true
|
|
97
|
+
: Number(status) !== ENTITY_STATUS.EMPLOYEE.INACTIVE;
|
|
98
|
+
|
|
99
|
+
case ENTITY.MANUFACTURER:
|
|
100
|
+
return status === undefined || status === null
|
|
101
|
+
? true
|
|
102
|
+
: Number(status) !== ENTITY_STATUS.MANUFACTURER.INACTIVE;
|
|
103
|
+
|
|
104
|
+
default:
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Message shown when an entity is switched off. Entity-specific so support can tell them apart. */
|
|
110
|
+
export function entityInactiveMessage(entityType) {
|
|
111
|
+
switch (entityType) {
|
|
112
|
+
case ENTITY.DISTRIBUTOR:
|
|
113
|
+
return 'Associated Partnership account is not active. Please contact support for assistance.';
|
|
114
|
+
case ENTITY.CLIENT:
|
|
115
|
+
return 'Your client account is inactive. Please contact support.';
|
|
116
|
+
case ENTITY.EMPLOYEE:
|
|
117
|
+
return 'Your employee account is inactive. Please contact support for assistance.';
|
|
118
|
+
case ENTITY.MANUFACTURER:
|
|
119
|
+
return 'Your manufacturer account is inactive. Please contact support for assistance.';
|
|
120
|
+
default:
|
|
121
|
+
return 'Your account is inactive. Please contact support for assistance.';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether a portal session's entities all permit the login, and which one refuses.
|
|
127
|
+
*
|
|
128
|
+
* The portal is every hat except client — the client app is a separate surface with its
|
|
129
|
+
* own gate. A user holding none of them (an admin, a plain business user) has nothing
|
|
130
|
+
* entity-level to fail on and passes.
|
|
131
|
+
*
|
|
132
|
+
* EVERY hat present is examined, not just the first. Partner and employee cannot coexist
|
|
133
|
+
* on one person, so that pair is safe either way — but manufacturer conflicts with
|
|
134
|
+
* neither, so a person holding an active partner hat and a switched-off manufacturer hat
|
|
135
|
+
* would pass a first-hat-only check without the manufacturer ever being looked at.
|
|
136
|
+
*
|
|
137
|
+
* Order still matters for the ANSWER rather than for the verdict: partner is reported
|
|
138
|
+
* ahead of employee and manufacturer, so the message names the hat the person is most
|
|
139
|
+
* likely on the portal for.
|
|
140
|
+
*
|
|
141
|
+
* @param {Object} p
|
|
142
|
+
* @param {*} [p.distributorId] the partner this user IS, from ownDistributorIdOf
|
|
143
|
+
* @param {Object} [p.distributor] partner doc, `status` projected
|
|
144
|
+
* @param {Object} [p.employee] employee doc, `status` projected
|
|
145
|
+
* @param {Object} [p.manufacturer] manufacturer doc, `status` projected
|
|
146
|
+
* @returns {{allowed: boolean, entityType?: string, message?: string}}
|
|
147
|
+
*/
|
|
148
|
+
export function checkPortalEntityAccess({ distributorId, distributor, employee, manufacturer }) {
|
|
149
|
+
const hats = [
|
|
150
|
+
distributorId ? [ENTITY.DISTRIBUTOR, distributor] : null,
|
|
151
|
+
employee ? [ENTITY.EMPLOYEE, employee] : null,
|
|
152
|
+
manufacturer ? [ENTITY.MANUFACTURER, manufacturer] : null,
|
|
153
|
+
].filter(Boolean);
|
|
154
|
+
|
|
155
|
+
if (!hats.length) return { allowed: true };
|
|
156
|
+
|
|
157
|
+
const refused = hats.find(([entityType, doc]) => !isEntityLoginAllowed(entityType, doc));
|
|
158
|
+
if (refused) {
|
|
159
|
+
const [entityType] = refused;
|
|
160
|
+
return { allowed: false, entityType, message: entityInactiveMessage(entityType) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The hat this session is best described by — the same precedence the list is built in.
|
|
164
|
+
return { allowed: true, entityType: hats[0][0] };
|
|
165
|
+
}
|
package/src/grid-schema.js
CHANGED
|
@@ -1,231 +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
|
-
};
|
|
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
|
+
};
|