@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.
@@ -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
+ }
@@ -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
+ };