tempest-express-sdk 0.25.0 → 0.27.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { z, looseBoolean, toDict, PasswordUtils } from './chunk-TIW2KPT2.js';
2
- export { PasswordUtils, VERSION, baseAppSettingsSchema, baseAppSettingsShape, baseResponseSchema, centsField, corsSettingsShape, databaseSettingsShape, looseBoolean as envBoolean, hexColorField, latitudeField, loadSettings, longitudeField, looseBoolean, nonEmptyStrField, nonNegativeFloatField, nonNegativeIntField, percentField, portField, positiveFloatField, positiveIntField, priceField, ratingField, ratioField, serverSettingsShape, slugField, toDict, z } from './chunk-TIW2KPT2.js';
1
+ import { z, looseBoolean, toDict, PasswordUtils } from './chunk-ADF7OHRH.js';
2
+ export { PasswordUtils, VERSION, baseAppSettingsSchema, baseAppSettingsShape, baseResponseSchema, centsField, corsSettingsShape, databaseSettingsShape, looseBoolean as envBoolean, hexColorField, latitudeField, loadSettings, longitudeField, looseBoolean, nonEmptyStrField, nonNegativeFloatField, nonNegativeIntField, percentField, portField, positiveFloatField, positiveIntField, priceField, ratingField, ratioField, serverSettingsShape, slugField, toDict, z } from './chunk-ADF7OHRH.js';
3
3
  import { AsyncLocalStorage } from 'async_hooks';
4
4
  import { Model, column, sql, BaseRepository, RecordNotFound, detectDialect, columnsOf, NodeSqliteDriver, AsyncEngine, or, and } from 'tempest-db-js';
5
5
  export { AsyncEngine, AsyncResult, AsyncSession, BaseRepository, Column, DeleteBuilder, InsertBuilder, Model, NoResultError, NodeSqliteDriver, PostgresDialect, RecordNotFound, SelectBuilder, SqliteDialect, SyncEngine, SyncSession, UpdateBuilder, and, belongsTo, column, columnsOf, createEngine, createSyncEngine, del, detectDialect, getDialect, hasMany, insert, join, loadRelations, not, or, parseDatabaseUrl, select, sql, update } from 'tempest-db-js';
@@ -9317,12 +9317,51 @@ var MessagingHub = class {
9317
9317
  }
9318
9318
  };
9319
9319
 
9320
+ // src/admin/dashboard.ts
9321
+ function metricCard(label, compute, helpText) {
9322
+ return helpText === void 0 ? { label, compute } : { label, compute, helpText };
9323
+ }
9324
+ function trendPercent(trend) {
9325
+ if (trend.previous === 0) return null;
9326
+ return (trend.value - trend.previous) / trend.previous * 100;
9327
+ }
9328
+ function trendDirection(trend) {
9329
+ if (trend.value > trend.previous) return "up";
9330
+ if (trend.value < trend.previous) return "down";
9331
+ return "flat";
9332
+ }
9333
+ function partitionTotal(partition) {
9334
+ return partition.segments.reduce((total, segment) => total + segment.value, 0);
9335
+ }
9336
+
9337
+ // src/admin/lenses.ts
9338
+ function slugify(name) {
9339
+ return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "lens";
9340
+ }
9341
+ function adminLens(options) {
9342
+ return {
9343
+ name: options.name,
9344
+ slug: slugify(options.name),
9345
+ label: options.label ?? options.name,
9346
+ filters: options.filters ?? {},
9347
+ orderBy: options.orderBy ?? null
9348
+ };
9349
+ }
9350
+
9351
+ // src/admin/permissions.ts
9352
+ var AdminPermission = {
9353
+ VIEW: "view",
9354
+ CREATE: "create",
9355
+ EDIT: "edit",
9356
+ DELETE: "delete"
9357
+ };
9358
+
9320
9359
  // src/admin/actions.ts
9321
- function slugify(label) {
9360
+ function slugify2(label) {
9322
9361
  return label.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "action";
9323
9362
  }
9324
9363
  function adminAction(options, handler) {
9325
- const name = options.name ?? slugify(options.label);
9364
+ const name = options.name ?? slugify2(options.label);
9326
9365
  if (name === "") throw new Error("adminAction requires a non-empty name or label");
9327
9366
  return {
9328
9367
  name,
@@ -9332,6 +9371,85 @@ function adminAction(options, handler) {
9332
9371
  };
9333
9372
  }
9334
9373
 
9374
+ // src/admin/multipart.ts
9375
+ var BUSBOY_HINT = "The admin's upload and CSV-import screens need the optional peer `busboy`. Install it with: npm install busboy";
9376
+ async function loadBusboy() {
9377
+ try {
9378
+ const module = await import('busboy');
9379
+ return module.default ?? module;
9380
+ } catch {
9381
+ throw new Error(BUSBOY_HINT);
9382
+ }
9383
+ }
9384
+ var MultipartLimitError = class extends Error {
9385
+ /**
9386
+ * @param message - The operator-facing explanation.
9387
+ */
9388
+ constructor(message) {
9389
+ super(message);
9390
+ this.name = "MultipartLimitError";
9391
+ }
9392
+ };
9393
+ async function parseMultipart(req, options = {}) {
9394
+ const maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024;
9395
+ const maxFiles = options.maxFiles ?? 10;
9396
+ const busboy = await loadBusboy();
9397
+ return await new Promise((resolve, reject) => {
9398
+ const fields = {};
9399
+ const files = [];
9400
+ let settled = false;
9401
+ const fail = (error) => {
9402
+ if (settled) return;
9403
+ settled = true;
9404
+ reject(error);
9405
+ };
9406
+ const parser = busboy({
9407
+ headers: req.headers,
9408
+ limits: { fileSize: maxFileBytes, files: maxFiles }
9409
+ });
9410
+ parser.on("field", ((name, value) => {
9411
+ fields[name] = value;
9412
+ }));
9413
+ parser.on("file", ((name, stream, info) => {
9414
+ const chunks = [];
9415
+ stream.on("data", ((chunk) => chunks.push(chunk)));
9416
+ stream.on("limit", () => {
9417
+ fail(
9418
+ new MultipartLimitError(
9419
+ `The file is larger than the ${Math.floor(maxFileBytes / 1024 / 1024)} MB limit.`
9420
+ )
9421
+ );
9422
+ });
9423
+ stream.on("end", () => {
9424
+ const data = Buffer.concat(chunks);
9425
+ const filename = (info.filename ?? "").split(/[/\\]/).pop() ?? "";
9426
+ if (filename === "" || data.length === 0) return;
9427
+ files.push({
9428
+ field: name,
9429
+ filename,
9430
+ contentType: info.mimeType ?? "application/octet-stream",
9431
+ data
9432
+ });
9433
+ });
9434
+ }));
9435
+ parser.on("filesLimit", () => {
9436
+ fail(new MultipartLimitError(`At most ${maxFiles} files can be uploaded at once.`));
9437
+ });
9438
+ parser.on("error", ((error) => {
9439
+ fail(error instanceof Error ? error : new Error(String(error)));
9440
+ }));
9441
+ parser.on("close", () => {
9442
+ if (settled) return;
9443
+ settled = true;
9444
+ resolve({ fields, files });
9445
+ });
9446
+ req.pipe(parser);
9447
+ });
9448
+ }
9449
+ function isMultipart(req) {
9450
+ return (req.header("content-type") ?? "").toLowerCase().startsWith("multipart/form-data");
9451
+ }
9452
+
9335
9453
  // src/admin/columns.ts
9336
9454
  function humanizeField(name) {
9337
9455
  return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().split(/\s+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
@@ -9425,9 +9543,17 @@ var NEVER_EDITABLE = [
9425
9543
  "id",
9426
9544
  "createdAt",
9427
9545
  "updatedAt",
9546
+ "createdBy",
9547
+ "updatedBy",
9428
9548
  "hashedPassword"
9429
9549
  ];
9430
9550
  var NEVER_LISTED = ["hashedPassword"];
9551
+ var AUDIT_FIELDS = [
9552
+ "createdAt",
9553
+ "updatedAt",
9554
+ "createdBy",
9555
+ "updatedBy"
9556
+ ];
9431
9557
  var AdminModel = class {
9432
9558
  /** The managed model class. */
9433
9559
  model;
@@ -9451,6 +9577,18 @@ var AdminModel = class {
9451
9577
  canEdit;
9452
9578
  /** Whether the delete action is exposed. */
9453
9579
  canDelete;
9580
+ /** Audit-log model backing the detail timeline, or `null`. */
9581
+ auditModel;
9582
+ /** Saved list-view presets, in declaration order. */
9583
+ lenses;
9584
+ /** Columns rendered as file inputs. */
9585
+ uploadFields;
9586
+ /** Backend persisting uploaded files, or `null`. */
9587
+ uploadStorage;
9588
+ /** Whether the CSV import page is exposed. */
9589
+ canImport;
9590
+ /** Foreign-key columns rendered as a typed search box. */
9591
+ autocompleteFields;
9454
9592
  actions = /* @__PURE__ */ new Map();
9455
9593
  slugOverride;
9456
9594
  listDisplayOverride;
@@ -9476,6 +9614,17 @@ var AdminModel = class {
9476
9614
  this.canCreate = options.canCreate ?? true;
9477
9615
  this.canEdit = options.canEdit ?? true;
9478
9616
  this.canDelete = options.canDelete ?? true;
9617
+ this.auditModel = options.auditModel ?? null;
9618
+ this.lenses = [...options.lenses ?? []];
9619
+ this.uploadFields = [...options.uploadFields ?? []];
9620
+ this.uploadStorage = options.uploadStorage ?? null;
9621
+ this.canImport = options.canImport ?? false;
9622
+ this.autocompleteFields = [...options.autocompleteFields ?? []];
9623
+ if (this.uploadFields.length > 0 && this.uploadStorage === null) {
9624
+ throw new Error(
9625
+ `AdminModel(${this.model.tablename}).uploadFields requires an uploadStorage (e.g. LocalUploadStorage / S3UploadStorage) \u2014 without one there is nowhere to write the file.`
9626
+ );
9627
+ }
9479
9628
  for (const action of options.actions ?? []) {
9480
9629
  if (this.actions.has(action.name)) {
9481
9630
  throw new Error(
@@ -9490,7 +9639,9 @@ var AdminModel = class {
9490
9639
  ["listFilter", this.listFilter],
9491
9640
  ["searchFields", this.searchFields],
9492
9641
  ["readonlyFields", this.readonlyFields],
9493
- ["identityField", [this.identityField]]
9642
+ ["identityField", [this.identityField]],
9643
+ ["uploadFields", this.uploadFields],
9644
+ ["autocompleteFields", this.autocompleteFields]
9494
9645
  ]) {
9495
9646
  for (const name of names) {
9496
9647
  if (!known.has(name)) {
@@ -9560,6 +9711,25 @@ var AdminModel = class {
9560
9711
  if (this.listDisplayOverride !== null) return [...this.listDisplayOverride];
9561
9712
  return this.columnNames().filter((name) => !NEVER_LISTED.includes(name));
9562
9713
  }
9714
+ /**
9715
+ * Look a lens up by its slug.
9716
+ *
9717
+ * @param slug - The `?lens=` value.
9718
+ * @returns The lens, or `null` when nothing matches.
9719
+ */
9720
+ getLens(slug) {
9721
+ return this.lenses.find((lens) => lens.slug === slug) ?? null;
9722
+ }
9723
+ /**
9724
+ * Return the audit/timestamp columns the model actually declares.
9725
+ *
9726
+ * @returns The subset of `createdAt` / `updatedAt` / `createdBy` /
9727
+ * `updatedBy` present on the model, in that order.
9728
+ */
9729
+ auditFieldNames() {
9730
+ const known = new Set(this.columnNames());
9731
+ return AUDIT_FIELDS.filter((name) => known.has(name));
9732
+ }
9563
9733
  /**
9564
9734
  * Return the columns the detail view renders.
9565
9735
  *
@@ -9568,10 +9738,16 @@ var AdminModel = class {
9568
9738
  * where an operator goes to see the whole record, so trimming it there would
9569
9739
  * hide data with nowhere else to read it.
9570
9740
  *
9571
- * @returns Every column but the password hash, in declaration order.
9741
+ * The audit/timestamp columns are held back too — they render in the detail
9742
+ * view's own audit panel, next to the change history, rather than scattered
9743
+ * among the domain fields.
9744
+ *
9745
+ * @returns Every domain column, in declaration order.
9572
9746
  */
9573
9747
  detailFieldNames() {
9574
- return this.columnNames().filter((name) => !NEVER_LISTED.includes(name));
9748
+ return this.columnNames().filter(
9749
+ (name) => !NEVER_LISTED.includes(name) && !AUDIT_FIELDS.includes(name)
9750
+ );
9575
9751
  }
9576
9752
  /**
9577
9753
  * Return the columns a create/edit form exposes.
@@ -9647,11 +9823,15 @@ function buildFormFields(admin, options = {}) {
9647
9823
  const values = options.values ?? {};
9648
9824
  const errors = options.errors ?? {};
9649
9825
  const foreignKeys = options.foreignKeyOptions ?? {};
9826
+ const autocompleteUrls = options.autocompleteUrls ?? {};
9827
+ const autocompleteLabels = options.autocompleteLabels ?? {};
9828
+ const uploads = new Set(admin.uploadFields);
9650
9829
  return admin.editableFieldNames().flatMap((name) => {
9651
9830
  const column6 = columns[name];
9652
9831
  if (column6 === void 0) return [];
9653
9832
  const related = foreignKeys[name];
9654
- const spec = related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
9833
+ const autocompleteUrl = autocompleteUrls[name];
9834
+ const spec = uploads.has(name) ? { widget: "file", step: null, options: [] } : autocompleteUrl !== void 0 ? { widget: "autocomplete", step: null, options: [] } : related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
9655
9835
  const raw = name in values ? values[name] : literalDefault(column6);
9656
9836
  return [
9657
9837
  {
@@ -9663,7 +9843,9 @@ function buildFormFields(admin, options = {}) {
9663
9843
  checked: spec.widget === "checkbox" && toBoolean(raw),
9664
9844
  step: spec.step,
9665
9845
  options: spec.options,
9666
- error: errors[name] ?? null
9846
+ error: errors[name] ?? null,
9847
+ autocompleteUrl: autocompleteUrl ?? null,
9848
+ displayLabel: autocompleteLabels[name] ?? ""
9667
9849
  }
9668
9850
  ];
9669
9851
  });
@@ -9711,13 +9893,15 @@ function coerceValue(column6, widget, raw) {
9711
9893
  return raw;
9712
9894
  }
9713
9895
  }
9714
- function parseFormBody(admin, body) {
9896
+ function parseFormBody(admin, body, options = {}) {
9715
9897
  const columns = adminColumns(admin.model);
9716
9898
  const data = {};
9717
9899
  const errors = {};
9900
+ const uploads = options.uploadsAsText === true ? /* @__PURE__ */ new Set() : new Set(admin.uploadFields);
9718
9901
  for (const name of admin.editableFieldNames()) {
9719
9902
  const column6 = columns[name];
9720
9903
  if (column6 === void 0) continue;
9904
+ if (uploads.has(name)) continue;
9721
9905
  const { widget } = widgetForColumn(column6);
9722
9906
  if (widget === "checkbox") {
9723
9907
  data[name] = toBoolean(body[name]);
@@ -10028,6 +10212,8 @@ var AdminSite = class {
10028
10212
  siteUrl;
10029
10213
  /** Typed appearance overrides. */
10030
10214
  theme;
10215
+ /** Business-metric cards rendered at the top of the dashboard. */
10216
+ dashboardCards;
10031
10217
  registry = /* @__PURE__ */ new Map();
10032
10218
  /**
10033
10219
  * Initialize the site.
@@ -10040,6 +10226,7 @@ var AdminSite = class {
10040
10226
  this.indexSubtitle = options.indexSubtitle ?? "Site administration";
10041
10227
  this.siteUrl = options.siteUrl ?? null;
10042
10228
  this.theme = options.theme ?? {};
10229
+ this.dashboardCards = [...options.dashboardCards ?? []];
10043
10230
  }
10044
10231
  /**
10045
10232
  * Return the centered header brand text.
@@ -11859,7 +12046,7 @@ function renderMfaPage(context, error) {
11859
12046
  </section>`;
11860
12047
  return renderLayout(context, `Two-factor \xB7 ${context.site.title}`, body);
11861
12048
  }
11862
- function renderDashboardPage(context, cards, metrics) {
12049
+ function renderDashboardPage(context, cards, metrics, businessCards = []) {
11863
12050
  const metricsPanel = metrics === null ? "" : `<div class="tempest-admin-stats" aria-label="System metrics">
11864
12051
  <div class="tempest-admin-stat">
11865
12052
  <span class="tempest-admin-stat__label">CPU</span>
@@ -11871,6 +12058,7 @@ function renderDashboardPage(context, cards, metrics) {
11871
12058
  <span class="tempest-admin-stat__sub">${escapeHtml(metrics.memoryUsedGb)} / ${escapeHtml(metrics.memoryTotalGb)} GB</span>
11872
12059
  </div>
11873
12060
  </div>`;
12061
+ const business = businessCards.length > 0 ? `<div class="tempest-admin-cards" aria-label="Business metrics">${businessCards.map(renderBusinessCard).join("")}</div>` : "";
11874
12062
  const models = cards.length > 0 ? `<div class="tempest-admin-models">${cards.map(
11875
12063
  (card) => `<article class="tempest-admin-model-card">
11876
12064
  <header class="tempest-admin-model-card__head">
@@ -11887,10 +12075,53 @@ function renderDashboardPage(context, cards, metrics) {
11887
12075
  <h1>${escapeHtml(context.site.title)}</h1>
11888
12076
  <p>${escapeHtml(context.site.indexSubtitle)}</p>
11889
12077
  ${metricsPanel}
12078
+ ${business}
11890
12079
  ${models}
11891
12080
  </section>`;
11892
12081
  return renderLayout(context, context.site.title, body);
11893
12082
  }
12083
+ function renderBusinessCard(card) {
12084
+ const help = card.helpText !== null ? `<span class="tempest-admin-card__help">${escapeHtml(card.helpText)}</span>` : "";
12085
+ if (card.error !== null) {
12086
+ return `<article class="tempest-admin-card tempest-admin-card--value">
12087
+ <span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
12088
+ <span class="tempest-admin-card__value">\u2014</span>
12089
+ <span class="tempest-admin-card__help">${escapeHtml(card.error)}</span>
12090
+ </article>`;
12091
+ }
12092
+ const unit = card.unit !== null ? ` <small>${escapeHtml(card.unit)}</small>` : "";
12093
+ if (card.kind === "partition") {
12094
+ const parts = card.segments.map(
12095
+ (segment) => `<li>
12096
+ <span class="tempest-admin-card__part-label">${escapeHtml(segment.label)}</span>
12097
+ <span class="tempest-admin-card__part-bar"><span style="width: ${Math.round(segment.percent)}%"></span></span>
12098
+ <span class="tempest-admin-card__part-value">${escapeHtml(segment.value)}</span>
12099
+ </li>`
12100
+ ).join("");
12101
+ return `<article class="tempest-admin-card tempest-admin-card--partition">
12102
+ <span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
12103
+ <ul class="tempest-admin-card__parts">${parts}</ul>
12104
+ ${help}
12105
+ </article>`;
12106
+ }
12107
+ if (card.kind === "trend") {
12108
+ const arrow = card.direction === "up" ? "\u25B2" : card.direction === "down" ? "\u25BC" : "\u25AC";
12109
+ return `<article class="tempest-admin-card tempest-admin-card--trend">
12110
+ <span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
12111
+ <span class="tempest-admin-card__value">${escapeHtml(card.value)}${unit}</span>
12112
+ <span class="tempest-admin-card__trend tempest-admin-card__trend--${escapeHtml(card.direction)}">
12113
+ ${arrow} ${card.percent === null ? "\u2014" : escapeHtml(card.percent)}
12114
+ <small>vs prev ${escapeHtml(card.previous)}</small>
12115
+ </span>
12116
+ ${help}
12117
+ </article>`;
12118
+ }
12119
+ return `<article class="tempest-admin-card tempest-admin-card--value">
12120
+ <span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
12121
+ <span class="tempest-admin-card__value">${escapeHtml(card.value)}${unit}</span>
12122
+ ${help}
12123
+ </article>`;
12124
+ }
11894
12125
  function renderFilter(filter) {
11895
12126
  const name = `filter_${filter.field}`;
11896
12127
  if (filter.kind === "select") {
@@ -11956,10 +12187,14 @@ function renderListPage(context, view) {
11956
12187
  </form>
11957
12188
  <div class="tempest-admin-list__actions">
11958
12189
  ${view.newUrl !== null ? `<a class="tempest-admin-list__new" href="${escapeHtml(view.newUrl)}">+ New</a>` : ""}
12190
+ ${view.importUrl !== null ? `<a href="${escapeHtml(view.importUrl)}">Import CSV</a>` : ""}
11959
12191
  <a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
11960
12192
  <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
11961
12193
  </div>
11962
12194
  </div>
12195
+ ${view.lenses.length > 0 ? `<nav class="tempest-admin-lenses" aria-label="Lenses">${view.lenses.map(
12196
+ (lens) => `<a class="tempest-admin-lens${lens.active ? " tempest-admin-lens--active" : ""}" href="${escapeHtml(lens.url)}">${escapeHtml(lens.label)}</a>`
12197
+ ).join("")}</nav>` : ""}
11963
12198
  ${bulkBar}
11964
12199
  <div class="tempest-admin-table-wrap">
11965
12200
  <table class="tempest-admin-list__table">
@@ -11983,6 +12218,7 @@ function renderDetailPage(context, view) {
11983
12218
  const fields = view.fields.map(
11984
12219
  (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
11985
12220
  ).join("");
12221
+ const auditPanel = view.audit === null ? "" : renderAuditPanel(view.audit);
11986
12222
  const body = `<section class="tempest-admin-detail">
11987
12223
  <header class="tempest-admin-detail__header">
11988
12224
  <h1>${escapeHtml(view.title)} \xB7 ${escapeHtml(view.identity)}</h1>
@@ -11996,9 +12232,37 @@ function renderDetailPage(context, view) {
11996
12232
  </div>
11997
12233
  </header>
11998
12234
  <dl class="tempest-admin-detail__fields">${fields}</dl>
12235
+ ${auditPanel}
11999
12236
  </section>`;
12000
12237
  return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
12001
12238
  }
12239
+ function renderAuditPanel(audit) {
12240
+ const rows = audit.fields.map(
12241
+ (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
12242
+ ).join("");
12243
+ const history = audit.history.length > 0 ? `<ol class="tempest-admin-history">${audit.history.map((entry) => {
12244
+ const changes = entry.changes.length > 0 ? `<table class="tempest-admin-history__changes"><thead><tr><th>Field</th><th>Before</th><th>After</th></tr></thead><tbody>${entry.changes.map(
12245
+ (change) => `<tr><td>${escapeHtml(change.field)}</td><td>${escapeHtml(change.before)}</td><td>${escapeHtml(change.after)}</td></tr>`
12246
+ ).join("")}</tbody></table>` : "<p><em>No field changes recorded.</em></p>";
12247
+ const context = entry.context !== null ? `<pre class="tempest-admin-detail__json">${escapeHtml(entry.context)}</pre>` : "";
12248
+ return `<li class="tempest-admin-history__item">
12249
+ <details>
12250
+ <summary>
12251
+ <span class="tempest-admin-history__action">${escapeHtml(entry.action)}</span>
12252
+ <span class="tempest-admin-history__actor">${escapeHtml(entry.actor)}</span>
12253
+ <span class="tempest-admin-history__at">${escapeHtml(entry.at)}</span>
12254
+ </summary>
12255
+ ${changes}
12256
+ ${context}
12257
+ </details>
12258
+ </li>`;
12259
+ }).join("")}</ol>` : "";
12260
+ return `<section class="tempest-admin-audit">
12261
+ <h2>Audit</h2>
12262
+ <dl class="tempest-admin-detail__fields">${rows}</dl>
12263
+ ${history}
12264
+ </section>`;
12265
+ }
12002
12266
  function renderFormField(field) {
12003
12267
  const required = field.required ? " required" : "";
12004
12268
  const name = escapeHtml(field.name);
@@ -12023,6 +12287,18 @@ function renderFormField(field) {
12023
12287
  control = `<label><span>${label}${field.required ? " *" : ""}</span><select name="${name}"${required}>${blank}${options}</select></label>`;
12024
12288
  break;
12025
12289
  }
12290
+ case "file":
12291
+ control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="file" name="${name}"${required}></label>${field.value === "" ? "" : `<small class="tempest-admin-form__hint">Current: ${value} \u2014 choose a file to replace it.</small>`}`;
12292
+ break;
12293
+ case "autocomplete":
12294
+ control = `<label><span>${label}${field.required ? " *" : ""}</span>
12295
+ <div class="tempest-admin-ac" data-ac data-ac-url="${escapeHtml(field.autocompleteUrl)}">
12296
+ <input type="text" class="tempest-admin-ac__search" value="${escapeHtml(field.displayLabel)}" placeholder="Search\u2026" autocomplete="off" data-ac-search>
12297
+ <input type="hidden" name="${name}" value="${value}"${required} data-ac-value>
12298
+ <ul class="tempest-admin-ac__results" data-ac-results hidden></ul>
12299
+ </div>
12300
+ </label>`;
12301
+ break;
12026
12302
  case "number":
12027
12303
  control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}></label>`;
12028
12304
  break;
@@ -12050,7 +12326,7 @@ function renderFormPage(context, view) {
12050
12326
  <a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
12051
12327
  </header>
12052
12328
  ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12053
- <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form">
12329
+ <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form"${view.fields.some((field) => field.widget === "file") ? ' enctype="multipart/form-data"' : ""}>
12054
12330
  <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12055
12331
  ${view.fields.map(renderFormField).join("")}
12056
12332
  <div class="tempest-admin-form__actions">
@@ -12058,10 +12334,98 @@ function renderFormPage(context, view) {
12058
12334
  <a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
12059
12335
  </div>
12060
12336
  </form>
12337
+ ${view.fields.some((field) => field.widget === "autocomplete") ? AUTOCOMPLETE_SCRIPT : ""}
12061
12338
  </section>`;
12062
12339
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12063
12340
  }
12341
+ function renderImportPage(context, view) {
12342
+ if (context.session === null) throw new Error("The import page requires a session");
12343
+ const summary = view.created === null ? "" : `<p class="tempest-admin-import__summary">Created ${escapeHtml(view.created)} record${view.created === 1 ? "" : "s"}.</p>`;
12344
+ const failures = view.rowErrors.length > 0 ? `<table class="tempest-admin-import__errors"><thead><tr><th>Row</th><th>Problem</th></tr></thead><tbody>${view.rowErrors.map(
12345
+ (failure) => `<tr><td>${escapeHtml(failure.row)}</td><td>${escapeHtml(failure.message)}</td></tr>`
12346
+ ).join("")}</tbody></table>` : "";
12347
+ const body = `<section class="tempest-admin-import">
12348
+ <header class="tempest-admin-detail__header">
12349
+ <h1>Import ${escapeHtml(view.title)}</h1>
12350
+ <a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
12351
+ </header>
12352
+ ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12353
+ ${summary}
12354
+ ${failures}
12355
+ <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form" enctype="multipart/form-data">
12356
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12357
+ <div class="tempest-admin-form__field">
12358
+ <label>
12359
+ <span>CSV file *</span>
12360
+ <input type="file" name="file" accept=".csv,text/csv" required>
12361
+ </label>
12362
+ <small class="tempest-admin-form__hint">
12363
+ UTF-8, comma-separated, with a header row. Recognised columns:
12364
+ <code>${escapeHtml(view.columns.join(", "))}</code>. Unknown columns are ignored.
12365
+ </small>
12366
+ </div>
12367
+ <div class="tempest-admin-form__actions">
12368
+ <button type="submit">Import</button>
12369
+ <a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
12370
+ </div>
12371
+ </form>
12372
+ </section>`;
12373
+ return renderLayout(context, `Import ${view.title} \xB7 ${context.site.title}`, body);
12374
+ }
12375
+ var AUTOCOMPLETE_SCRIPT = `<script>
12376
+ (function () {
12377
+ document.querySelectorAll("[data-ac]").forEach(function (box) {
12378
+ var search = box.querySelector("[data-ac-search]");
12379
+ var value = box.querySelector("[data-ac-value]");
12380
+ var results = box.querySelector("[data-ac-results]");
12381
+ var url = box.getAttribute("data-ac-url");
12382
+ var timer = null;
12383
+
12384
+ function close() {
12385
+ results.hidden = true;
12386
+ results.innerHTML = "";
12387
+ }
12388
+
12389
+ function pick(option) {
12390
+ value.value = option.value;
12391
+ search.value = option.label;
12392
+ close();
12393
+ }
12394
+
12395
+ function run() {
12396
+ var term = search.value.trim();
12397
+ fetch(url + "?q=" + encodeURIComponent(term), { credentials: "same-origin" })
12398
+ .then(function (response) { return response.ok ? response.json() : { options: [] }; })
12399
+ .then(function (payload) {
12400
+ results.innerHTML = "";
12401
+ (payload.options || []).forEach(function (option) {
12402
+ var item = document.createElement("li");
12403
+ item.textContent = option.label;
12404
+ item.setAttribute("role", "option");
12405
+ item.addEventListener("mousedown", function (event) {
12406
+ event.preventDefault();
12407
+ pick(option);
12408
+ });
12409
+ results.appendChild(item);
12410
+ });
12411
+ results.hidden = results.children.length === 0;
12412
+ })
12413
+ .catch(close);
12414
+ }
12415
+
12416
+ search.addEventListener("input", function () {
12417
+ value.value = "";
12418
+ window.clearTimeout(timer);
12419
+ timer = window.setTimeout(run, 250);
12420
+ });
12421
+ search.addEventListener("focus", run);
12422
+ search.addEventListener("blur", function () { window.setTimeout(close, 150); });
12423
+ });
12424
+ })();
12425
+ </script>`;
12064
12426
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12427
+ var AUTOCOMPLETE_LIMIT = 20;
12428
+ var AUDIT_HISTORY_LIMIT = 50;
12065
12429
  var FK_OPTION_CAP = 1e3;
12066
12430
  var FLASH_MAX_LENGTH = 300;
12067
12431
  var FLASH_MESSAGES = {
@@ -12090,6 +12454,7 @@ function makeAdminRouter(site, options) {
12090
12454
  const theme = resolveAdminTheme(site.theme);
12091
12455
  const showMetrics = options.showMetrics ?? true;
12092
12456
  const exportMaxRows = options.exportMaxRows ?? 5e3;
12457
+ const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
12093
12458
  const sessions = new AdminSessionStore({
12094
12459
  secret: options.secretKey,
12095
12460
  ...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
@@ -12100,18 +12465,23 @@ function makeAdminRouter(site, options) {
12100
12465
  const backend = options.authBackend;
12101
12466
  const router = express3.Router();
12102
12467
  router.use(prefix, express3.urlencoded({ extended: false }));
12103
- const context = (req, session) => ({
12468
+ const context = (req, session, models = site.list()) => ({
12104
12469
  site,
12105
12470
  theme,
12106
12471
  prefix,
12107
12472
  session,
12108
12473
  currentPath: req.originalUrl.split("?")[0] ?? req.path,
12109
- navModels: site.list().map((admin) => ({
12474
+ navModels: models.map((admin) => ({
12110
12475
  label: admin.verboseNamePlural(),
12111
12476
  url: `${prefix}/m/${admin.slug()}`
12112
12477
  })),
12113
12478
  messages: flashFor(req)
12114
12479
  });
12480
+ const allows = async (principal, admin, action) => {
12481
+ if (!flagAllows(admin, action)) return false;
12482
+ if (options.accessPolicy === void 0) return true;
12483
+ return Boolean(await options.accessPolicy(principal, admin, action));
12484
+ };
12115
12485
  const flashFor = (req) => {
12116
12486
  const fixed = FLASH_MESSAGES[queryString(req.query.ok)];
12117
12487
  if (fixed !== void 0) return [fixed];
@@ -12159,14 +12529,28 @@ function makeAdminRouter(site, options) {
12159
12529
  res.redirect(`${prefix}/mfa`);
12160
12530
  return null;
12161
12531
  }
12162
- return { session, dbSession, principal };
12532
+ const visible = [];
12533
+ for (const admin of site.list()) {
12534
+ if (await allows(principal, admin, AdminPermission.VIEW)) visible.push(admin);
12535
+ }
12536
+ return { session, dbSession, principal, visible };
12163
12537
  };
12164
- const resolveAdmin = (req, res, state) => {
12538
+ const resolveAdmin = async (req, res, state, action = AdminPermission.VIEW) => {
12165
12539
  const admin = site.get(String(req.params.slug));
12166
- if (admin === null) {
12167
- html(res, renderNotFound(context(req, state.session)), 404);
12540
+ if (admin === null || !await allows(state.principal, admin, AdminPermission.VIEW)) {
12541
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12168
12542
  return null;
12169
12543
  }
12544
+ if (action !== AdminPermission.VIEW) {
12545
+ if (!flagAllows(admin, action)) {
12546
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12547
+ return null;
12548
+ }
12549
+ if (!await allows(state.principal, admin, action)) {
12550
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12551
+ return null;
12552
+ }
12553
+ }
12170
12554
  return admin;
12171
12555
  };
12172
12556
  const renderNotFound = (ctx) => renderDashboardPage(
@@ -12177,7 +12561,7 @@ function makeAdminRouter(site, options) {
12177
12561
  const checkCsrf = (req, res, state) => {
12178
12562
  const body = req.body;
12179
12563
  if (csrfTokenMatches(state.session, body?.csrf_token)) return true;
12180
- html(res, renderNotFound(context(req, state.session)), 403);
12564
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12181
12565
  return false;
12182
12566
  };
12183
12567
  router.get(`${prefix}/static/admin.css`, (_req, res) => {
@@ -12259,7 +12643,7 @@ function makeAdminRouter(site, options) {
12259
12643
  const state = await authenticate(req, res);
12260
12644
  if (state === null) return;
12261
12645
  const cards = [];
12262
- for (const admin of site.list()) {
12646
+ for (const admin of state.visible) {
12263
12647
  let count = null;
12264
12648
  try {
12265
12649
  count = await admin.repository(state.dbSession).count();
@@ -12273,9 +12657,13 @@ function makeAdminRouter(site, options) {
12273
12657
  label: admin.verboseNamePlural(),
12274
12658
  count,
12275
12659
  url: `${prefix}/m/${admin.slug()}`,
12276
- newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null
12660
+ newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null
12277
12661
  });
12278
12662
  }
12663
+ const businessCards = [];
12664
+ for (const card of site.dashboardCards) {
12665
+ businessCards.push(await computeBusinessCard(card, state.dbSession));
12666
+ }
12279
12667
  let metrics = null;
12280
12668
  if (showMetrics) {
12281
12669
  const snapshot2 = MetricsUtils.system();
@@ -12286,7 +12674,15 @@ function makeAdminRouter(site, options) {
12286
12674
  memoryTotalGb: (snapshot2.memory.total / 1024 ** 3).toFixed(1)
12287
12675
  };
12288
12676
  }
12289
- html(res, renderDashboardPage(context(req, state.session), cards, metrics));
12677
+ html(
12678
+ res,
12679
+ renderDashboardPage(
12680
+ context(req, state.session, state.visible),
12681
+ cards,
12682
+ metrics,
12683
+ businessCards
12684
+ )
12685
+ );
12290
12686
  })
12291
12687
  );
12292
12688
  router.get(
@@ -12294,7 +12690,7 @@ function makeAdminRouter(site, options) {
12294
12690
  guarded(async (req, res) => {
12295
12691
  const state = await authenticate(req, res);
12296
12692
  if (state === null) return;
12297
- const admin = resolveAdmin(req, res, state);
12693
+ const admin = await resolveAdmin(req, res, state);
12298
12694
  if (admin === null) return;
12299
12695
  html(res, await renderList(req, admin, state));
12300
12696
  })
@@ -12304,19 +12700,20 @@ function makeAdminRouter(site, options) {
12304
12700
  guarded(async (req, res) => {
12305
12701
  const state = await authenticate(req, res);
12306
12702
  if (state === null) return;
12307
- const admin = resolveAdmin(req, res, state);
12703
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
12308
12704
  if (admin === null) return;
12309
12705
  if (!admin.canCreate) {
12310
- html(res, renderNotFound(context(req, state.session)), 404);
12706
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12311
12707
  return;
12312
12708
  }
12313
12709
  html(
12314
12710
  res,
12315
- renderFormPage(context(req, state.session), {
12711
+ renderFormPage(context(req, state.session, state.visible), {
12316
12712
  mode: "create",
12317
12713
  title: admin.verboseName(),
12318
12714
  fields: buildFormFields(admin, {
12319
- foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12715
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
12716
+ autocompleteUrls: autocompleteUrlsFor(admin)
12320
12717
  }),
12321
12718
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12322
12719
  backUrl: `${prefix}/m/${admin.slug()}`,
@@ -12330,26 +12727,46 @@ function makeAdminRouter(site, options) {
12330
12727
  guarded(async (req, res) => {
12331
12728
  const state = await authenticate(req, res);
12332
12729
  if (state === null) return;
12333
- const admin = resolveAdmin(req, res, state);
12730
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
12334
12731
  if (admin === null) return;
12335
12732
  if (!admin.canCreate) {
12336
- html(res, renderNotFound(context(req, state.session)), 404);
12733
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12734
+ return;
12735
+ }
12736
+ let uploadError = null;
12737
+ let submission;
12738
+ try {
12739
+ submission = await readSubmission(req);
12740
+ } catch (error) {
12741
+ if (!(error instanceof MultipartLimitError)) throw error;
12742
+ submission = { fields: {}, files: [] };
12743
+ uploadError = error.message;
12744
+ }
12745
+ const body = submission.fields;
12746
+ if (uploadError === null && !csrfTokenMatches(state.session, body.csrf_token)) {
12747
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12337
12748
  return;
12338
12749
  }
12339
- if (!checkCsrf(req, res, state)) return;
12340
- const body = req.body;
12341
12750
  const parsed = parseFormBody(admin, body);
12751
+ await applyUploads(admin, parsed.data, parsed.errors, submission.files, true);
12342
12752
  const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12753
+ const autocompleteLabels = await autocompleteLabelsFor(
12754
+ admin,
12755
+ body,
12756
+ state.dbSession
12757
+ );
12343
12758
  const rerender = (error, status) => {
12344
12759
  html(
12345
12760
  res,
12346
- renderFormPage(context(req, state.session), {
12761
+ renderFormPage(context(req, state.session, state.visible), {
12347
12762
  mode: "create",
12348
12763
  title: admin.verboseName(),
12349
12764
  fields: buildFormFields(admin, {
12350
12765
  values: body,
12351
12766
  errors: parsed.errors,
12352
- foreignKeyOptions
12767
+ foreignKeyOptions,
12768
+ autocompleteUrls: autocompleteUrlsFor(admin),
12769
+ autocompleteLabels
12353
12770
  }),
12354
12771
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12355
12772
  backUrl: `${prefix}/m/${admin.slug()}`,
@@ -12358,10 +12775,15 @@ function makeAdminRouter(site, options) {
12358
12775
  status
12359
12776
  );
12360
12777
  };
12778
+ if (uploadError !== null) {
12779
+ rerender(uploadError, 400);
12780
+ return;
12781
+ }
12361
12782
  if (Object.keys(parsed.errors).length > 0) {
12362
12783
  rerender("Please fix the highlighted fields.", 400);
12363
12784
  return;
12364
12785
  }
12786
+ stampActor(admin, parsed.data, backend.principalId(state.principal), true);
12365
12787
  try {
12366
12788
  await admin.repository(state.dbSession).create(parsed.data);
12367
12789
  } catch (error) {
@@ -12376,11 +12798,11 @@ function makeAdminRouter(site, options) {
12376
12798
  guarded(async (req, res) => {
12377
12799
  const state = await authenticate(req, res);
12378
12800
  if (state === null) return;
12379
- const admin = resolveAdmin(req, res, state);
12801
+ const admin = await resolveAdmin(req, res, state);
12380
12802
  if (admin === null) return;
12381
12803
  const format = String(req.params.format);
12382
12804
  if (format !== "csv" && format !== "json") {
12383
- html(res, renderNotFound(context(req, state.session)), 404);
12805
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12384
12806
  return;
12385
12807
  }
12386
12808
  const query = await resolveListQuery(req, admin, state.dbSession);
@@ -12402,7 +12824,7 @@ function makeAdminRouter(site, options) {
12402
12824
  guarded(async (req, res) => {
12403
12825
  const state = await authenticate(req, res);
12404
12826
  if (state === null) return;
12405
- const admin = resolveAdmin(req, res, state);
12827
+ const admin = await resolveAdmin(req, res, state);
12406
12828
  if (admin === null) return;
12407
12829
  if (!checkCsrf(req, res, state)) return;
12408
12830
  const body = req.body;
@@ -12418,7 +12840,12 @@ function makeAdminRouter(site, options) {
12418
12840
  return;
12419
12841
  }
12420
12842
  if (!bulkActionsFor(admin).some((option) => option.value === action)) {
12421
- html(res, renderNotFound(context(req, state.session)), 400);
12843
+ html(res, renderNotFound(context(req, state.session, state.visible)), 400);
12844
+ return;
12845
+ }
12846
+ const needed = action === "delete" ? AdminPermission.DELETE : AdminPermission.EDIT;
12847
+ if (!await allows(state.principal, admin, needed)) {
12848
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12422
12849
  return;
12423
12850
  }
12424
12851
  const repository = admin.repository(state.dbSession);
@@ -12426,7 +12853,7 @@ function makeAdminRouter(site, options) {
12426
12853
  if (action.startsWith("custom:")) {
12427
12854
  const custom = admin.getAction(action.slice("custom:".length));
12428
12855
  if (custom === null) {
12429
- html(res, renderNotFound(context(req, state.session)), 400);
12856
+ html(res, renderNotFound(context(req, state.session, state.visible)), 400);
12430
12857
  return;
12431
12858
  }
12432
12859
  let result;
@@ -12471,28 +12898,152 @@ function makeAdminRouter(site, options) {
12471
12898
  );
12472
12899
  })
12473
12900
  );
12901
+ router.get(
12902
+ `${prefix}/m/:slug/import`,
12903
+ guarded(async (req, res) => {
12904
+ const state = await authenticate(req, res);
12905
+ if (state === null) return;
12906
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
12907
+ if (admin === null) return;
12908
+ if (!admin.canImport) {
12909
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12910
+ return;
12911
+ }
12912
+ html(res, renderImport(req, state, admin, null, null, []));
12913
+ })
12914
+ );
12915
+ router.post(
12916
+ `${prefix}/m/:slug/import`,
12917
+ guarded(async (req, res) => {
12918
+ const state = await authenticate(req, res);
12919
+ if (state === null) return;
12920
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
12921
+ if (admin === null) return;
12922
+ if (!admin.canImport) {
12923
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12924
+ return;
12925
+ }
12926
+ let submission;
12927
+ try {
12928
+ submission = await readSubmission(req);
12929
+ } catch (error) {
12930
+ if (!(error instanceof MultipartLimitError)) throw error;
12931
+ html(res, renderImport(req, state, admin, error.message, null, []), 400);
12932
+ return;
12933
+ }
12934
+ if (!csrfTokenMatches(state.session, submission.fields.csrf_token)) {
12935
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12936
+ return;
12937
+ }
12938
+ const file = submission.files[0];
12939
+ if (file === void 0) {
12940
+ html(
12941
+ res,
12942
+ renderImport(req, state, admin, "Choose a CSV file to import.", null, []),
12943
+ 400
12944
+ );
12945
+ return;
12946
+ }
12947
+ let rows;
12948
+ try {
12949
+ rows = parseCsv(file.data.toString("utf8"));
12950
+ } catch (error) {
12951
+ const message = error instanceof Error ? error.message : "Could not read the file as UTF-8 CSV.";
12952
+ html(res, renderImport(req, state, admin, message, null, []), 400);
12953
+ return;
12954
+ }
12955
+ const repository = admin.repository(state.dbSession);
12956
+ const actorId = backend.principalId(state.principal);
12957
+ const rowErrors = [];
12958
+ let created = 0;
12959
+ for (const [index, row] of rows.entries()) {
12960
+ const parsed = parseFormBody(admin, row, { uploadsAsText: true });
12961
+ const failures = Object.entries(parsed.errors);
12962
+ if (failures.length > 0) {
12963
+ rowErrors.push({
12964
+ row: index + 2,
12965
+ message: failures.map(([field, error]) => `${field}: ${error}`).join("; ")
12966
+ });
12967
+ continue;
12968
+ }
12969
+ stampActor(admin, parsed.data, actorId, true);
12970
+ try {
12971
+ await repository.create(parsed.data);
12972
+ created += 1;
12973
+ } catch (error) {
12974
+ rowErrors.push({
12975
+ row: index + 2,
12976
+ message: error instanceof Error ? error.message : String(error)
12977
+ });
12978
+ }
12979
+ }
12980
+ html(res, renderImport(req, state, admin, null, created, rowErrors));
12981
+ })
12982
+ );
12983
+ router.get(
12984
+ `${prefix}/m/:slug/autocomplete/:field`,
12985
+ guarded(async (req, res) => {
12986
+ if (sessions.load(req) === null) {
12987
+ res.status(401).json({ options: [] });
12988
+ return;
12989
+ }
12990
+ const state = await authenticate(req, res);
12991
+ if (state === null) return;
12992
+ const admin = site.get(String(req.params.slug));
12993
+ const field = String(req.params.field);
12994
+ if (admin === null || !admin.autocompleteFields.includes(field) || !await allows(state.principal, admin, AdminPermission.VIEW)) {
12995
+ res.status(404).json({ options: [] });
12996
+ return;
12997
+ }
12998
+ const column6 = adminColumns(admin.model)[field];
12999
+ const table = column6 === void 0 ? null : foreignKeyTable(column6);
13000
+ const referenced = table === null ? null : site.get(table);
13001
+ if (referenced === null) {
13002
+ res.json({ options: [] });
13003
+ return;
13004
+ }
13005
+ const term = queryString(req.query.q);
13006
+ const searchable = referenced.searchFields.filter((name) => {
13007
+ const target = adminColumns(referenced.model)[name];
13008
+ return target !== void 0 && isSearchableColumn(target);
13009
+ });
13010
+ const filters = term === "" || searchable.length === 0 ? void 0 : or(...searchable.map((name) => ({ [name]: { ilike: `%${term}%` } })));
13011
+ const page2 = await referenced.repository(state.dbSession).paginate({
13012
+ page: 1,
13013
+ pageSize: AUTOCOMPLETE_LIMIT,
13014
+ ...filters === void 0 ? {} : { filters }
13015
+ });
13016
+ res.json({
13017
+ options: page2.items.map((row) => ({
13018
+ value: String(row[referenced.identityField]),
13019
+ label: foreignKeyLabel(referenced, row)
13020
+ }))
13021
+ });
13022
+ })
13023
+ );
12474
13024
  router.get(
12475
13025
  `${prefix}/m/:slug/:identity`,
12476
13026
  guarded(async (req, res) => {
12477
13027
  const state = await authenticate(req, res);
12478
13028
  if (state === null) return;
12479
- const admin = resolveAdmin(req, res, state);
13029
+ const admin = await resolveAdmin(req, res, state);
12480
13030
  if (admin === null) return;
12481
13031
  const row = await findRow(admin, state.dbSession, String(req.params.identity));
12482
13032
  if (row === null) {
12483
- html(res, renderNotFound(context(req, state.session)), 404);
13033
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12484
13034
  return;
12485
13035
  }
12486
13036
  const identity = String(row[admin.identityField]);
12487
13037
  html(
12488
13038
  res,
12489
- renderDetailPage(context(req, state.session), {
13039
+ renderDetailPage(context(req, state.session, state.visible), {
12490
13040
  title: admin.verboseName(),
12491
13041
  identity,
13042
+ audit: await buildAuditView(admin, row, state.dbSession),
12492
13043
  fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
12493
13044
  backUrl: `${prefix}/m/${admin.slug()}`,
12494
- editUrl: admin.canEdit ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
12495
- deleteUrl: admin.canDelete ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
13045
+ editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
13046
+ deleteUrl: await allows(state.principal, admin, AdminPermission.DELETE) ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
12496
13047
  })
12497
13048
  );
12498
13049
  })
@@ -12502,22 +13053,24 @@ function makeAdminRouter(site, options) {
12502
13053
  guarded(async (req, res) => {
12503
13054
  const state = await authenticate(req, res);
12504
13055
  if (state === null) return;
12505
- const admin = resolveAdmin(req, res, state);
13056
+ const admin = await resolveAdmin(req, res, state, AdminPermission.EDIT);
12506
13057
  if (admin === null) return;
12507
13058
  const identity = String(req.params.identity);
12508
13059
  const row = admin.canEdit ? await findRow(admin, state.dbSession, identity) : null;
12509
13060
  if (row === null) {
12510
- html(res, renderNotFound(context(req, state.session)), 404);
13061
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12511
13062
  return;
12512
13063
  }
12513
13064
  html(
12514
13065
  res,
12515
- renderFormPage(context(req, state.session), {
13066
+ renderFormPage(context(req, state.session, state.visible), {
12516
13067
  mode: "edit",
12517
13068
  title: admin.verboseName(),
12518
13069
  fields: buildFormFields(admin, {
12519
13070
  values: row,
12520
- foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
13071
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
13072
+ autocompleteUrls: autocompleteUrlsFor(admin),
13073
+ autocompleteLabels: await autocompleteLabelsFor(admin, row, state.dbSession)
12521
13074
  }),
12522
13075
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12523
13076
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
@@ -12531,27 +13084,47 @@ function makeAdminRouter(site, options) {
12531
13084
  guarded(async (req, res) => {
12532
13085
  const state = await authenticate(req, res);
12533
13086
  if (state === null) return;
12534
- const admin = resolveAdmin(req, res, state);
13087
+ const admin = await resolveAdmin(req, res, state, AdminPermission.EDIT);
12535
13088
  if (admin === null) return;
12536
13089
  const identity = String(req.params.identity);
12537
13090
  if (!admin.canEdit) {
12538
- html(res, renderNotFound(context(req, state.session)), 404);
13091
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13092
+ return;
13093
+ }
13094
+ let uploadError = null;
13095
+ let submission;
13096
+ try {
13097
+ submission = await readSubmission(req);
13098
+ } catch (error) {
13099
+ if (!(error instanceof MultipartLimitError)) throw error;
13100
+ submission = { fields: {}, files: [] };
13101
+ uploadError = error.message;
13102
+ }
13103
+ const body = submission.fields;
13104
+ if (uploadError === null && !csrfTokenMatches(state.session, body.csrf_token)) {
13105
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12539
13106
  return;
12540
13107
  }
12541
- if (!checkCsrf(req, res, state)) return;
12542
- const body = req.body;
12543
13108
  const parsed = parseFormBody(admin, body);
13109
+ await applyUploads(admin, parsed.data, parsed.errors, submission.files, false);
12544
13110
  const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
13111
+ const autocompleteLabels = await autocompleteLabelsFor(
13112
+ admin,
13113
+ body,
13114
+ state.dbSession
13115
+ );
12545
13116
  const rerender = (error, status) => {
12546
13117
  html(
12547
13118
  res,
12548
- renderFormPage(context(req, state.session), {
13119
+ renderFormPage(context(req, state.session, state.visible), {
12549
13120
  mode: "edit",
12550
13121
  title: admin.verboseName(),
12551
13122
  fields: buildFormFields(admin, {
12552
13123
  values: body,
12553
13124
  errors: parsed.errors,
12554
- foreignKeyOptions
13125
+ foreignKeyOptions,
13126
+ autocompleteUrls: autocompleteUrlsFor(admin),
13127
+ autocompleteLabels
12555
13128
  }),
12556
13129
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12557
13130
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
@@ -12560,14 +13133,24 @@ function makeAdminRouter(site, options) {
12560
13133
  status
12561
13134
  );
12562
13135
  };
13136
+ if (uploadError !== null) {
13137
+ rerender(uploadError, 400);
13138
+ return;
13139
+ }
12563
13140
  if (Object.keys(parsed.errors).length > 0) {
12564
13141
  rerender("Please fix the highlighted fields.", 400);
12565
13142
  return;
12566
13143
  }
13144
+ stampActor(
13145
+ admin,
13146
+ parsed.data,
13147
+ backend.principalId(state.principal),
13148
+ false
13149
+ );
12567
13150
  try {
12568
13151
  const changed = await admin.repository(state.dbSession).update({ [admin.identityField]: identity }, parsed.data);
12569
13152
  if (changed === 0) {
12570
- html(res, renderNotFound(context(req, state.session)), 404);
13153
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12571
13154
  return;
12572
13155
  }
12573
13156
  } catch (error) {
@@ -12582,10 +13165,10 @@ function makeAdminRouter(site, options) {
12582
13165
  guarded(async (req, res) => {
12583
13166
  const state = await authenticate(req, res);
12584
13167
  if (state === null) return;
12585
- const admin = resolveAdmin(req, res, state);
13168
+ const admin = await resolveAdmin(req, res, state, AdminPermission.DELETE);
12586
13169
  if (admin === null) return;
12587
13170
  if (!admin.canDelete) {
12588
- html(res, renderNotFound(context(req, state.session)), 404);
13171
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12589
13172
  return;
12590
13173
  }
12591
13174
  if (!checkCsrf(req, res, state)) return;
@@ -12593,10 +13176,96 @@ function makeAdminRouter(site, options) {
12593
13176
  res.redirect(`${prefix}/m/${admin.slug()}?ok=deleted`);
12594
13177
  })
12595
13178
  );
13179
+ async function actorLabel(actor, dbSession) {
13180
+ if (actor === null || actor === void 0 || actor === "") return "";
13181
+ const principal = await backend.loadPrincipal(dbSession, String(actor));
13182
+ return principal === null ? String(actor) : backend.displayName(principal);
13183
+ }
13184
+ async function buildAuditView(admin, row, dbSession) {
13185
+ const fields = [];
13186
+ for (const name of admin.auditFieldNames()) {
13187
+ const value = name === "createdBy" || name === "updatedBy" ? await actorLabel(row[name], dbSession) : formatCellValue(row[name]);
13188
+ fields.push({ label: humanizeField(name), value });
13189
+ }
13190
+ const history = [];
13191
+ if (admin.auditModel !== null) {
13192
+ const entity = admin.model.name;
13193
+ const entityId = String(row[admin.identityField]);
13194
+ const page2 = await new BaseRepository(admin.auditModel, dbSession).paginate({
13195
+ page: 1,
13196
+ pageSize: AUDIT_HISTORY_LIMIT,
13197
+ orderBy: "createdAt",
13198
+ ascending: false,
13199
+ filters: { entity, entityId }
13200
+ });
13201
+ for (const entry of page2.items) {
13202
+ const changes = entry.changes ?? {};
13203
+ history.push({
13204
+ action: String(entry.action ?? ""),
13205
+ at: formatCellValue(entry.createdAt),
13206
+ actor: await actorLabel(entry.actor, dbSession) || "\u2014",
13207
+ changes: Object.entries(changes).map(([field, change]) => ({
13208
+ field,
13209
+ before: formatCellValue(change?.before),
13210
+ after: formatCellValue(change?.after)
13211
+ })),
13212
+ context: entry.context === null || entry.context === void 0 ? null : JSON.stringify(entry.context, null, 2)
13213
+ });
13214
+ }
13215
+ }
13216
+ if (fields.length === 0 && history.length === 0) return null;
13217
+ return { fields, history };
13218
+ }
12596
13219
  async function findRow(admin, dbSession, identity) {
12597
13220
  const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
12598
13221
  return row ?? null;
12599
13222
  }
13223
+ function renderImport(req, state, admin, error, created, rowErrors) {
13224
+ return renderImportPage(context(req, state.session, state.visible), {
13225
+ title: admin.verboseNamePlural(),
13226
+ actionUrl: `${prefix}/m/${admin.slug()}/import`,
13227
+ backUrl: `${prefix}/m/${admin.slug()}`,
13228
+ columns: admin.editableFieldNames(),
13229
+ error,
13230
+ created,
13231
+ rowErrors
13232
+ });
13233
+ }
13234
+ async function readSubmission(req) {
13235
+ if (!isMultipart(req)) {
13236
+ return { fields: req.body ?? {}, files: [] };
13237
+ }
13238
+ const parsed = await parseMultipart(req, { maxFileBytes: maxUploadBytes });
13239
+ return { fields: parsed.fields, files: parsed.files };
13240
+ }
13241
+ async function applyUploads(admin, data, errors, files, creating) {
13242
+ if (admin.uploadFields.length === 0) return;
13243
+ const storage2 = admin.uploadStorage;
13244
+ if (storage2 === null) return;
13245
+ const columns = adminColumns(admin.model);
13246
+ for (const field of admin.uploadFields) {
13247
+ const file = files.find((candidate) => candidate.field === field);
13248
+ if (file === void 0) {
13249
+ const column6 = columns[field];
13250
+ if (creating && column6 !== void 0 && !isColumnOptional(column6)) {
13251
+ errors[field] = "This field is required.";
13252
+ }
13253
+ continue;
13254
+ }
13255
+ const extension = file.filename.includes(".") ? `.${file.filename.split(".").pop()}` : "";
13256
+ const key = `${admin.slug()}/${field}/${randomUUID()}${extension}`;
13257
+ const saved = await storage2.save(key, file.data, { contentType: file.contentType });
13258
+ data[field] = saved.key;
13259
+ }
13260
+ }
13261
+ async function permittedBulkActions(admin, principal) {
13262
+ const permitted = [];
13263
+ for (const option of bulkActionsFor(admin)) {
13264
+ const needed = option.value === "delete" ? AdminPermission.DELETE : AdminPermission.EDIT;
13265
+ if (await allows(principal, admin, needed)) permitted.push(option);
13266
+ }
13267
+ return permitted;
13268
+ }
12600
13269
  async function renderList(req, admin, state) {
12601
13270
  const columns = adminColumns(admin.model);
12602
13271
  const query = await resolveListQuery(req, admin, state.dbSession);
@@ -12651,21 +13320,37 @@ function makeAdminRouter(site, options) {
12651
13320
  searchValue: query.search,
12652
13321
  filters: query.filterViews,
12653
13322
  sort,
12654
- newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null,
12655
- bulkActions: bulkActionsFor(admin),
13323
+ newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null,
13324
+ importUrl: admin.canImport && await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/import` : null,
13325
+ bulkActions: await permittedBulkActions(admin, state.principal),
12656
13326
  bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
12657
13327
  exportCsvUrl: exportUrl("csv"),
12658
- exportJsonUrl: exportUrl("json")
13328
+ exportJsonUrl: exportUrl("json"),
13329
+ lenses: admin.lenses.length === 0 ? [] : [
13330
+ {
13331
+ label: "All",
13332
+ url: `?${buildQuery({ ...query.baseQuery, lens: void 0 })}`,
13333
+ active: query.lens === ""
13334
+ },
13335
+ ...admin.lenses.map((entry) => ({
13336
+ label: entry.label,
13337
+ url: `?${buildQuery({ ...query.baseQuery, lens: entry.slug })}`,
13338
+ active: query.lens === entry.slug
13339
+ }))
13340
+ ]
12659
13341
  };
12660
- return renderListPage(context(req, state.session), view);
13342
+ return renderListPage(context(req, state.session, state.visible), view);
12661
13343
  }
12662
13344
  async function resolveListQuery(req, admin, dbSession) {
12663
13345
  const columns = adminColumns(admin.model);
12664
13346
  const search = queryString(req.query.q);
12665
13347
  const sortField = queryString(req.query.sort);
12666
13348
  const sortColumn = sortField in columns ? sortField : null;
12667
- const ascending = sortColumn === null ? admin.orderAscending : queryString(req.query.dir) !== "desc";
13349
+ const lens = admin.getLens(queryString(req.query.lens));
12668
13350
  const conditions = [];
13351
+ if (lens !== null && Object.keys(lens.filters).length > 0) {
13352
+ conditions.push(lens.filters);
13353
+ }
12669
13354
  const filterViews = [];
12670
13355
  for (const field of admin.listFilter) {
12671
13356
  const column6 = columns[field];
@@ -12729,15 +13414,21 @@ function makeAdminRouter(site, options) {
12729
13414
  baseQuery[`filter_${view.field}`] = view.value;
12730
13415
  }
12731
13416
  }
13417
+ const lensOrder = lens?.orderBy ?? null;
13418
+ const lensDescending = lensOrder?.startsWith("-");
13419
+ const lensColumn = lensOrder === null ? null : lensOrder.replace(/^-/, "");
13420
+ const ascending = sortColumn !== null ? queryString(req.query.dir) !== "desc" : lensColumn !== null ? !lensDescending : admin.orderAscending;
13421
+ if (lens !== null) baseQuery.lens = lens.slug;
12732
13422
  return {
12733
13423
  search,
12734
13424
  searchable,
12735
13425
  where: conditions.length === 0 ? void 0 : and(...conditions),
12736
- orderBy: sortColumn ?? admin.orderKey ?? void 0,
13426
+ orderBy: sortColumn ?? lensColumn ?? admin.orderKey ?? void 0,
12737
13427
  ascending,
12738
13428
  sortColumn,
12739
13429
  filterViews,
12740
- baseQuery
13430
+ baseQuery,
13431
+ lens: lens?.slug ?? ""
12741
13432
  };
12742
13433
  }
12743
13434
  async function relatedOptions(column6, dbSession) {
@@ -12755,6 +13446,7 @@ function makeAdminRouter(site, options) {
12755
13446
  const columns = adminColumns(admin.model);
12756
13447
  const options2 = {};
12757
13448
  for (const field of Object.keys(foreignKeyFields(admin))) {
13449
+ if (admin.autocompleteFields.includes(field)) continue;
12758
13450
  const column6 = columns[field];
12759
13451
  if (column6 === void 0) continue;
12760
13452
  const related = await relatedOptions(column6, dbSession);
@@ -12762,8 +13454,101 @@ function makeAdminRouter(site, options) {
12762
13454
  }
12763
13455
  return options2;
12764
13456
  }
13457
+ function autocompleteUrlsFor(admin) {
13458
+ const urls = {};
13459
+ for (const field of admin.autocompleteFields) {
13460
+ urls[field] = `${prefix}/m/${admin.slug()}/autocomplete/${field}`;
13461
+ }
13462
+ return urls;
13463
+ }
13464
+ async function autocompleteLabelsFor(admin, row, dbSession) {
13465
+ const labels = {};
13466
+ if (row === null) return labels;
13467
+ const columns = adminColumns(admin.model);
13468
+ for (const field of admin.autocompleteFields) {
13469
+ const value = row[field];
13470
+ if (value === null || value === void 0 || value === "") continue;
13471
+ const column6 = columns[field];
13472
+ const table = column6 === void 0 ? null : foreignKeyTable(column6);
13473
+ const referenced = table === null ? null : site.get(table);
13474
+ if (referenced === null) continue;
13475
+ const related = await referenced.repository(dbSession).first({ [referenced.identityField]: value });
13476
+ if (related !== null) labels[field] = foreignKeyLabel(referenced, related);
13477
+ }
13478
+ return labels;
13479
+ }
12765
13480
  return router;
12766
13481
  }
13482
+ function flagAllows(admin, action) {
13483
+ if (action === AdminPermission.CREATE) return admin.canCreate;
13484
+ if (action === AdminPermission.EDIT) return admin.canEdit;
13485
+ if (action === AdminPermission.DELETE) return admin.canDelete;
13486
+ return true;
13487
+ }
13488
+ function stampActor(admin, data, actorId, creating) {
13489
+ const columns = adminColumns(admin.model);
13490
+ if (creating && "createdBy" in columns) data.createdBy = actorId;
13491
+ if ("updatedBy" in columns) data.updatedBy = actorId;
13492
+ }
13493
+ function formatMetric(value) {
13494
+ if (typeof value === "string") return value;
13495
+ return Number.isInteger(value) ? String(value) : value.toFixed(2);
13496
+ }
13497
+ async function computeBusinessCard(card, dbSession) {
13498
+ const base = {
13499
+ label: card.label,
13500
+ unit: null,
13501
+ direction: "flat",
13502
+ percent: null,
13503
+ previous: "",
13504
+ segments: [],
13505
+ helpText: card.helpText ?? null
13506
+ };
13507
+ let data;
13508
+ try {
13509
+ data = await card.compute(dbSession);
13510
+ } catch (error) {
13511
+ logger2.warning("Admin dashboard card failed", {
13512
+ card: card.label,
13513
+ error: error instanceof Error ? error.message : String(error)
13514
+ });
13515
+ return { ...base, kind: "value", value: "", error: "Could not compute this metric." };
13516
+ }
13517
+ if (data.kind === "partition") {
13518
+ const total = partitionTotal(data);
13519
+ return {
13520
+ ...base,
13521
+ kind: "partition",
13522
+ value: formatMetric(total),
13523
+ segments: data.segments.map((segment) => ({
13524
+ label: segment.label,
13525
+ value: formatMetric(segment.value),
13526
+ percent: total === 0 ? 0 : segment.value / total * 100
13527
+ })),
13528
+ error: null
13529
+ };
13530
+ }
13531
+ if (data.kind === "trend") {
13532
+ const percent = trendPercent(data);
13533
+ return {
13534
+ ...base,
13535
+ kind: "trend",
13536
+ value: formatMetric(data.value),
13537
+ unit: data.unit ?? null,
13538
+ direction: trendDirection(data),
13539
+ percent: percent === null ? null : `${percent >= 0 ? "+" : ""}${percent.toFixed(1)}%`,
13540
+ previous: formatMetric(data.previous),
13541
+ error: null
13542
+ };
13543
+ }
13544
+ return {
13545
+ ...base,
13546
+ kind: "value",
13547
+ value: formatMetric(data.value),
13548
+ unit: data.unit ?? null,
13549
+ error: null
13550
+ };
13551
+ }
12767
13552
  function bulkActionsFor(admin) {
12768
13553
  const actions = [];
12769
13554
  const hasActiveFlag = "isActive" in adminColumns(admin.model);
@@ -12783,6 +13568,55 @@ function bulkActionsFor(admin) {
12783
13568
  }
12784
13569
  return actions;
12785
13570
  }
13571
+ function parseCsv(text) {
13572
+ const source = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
13573
+ const rows = [];
13574
+ let row = [];
13575
+ let field = "";
13576
+ let quoted = false;
13577
+ for (let index = 0; index < source.length; index += 1) {
13578
+ const char = source[index];
13579
+ if (quoted) {
13580
+ if (char === '"') {
13581
+ if (source[index + 1] === '"') {
13582
+ field += '"';
13583
+ index += 1;
13584
+ } else {
13585
+ quoted = false;
13586
+ }
13587
+ } else {
13588
+ field += char;
13589
+ }
13590
+ continue;
13591
+ }
13592
+ if (char === '"') {
13593
+ quoted = true;
13594
+ } else if (char === ",") {
13595
+ row.push(field);
13596
+ field = "";
13597
+ } else if (char === "\n" || char === "\r") {
13598
+ if (char === "\r" && source[index + 1] === "\n") index += 1;
13599
+ row.push(field);
13600
+ rows.push(row);
13601
+ row = [];
13602
+ field = "";
13603
+ } else {
13604
+ field += char;
13605
+ }
13606
+ }
13607
+ if (field !== "" || row.length > 0) {
13608
+ row.push(field);
13609
+ rows.push(row);
13610
+ }
13611
+ const header = rows.shift();
13612
+ if (header === void 0 || header.length === 0) {
13613
+ throw new Error("The file has no header row.");
13614
+ }
13615
+ const keys = header.map((name) => name.trim());
13616
+ return rows.filter((entry) => entry.some((value) => value.trim() !== "")).map(
13617
+ (entry) => Object.fromEntries(keys.map((key, position) => [key, entry[position] ?? ""]))
13618
+ );
13619
+ }
12786
13620
  function exportValue(value) {
12787
13621
  if (value instanceof Date) return value.toISOString();
12788
13622
  if (typeof value === "bigint") return value.toString();
@@ -14815,6 +15649,6 @@ async function withTestDatabase(models, fn) {
14815
15649
  }
14816
15650
  }
14817
15651
 
14818
- export { ADMIN_CSS, ActivationService, AdminJsonSite, AdminModel, AdminSessionStore, AdminSite, AppException, AttemptThrottle, AuditAction, BaseAuditLogModel, BaseController, BaseModel, BaseOAuthClient, BaseOutboxModel, BaseService, BaseUserModel, BaseUserRefreshTokenModel, BaseUserTokenModel, CEP_PATTERN, CNPJ_PATTERN, CPF_PATTERN, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, CircuitOpenError, CompositeFeatureFlagBackend, ConflictException, DEFAULT_DOCS_FAVICON, DEFAULT_LOCALE, EmailProvider, EmailUtils, EnvFeatureFlagBackend, EventStream, ExpiredTokenException, FeatureFlags, ForbiddenException, GitHubOAuthClient, GoogleOAuthClient, GracefulShutdown, HTTPClient, HTTP_500_LOG_FILE, HTTP_500_MARKER, HttpMetrics, IDEMPOTENCY_HEADER, InvalidTokenException, JSONLogger, JWTUtils, LEVEL_LOG_FILES, LocalUploadStorage, MemoryBroker, MemoryCacheManager, MemoryFeatureFlagBackend, MemoryIdempotencyStore, MemoryRateLimitStore, MemorySessionStore, MemoryThrottleBackend, MessageCatalog, MessagingHub, MetricsUtils, MfaService, NotFoundException, OAuthError, OIDCProvider, OutboxRelay, OutboxStatus, PHONE_BR_PATTERN, PasswordResetService, REDOC_CDN_URL, REQUEST_ID_HEADER, RabbitBroker, RedisCacheManager, RedisIdempotencyStore, RedisRateLimitStore, RedisSSEBroker, RedisSessionStore, Region, RetryPolicy, S3UploadStorage, SSEBroker, ServerSentEvent, SessionService, TOTPHelper, TaskManager, TelegramProvider, TenantScopedRepository, TooManyRequestsException, TwilioSmsProvider, UF, UnauthorizedException, UserAuthService, UserModelAuthBackend, UserTokenPurpose, ValidationException, WebPushDispatcher, WebPushError, WebPushGoneError, WebSocketHub, WebhookSignatureVerifier, WhatsAppProvider, activationSchema, addLogSink, adminAction, adminColumns, adminThemeCss, attachWebSocketHub, authResponseSchema, authSettingsShape, backupDatabase, bearerToken, bodySizeLimitMiddleware, broadcastText, buildContentDisposition, buildFormFields, buildPaginationLinkHeader, cached2 as cached, cepField, citiesByUf, cnpjField, coerceFlag, configureFileLogging, configureLogging, cpfField, cpfOrCnpjField, createApp, createOpenApiRegistry, createTestDatabase, createdByColumn, csrfMiddleware, csrfTokenMatches, cursorPaginationFilterSchema, cursorPaginationSchema, decodeCursor, defaultMessageCatalog, defineEnum, deletedAtColumn, diffSnapshots, emailSettingsShape, encodeCursor, envList, escapeHtml, filterForColumn, foreignKeyFields, foreignKeyLabel, foreignKeyTable, formatCellValue, formatFieldValue, generateCsrfToken, generateOAuthState, generateOpaqueToken, generateOpenApiDocument, getAuth, getClientIp, getConditions, getPaginationConditions, getRequestId, getState, hashOpaqueToken, humanizeField, idempotencyMiddleware, inboundMessageSchema, isColumnOptional, isSearchableColumn, isValidCep, isValidCity, isValidCnpj, isValidCpf, isValidCpfCnpj, isValidPhoneBr, isValidUf, jwtSettingsShape, keyByHeader, keyByIp, keyByJwtClaim, keyByJwtSubject, listStates, logEntrySchema, logSettingsShape, loginSchema, makeAdminJsonRouter, makeAdminRouter, makeAppExceptionHandler, makeAuthRouter, makeFlagGuard, makeHealthRouter, makeJwtAuthMiddleware, makeLogsRouter, makeMetricsRouter, makeSessionMiddleware, makeToolSpecRouter, makeTwilioWebhookRouter, makeUnhandledExceptionHandler, makeWhatsAppWebhookRouter, mfaChallengeSchema, mfaCodeSchema, mfaEnrollResponseSchema, minioSettingsShape, modifyDict, mountOpenApiJson, mountRedoc, mountSwaggerUi, normalizeCep, normalizeCnpj, normalizeCpf, normalizeCpfCnpj, normalizePhoneBr, normalizeUf, notFoundHandler, onlyDigits, paginationFilterSchema, paginationSchema, parseAcceptLanguage, parseCookies, parseFormBody, passwordResetConfirmSchema, passwordResetRequestSchema, phoneBrField, prometheusMiddleware, rabbitmqSettingsShape, rateLimitMiddleware, redisSettingsShape, refreshSchema, registerExceptionHandlers, renderAuthResultPage, renderDashboardPage, renderDetailPage, renderFormPage, renderLayout, renderListPage, renderLoginPage, renderMfaPage, renderPasswordResetFormPage, requestIdMiddleware, requestTracingMiddleware, requireRoles, resolveAdminTheme, resolveDownloadPath, resolveRedocBundle, runServer, runWithRequestContext, sendBytesDownload, sendFileDownload, sessionCookie, sessionSettingsShape, setRequestId, signupSchema, snapshot, sseResponse, statesByRegion, syncFilterSchema, syncPaginationSchema, tableNameFor, toUtc, tokenFromUrl, tokenPairSchema, tokenSettingsShape, ufField, updatedByColumn, uploadSettingsShape, userPublicSchema, utcnow, validateTwilioSignature, verifyOpaqueToken, webPushKeysSchema, webPushPayloadSchema, webPushSettingsShape, webPushSubscriptionSchema, webSocketSettingsShape, widgetForColumn, withTestDatabase, wrapWithSlowQueryLog, wsEnvelopeSchema };
15652
+ export { ADMIN_CSS, ActivationService, AdminJsonSite, AdminModel, AdminPermission, AdminSessionStore, AdminSite, AppException, AttemptThrottle, AuditAction, BaseAuditLogModel, BaseController, BaseModel, BaseOAuthClient, BaseOutboxModel, BaseService, BaseUserModel, BaseUserRefreshTokenModel, BaseUserTokenModel, CEP_PATTERN, CNPJ_PATTERN, CPF_PATTERN, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, CircuitOpenError, CompositeFeatureFlagBackend, ConflictException, DEFAULT_DOCS_FAVICON, DEFAULT_LOCALE, EmailProvider, EmailUtils, EnvFeatureFlagBackend, EventStream, ExpiredTokenException, FeatureFlags, ForbiddenException, GitHubOAuthClient, GoogleOAuthClient, GracefulShutdown, HTTPClient, HTTP_500_LOG_FILE, HTTP_500_MARKER, HttpMetrics, IDEMPOTENCY_HEADER, InvalidTokenException, JSONLogger, JWTUtils, LEVEL_LOG_FILES, LocalUploadStorage, MemoryBroker, MemoryCacheManager, MemoryFeatureFlagBackend, MemoryIdempotencyStore, MemoryRateLimitStore, MemorySessionStore, MemoryThrottleBackend, MessageCatalog, MessagingHub, MetricsUtils, MfaService, MultipartLimitError, NotFoundException, OAuthError, OIDCProvider, OutboxRelay, OutboxStatus, PHONE_BR_PATTERN, PasswordResetService, REDOC_CDN_URL, REQUEST_ID_HEADER, RabbitBroker, RedisCacheManager, RedisIdempotencyStore, RedisRateLimitStore, RedisSSEBroker, RedisSessionStore, Region, RetryPolicy, S3UploadStorage, SSEBroker, ServerSentEvent, SessionService, TOTPHelper, TaskManager, TelegramProvider, TenantScopedRepository, TooManyRequestsException, TwilioSmsProvider, UF, UnauthorizedException, UserAuthService, UserModelAuthBackend, UserTokenPurpose, ValidationException, WebPushDispatcher, WebPushError, WebPushGoneError, WebSocketHub, WebhookSignatureVerifier, WhatsAppProvider, activationSchema, addLogSink, adminAction, adminColumns, adminLens, adminThemeCss, attachWebSocketHub, authResponseSchema, authSettingsShape, backupDatabase, bearerToken, bodySizeLimitMiddleware, broadcastText, buildContentDisposition, buildFormFields, buildPaginationLinkHeader, cached2 as cached, cepField, citiesByUf, cnpjField, coerceFlag, configureFileLogging, configureLogging, cpfField, cpfOrCnpjField, createApp, createOpenApiRegistry, createTestDatabase, createdByColumn, csrfMiddleware, csrfTokenMatches, cursorPaginationFilterSchema, cursorPaginationSchema, decodeCursor, defaultMessageCatalog, defineEnum, deletedAtColumn, diffSnapshots, emailSettingsShape, encodeCursor, envList, escapeHtml, filterForColumn, foreignKeyFields, foreignKeyLabel, foreignKeyTable, formatCellValue, formatFieldValue, generateCsrfToken, generateOAuthState, generateOpaqueToken, generateOpenApiDocument, getAuth, getClientIp, getConditions, getPaginationConditions, getRequestId, getState, hashOpaqueToken, humanizeField, idempotencyMiddleware, inboundMessageSchema, isColumnOptional, isMultipart, isSearchableColumn, isValidCep, isValidCity, isValidCnpj, isValidCpf, isValidCpfCnpj, isValidPhoneBr, isValidUf, jwtSettingsShape, keyByHeader, keyByIp, keyByJwtClaim, keyByJwtSubject, listStates, logEntrySchema, logSettingsShape, loginSchema, makeAdminJsonRouter, makeAdminRouter, makeAppExceptionHandler, makeAuthRouter, makeFlagGuard, makeHealthRouter, makeJwtAuthMiddleware, makeLogsRouter, makeMetricsRouter, makeSessionMiddleware, makeToolSpecRouter, makeTwilioWebhookRouter, makeUnhandledExceptionHandler, makeWhatsAppWebhookRouter, metricCard, mfaChallengeSchema, mfaCodeSchema, mfaEnrollResponseSchema, minioSettingsShape, modifyDict, mountOpenApiJson, mountRedoc, mountSwaggerUi, normalizeCep, normalizeCnpj, normalizeCpf, normalizeCpfCnpj, normalizePhoneBr, normalizeUf, notFoundHandler, onlyDigits, paginationFilterSchema, paginationSchema, parseAcceptLanguage, parseCookies, parseCsv, parseFormBody, parseMultipart, partitionTotal, passwordResetConfirmSchema, passwordResetRequestSchema, phoneBrField, prometheusMiddleware, rabbitmqSettingsShape, rateLimitMiddleware, redisSettingsShape, refreshSchema, registerExceptionHandlers, renderAuthResultPage, renderDashboardPage, renderDetailPage, renderFormPage, renderImportPage, renderLayout, renderListPage, renderLoginPage, renderMfaPage, renderPasswordResetFormPage, requestIdMiddleware, requestTracingMiddleware, requireRoles, resolveAdminTheme, resolveDownloadPath, resolveRedocBundle, runServer, runWithRequestContext, sendBytesDownload, sendFileDownload, sessionCookie, sessionSettingsShape, setRequestId, signupSchema, snapshot, sseResponse, statesByRegion, syncFilterSchema, syncPaginationSchema, tableNameFor, toUtc, tokenFromUrl, tokenPairSchema, tokenSettingsShape, trendDirection, trendPercent, ufField, updatedByColumn, uploadSettingsShape, userPublicSchema, utcnow, validateTwilioSignature, verifyOpaqueToken, webPushKeysSchema, webPushPayloadSchema, webPushSettingsShape, webPushSubscriptionSchema, webSocketSettingsShape, widgetForColumn, withTestDatabase, wrapWithSlowQueryLog, wsEnvelopeSchema };
14819
15653
  //# sourceMappingURL=index.js.map
14820
15654
  //# sourceMappingURL=index.js.map