@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.
@@ -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
+ };
package/src/index.js CHANGED
@@ -1,74 +1,75 @@
1
- /**
2
- * @i4e/invest4edu-access-core — shared access-control primitives for the NFD backends.
3
- * Tenant isolation (D1 keystone) + role capabilities (G7).
4
- */
5
- export { default as als, runWithTenant, runAsSystem, getTenantStore } from "./tenant-context.js";
6
- export {
7
- default as tenantPlugin,
8
- setTenantMode,
9
- getTenantMode,
10
- setTenantWriteMode,
11
- getTenantWriteMode,
12
- TENANT_MODES,
13
- } from "./tenant-plugin.js";
14
- export {
15
- CAPABILITY_FLAGS,
16
- DEFAULT_ROLE_CAPABILITIES,
17
- buildCapabilityMap,
18
- resolveCapabilities,
19
- } from "./role-capabilities.js";
20
- export { resolveReporteeUserIds } from "./reportee-tree.js";
21
- export {
22
- ENTITY,
23
- ENTITY_STATUS,
24
- isEntityLoginAllowed,
25
- entityInactiveMessage,
26
- checkPortalEntityAccess,
27
- } from "./entity-status.js";
28
- export {
29
- CONFLICTING_ENTITIES,
30
- conflictingEntityFor,
31
- entityConflictMessage,
32
- } from "./user-entity-link.js";
33
- export {
34
- CLIENT_APP_ROLE_NAMES,
35
- ROLE_TYPE_PRIORITY,
36
- pickPortalRoleName,
37
- } from "./portal-roles.js";
38
- export {
39
- DEFAULT_ACCESS_FLAGS,
40
- ACCESS_FLAG_ENV_NAMES,
41
- flagEnvName,
42
- readEnvFlags,
43
- resolveFlags,
44
- } from "./access-config.js";
45
-
46
- // ── Unified access framework (P1) ─────────────────────────────────────────────────────────────
47
- export {
48
- FEATURE_TYPES,
49
- REGISTRY_STATUSES,
50
- ROLLOUT_MODES,
51
- GRANT_EFFECTS,
52
- GRANT_SUBJECTS,
53
- FEATURE_CODE_PATTERN,
54
- isValidFeatureCode,
55
- parentCodeOf,
56
- moduleCodeOf,
57
- ACCESS_MODULE_DEF,
58
- ACCESS_FEATURE_DEF,
59
- ACCESS_GRANT_DEF,
60
- } from "./access-schema.js";
61
- export {
62
- resolveAccessSnapshot,
63
- mergeGrants,
64
- buildNav,
65
- hasFeature,
66
- isUsable,
67
- ENTITLEMENT_REASONS,
68
- } from "./access-resolver.js";
69
- export {
70
- evaluateVisibleWhen,
71
- filterVisible,
72
- applyRelabel,
73
- resolveColumns,
74
- } from "./visible-when.js";
1
+ /**
2
+ * @i4e/invest4edu-access-core — shared access-control primitives for the NFD backends.
3
+ * Tenant isolation (D1 keystone) + role capabilities (G7).
4
+ */
5
+ export { default as als, runWithTenant, runAsSystem, getTenantStore } from "./tenant-context.js";
6
+ export {
7
+ default as tenantPlugin,
8
+ setTenantMode,
9
+ getTenantMode,
10
+ setTenantWriteMode,
11
+ getTenantWriteMode,
12
+ TENANT_MODES,
13
+ } from "./tenant-plugin.js";
14
+ export {
15
+ CAPABILITY_FLAGS,
16
+ DEFAULT_ROLE_CAPABILITIES,
17
+ buildCapabilityMap,
18
+ resolveCapabilities,
19
+ } from "./role-capabilities.js";
20
+ export { resolveReporteeUserIds } from "./reportee-tree.js";
21
+ export {
22
+ ENTITY,
23
+ ENTITY_STATUS,
24
+ isEntityLoginAllowed,
25
+ entityInactiveMessage,
26
+ checkPortalEntityAccess,
27
+ } from "./entity-status.js";
28
+ export {
29
+ CONFLICTING_ENTITIES,
30
+ conflictingEntityFor,
31
+ entityConflictMessage,
32
+ } from "./user-entity-link.js";
33
+ export {
34
+ CLIENT_APP_ROLE_NAMES,
35
+ ROLE_TYPE_PRIORITY,
36
+ pickPortalRoleName,
37
+ } from "./portal-roles.js";
38
+ export {
39
+ DEFAULT_ACCESS_FLAGS,
40
+ ACCESS_FLAG_ENV_NAMES,
41
+ flagEnvName,
42
+ readEnvFlags,
43
+ resolveFlags,
44
+ } from "./access-config.js";
45
+
46
+ // ── Unified access framework (P1) ─────────────────────────────────────────────────────────────
47
+ export {
48
+ FEATURE_TYPES,
49
+ REGISTRY_STATUSES,
50
+ ROLLOUT_MODES,
51
+ GRANT_EFFECTS,
52
+ GRANT_SUBJECTS,
53
+ FEATURE_CODE_PATTERN,
54
+ isValidFeatureCode,
55
+ parentCodeOf,
56
+ moduleCodeOf,
57
+ ACCESS_MODULE_DEF,
58
+ ACCESS_FEATURE_DEF,
59
+ ACCESS_GRANT_DEF,
60
+ } from "./access-schema.js";
61
+ export { normalizeRoute, screenForRoute } from "./route-screen.js";
62
+ export {
63
+ resolveAccessSnapshot,
64
+ mergeGrants,
65
+ buildNav,
66
+ hasFeature,
67
+ isUsable,
68
+ ENTITLEMENT_REASONS,
69
+ } from "./access-resolver.js";
70
+ export {
71
+ evaluateVisibleWhen,
72
+ filterVisible,
73
+ applyRelabel,
74
+ resolveColumns,
75
+ } from "./visible-when.js";