@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.
Files changed (118) hide show
  1. package/README.md +56 -0
  2. package/dist/admin.module.js +6 -0
  3. package/dist/admin.module.js.map +1 -1
  4. package/dist/api/action.service.js +3 -0
  5. package/dist/api/action.service.js.map +1 -1
  6. package/dist/api/admin-api.service.d.ts +54 -2
  7. package/dist/api/admin-api.service.js +320 -11
  8. package/dist/api/admin-api.service.js.map +1 -1
  9. package/dist/api/menu-routes.d.ts +16 -0
  10. package/dist/api/menu-routes.js +10 -0
  11. package/dist/api/menu-routes.js.map +1 -0
  12. package/dist/api/menu.d.ts +15 -0
  13. package/dist/api/menu.js +85 -0
  14. package/dist/api/menu.js.map +1 -0
  15. package/dist/appearance/appearance.service.d.ts +28 -0
  16. package/dist/appearance/appearance.service.js +88 -0
  17. package/dist/appearance/appearance.service.js.map +1 -0
  18. package/dist/appearance/entities.d.ts +8 -0
  19. package/dist/appearance/entities.js +35 -0
  20. package/dist/appearance/entities.js.map +1 -0
  21. package/dist/audit/entities.d.ts +4 -0
  22. package/dist/audit/entities.js.map +1 -1
  23. package/dist/constants.d.ts +2 -0
  24. package/dist/constants.js +2 -0
  25. package/dist/constants.js.map +1 -1
  26. package/dist/contract.d.ts +256 -7
  27. package/dist/crud/computed-columns.d.ts +65 -0
  28. package/dist/crud/computed-columns.js +264 -0
  29. package/dist/crud/computed-columns.js.map +1 -0
  30. package/dist/crud/list-query-builder.d.ts +3 -1
  31. package/dist/crud/list-query-builder.js +5 -2
  32. package/dist/crud/list-query-builder.js.map +1 -1
  33. package/dist/crud/list-query.js +6 -1
  34. package/dist/crud/list-query.js.map +1 -1
  35. package/dist/crud/list-tabs.d.ts +7 -0
  36. package/dist/crud/list-tabs.js +44 -0
  37. package/dist/crud/list-tabs.js.map +1 -0
  38. package/dist/dashboard/dashboard-routes.d.ts +18 -0
  39. package/dist/dashboard/dashboard-routes.js +13 -0
  40. package/dist/dashboard/dashboard-routes.js.map +1 -0
  41. package/dist/dashboard/dashboard.service.d.ts +20 -0
  42. package/dist/dashboard/dashboard.service.js +80 -0
  43. package/dist/dashboard/dashboard.service.js.map +1 -0
  44. package/dist/dashboard/widget-registry.d.ts +22 -0
  45. package/dist/dashboard/widget-registry.js +77 -0
  46. package/dist/dashboard/widget-registry.js.map +1 -0
  47. package/dist/decorators/admin-action.d.ts +15 -2
  48. package/dist/decorators/admin-action.js +21 -2
  49. package/dist/decorators/admin-action.js.map +1 -1
  50. package/dist/decorators/admin-field.d.ts +23 -3
  51. package/dist/decorators/admin-field.js +1 -1
  52. package/dist/decorators/admin-field.js.map +1 -1
  53. package/dist/decorators/admin-print.d.ts +35 -0
  54. package/dist/decorators/admin-print.js +53 -0
  55. package/dist/decorators/admin-print.js.map +1 -0
  56. package/dist/decorators/admin-resource.d.ts +7 -0
  57. package/dist/decorators/admin-resource.js.map +1 -1
  58. package/dist/decorators/admin-widget.d.ts +27 -0
  59. package/dist/decorators/admin-widget.js +14 -0
  60. package/dist/decorators/admin-widget.js.map +1 -0
  61. package/dist/export/export-cells.d.ts +8 -0
  62. package/dist/export/export-cells.js +45 -0
  63. package/dist/export/export-cells.js.map +1 -0
  64. package/dist/export/xlsx.d.ts +23 -0
  65. package/dist/export/xlsx.js +115 -0
  66. package/dist/export/xlsx.js.map +1 -0
  67. package/dist/http/admin-http.server.d.ts +5 -1
  68. package/dist/http/admin-http.server.js +41 -2
  69. package/dist/http/admin-http.server.js.map +1 -1
  70. package/dist/index.d.ts +6 -2
  71. package/dist/index.js +4 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/inlines/inline-config.d.ts +3 -0
  74. package/dist/inlines/inline-config.js +11 -3
  75. package/dist/inlines/inline-config.js.map +1 -1
  76. package/dist/inlines/inline-reader.d.ts +5 -0
  77. package/dist/inlines/inline-reader.js +9 -2
  78. package/dist/inlines/inline-reader.js.map +1 -1
  79. package/dist/inlines/inline-writer.js +4 -0
  80. package/dist/inlines/inline-writer.js.map +1 -1
  81. package/dist/internal.d.ts +3 -0
  82. package/dist/internal.js +2 -0
  83. package/dist/internal.js.map +1 -1
  84. package/dist/options.d.ts +49 -0
  85. package/dist/options.js +31 -0
  86. package/dist/options.js.map +1 -1
  87. package/dist/policy/admin-policy.service.d.ts +14 -1
  88. package/dist/policy/admin-policy.service.js +127 -9
  89. package/dist/policy/admin-policy.service.js.map +1 -1
  90. package/dist/policy/catalog.d.ts +2 -2
  91. package/dist/policy/catalog.js +5 -4
  92. package/dist/policy/catalog.js.map +1 -1
  93. package/dist/policy/explain.js +3 -1
  94. package/dist/policy/explain.js.map +1 -1
  95. package/dist/print/html.d.ts +16 -0
  96. package/dist/print/html.js +37 -0
  97. package/dist/print/html.js.map +1 -0
  98. package/dist/print/print-page.d.ts +10 -0
  99. package/dist/print/print-page.js +47 -0
  100. package/dist/print/print-page.js.map +1 -0
  101. package/dist/rbac/rbac.service.d.ts +3 -1
  102. package/dist/rbac/rbac.service.js +8 -4
  103. package/dist/rbac/rbac.service.js.map +1 -1
  104. package/dist/registry/resource-registry.d.ts +25 -0
  105. package/dist/registry/resource-registry.js +109 -0
  106. package/dist/registry/resource-registry.js.map +1 -1
  107. package/dist/resource/admin-resource-base.d.ts +120 -9
  108. package/dist/resource/admin-resource-base.js +16 -2
  109. package/dist/resource/admin-resource-base.js.map +1 -1
  110. package/dist/schema/build-resource-schema.js +104 -6
  111. package/dist/schema/build-resource-schema.js.map +1 -1
  112. package/dist/schema/currency.d.ts +9 -0
  113. package/dist/schema/currency.js +11 -0
  114. package/dist/schema/currency.js.map +1 -0
  115. package/dist/schema/field-config.d.ts +33 -4
  116. package/dist/schema/field-config.js +68 -3
  117. package/dist/schema/field-config.js.map +1 -1
  118. 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, pages, audit) {
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({ policy, serialize: (entry, entities, ctx) => this.fullRecords(entry, entities, ctx) });
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
- const resources = this.registry.list().filter((entry) => !ctx || this.policy.permissionsOf(ctx).canOn(entry.schema.name, 'view'));
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
- return { schemaVersion: 1, title: text(this.options.title), locale, locales: this.options.locales, groups };
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 localized = { ...entry.schema, label: text(entry.label), related, fields, form, actions };
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: await this.records(entry, items, loaded, ctx),
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