@nest-my-admin/core 0.3.2 → 0.5.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 +56 -0
- package/dist/admin.module.js +6 -0
- package/dist/admin.module.js.map +1 -1
- package/dist/api/action.service.js +3 -0
- package/dist/api/action.service.js.map +1 -1
- package/dist/api/admin-api.service.d.ts +54 -2
- package/dist/api/admin-api.service.js +320 -11
- package/dist/api/admin-api.service.js.map +1 -1
- package/dist/api/menu-routes.d.ts +16 -0
- package/dist/api/menu-routes.js +10 -0
- package/dist/api/menu-routes.js.map +1 -0
- package/dist/api/menu.d.ts +15 -0
- package/dist/api/menu.js +85 -0
- package/dist/api/menu.js.map +1 -0
- package/dist/appearance/appearance.service.d.ts +28 -0
- package/dist/appearance/appearance.service.js +88 -0
- package/dist/appearance/appearance.service.js.map +1 -0
- package/dist/appearance/entities.d.ts +8 -0
- package/dist/appearance/entities.js +35 -0
- package/dist/appearance/entities.js.map +1 -0
- package/dist/audit/entities.d.ts +4 -0
- package/dist/audit/entities.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/constants.js.map +1 -1
- package/dist/contract.d.ts +256 -7
- package/dist/crud/computed-columns.d.ts +65 -0
- package/dist/crud/computed-columns.js +264 -0
- package/dist/crud/computed-columns.js.map +1 -0
- package/dist/crud/list-query-builder.d.ts +3 -1
- package/dist/crud/list-query-builder.js +5 -2
- package/dist/crud/list-query-builder.js.map +1 -1
- package/dist/crud/list-query.js +6 -1
- package/dist/crud/list-query.js.map +1 -1
- package/dist/crud/list-tabs.d.ts +7 -0
- package/dist/crud/list-tabs.js +44 -0
- package/dist/crud/list-tabs.js.map +1 -0
- package/dist/dashboard/dashboard-routes.d.ts +18 -0
- package/dist/dashboard/dashboard-routes.js +13 -0
- package/dist/dashboard/dashboard-routes.js.map +1 -0
- package/dist/dashboard/dashboard.service.d.ts +20 -0
- package/dist/dashboard/dashboard.service.js +80 -0
- package/dist/dashboard/dashboard.service.js.map +1 -0
- package/dist/dashboard/widget-registry.d.ts +22 -0
- package/dist/dashboard/widget-registry.js +77 -0
- package/dist/dashboard/widget-registry.js.map +1 -0
- package/dist/decorators/admin-action.d.ts +15 -2
- package/dist/decorators/admin-action.js +21 -2
- package/dist/decorators/admin-action.js.map +1 -1
- package/dist/decorators/admin-field.d.ts +23 -3
- package/dist/decorators/admin-field.js +1 -1
- package/dist/decorators/admin-field.js.map +1 -1
- package/dist/decorators/admin-print.d.ts +35 -0
- package/dist/decorators/admin-print.js +53 -0
- package/dist/decorators/admin-print.js.map +1 -0
- package/dist/decorators/admin-resource.d.ts +7 -0
- package/dist/decorators/admin-resource.js.map +1 -1
- package/dist/decorators/admin-widget.d.ts +27 -0
- package/dist/decorators/admin-widget.js +14 -0
- package/dist/decorators/admin-widget.js.map +1 -0
- package/dist/export/export-cells.d.ts +8 -0
- package/dist/export/export-cells.js +45 -0
- package/dist/export/export-cells.js.map +1 -0
- package/dist/export/xlsx.d.ts +23 -0
- package/dist/export/xlsx.js +115 -0
- package/dist/export/xlsx.js.map +1 -0
- package/dist/http/admin-http.server.d.ts +5 -1
- package/dist/http/admin-http.server.js +41 -2
- package/dist/http/admin-http.server.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/inlines/inline-config.d.ts +3 -0
- package/dist/inlines/inline-config.js +11 -3
- package/dist/inlines/inline-config.js.map +1 -1
- package/dist/inlines/inline-reader.d.ts +5 -0
- package/dist/inlines/inline-reader.js +9 -2
- package/dist/inlines/inline-reader.js.map +1 -1
- package/dist/inlines/inline-writer.js +4 -0
- package/dist/inlines/inline-writer.js.map +1 -1
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -1
- package/dist/options.d.ts +49 -0
- package/dist/options.js +31 -0
- package/dist/options.js.map +1 -1
- package/dist/policy/admin-policy.service.d.ts +14 -1
- package/dist/policy/admin-policy.service.js +127 -9
- package/dist/policy/admin-policy.service.js.map +1 -1
- package/dist/policy/catalog.d.ts +2 -2
- package/dist/policy/catalog.js +5 -4
- package/dist/policy/catalog.js.map +1 -1
- package/dist/policy/explain.js +3 -1
- package/dist/policy/explain.js.map +1 -1
- package/dist/print/html.d.ts +16 -0
- package/dist/print/html.js +37 -0
- package/dist/print/html.js.map +1 -0
- package/dist/print/print-page.d.ts +10 -0
- package/dist/print/print-page.js +47 -0
- package/dist/print/print-page.js.map +1 -0
- package/dist/rbac/rbac.service.d.ts +3 -1
- package/dist/rbac/rbac.service.js +8 -4
- package/dist/rbac/rbac.service.js.map +1 -1
- package/dist/registry/resource-registry.d.ts +25 -0
- package/dist/registry/resource-registry.js +109 -0
- package/dist/registry/resource-registry.js.map +1 -1
- package/dist/resource/admin-resource-base.d.ts +120 -9
- package/dist/resource/admin-resource-base.js +16 -2
- package/dist/resource/admin-resource-base.js.map +1 -1
- package/dist/schema/build-resource-schema.js +104 -6
- package/dist/schema/build-resource-schema.js.map +1 -1
- package/dist/schema/currency.d.ts +9 -0
- package/dist/schema/currency.js +11 -0
- package/dist/schema/currency.js.map +1 -0
- package/dist/schema/field-config.d.ts +33 -4
- package/dist/schema/field-config.js +68 -3
- package/dist/schema/field-config.js.map +1 -1
- package/package.json +2 -2
|
@@ -11,15 +11,29 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
11
11
|
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
12
|
};
|
|
13
13
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
14
|
-
import { applyFieldConfig, localizeLayout } from '../schema/field-config.js';
|
|
14
|
+
import { applyFieldConfig, localizeLayout, mainLayout } from '../schema/field-config.js';
|
|
15
15
|
import { inlineOf, toLayoutInline } from '../inlines/inline-config.js';
|
|
16
16
|
import { InlineReader } from '../inlines/inline-reader.js';
|
|
17
17
|
import { InlineWriter } from '../inlines/inline-writer.js';
|
|
18
18
|
import { persianLike } from '../i18n/persian.js';
|
|
19
|
-
import { toErrorResponse } from '../http/error-response.js';
|
|
19
|
+
import { hostError, toErrorResponse } from '../http/error-response.js';
|
|
20
20
|
import { toActionSchema } from '../decorators/admin-action.js';
|
|
21
|
+
import { printCode, toPrintSchema } from '../decorators/admin-print.js';
|
|
22
|
+
import { printPage } from '../print/print-page.js';
|
|
23
|
+
import { toCell } from '../export/export-cells.js';
|
|
24
|
+
import { workbook } from '../export/xlsx.js';
|
|
25
|
+
import { isRtlLocale } from '../i18n/localized-text.js';
|
|
26
|
+
/** Rows read per query while exporting. */
|
|
27
|
+
const EXPORT_PAGE = 500;
|
|
28
|
+
/** Records one bulk print may hold. */
|
|
29
|
+
const PRINT_MAX = 100;
|
|
21
30
|
import { ADMIN_OPTIONS, MAX_BULK } from '../constants.js';
|
|
22
31
|
import { parseListQuery } from '../crud/list-query.js';
|
|
32
|
+
import { buildMenu, checkMenu } from './menu.js';
|
|
33
|
+
import { DashboardService } from '../dashboard/dashboard.service.js';
|
|
34
|
+
import { WidgetRegistry } from '../dashboard/widget-registry.js';
|
|
35
|
+
import { loadComputed } from '../crud/computed-columns.js';
|
|
36
|
+
import { resourceFileOwner } from '../files/file-owner.js';
|
|
23
37
|
import { encodeRecordId, parseRecordId, recordIdOf } from '../crud/record-id.js';
|
|
24
38
|
import { likePattern } from '../crud/list-query-builder.js';
|
|
25
39
|
import { parseId } from '../crud/list-query.js';
|
|
@@ -46,19 +60,41 @@ let AdminApiService = class AdminApiService {
|
|
|
46
60
|
options;
|
|
47
61
|
policy;
|
|
48
62
|
files;
|
|
63
|
+
pageRegistry;
|
|
64
|
+
audit;
|
|
65
|
+
dashboard;
|
|
66
|
+
widgets;
|
|
49
67
|
logger = new Logger('NestMyAdmin');
|
|
50
68
|
/** The write transaction and what takes part in every change made inside it: files, then the audit log. */
|
|
51
69
|
writes;
|
|
52
|
-
constructor(registry, options, policy, files,
|
|
70
|
+
constructor(registry, options, policy, files, pageRegistry, audit, dashboard, widgets) {
|
|
53
71
|
this.registry = registry;
|
|
54
72
|
this.options = options;
|
|
55
73
|
this.policy = policy;
|
|
56
74
|
this.files = files;
|
|
75
|
+
this.pageRegistry = pageRegistry;
|
|
76
|
+
this.audit = audit;
|
|
77
|
+
this.dashboard = dashboard;
|
|
78
|
+
this.widgets = widgets;
|
|
79
|
+
const pages = pageRegistry;
|
|
57
80
|
// Without `audit` nothing is recorded: the audit log is not even a participant.
|
|
58
81
|
this.writes = new WriteRunner(options, this.logger, [filesParticipant(files), ...(audit.enabled ? [auditParticipant(audit)] : [])]);
|
|
59
82
|
this.pages = new PageService({ pages, policy, options, files, writes: this.writes });
|
|
60
83
|
this.actions = new ActionService({ policy, options, logger: this.logger, writes: this.writes, record: (entry, entity, ctx) => this.record(entry, entity, ctx) });
|
|
61
|
-
this.inlines = new InlineReader({
|
|
84
|
+
this.inlines = new InlineReader({
|
|
85
|
+
policy,
|
|
86
|
+
serialize: (entry, entities, ctx) => this.fullRecords(entry, entities, ctx),
|
|
87
|
+
computed: async (inline, entities, records, ctx) => {
|
|
88
|
+
const child = inline.child;
|
|
89
|
+
const visible = policy.computedVisible(child, ctx);
|
|
90
|
+
const columns = inline.computed.filter((key) => visible.has(key)).map((key) => child.computed.get(key));
|
|
91
|
+
if (columns.length === 0)
|
|
92
|
+
return;
|
|
93
|
+
const values = await this.computedValues(child, columns, entities, ctx);
|
|
94
|
+
for (const record of records)
|
|
95
|
+
record._computed = values.get(String(record._id)) ?? {};
|
|
96
|
+
},
|
|
97
|
+
});
|
|
62
98
|
this.inlineWriter = new InlineWriter({
|
|
63
99
|
policy,
|
|
64
100
|
view: (entry, ctx) => this.view(entry, ctx),
|
|
@@ -95,7 +131,8 @@ let AdminApiService = class AdminApiService {
|
|
|
95
131
|
meta(locale = this.options.locale, ctx) {
|
|
96
132
|
const text = (label) => resolveText(label, locale, this.options.locale);
|
|
97
133
|
// Only resources the user may view (spec §6.5: meta is filtered per user).
|
|
98
|
-
|
|
134
|
+
// `nav: false` resources have no entry of their own (spec §5): reachable by URL, not listed.
|
|
135
|
+
const resources = this.registry.list().filter((entry) => entry.nav && (!ctx || this.policy.permissionsOf(ctx).canOn(entry.schema.name, 'view')));
|
|
99
136
|
const pages = this.pages.summaries(locale, ctx);
|
|
100
137
|
// Inside a group: `order` first (lower first), then the label in the request's language.
|
|
101
138
|
const byOrder = (a, b) => a.order - b.order || a.summary.label.localeCompare(b.summary.label, locale);
|
|
@@ -130,7 +167,99 @@ let AdminApiService = class AdminApiService {
|
|
|
130
167
|
resources: items,
|
|
131
168
|
pages: groupPages,
|
|
132
169
|
}));
|
|
133
|
-
|
|
170
|
+
const menu = this.options.menu && buildMenu(this.options.menu, text, groups.flatMap((group) => group.resources), groups.flatMap((group) => group.pages));
|
|
171
|
+
// With widgets in the app, whether this user has a dashboard (it replaces the home page).
|
|
172
|
+
const dashboard = this.widgets.list().length > 0 ? { dashboard: ctx ? this.dashboard.has(ctx) : true } : {};
|
|
173
|
+
return { schemaVersion: 1, title: text(this.options.title), locale, locales: this.options.locales, groups, ...dashboard, ...(menu ? { menu } : {}) };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Image cells hold a file key; the page gets its URL (the storage's public one, or the admin's download route, which
|
|
177
|
+
* checks the viewer again). A value that is already an http(s) or relative URL stays; anything else becomes null.
|
|
178
|
+
*/
|
|
179
|
+
async imageUrls(entry, computed, values, ctx) {
|
|
180
|
+
for (const column of computed) {
|
|
181
|
+
if (column.column.kind !== 'image')
|
|
182
|
+
continue;
|
|
183
|
+
const source = column.source;
|
|
184
|
+
const holder = source.type === 'first' ? this.registry.forEntity(source.relation.inverseEntityMetadata.target, entry.dataSource) : source.type === 'field' && !source.path.includes('.') ? entry : undefined;
|
|
185
|
+
const field = source.type === 'first' ? source.field : source.type === 'field' ? source.path : undefined;
|
|
186
|
+
const isFile = holder && field && ['file', 'image'].includes(holder.fieldConfig.get(field)?.widget ?? '');
|
|
187
|
+
const cells = [...values.values()].map((row) => row[column.key]).filter((cell) => cell !== undefined);
|
|
188
|
+
const keys = cells.map((cell) => cell.value).filter((value) => typeof value === 'string');
|
|
189
|
+
const urls = isFile && keys.length > 0 ? await this.files.refs(resourceFileOwner(holder, this.policy), keys.map((key) => ({ [field]: key })), [field], ctx) : [];
|
|
190
|
+
const byKey = new Map(keys.map((key, index) => [key, urls[index]?.[field]?.url]));
|
|
191
|
+
for (const cell of cells) {
|
|
192
|
+
const value = typeof cell.value === 'string' ? cell.value : undefined;
|
|
193
|
+
cell.value = value === undefined ? null : (byKey.get(value) ?? (/^(https?:\/\/|\/)/i.test(value) ? value : null));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* A list's count for this user (menu badges, tab counts): through the resource's findMany, so `query()` and scopes
|
|
199
|
+
* apply; an overridden findMany may ignore scopes, so for a scoped user it goes through the scoped base path.
|
|
200
|
+
*/
|
|
201
|
+
async count(entry, params, ctx) {
|
|
202
|
+
const overridden = entry.resource.findMany !== AdminResourceBase.prototype.findMany;
|
|
203
|
+
const found = overridden && this.policy.scoped(entry, 'view', ctx) ? await AdminResourceBase.prototype.findMany.call(entry.resource, params, ctx) : await entry.resource.findMany(params, ctx);
|
|
204
|
+
return found.total === null || found.total === undefined ? undefined : { count: found.total, estimate: found.estimated === true };
|
|
205
|
+
}
|
|
206
|
+
/** `GET /api/resources/:resource/tab-counts`: each tab this user may use, counted in parallel (`null`: `count: 'none'`). */
|
|
207
|
+
async tabCounts(name, ctx) {
|
|
208
|
+
const entry = this.registry.get(name);
|
|
209
|
+
this.policy.require(entry, 'view', ctx);
|
|
210
|
+
const schema = this.view(entry, ctx).schema;
|
|
211
|
+
const counts = await Promise.all((schema.list.tabs ?? []).map(async (tab) => {
|
|
212
|
+
if (tab.count === 'none')
|
|
213
|
+
return [tab.key, null];
|
|
214
|
+
const params = parseListQuery(new URLSearchParams({ ...tab.filter, ...(tab.where ? { tab: tab.key } : {}), pageSize: '1' }), schema);
|
|
215
|
+
params.count = tab.count;
|
|
216
|
+
if (params.pagination === 'keyset')
|
|
217
|
+
params.after = undefined;
|
|
218
|
+
return [tab.key, (await this.count(entry, params, ctx)) ?? null];
|
|
219
|
+
}));
|
|
220
|
+
return Object.fromEntries(counts);
|
|
221
|
+
}
|
|
222
|
+
/** Computed columns of some rows of a resource, image cells as URLs. */
|
|
223
|
+
async computedValues(entry, columns, entities, ctx) {
|
|
224
|
+
const locale = ctx.locale ?? this.options.locale;
|
|
225
|
+
const values = await loadComputed(columns, entities, entry.metadata, ctx, this.registry.childQuery, (alias) => entry.resource.readQuery(alias, ctx), (label) => resolveText(label, locale, this.options.locale), (target, field) => this.registry.forEntity(target, entry.dataSource)?.schema.fields.find((candidate) => candidate.name === field));
|
|
226
|
+
await this.imageUrls(entry, columns, values, ctx);
|
|
227
|
+
return values;
|
|
228
|
+
}
|
|
229
|
+
/** The menu's references are checked once every resource and settings page is registered. */
|
|
230
|
+
onApplicationBootstrap() {
|
|
231
|
+
if (this.options.menu)
|
|
232
|
+
checkMenu(this.options.menu, this.registry, this.pageRegistry);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* `GET /api/menu/badges`: each menu badge counted for this user through the list's own path (scopes and `query()`
|
|
236
|
+
* apply). A badge whose filters name a field this user cannot see, or on a resource they cannot view, is left out.
|
|
237
|
+
*/
|
|
238
|
+
async badges(ctx) {
|
|
239
|
+
const out = {};
|
|
240
|
+
const perms = this.policy.permissionsOf(ctx);
|
|
241
|
+
for (const section of this.options.menu?.sections ?? []) {
|
|
242
|
+
for (const item of section.items) {
|
|
243
|
+
if (!('resource' in item) || !item.badge)
|
|
244
|
+
continue;
|
|
245
|
+
const entry = this.registry.get(item.resource);
|
|
246
|
+
if (!perms.canOn(entry.schema.name, 'view'))
|
|
247
|
+
continue;
|
|
248
|
+
let params;
|
|
249
|
+
try {
|
|
250
|
+
params = parseListQuery(new URLSearchParams({ ...item.badge, pageSize: '1' }), this.view(entry, ctx).schema);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
continue; // a filter on a field this user cannot see: no count, so nothing to compare
|
|
254
|
+
}
|
|
255
|
+
// `none` lists are not counted page by page, but a badge is a count: estimate it instead.
|
|
256
|
+
params.count = params.count === 'none' ? 'estimate' : params.count;
|
|
257
|
+
const counted = await this.count(entry, params, ctx);
|
|
258
|
+
if (counted)
|
|
259
|
+
out[`${section.key}.${item.resource}`] = counted;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
134
263
|
}
|
|
135
264
|
/** A resource's schema with its labels in `locale`. */
|
|
136
265
|
schema(name, locale = this.options.locale, ctx) {
|
|
@@ -144,11 +273,17 @@ let AdminApiService = class AdminApiService {
|
|
|
144
273
|
return { ...item, label: fieldLabel ? `${base} (${fieldLabel})` : base };
|
|
145
274
|
});
|
|
146
275
|
const fields = entry.schema.fields.map((field) => applyFieldConfig(field, entry.fieldConfig.get(field.name), locale, this.options.locale, this.options.files?.maxBytes, this.files.enabled));
|
|
147
|
-
const layout = entry.resource.form?.layout;
|
|
276
|
+
const layout = mainLayout(entry.resource.form?.layout);
|
|
148
277
|
const inline = (node, label) => toLayoutInline(inlineOf(entry, node), label, locale, this.options.locale);
|
|
149
278
|
const form = layout ? { ...entry.schema.form, layout: localizeLayout(layout, locale, this.options.locale, inline) } : entry.schema.form;
|
|
150
279
|
const actions = [...entry.actions.values()].map((action) => toActionSchema(action, locale, this.options.locale));
|
|
151
|
-
const
|
|
280
|
+
const prints = [...entry.prints.values()].map((print) => toPrintSchema(print, locale, this.options.locale));
|
|
281
|
+
const computed = entry.schema.list.computed && Object.fromEntries(Object.entries(entry.schema.list.computed).map(([key, column]) => [key, { ...column, label: text(entry.computed.get(key).column.label) }]));
|
|
282
|
+
const tabs = entry.schema.list.tabs?.map((tab, index) => ({ ...tab, label: text(entry.resource.list.tabs[index].label) }));
|
|
283
|
+
const list = { ...entry.schema.list, ...(computed ? { computed } : {}), ...(tabs ? { tabs } : {}) };
|
|
284
|
+
const asideConfig = entry.resource.form?.layout && !Array.isArray(entry.resource.form.layout) ? entry.resource.form.layout.aside : undefined;
|
|
285
|
+
const localizedForm = form.aside && asideConfig ? { ...form, aside: form.aside.map((card, index) => ({ ...card, title: text(asideConfig[index].title) })) } : form;
|
|
286
|
+
const localized = { ...entry.schema, label: text(entry.label), related, fields, form: localizedForm, actions, prints, list };
|
|
152
287
|
const schema = ctx ? this.policy.view(entry, localized, ctx).schema : localized;
|
|
153
288
|
// The child schema of every inline left in the layout (the policy removed those the user may not view).
|
|
154
289
|
const children = (schema.form.layout ?? []).flatMap((node) => ('tab' in node ? node.sections : [node])).flatMap((node) => ('inline' in node ? [node.inline] : []));
|
|
@@ -176,8 +311,18 @@ let AdminApiService = class AdminApiService {
|
|
|
176
311
|
const mobile = schema.list.mobile;
|
|
177
312
|
const shown = new Set([...schema.list.columns, ...(mobile ? [mobile.title, mobile.subtitle, mobile.badge, ...mobile.meta] : [])]);
|
|
178
313
|
const loaded = schema.fields.filter((field) => isLoadedField(field) && shown.has(field.name));
|
|
314
|
+
const records = await this.records(entry, items, loaded, ctx);
|
|
315
|
+
// Computed columns the viewer sees (the schema kept only those) and the page shows: one batch for the page.
|
|
316
|
+
const computed = [...entry.computed.values()].filter((column) => schema.list.computed?.[column.key] && shown.has(column.key));
|
|
317
|
+
if (computed.length > 0) {
|
|
318
|
+
const locale = ctx.locale ?? this.options.locale;
|
|
319
|
+
const values = await loadComputed(computed, items, entry.metadata, ctx, this.registry.childQuery, (alias) => entry.resource.readQuery(alias, ctx), (label) => resolveText(label, locale, this.options.locale), (entity, field) => this.registry.forEntity(entity, entry.dataSource)?.schema.fields.find((candidate) => candidate.name === field));
|
|
320
|
+
await this.imageUrls(entry, computed, values, ctx);
|
|
321
|
+
for (const record of records)
|
|
322
|
+
record._computed = values.get(String(record._id)) ?? {};
|
|
323
|
+
}
|
|
179
324
|
return {
|
|
180
|
-
items:
|
|
325
|
+
items: records,
|
|
181
326
|
total: total ?? null,
|
|
182
327
|
...(estimated ? { estimated: true } : {}),
|
|
183
328
|
...(hasMore !== undefined ? { hasMore } : {}),
|
|
@@ -200,7 +345,7 @@ let AdminApiService = class AdminApiService {
|
|
|
200
345
|
throw new AdminValidationError({ limit: ['must be an integer from 1 to 20'] });
|
|
201
346
|
const locale = ctx.locale ?? this.options.locale;
|
|
202
347
|
const perms = this.policy.permissionsOf(ctx);
|
|
203
|
-
const searchable = this.registry.list().filter((entry) => perms.canOn(entry.schema.name, 'view') && this.view(entry, ctx).schema.list.search.length > 0);
|
|
348
|
+
const searchable = this.registry.list().filter((entry) => entry.nav && perms.canOn(entry.schema.name, 'view') && this.view(entry, ctx).schema.list.search.length > 0);
|
|
204
349
|
const results = await Promise.all(searchable.map(async (entry) => {
|
|
205
350
|
const schema = this.view(entry, ctx).schema;
|
|
206
351
|
const sortPath = entry.sortPaths.get(schema.list.defaultSort.field);
|
|
@@ -246,8 +391,57 @@ let AdminApiService = class AdminApiService {
|
|
|
246
391
|
const record = await this.record(entry, entity, ctx);
|
|
247
392
|
if (entry.inlines.length > 0)
|
|
248
393
|
await this.inlines.readInto(record, entry, entity, ctx);
|
|
394
|
+
await this.recordPage(entry, entity, record, ctx);
|
|
249
395
|
return record;
|
|
250
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* What the record page shows beside the form (spec §5.6): each relation aside card's record (`_aside`, read through
|
|
399
|
+
* the target's own GET, so its permissions and scopes apply; absent when this user may not see it) and the computed
|
|
400
|
+
* columns its header names (`_computed`).
|
|
401
|
+
*/
|
|
402
|
+
async recordPage(entry, entity, record, ctx) {
|
|
403
|
+
const form = this.view(entry, ctx).schema.form;
|
|
404
|
+
const aside = {};
|
|
405
|
+
for (const [index, card] of (form.aside ?? []).entries()) {
|
|
406
|
+
if (!card.relation)
|
|
407
|
+
continue;
|
|
408
|
+
const value = record[card.relation.field];
|
|
409
|
+
const id = value && typeof value === 'object' ? value.id : value;
|
|
410
|
+
if (id === null || id === undefined)
|
|
411
|
+
continue;
|
|
412
|
+
try {
|
|
413
|
+
const related = await this.get(card.relation.resource, encodeRecordId([id]), ctx);
|
|
414
|
+
aside[String(index)] = { _id: related._id, _title: related._title, ...Object.fromEntries(card.fields.filter((field) => field in related).map((field) => [field, related[field]])) };
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
if (!(error instanceof AdminNotFoundError) && !(error instanceof AdminForbiddenError))
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (Object.keys(aside).length > 0)
|
|
422
|
+
record._aside = aside;
|
|
423
|
+
// Stepper fields: when each step was last reached, from the history child (its read path: query() and scopes),
|
|
424
|
+
// only when this user may see its value and time fields.
|
|
425
|
+
for (const field of this.view(entry, ctx).schema.fields) {
|
|
426
|
+
const history = field.steps?.history ? entry.steppers.get(field.name) : undefined;
|
|
427
|
+
if (!history)
|
|
428
|
+
continue;
|
|
429
|
+
const { child } = history;
|
|
430
|
+
const levels = this.policy.levels(child, ctx);
|
|
431
|
+
if (!this.policy.permissionsOf(ctx).canOn(child.schema.name, 'view') || [history.value, history.at].some((name) => (levels.get(name) ?? 'hidden') === 'hidden'))
|
|
432
|
+
continue;
|
|
433
|
+
const qb = child.resource.readQuery('nma_h', ctx).select(`nma_h.${history.value}`, 'v').addSelect(`MAX(nma_h.${history.at})`, 't');
|
|
434
|
+
qb.andWhere(`nma_h.${history.link} = :nmaParent`, { nmaParent: recordIdValue(entity, entry.schema.primaryKeys[0]) });
|
|
435
|
+
Object.entries(history.where).forEach(([name, value], index) => qb.andWhere(`nma_h.${name} = :nmaWhere${index}`, { [`nmaWhere${index}`]: value }));
|
|
436
|
+
const rows = await qb.groupBy(`nma_h.${history.value}`).getRawMany();
|
|
437
|
+
record._steps = { ...(record._steps ?? {}), ...Object.fromEntries(rows.map((row) => [row.v, isoTime(row.t)])) };
|
|
438
|
+
}
|
|
439
|
+
const summaries = (form.layout ?? []).flatMap((node) => ('tab' in node ? node.sections : [node])).flatMap((node) => ('summary' in node ? node.summary : []));
|
|
440
|
+
const keys = [...(form.header?.subtitle ?? []), ...summaries.map((row) => row.value)].flatMap((item) => ('computed' in item ? [item.computed] : []));
|
|
441
|
+
const computed = keys.flatMap((key) => (entry.computed.has(key) ? [entry.computed.get(key)] : []));
|
|
442
|
+
if (computed.length > 0)
|
|
443
|
+
record._computed = (await this.computedValues(entry, [...new Set(computed)], [entity], ctx)).get(String(record._id)) ?? {};
|
|
444
|
+
}
|
|
251
445
|
/** Serializes entities with their relation values and paths (`fields`) loaded and their titles. */
|
|
252
446
|
async records(entry, entities, fields, ctx) {
|
|
253
447
|
// Only what this user may see is serialized (spec §6.5).
|
|
@@ -610,6 +804,105 @@ let AdminApiService = class AdminApiService {
|
|
|
610
804
|
runAction(name, action, body, ctx) {
|
|
611
805
|
return this.actions.run(this.registry.get(name), action, body, ctx);
|
|
612
806
|
}
|
|
807
|
+
/**
|
|
808
|
+
* `GET /resources/:name/print/:print?id=…`: an `@AdminPrint` template's page for one record (or up to 100 with
|
|
809
|
+
* `bulk`). Each record is read like `GET /resources/:name/:id` (view permission, scopes) and its printing is audited.
|
|
810
|
+
*/
|
|
811
|
+
async print(name, printName, rawIds, ctx) {
|
|
812
|
+
const entry = this.registry.get(name);
|
|
813
|
+
this.policy.require(entry, 'view', ctx);
|
|
814
|
+
const print = entry.prints.get(printName);
|
|
815
|
+
if (!print)
|
|
816
|
+
throw new AdminNotFoundError(`${entry.schema.label} has no print template "${printName}"`);
|
|
817
|
+
if (!this.policy.canPrint(entry, printName, ctx))
|
|
818
|
+
throw new AdminForbiddenError(`You may not print "${resolveText(print.label, ctx.locale ?? this.options.locale, this.options.locale)}"`);
|
|
819
|
+
const ids = [...new Set(rawIds)];
|
|
820
|
+
if (ids.length === 0)
|
|
821
|
+
throw new AdminBadRequestError('id is required');
|
|
822
|
+
if (ids.length > (print.bulk ? PRINT_MAX : 1))
|
|
823
|
+
throw new AdminBadRequestError(print.bulk ? `at most ${PRINT_MAX} records at once` : 'this template prints one record');
|
|
824
|
+
const entities = [];
|
|
825
|
+
for (const rawId of ids) {
|
|
826
|
+
const id = parseRecordId(rawId, entry.schema);
|
|
827
|
+
const entity = await entry.resource.findOne(id, ctx);
|
|
828
|
+
if (!entity)
|
|
829
|
+
throw new AdminNotFoundError(`${entry.schema.label} "${rawId}" not found`);
|
|
830
|
+
await this.policy.requireInScope(entry, id, rawId, 'view', ctx);
|
|
831
|
+
entities.push(entity);
|
|
832
|
+
}
|
|
833
|
+
const method = entry.resource[printName];
|
|
834
|
+
let body;
|
|
835
|
+
try {
|
|
836
|
+
body = await method.call(entry.resource, entities, ctx);
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
throw hostError(error);
|
|
840
|
+
}
|
|
841
|
+
const locale = ctx.locale ?? this.options.locale;
|
|
842
|
+
const label = resolveText(print.label, locale, this.options.locale);
|
|
843
|
+
for (const entity of entities) {
|
|
844
|
+
await this.audit.recordAfter({ event: printCode(entry.schema.name, printName), entry, recordId: recordIdOf(entity, entry.schema.primaryKeys), title: entry.title(entity) }, ctx);
|
|
845
|
+
}
|
|
846
|
+
const title = entities.length === 1 ? `${label} · ${entry.title(entities[0])}` : label;
|
|
847
|
+
return printPage({ title, body: String(body ?? ''), size: print.size, locale });
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* `GET /resources/:name/export?<list query>`: the rows the list shows for this query (filters, search, tab, sort and
|
|
851
|
+
* scopes, page and page size ignored) as an `.xlsx`, in the export's columns this user may see, up to `max` rows.
|
|
852
|
+
* `offset` (minutes east of UTC) puts date-times on the exporter's clock. The export is audited.
|
|
853
|
+
*/
|
|
854
|
+
async export(name, query, ctx) {
|
|
855
|
+
const entry = this.registry.get(name);
|
|
856
|
+
this.policy.require(entry, 'view', ctx);
|
|
857
|
+
if (!entry.export)
|
|
858
|
+
throw new AdminNotFoundError(`${entry.schema.label} has no export`);
|
|
859
|
+
if (!this.policy.canExport(entry, ctx))
|
|
860
|
+
throw new AdminForbiddenError(`You may not export ${entry.schema.label} records`);
|
|
861
|
+
const rest = new URLSearchParams(query);
|
|
862
|
+
const rawOffset = rest.get('offset');
|
|
863
|
+
const offset = rawOffset === null ? 0 : Number(rawOffset);
|
|
864
|
+
if (!Number.isInteger(offset) || Math.abs(offset) > 14 * 60)
|
|
865
|
+
throw new AdminValidationError({ offset: ['must be minutes east of UTC, from -840 to 840'] });
|
|
866
|
+
for (const key of ['offset', 'page', 'pageSize', 'after'])
|
|
867
|
+
rest.delete(key);
|
|
868
|
+
const schema = this.view(entry, ctx).schema;
|
|
869
|
+
const params = parseListQuery(rest, schema);
|
|
870
|
+
const sortPath = entry.sortPaths.get(params.sort.field);
|
|
871
|
+
if (sortPath)
|
|
872
|
+
params.sort = { ...params.sort, field: sortPath };
|
|
873
|
+
const { max } = entry.export;
|
|
874
|
+
const scoped = entry.resource.findMany !== AdminResourceBase.prototype.findMany && this.policy.scoped(entry, 'view', ctx);
|
|
875
|
+
const items = [];
|
|
876
|
+
let truncated = false;
|
|
877
|
+
for (let page = 1;; page++) {
|
|
878
|
+
const found = await entry.resource.findMany({ ...params, page, pageSize: EXPORT_PAGE, count: 'none', pagination: 'offset', after: undefined }, ctx);
|
|
879
|
+
let batch = found.items;
|
|
880
|
+
// A findMany override may ignore the scopes; rows outside them never leave (spec §6.4).
|
|
881
|
+
if (scoped) {
|
|
882
|
+
const allowed = await this.policy.allowedIds(entry, batch.map((item) => this.idOf(entry, item)), 'view', ctx);
|
|
883
|
+
batch = batch.filter((item) => allowed.has(recordIdOf(item, schema.primaryKeys)));
|
|
884
|
+
}
|
|
885
|
+
items.push(...batch);
|
|
886
|
+
if (items.length > max) {
|
|
887
|
+
items.length = max;
|
|
888
|
+
truncated = true;
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
if (found.items.length < EXPORT_PAGE || found.hasMore === false)
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
const locale = ctx.locale ?? this.options.locale;
|
|
895
|
+
const localized = this.schema(name, locale, ctx);
|
|
896
|
+
const visible = new Map(localized.fields.map((field) => [field.name, field]));
|
|
897
|
+
const columns = entry.export.columns.flatMap((column) => (visible.has(column) ? [visible.get(column)] : []));
|
|
898
|
+
const records = await this.records(entry, items, schema.fields.filter((field) => columns.some((column) => column.name === field.name) && isLoadedField(field)), ctx);
|
|
899
|
+
const rows = records.map((record) => columns.map((field) => toCell(record[field.name], field, locale, offset)));
|
|
900
|
+
const label = localized.label;
|
|
901
|
+
const file = workbook({ sheet: label, header: columns.map((field) => field.label), rows, rtl: isRtlLocale(locale) });
|
|
902
|
+
await this.audit.recordAfter({ event: `${entry.schema.name}.export`, entry, meta: { query: rest.toString().slice(0, 2_000), rows: rows.length, ...(truncated ? { truncated: true } : {}) } }, ctx);
|
|
903
|
+
const day = new Date(Date.now() + offset * 60_000).toISOString().slice(0, 10);
|
|
904
|
+
return { file, filename: `${entry.schema.name}-${day}.xlsx`, rows: rows.length, truncated };
|
|
905
|
+
}
|
|
613
906
|
/** Every settings page with its group, labelled in `locale` (the roles catalog). */
|
|
614
907
|
pageSummaries(locale = this.options.locale) {
|
|
615
908
|
return this.pages.summaries(locale);
|
|
@@ -708,7 +1001,9 @@ AdminApiService = __decorate([
|
|
|
708
1001
|
__metadata("design:paramtypes", [ResourceRegistry, Object, AdminPolicy,
|
|
709
1002
|
FilesService,
|
|
710
1003
|
PageRegistry,
|
|
711
|
-
AdminAudit
|
|
1004
|
+
AdminAudit,
|
|
1005
|
+
DashboardService,
|
|
1006
|
+
WidgetRegistry])
|
|
712
1007
|
], AdminApiService);
|
|
713
1008
|
export { AdminApiService };
|
|
714
1009
|
/** A serialized record as relationOptions() values: relation refs become ids. */
|
|
@@ -744,4 +1039,18 @@ function parseValues(raw) {
|
|
|
744
1039
|
}
|
|
745
1040
|
return parsed;
|
|
746
1041
|
}
|
|
1042
|
+
/** The record's (single) key value. */
|
|
1043
|
+
function recordIdValue(entity, key) {
|
|
1044
|
+
return entity[key];
|
|
1045
|
+
}
|
|
1046
|
+
/** A time from MAX() as an ISO string: drivers answer a Date, or (SQLite) text in UTC without a zone. */
|
|
1047
|
+
function isoTime(value) {
|
|
1048
|
+
if (value === null || value === undefined)
|
|
1049
|
+
return null;
|
|
1050
|
+
if (value instanceof Date)
|
|
1051
|
+
return value.toISOString();
|
|
1052
|
+
const text = String(value);
|
|
1053
|
+
const date = new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(text) ? text : `${text.replace(' ', 'T')}Z`);
|
|
1054
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
1055
|
+
}
|
|
747
1056
|
//# sourceMappingURL=admin-api.service.js.map
|