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.cjs CHANGED
@@ -9444,12 +9444,51 @@ var MessagingHub = class {
9444
9444
  }
9445
9445
  };
9446
9446
 
9447
+ // src/admin/dashboard.ts
9448
+ function metricCard(label, compute, helpText) {
9449
+ return helpText === void 0 ? { label, compute } : { label, compute, helpText };
9450
+ }
9451
+ function trendPercent(trend) {
9452
+ if (trend.previous === 0) return null;
9453
+ return (trend.value - trend.previous) / trend.previous * 100;
9454
+ }
9455
+ function trendDirection(trend) {
9456
+ if (trend.value > trend.previous) return "up";
9457
+ if (trend.value < trend.previous) return "down";
9458
+ return "flat";
9459
+ }
9460
+ function partitionTotal(partition) {
9461
+ return partition.segments.reduce((total, segment) => total + segment.value, 0);
9462
+ }
9463
+
9464
+ // src/admin/lenses.ts
9465
+ function slugify(name) {
9466
+ return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "lens";
9467
+ }
9468
+ function adminLens(options) {
9469
+ return {
9470
+ name: options.name,
9471
+ slug: slugify(options.name),
9472
+ label: options.label ?? options.name,
9473
+ filters: options.filters ?? {},
9474
+ orderBy: options.orderBy ?? null
9475
+ };
9476
+ }
9477
+
9478
+ // src/admin/permissions.ts
9479
+ var AdminPermission = {
9480
+ VIEW: "view",
9481
+ CREATE: "create",
9482
+ EDIT: "edit",
9483
+ DELETE: "delete"
9484
+ };
9485
+
9447
9486
  // src/admin/actions.ts
9448
- function slugify(label) {
9487
+ function slugify2(label) {
9449
9488
  return label.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "action";
9450
9489
  }
9451
9490
  function adminAction(options, handler) {
9452
- const name = options.name ?? slugify(options.label);
9491
+ const name = options.name ?? slugify2(options.label);
9453
9492
  if (name === "") throw new Error("adminAction requires a non-empty name or label");
9454
9493
  return {
9455
9494
  name,
@@ -9459,6 +9498,85 @@ function adminAction(options, handler) {
9459
9498
  };
9460
9499
  }
9461
9500
 
9501
+ // src/admin/multipart.ts
9502
+ var BUSBOY_HINT = "The admin's upload and CSV-import screens need the optional peer `busboy`. Install it with: npm install busboy";
9503
+ async function loadBusboy() {
9504
+ try {
9505
+ const module = await import('busboy');
9506
+ return module.default ?? module;
9507
+ } catch {
9508
+ throw new Error(BUSBOY_HINT);
9509
+ }
9510
+ }
9511
+ var MultipartLimitError = class extends Error {
9512
+ /**
9513
+ * @param message - The operator-facing explanation.
9514
+ */
9515
+ constructor(message) {
9516
+ super(message);
9517
+ this.name = "MultipartLimitError";
9518
+ }
9519
+ };
9520
+ async function parseMultipart(req, options = {}) {
9521
+ const maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024;
9522
+ const maxFiles = options.maxFiles ?? 10;
9523
+ const busboy = await loadBusboy();
9524
+ return await new Promise((resolve, reject) => {
9525
+ const fields = {};
9526
+ const files = [];
9527
+ let settled = false;
9528
+ const fail = (error) => {
9529
+ if (settled) return;
9530
+ settled = true;
9531
+ reject(error);
9532
+ };
9533
+ const parser = busboy({
9534
+ headers: req.headers,
9535
+ limits: { fileSize: maxFileBytes, files: maxFiles }
9536
+ });
9537
+ parser.on("field", ((name, value) => {
9538
+ fields[name] = value;
9539
+ }));
9540
+ parser.on("file", ((name, stream, info) => {
9541
+ const chunks = [];
9542
+ stream.on("data", ((chunk) => chunks.push(chunk)));
9543
+ stream.on("limit", () => {
9544
+ fail(
9545
+ new MultipartLimitError(
9546
+ `The file is larger than the ${Math.floor(maxFileBytes / 1024 / 1024)} MB limit.`
9547
+ )
9548
+ );
9549
+ });
9550
+ stream.on("end", () => {
9551
+ const data = Buffer.concat(chunks);
9552
+ const filename = (info.filename ?? "").split(/[/\\]/).pop() ?? "";
9553
+ if (filename === "" || data.length === 0) return;
9554
+ files.push({
9555
+ field: name,
9556
+ filename,
9557
+ contentType: info.mimeType ?? "application/octet-stream",
9558
+ data
9559
+ });
9560
+ });
9561
+ }));
9562
+ parser.on("filesLimit", () => {
9563
+ fail(new MultipartLimitError(`At most ${maxFiles} files can be uploaded at once.`));
9564
+ });
9565
+ parser.on("error", ((error) => {
9566
+ fail(error instanceof Error ? error : new Error(String(error)));
9567
+ }));
9568
+ parser.on("close", () => {
9569
+ if (settled) return;
9570
+ settled = true;
9571
+ resolve({ fields, files });
9572
+ });
9573
+ req.pipe(parser);
9574
+ });
9575
+ }
9576
+ function isMultipart(req) {
9577
+ return (req.header("content-type") ?? "").toLowerCase().startsWith("multipart/form-data");
9578
+ }
9579
+
9462
9580
  // src/admin/columns.ts
9463
9581
  function humanizeField(name) {
9464
9582
  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(" ");
@@ -9552,9 +9670,17 @@ var NEVER_EDITABLE = [
9552
9670
  "id",
9553
9671
  "createdAt",
9554
9672
  "updatedAt",
9673
+ "createdBy",
9674
+ "updatedBy",
9555
9675
  "hashedPassword"
9556
9676
  ];
9557
9677
  var NEVER_LISTED = ["hashedPassword"];
9678
+ var AUDIT_FIELDS = [
9679
+ "createdAt",
9680
+ "updatedAt",
9681
+ "createdBy",
9682
+ "updatedBy"
9683
+ ];
9558
9684
  var AdminModel = class {
9559
9685
  /** The managed model class. */
9560
9686
  model;
@@ -9578,6 +9704,18 @@ var AdminModel = class {
9578
9704
  canEdit;
9579
9705
  /** Whether the delete action is exposed. */
9580
9706
  canDelete;
9707
+ /** Audit-log model backing the detail timeline, or `null`. */
9708
+ auditModel;
9709
+ /** Saved list-view presets, in declaration order. */
9710
+ lenses;
9711
+ /** Columns rendered as file inputs. */
9712
+ uploadFields;
9713
+ /** Backend persisting uploaded files, or `null`. */
9714
+ uploadStorage;
9715
+ /** Whether the CSV import page is exposed. */
9716
+ canImport;
9717
+ /** Foreign-key columns rendered as a typed search box. */
9718
+ autocompleteFields;
9581
9719
  actions = /* @__PURE__ */ new Map();
9582
9720
  slugOverride;
9583
9721
  listDisplayOverride;
@@ -9603,6 +9741,17 @@ var AdminModel = class {
9603
9741
  this.canCreate = options.canCreate ?? true;
9604
9742
  this.canEdit = options.canEdit ?? true;
9605
9743
  this.canDelete = options.canDelete ?? true;
9744
+ this.auditModel = options.auditModel ?? null;
9745
+ this.lenses = [...options.lenses ?? []];
9746
+ this.uploadFields = [...options.uploadFields ?? []];
9747
+ this.uploadStorage = options.uploadStorage ?? null;
9748
+ this.canImport = options.canImport ?? false;
9749
+ this.autocompleteFields = [...options.autocompleteFields ?? []];
9750
+ if (this.uploadFields.length > 0 && this.uploadStorage === null) {
9751
+ throw new Error(
9752
+ `AdminModel(${this.model.tablename}).uploadFields requires an uploadStorage (e.g. LocalUploadStorage / S3UploadStorage) \u2014 without one there is nowhere to write the file.`
9753
+ );
9754
+ }
9606
9755
  for (const action of options.actions ?? []) {
9607
9756
  if (this.actions.has(action.name)) {
9608
9757
  throw new Error(
@@ -9617,7 +9766,9 @@ var AdminModel = class {
9617
9766
  ["listFilter", this.listFilter],
9618
9767
  ["searchFields", this.searchFields],
9619
9768
  ["readonlyFields", this.readonlyFields],
9620
- ["identityField", [this.identityField]]
9769
+ ["identityField", [this.identityField]],
9770
+ ["uploadFields", this.uploadFields],
9771
+ ["autocompleteFields", this.autocompleteFields]
9621
9772
  ]) {
9622
9773
  for (const name of names) {
9623
9774
  if (!known.has(name)) {
@@ -9687,6 +9838,25 @@ var AdminModel = class {
9687
9838
  if (this.listDisplayOverride !== null) return [...this.listDisplayOverride];
9688
9839
  return this.columnNames().filter((name) => !NEVER_LISTED.includes(name));
9689
9840
  }
9841
+ /**
9842
+ * Look a lens up by its slug.
9843
+ *
9844
+ * @param slug - The `?lens=` value.
9845
+ * @returns The lens, or `null` when nothing matches.
9846
+ */
9847
+ getLens(slug) {
9848
+ return this.lenses.find((lens) => lens.slug === slug) ?? null;
9849
+ }
9850
+ /**
9851
+ * Return the audit/timestamp columns the model actually declares.
9852
+ *
9853
+ * @returns The subset of `createdAt` / `updatedAt` / `createdBy` /
9854
+ * `updatedBy` present on the model, in that order.
9855
+ */
9856
+ auditFieldNames() {
9857
+ const known = new Set(this.columnNames());
9858
+ return AUDIT_FIELDS.filter((name) => known.has(name));
9859
+ }
9690
9860
  /**
9691
9861
  * Return the columns the detail view renders.
9692
9862
  *
@@ -9695,10 +9865,16 @@ var AdminModel = class {
9695
9865
  * where an operator goes to see the whole record, so trimming it there would
9696
9866
  * hide data with nowhere else to read it.
9697
9867
  *
9698
- * @returns Every column but the password hash, in declaration order.
9868
+ * The audit/timestamp columns are held back too — they render in the detail
9869
+ * view's own audit panel, next to the change history, rather than scattered
9870
+ * among the domain fields.
9871
+ *
9872
+ * @returns Every domain column, in declaration order.
9699
9873
  */
9700
9874
  detailFieldNames() {
9701
- return this.columnNames().filter((name) => !NEVER_LISTED.includes(name));
9875
+ return this.columnNames().filter(
9876
+ (name) => !NEVER_LISTED.includes(name) && !AUDIT_FIELDS.includes(name)
9877
+ );
9702
9878
  }
9703
9879
  /**
9704
9880
  * Return the columns a create/edit form exposes.
@@ -9774,11 +9950,15 @@ function buildFormFields(admin, options = {}) {
9774
9950
  const values = options.values ?? {};
9775
9951
  const errors = options.errors ?? {};
9776
9952
  const foreignKeys = options.foreignKeyOptions ?? {};
9953
+ const autocompleteUrls = options.autocompleteUrls ?? {};
9954
+ const autocompleteLabels = options.autocompleteLabels ?? {};
9955
+ const uploads = new Set(admin.uploadFields);
9777
9956
  return admin.editableFieldNames().flatMap((name) => {
9778
9957
  const column6 = columns[name];
9779
9958
  if (column6 === void 0) return [];
9780
9959
  const related = foreignKeys[name];
9781
- const spec = related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
9960
+ const autocompleteUrl = autocompleteUrls[name];
9961
+ 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 };
9782
9962
  const raw = name in values ? values[name] : literalDefault(column6);
9783
9963
  return [
9784
9964
  {
@@ -9790,7 +9970,9 @@ function buildFormFields(admin, options = {}) {
9790
9970
  checked: spec.widget === "checkbox" && toBoolean(raw),
9791
9971
  step: spec.step,
9792
9972
  options: spec.options,
9793
- error: errors[name] ?? null
9973
+ error: errors[name] ?? null,
9974
+ autocompleteUrl: autocompleteUrl ?? null,
9975
+ displayLabel: autocompleteLabels[name] ?? ""
9794
9976
  }
9795
9977
  ];
9796
9978
  });
@@ -9838,13 +10020,15 @@ function coerceValue(column6, widget, raw) {
9838
10020
  return raw;
9839
10021
  }
9840
10022
  }
9841
- function parseFormBody(admin, body) {
10023
+ function parseFormBody(admin, body, options = {}) {
9842
10024
  const columns = adminColumns(admin.model);
9843
10025
  const data = {};
9844
10026
  const errors = {};
10027
+ const uploads = options.uploadsAsText === true ? /* @__PURE__ */ new Set() : new Set(admin.uploadFields);
9845
10028
  for (const name of admin.editableFieldNames()) {
9846
10029
  const column6 = columns[name];
9847
10030
  if (column6 === void 0) continue;
10031
+ if (uploads.has(name)) continue;
9848
10032
  const { widget } = widgetForColumn(column6);
9849
10033
  if (widget === "checkbox") {
9850
10034
  data[name] = toBoolean(body[name]);
@@ -10155,6 +10339,8 @@ var AdminSite = class {
10155
10339
  siteUrl;
10156
10340
  /** Typed appearance overrides. */
10157
10341
  theme;
10342
+ /** Business-metric cards rendered at the top of the dashboard. */
10343
+ dashboardCards;
10158
10344
  registry = /* @__PURE__ */ new Map();
10159
10345
  /**
10160
10346
  * Initialize the site.
@@ -10167,6 +10353,7 @@ var AdminSite = class {
10167
10353
  this.indexSubtitle = options.indexSubtitle ?? "Site administration";
10168
10354
  this.siteUrl = options.siteUrl ?? null;
10169
10355
  this.theme = options.theme ?? {};
10356
+ this.dashboardCards = [...options.dashboardCards ?? []];
10170
10357
  }
10171
10358
  /**
10172
10359
  * Return the centered header brand text.
@@ -11986,7 +12173,7 @@ function renderMfaPage(context, error) {
11986
12173
  </section>`;
11987
12174
  return renderLayout(context, `Two-factor \xB7 ${context.site.title}`, body);
11988
12175
  }
11989
- function renderDashboardPage(context, cards, metrics) {
12176
+ function renderDashboardPage(context, cards, metrics, businessCards = []) {
11990
12177
  const metricsPanel = metrics === null ? "" : `<div class="tempest-admin-stats" aria-label="System metrics">
11991
12178
  <div class="tempest-admin-stat">
11992
12179
  <span class="tempest-admin-stat__label">CPU</span>
@@ -11998,6 +12185,7 @@ function renderDashboardPage(context, cards, metrics) {
11998
12185
  <span class="tempest-admin-stat__sub">${escapeHtml(metrics.memoryUsedGb)} / ${escapeHtml(metrics.memoryTotalGb)} GB</span>
11999
12186
  </div>
12000
12187
  </div>`;
12188
+ const business = businessCards.length > 0 ? `<div class="tempest-admin-cards" aria-label="Business metrics">${businessCards.map(renderBusinessCard).join("")}</div>` : "";
12001
12189
  const models = cards.length > 0 ? `<div class="tempest-admin-models">${cards.map(
12002
12190
  (card) => `<article class="tempest-admin-model-card">
12003
12191
  <header class="tempest-admin-model-card__head">
@@ -12014,10 +12202,53 @@ function renderDashboardPage(context, cards, metrics) {
12014
12202
  <h1>${escapeHtml(context.site.title)}</h1>
12015
12203
  <p>${escapeHtml(context.site.indexSubtitle)}</p>
12016
12204
  ${metricsPanel}
12205
+ ${business}
12017
12206
  ${models}
12018
12207
  </section>`;
12019
12208
  return renderLayout(context, context.site.title, body);
12020
12209
  }
12210
+ function renderBusinessCard(card) {
12211
+ const help = card.helpText !== null ? `<span class="tempest-admin-card__help">${escapeHtml(card.helpText)}</span>` : "";
12212
+ if (card.error !== null) {
12213
+ return `<article class="tempest-admin-card tempest-admin-card--value">
12214
+ <span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
12215
+ <span class="tempest-admin-card__value">\u2014</span>
12216
+ <span class="tempest-admin-card__help">${escapeHtml(card.error)}</span>
12217
+ </article>`;
12218
+ }
12219
+ const unit = card.unit !== null ? ` <small>${escapeHtml(card.unit)}</small>` : "";
12220
+ if (card.kind === "partition") {
12221
+ const parts = card.segments.map(
12222
+ (segment) => `<li>
12223
+ <span class="tempest-admin-card__part-label">${escapeHtml(segment.label)}</span>
12224
+ <span class="tempest-admin-card__part-bar"><span style="width: ${Math.round(segment.percent)}%"></span></span>
12225
+ <span class="tempest-admin-card__part-value">${escapeHtml(segment.value)}</span>
12226
+ </li>`
12227
+ ).join("");
12228
+ return `<article class="tempest-admin-card tempest-admin-card--partition">
12229
+ <span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
12230
+ <ul class="tempest-admin-card__parts">${parts}</ul>
12231
+ ${help}
12232
+ </article>`;
12233
+ }
12234
+ if (card.kind === "trend") {
12235
+ const arrow = card.direction === "up" ? "\u25B2" : card.direction === "down" ? "\u25BC" : "\u25AC";
12236
+ return `<article class="tempest-admin-card tempest-admin-card--trend">
12237
+ <span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
12238
+ <span class="tempest-admin-card__value">${escapeHtml(card.value)}${unit}</span>
12239
+ <span class="tempest-admin-card__trend tempest-admin-card__trend--${escapeHtml(card.direction)}">
12240
+ ${arrow} ${card.percent === null ? "\u2014" : escapeHtml(card.percent)}
12241
+ <small>vs prev ${escapeHtml(card.previous)}</small>
12242
+ </span>
12243
+ ${help}
12244
+ </article>`;
12245
+ }
12246
+ return `<article class="tempest-admin-card tempest-admin-card--value">
12247
+ <span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
12248
+ <span class="tempest-admin-card__value">${escapeHtml(card.value)}${unit}</span>
12249
+ ${help}
12250
+ </article>`;
12251
+ }
12021
12252
  function renderFilter(filter) {
12022
12253
  const name = `filter_${filter.field}`;
12023
12254
  if (filter.kind === "select") {
@@ -12083,10 +12314,14 @@ function renderListPage(context, view) {
12083
12314
  </form>
12084
12315
  <div class="tempest-admin-list__actions">
12085
12316
  ${view.newUrl !== null ? `<a class="tempest-admin-list__new" href="${escapeHtml(view.newUrl)}">+ New</a>` : ""}
12317
+ ${view.importUrl !== null ? `<a href="${escapeHtml(view.importUrl)}">Import CSV</a>` : ""}
12086
12318
  <a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
12087
12319
  <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
12088
12320
  </div>
12089
12321
  </div>
12322
+ ${view.lenses.length > 0 ? `<nav class="tempest-admin-lenses" aria-label="Lenses">${view.lenses.map(
12323
+ (lens) => `<a class="tempest-admin-lens${lens.active ? " tempest-admin-lens--active" : ""}" href="${escapeHtml(lens.url)}">${escapeHtml(lens.label)}</a>`
12324
+ ).join("")}</nav>` : ""}
12090
12325
  ${bulkBar}
12091
12326
  <div class="tempest-admin-table-wrap">
12092
12327
  <table class="tempest-admin-list__table">
@@ -12110,6 +12345,7 @@ function renderDetailPage(context, view) {
12110
12345
  const fields = view.fields.map(
12111
12346
  (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
12112
12347
  ).join("");
12348
+ const auditPanel = view.audit === null ? "" : renderAuditPanel(view.audit);
12113
12349
  const body = `<section class="tempest-admin-detail">
12114
12350
  <header class="tempest-admin-detail__header">
12115
12351
  <h1>${escapeHtml(view.title)} \xB7 ${escapeHtml(view.identity)}</h1>
@@ -12123,9 +12359,37 @@ function renderDetailPage(context, view) {
12123
12359
  </div>
12124
12360
  </header>
12125
12361
  <dl class="tempest-admin-detail__fields">${fields}</dl>
12362
+ ${auditPanel}
12126
12363
  </section>`;
12127
12364
  return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
12128
12365
  }
12366
+ function renderAuditPanel(audit) {
12367
+ const rows = audit.fields.map(
12368
+ (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
12369
+ ).join("");
12370
+ const history = audit.history.length > 0 ? `<ol class="tempest-admin-history">${audit.history.map((entry) => {
12371
+ 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(
12372
+ (change) => `<tr><td>${escapeHtml(change.field)}</td><td>${escapeHtml(change.before)}</td><td>${escapeHtml(change.after)}</td></tr>`
12373
+ ).join("")}</tbody></table>` : "<p><em>No field changes recorded.</em></p>";
12374
+ const context = entry.context !== null ? `<pre class="tempest-admin-detail__json">${escapeHtml(entry.context)}</pre>` : "";
12375
+ return `<li class="tempest-admin-history__item">
12376
+ <details>
12377
+ <summary>
12378
+ <span class="tempest-admin-history__action">${escapeHtml(entry.action)}</span>
12379
+ <span class="tempest-admin-history__actor">${escapeHtml(entry.actor)}</span>
12380
+ <span class="tempest-admin-history__at">${escapeHtml(entry.at)}</span>
12381
+ </summary>
12382
+ ${changes}
12383
+ ${context}
12384
+ </details>
12385
+ </li>`;
12386
+ }).join("")}</ol>` : "";
12387
+ return `<section class="tempest-admin-audit">
12388
+ <h2>Audit</h2>
12389
+ <dl class="tempest-admin-detail__fields">${rows}</dl>
12390
+ ${history}
12391
+ </section>`;
12392
+ }
12129
12393
  function renderFormField(field) {
12130
12394
  const required = field.required ? " required" : "";
12131
12395
  const name = escapeHtml(field.name);
@@ -12150,6 +12414,18 @@ function renderFormField(field) {
12150
12414
  control = `<label><span>${label}${field.required ? " *" : ""}</span><select name="${name}"${required}>${blank}${options}</select></label>`;
12151
12415
  break;
12152
12416
  }
12417
+ case "file":
12418
+ 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>`}`;
12419
+ break;
12420
+ case "autocomplete":
12421
+ control = `<label><span>${label}${field.required ? " *" : ""}</span>
12422
+ <div class="tempest-admin-ac" data-ac data-ac-url="${escapeHtml(field.autocompleteUrl)}">
12423
+ <input type="text" class="tempest-admin-ac__search" value="${escapeHtml(field.displayLabel)}" placeholder="Search\u2026" autocomplete="off" data-ac-search>
12424
+ <input type="hidden" name="${name}" value="${value}"${required} data-ac-value>
12425
+ <ul class="tempest-admin-ac__results" data-ac-results hidden></ul>
12426
+ </div>
12427
+ </label>`;
12428
+ break;
12153
12429
  case "number":
12154
12430
  control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}></label>`;
12155
12431
  break;
@@ -12177,7 +12453,7 @@ function renderFormPage(context, view) {
12177
12453
  <a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
12178
12454
  </header>
12179
12455
  ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12180
- <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form">
12456
+ <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form"${view.fields.some((field) => field.widget === "file") ? ' enctype="multipart/form-data"' : ""}>
12181
12457
  <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12182
12458
  ${view.fields.map(renderFormField).join("")}
12183
12459
  <div class="tempest-admin-form__actions">
@@ -12185,10 +12461,98 @@ function renderFormPage(context, view) {
12185
12461
  <a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
12186
12462
  </div>
12187
12463
  </form>
12464
+ ${view.fields.some((field) => field.widget === "autocomplete") ? AUTOCOMPLETE_SCRIPT : ""}
12188
12465
  </section>`;
12189
12466
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12190
12467
  }
12468
+ function renderImportPage(context, view) {
12469
+ if (context.session === null) throw new Error("The import page requires a session");
12470
+ const summary = view.created === null ? "" : `<p class="tempest-admin-import__summary">Created ${escapeHtml(view.created)} record${view.created === 1 ? "" : "s"}.</p>`;
12471
+ 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(
12472
+ (failure) => `<tr><td>${escapeHtml(failure.row)}</td><td>${escapeHtml(failure.message)}</td></tr>`
12473
+ ).join("")}</tbody></table>` : "";
12474
+ const body = `<section class="tempest-admin-import">
12475
+ <header class="tempest-admin-detail__header">
12476
+ <h1>Import ${escapeHtml(view.title)}</h1>
12477
+ <a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
12478
+ </header>
12479
+ ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12480
+ ${summary}
12481
+ ${failures}
12482
+ <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form" enctype="multipart/form-data">
12483
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12484
+ <div class="tempest-admin-form__field">
12485
+ <label>
12486
+ <span>CSV file *</span>
12487
+ <input type="file" name="file" accept=".csv,text/csv" required>
12488
+ </label>
12489
+ <small class="tempest-admin-form__hint">
12490
+ UTF-8, comma-separated, with a header row. Recognised columns:
12491
+ <code>${escapeHtml(view.columns.join(", "))}</code>. Unknown columns are ignored.
12492
+ </small>
12493
+ </div>
12494
+ <div class="tempest-admin-form__actions">
12495
+ <button type="submit">Import</button>
12496
+ <a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
12497
+ </div>
12498
+ </form>
12499
+ </section>`;
12500
+ return renderLayout(context, `Import ${view.title} \xB7 ${context.site.title}`, body);
12501
+ }
12502
+ var AUTOCOMPLETE_SCRIPT = `<script>
12503
+ (function () {
12504
+ document.querySelectorAll("[data-ac]").forEach(function (box) {
12505
+ var search = box.querySelector("[data-ac-search]");
12506
+ var value = box.querySelector("[data-ac-value]");
12507
+ var results = box.querySelector("[data-ac-results]");
12508
+ var url = box.getAttribute("data-ac-url");
12509
+ var timer = null;
12510
+
12511
+ function close() {
12512
+ results.hidden = true;
12513
+ results.innerHTML = "";
12514
+ }
12515
+
12516
+ function pick(option) {
12517
+ value.value = option.value;
12518
+ search.value = option.label;
12519
+ close();
12520
+ }
12521
+
12522
+ function run() {
12523
+ var term = search.value.trim();
12524
+ fetch(url + "?q=" + encodeURIComponent(term), { credentials: "same-origin" })
12525
+ .then(function (response) { return response.ok ? response.json() : { options: [] }; })
12526
+ .then(function (payload) {
12527
+ results.innerHTML = "";
12528
+ (payload.options || []).forEach(function (option) {
12529
+ var item = document.createElement("li");
12530
+ item.textContent = option.label;
12531
+ item.setAttribute("role", "option");
12532
+ item.addEventListener("mousedown", function (event) {
12533
+ event.preventDefault();
12534
+ pick(option);
12535
+ });
12536
+ results.appendChild(item);
12537
+ });
12538
+ results.hidden = results.children.length === 0;
12539
+ })
12540
+ .catch(close);
12541
+ }
12542
+
12543
+ search.addEventListener("input", function () {
12544
+ value.value = "";
12545
+ window.clearTimeout(timer);
12546
+ timer = window.setTimeout(run, 250);
12547
+ });
12548
+ search.addEventListener("focus", run);
12549
+ search.addEventListener("blur", function () { window.setTimeout(close, 150); });
12550
+ });
12551
+ })();
12552
+ </script>`;
12191
12553
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12554
+ var AUTOCOMPLETE_LIMIT = 20;
12555
+ var AUDIT_HISTORY_LIMIT = 50;
12192
12556
  var FK_OPTION_CAP = 1e3;
12193
12557
  var FLASH_MAX_LENGTH = 300;
12194
12558
  var FLASH_MESSAGES = {
@@ -12217,6 +12581,7 @@ function makeAdminRouter(site, options) {
12217
12581
  const theme = resolveAdminTheme(site.theme);
12218
12582
  const showMetrics = options.showMetrics ?? true;
12219
12583
  const exportMaxRows = options.exportMaxRows ?? 5e3;
12584
+ const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
12220
12585
  const sessions = new AdminSessionStore({
12221
12586
  secret: options.secretKey,
12222
12587
  ...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
@@ -12227,18 +12592,23 @@ function makeAdminRouter(site, options) {
12227
12592
  const backend = options.authBackend;
12228
12593
  const router = express3__default.default.Router();
12229
12594
  router.use(prefix, express3__default.default.urlencoded({ extended: false }));
12230
- const context = (req, session) => ({
12595
+ const context = (req, session, models = site.list()) => ({
12231
12596
  site,
12232
12597
  theme,
12233
12598
  prefix,
12234
12599
  session,
12235
12600
  currentPath: req.originalUrl.split("?")[0] ?? req.path,
12236
- navModels: site.list().map((admin) => ({
12601
+ navModels: models.map((admin) => ({
12237
12602
  label: admin.verboseNamePlural(),
12238
12603
  url: `${prefix}/m/${admin.slug()}`
12239
12604
  })),
12240
12605
  messages: flashFor(req)
12241
12606
  });
12607
+ const allows = async (principal, admin, action) => {
12608
+ if (!flagAllows(admin, action)) return false;
12609
+ if (options.accessPolicy === void 0) return true;
12610
+ return Boolean(await options.accessPolicy(principal, admin, action));
12611
+ };
12242
12612
  const flashFor = (req) => {
12243
12613
  const fixed = FLASH_MESSAGES[queryString(req.query.ok)];
12244
12614
  if (fixed !== void 0) return [fixed];
@@ -12286,14 +12656,28 @@ function makeAdminRouter(site, options) {
12286
12656
  res.redirect(`${prefix}/mfa`);
12287
12657
  return null;
12288
12658
  }
12289
- return { session, dbSession, principal };
12659
+ const visible = [];
12660
+ for (const admin of site.list()) {
12661
+ if (await allows(principal, admin, AdminPermission.VIEW)) visible.push(admin);
12662
+ }
12663
+ return { session, dbSession, principal, visible };
12290
12664
  };
12291
- const resolveAdmin = (req, res, state) => {
12665
+ const resolveAdmin = async (req, res, state, action = AdminPermission.VIEW) => {
12292
12666
  const admin = site.get(String(req.params.slug));
12293
- if (admin === null) {
12294
- html(res, renderNotFound(context(req, state.session)), 404);
12667
+ if (admin === null || !await allows(state.principal, admin, AdminPermission.VIEW)) {
12668
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12295
12669
  return null;
12296
12670
  }
12671
+ if (action !== AdminPermission.VIEW) {
12672
+ if (!flagAllows(admin, action)) {
12673
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12674
+ return null;
12675
+ }
12676
+ if (!await allows(state.principal, admin, action)) {
12677
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12678
+ return null;
12679
+ }
12680
+ }
12297
12681
  return admin;
12298
12682
  };
12299
12683
  const renderNotFound = (ctx) => renderDashboardPage(
@@ -12304,7 +12688,7 @@ function makeAdminRouter(site, options) {
12304
12688
  const checkCsrf = (req, res, state) => {
12305
12689
  const body = req.body;
12306
12690
  if (csrfTokenMatches(state.session, body?.csrf_token)) return true;
12307
- html(res, renderNotFound(context(req, state.session)), 403);
12691
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12308
12692
  return false;
12309
12693
  };
12310
12694
  router.get(`${prefix}/static/admin.css`, (_req, res) => {
@@ -12386,7 +12770,7 @@ function makeAdminRouter(site, options) {
12386
12770
  const state = await authenticate(req, res);
12387
12771
  if (state === null) return;
12388
12772
  const cards = [];
12389
- for (const admin of site.list()) {
12773
+ for (const admin of state.visible) {
12390
12774
  let count = null;
12391
12775
  try {
12392
12776
  count = await admin.repository(state.dbSession).count();
@@ -12400,9 +12784,13 @@ function makeAdminRouter(site, options) {
12400
12784
  label: admin.verboseNamePlural(),
12401
12785
  count,
12402
12786
  url: `${prefix}/m/${admin.slug()}`,
12403
- newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null
12787
+ newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null
12404
12788
  });
12405
12789
  }
12790
+ const businessCards = [];
12791
+ for (const card of site.dashboardCards) {
12792
+ businessCards.push(await computeBusinessCard(card, state.dbSession));
12793
+ }
12406
12794
  let metrics = null;
12407
12795
  if (showMetrics) {
12408
12796
  const snapshot2 = MetricsUtils.system();
@@ -12413,7 +12801,15 @@ function makeAdminRouter(site, options) {
12413
12801
  memoryTotalGb: (snapshot2.memory.total / 1024 ** 3).toFixed(1)
12414
12802
  };
12415
12803
  }
12416
- html(res, renderDashboardPage(context(req, state.session), cards, metrics));
12804
+ html(
12805
+ res,
12806
+ renderDashboardPage(
12807
+ context(req, state.session, state.visible),
12808
+ cards,
12809
+ metrics,
12810
+ businessCards
12811
+ )
12812
+ );
12417
12813
  })
12418
12814
  );
12419
12815
  router.get(
@@ -12421,7 +12817,7 @@ function makeAdminRouter(site, options) {
12421
12817
  guarded(async (req, res) => {
12422
12818
  const state = await authenticate(req, res);
12423
12819
  if (state === null) return;
12424
- const admin = resolveAdmin(req, res, state);
12820
+ const admin = await resolveAdmin(req, res, state);
12425
12821
  if (admin === null) return;
12426
12822
  html(res, await renderList(req, admin, state));
12427
12823
  })
@@ -12431,19 +12827,20 @@ function makeAdminRouter(site, options) {
12431
12827
  guarded(async (req, res) => {
12432
12828
  const state = await authenticate(req, res);
12433
12829
  if (state === null) return;
12434
- const admin = resolveAdmin(req, res, state);
12830
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
12435
12831
  if (admin === null) return;
12436
12832
  if (!admin.canCreate) {
12437
- html(res, renderNotFound(context(req, state.session)), 404);
12833
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12438
12834
  return;
12439
12835
  }
12440
12836
  html(
12441
12837
  res,
12442
- renderFormPage(context(req, state.session), {
12838
+ renderFormPage(context(req, state.session, state.visible), {
12443
12839
  mode: "create",
12444
12840
  title: admin.verboseName(),
12445
12841
  fields: buildFormFields(admin, {
12446
- foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12842
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
12843
+ autocompleteUrls: autocompleteUrlsFor(admin)
12447
12844
  }),
12448
12845
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12449
12846
  backUrl: `${prefix}/m/${admin.slug()}`,
@@ -12457,26 +12854,46 @@ function makeAdminRouter(site, options) {
12457
12854
  guarded(async (req, res) => {
12458
12855
  const state = await authenticate(req, res);
12459
12856
  if (state === null) return;
12460
- const admin = resolveAdmin(req, res, state);
12857
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
12461
12858
  if (admin === null) return;
12462
12859
  if (!admin.canCreate) {
12463
- html(res, renderNotFound(context(req, state.session)), 404);
12860
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12861
+ return;
12862
+ }
12863
+ let uploadError = null;
12864
+ let submission;
12865
+ try {
12866
+ submission = await readSubmission(req);
12867
+ } catch (error) {
12868
+ if (!(error instanceof MultipartLimitError)) throw error;
12869
+ submission = { fields: {}, files: [] };
12870
+ uploadError = error.message;
12871
+ }
12872
+ const body = submission.fields;
12873
+ if (uploadError === null && !csrfTokenMatches(state.session, body.csrf_token)) {
12874
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12464
12875
  return;
12465
12876
  }
12466
- if (!checkCsrf(req, res, state)) return;
12467
- const body = req.body;
12468
12877
  const parsed = parseFormBody(admin, body);
12878
+ await applyUploads(admin, parsed.data, parsed.errors, submission.files, true);
12469
12879
  const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12880
+ const autocompleteLabels = await autocompleteLabelsFor(
12881
+ admin,
12882
+ body,
12883
+ state.dbSession
12884
+ );
12470
12885
  const rerender = (error, status) => {
12471
12886
  html(
12472
12887
  res,
12473
- renderFormPage(context(req, state.session), {
12888
+ renderFormPage(context(req, state.session, state.visible), {
12474
12889
  mode: "create",
12475
12890
  title: admin.verboseName(),
12476
12891
  fields: buildFormFields(admin, {
12477
12892
  values: body,
12478
12893
  errors: parsed.errors,
12479
- foreignKeyOptions
12894
+ foreignKeyOptions,
12895
+ autocompleteUrls: autocompleteUrlsFor(admin),
12896
+ autocompleteLabels
12480
12897
  }),
12481
12898
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12482
12899
  backUrl: `${prefix}/m/${admin.slug()}`,
@@ -12485,10 +12902,15 @@ function makeAdminRouter(site, options) {
12485
12902
  status
12486
12903
  );
12487
12904
  };
12905
+ if (uploadError !== null) {
12906
+ rerender(uploadError, 400);
12907
+ return;
12908
+ }
12488
12909
  if (Object.keys(parsed.errors).length > 0) {
12489
12910
  rerender("Please fix the highlighted fields.", 400);
12490
12911
  return;
12491
12912
  }
12913
+ stampActor(admin, parsed.data, backend.principalId(state.principal), true);
12492
12914
  try {
12493
12915
  await admin.repository(state.dbSession).create(parsed.data);
12494
12916
  } catch (error) {
@@ -12503,11 +12925,11 @@ function makeAdminRouter(site, options) {
12503
12925
  guarded(async (req, res) => {
12504
12926
  const state = await authenticate(req, res);
12505
12927
  if (state === null) return;
12506
- const admin = resolveAdmin(req, res, state);
12928
+ const admin = await resolveAdmin(req, res, state);
12507
12929
  if (admin === null) return;
12508
12930
  const format = String(req.params.format);
12509
12931
  if (format !== "csv" && format !== "json") {
12510
- html(res, renderNotFound(context(req, state.session)), 404);
12932
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12511
12933
  return;
12512
12934
  }
12513
12935
  const query = await resolveListQuery(req, admin, state.dbSession);
@@ -12529,7 +12951,7 @@ function makeAdminRouter(site, options) {
12529
12951
  guarded(async (req, res) => {
12530
12952
  const state = await authenticate(req, res);
12531
12953
  if (state === null) return;
12532
- const admin = resolveAdmin(req, res, state);
12954
+ const admin = await resolveAdmin(req, res, state);
12533
12955
  if (admin === null) return;
12534
12956
  if (!checkCsrf(req, res, state)) return;
12535
12957
  const body = req.body;
@@ -12545,7 +12967,12 @@ function makeAdminRouter(site, options) {
12545
12967
  return;
12546
12968
  }
12547
12969
  if (!bulkActionsFor(admin).some((option) => option.value === action)) {
12548
- html(res, renderNotFound(context(req, state.session)), 400);
12970
+ html(res, renderNotFound(context(req, state.session, state.visible)), 400);
12971
+ return;
12972
+ }
12973
+ const needed = action === "delete" ? AdminPermission.DELETE : AdminPermission.EDIT;
12974
+ if (!await allows(state.principal, admin, needed)) {
12975
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12549
12976
  return;
12550
12977
  }
12551
12978
  const repository = admin.repository(state.dbSession);
@@ -12553,7 +12980,7 @@ function makeAdminRouter(site, options) {
12553
12980
  if (action.startsWith("custom:")) {
12554
12981
  const custom = admin.getAction(action.slice("custom:".length));
12555
12982
  if (custom === null) {
12556
- html(res, renderNotFound(context(req, state.session)), 400);
12983
+ html(res, renderNotFound(context(req, state.session, state.visible)), 400);
12557
12984
  return;
12558
12985
  }
12559
12986
  let result;
@@ -12598,28 +13025,152 @@ function makeAdminRouter(site, options) {
12598
13025
  );
12599
13026
  })
12600
13027
  );
13028
+ router.get(
13029
+ `${prefix}/m/:slug/import`,
13030
+ guarded(async (req, res) => {
13031
+ const state = await authenticate(req, res);
13032
+ if (state === null) return;
13033
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
13034
+ if (admin === null) return;
13035
+ if (!admin.canImport) {
13036
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13037
+ return;
13038
+ }
13039
+ html(res, renderImport(req, state, admin, null, null, []));
13040
+ })
13041
+ );
13042
+ router.post(
13043
+ `${prefix}/m/:slug/import`,
13044
+ guarded(async (req, res) => {
13045
+ const state = await authenticate(req, res);
13046
+ if (state === null) return;
13047
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
13048
+ if (admin === null) return;
13049
+ if (!admin.canImport) {
13050
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13051
+ return;
13052
+ }
13053
+ let submission;
13054
+ try {
13055
+ submission = await readSubmission(req);
13056
+ } catch (error) {
13057
+ if (!(error instanceof MultipartLimitError)) throw error;
13058
+ html(res, renderImport(req, state, admin, error.message, null, []), 400);
13059
+ return;
13060
+ }
13061
+ if (!csrfTokenMatches(state.session, submission.fields.csrf_token)) {
13062
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
13063
+ return;
13064
+ }
13065
+ const file = submission.files[0];
13066
+ if (file === void 0) {
13067
+ html(
13068
+ res,
13069
+ renderImport(req, state, admin, "Choose a CSV file to import.", null, []),
13070
+ 400
13071
+ );
13072
+ return;
13073
+ }
13074
+ let rows;
13075
+ try {
13076
+ rows = parseCsv(file.data.toString("utf8"));
13077
+ } catch (error) {
13078
+ const message = error instanceof Error ? error.message : "Could not read the file as UTF-8 CSV.";
13079
+ html(res, renderImport(req, state, admin, message, null, []), 400);
13080
+ return;
13081
+ }
13082
+ const repository = admin.repository(state.dbSession);
13083
+ const actorId = backend.principalId(state.principal);
13084
+ const rowErrors = [];
13085
+ let created = 0;
13086
+ for (const [index, row] of rows.entries()) {
13087
+ const parsed = parseFormBody(admin, row, { uploadsAsText: true });
13088
+ const failures = Object.entries(parsed.errors);
13089
+ if (failures.length > 0) {
13090
+ rowErrors.push({
13091
+ row: index + 2,
13092
+ message: failures.map(([field, error]) => `${field}: ${error}`).join("; ")
13093
+ });
13094
+ continue;
13095
+ }
13096
+ stampActor(admin, parsed.data, actorId, true);
13097
+ try {
13098
+ await repository.create(parsed.data);
13099
+ created += 1;
13100
+ } catch (error) {
13101
+ rowErrors.push({
13102
+ row: index + 2,
13103
+ message: error instanceof Error ? error.message : String(error)
13104
+ });
13105
+ }
13106
+ }
13107
+ html(res, renderImport(req, state, admin, null, created, rowErrors));
13108
+ })
13109
+ );
13110
+ router.get(
13111
+ `${prefix}/m/:slug/autocomplete/:field`,
13112
+ guarded(async (req, res) => {
13113
+ if (sessions.load(req) === null) {
13114
+ res.status(401).json({ options: [] });
13115
+ return;
13116
+ }
13117
+ const state = await authenticate(req, res);
13118
+ if (state === null) return;
13119
+ const admin = site.get(String(req.params.slug));
13120
+ const field = String(req.params.field);
13121
+ if (admin === null || !admin.autocompleteFields.includes(field) || !await allows(state.principal, admin, AdminPermission.VIEW)) {
13122
+ res.status(404).json({ options: [] });
13123
+ return;
13124
+ }
13125
+ const column6 = adminColumns(admin.model)[field];
13126
+ const table = column6 === void 0 ? null : foreignKeyTable(column6);
13127
+ const referenced = table === null ? null : site.get(table);
13128
+ if (referenced === null) {
13129
+ res.json({ options: [] });
13130
+ return;
13131
+ }
13132
+ const term = queryString(req.query.q);
13133
+ const searchable = referenced.searchFields.filter((name) => {
13134
+ const target = adminColumns(referenced.model)[name];
13135
+ return target !== void 0 && isSearchableColumn(target);
13136
+ });
13137
+ const filters = term === "" || searchable.length === 0 ? void 0 : tempestDbJs.or(...searchable.map((name) => ({ [name]: { ilike: `%${term}%` } })));
13138
+ const page2 = await referenced.repository(state.dbSession).paginate({
13139
+ page: 1,
13140
+ pageSize: AUTOCOMPLETE_LIMIT,
13141
+ ...filters === void 0 ? {} : { filters }
13142
+ });
13143
+ res.json({
13144
+ options: page2.items.map((row) => ({
13145
+ value: String(row[referenced.identityField]),
13146
+ label: foreignKeyLabel(referenced, row)
13147
+ }))
13148
+ });
13149
+ })
13150
+ );
12601
13151
  router.get(
12602
13152
  `${prefix}/m/:slug/:identity`,
12603
13153
  guarded(async (req, res) => {
12604
13154
  const state = await authenticate(req, res);
12605
13155
  if (state === null) return;
12606
- const admin = resolveAdmin(req, res, state);
13156
+ const admin = await resolveAdmin(req, res, state);
12607
13157
  if (admin === null) return;
12608
13158
  const row = await findRow(admin, state.dbSession, String(req.params.identity));
12609
13159
  if (row === null) {
12610
- html(res, renderNotFound(context(req, state.session)), 404);
13160
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12611
13161
  return;
12612
13162
  }
12613
13163
  const identity = String(row[admin.identityField]);
12614
13164
  html(
12615
13165
  res,
12616
- renderDetailPage(context(req, state.session), {
13166
+ renderDetailPage(context(req, state.session, state.visible), {
12617
13167
  title: admin.verboseName(),
12618
13168
  identity,
13169
+ audit: await buildAuditView(admin, row, state.dbSession),
12619
13170
  fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
12620
13171
  backUrl: `${prefix}/m/${admin.slug()}`,
12621
- editUrl: admin.canEdit ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
12622
- deleteUrl: admin.canDelete ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
13172
+ editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
13173
+ deleteUrl: await allows(state.principal, admin, AdminPermission.DELETE) ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
12623
13174
  })
12624
13175
  );
12625
13176
  })
@@ -12629,22 +13180,24 @@ function makeAdminRouter(site, options) {
12629
13180
  guarded(async (req, res) => {
12630
13181
  const state = await authenticate(req, res);
12631
13182
  if (state === null) return;
12632
- const admin = resolveAdmin(req, res, state);
13183
+ const admin = await resolveAdmin(req, res, state, AdminPermission.EDIT);
12633
13184
  if (admin === null) return;
12634
13185
  const identity = String(req.params.identity);
12635
13186
  const row = admin.canEdit ? await findRow(admin, state.dbSession, identity) : null;
12636
13187
  if (row === null) {
12637
- html(res, renderNotFound(context(req, state.session)), 404);
13188
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12638
13189
  return;
12639
13190
  }
12640
13191
  html(
12641
13192
  res,
12642
- renderFormPage(context(req, state.session), {
13193
+ renderFormPage(context(req, state.session, state.visible), {
12643
13194
  mode: "edit",
12644
13195
  title: admin.verboseName(),
12645
13196
  fields: buildFormFields(admin, {
12646
13197
  values: row,
12647
- foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
13198
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
13199
+ autocompleteUrls: autocompleteUrlsFor(admin),
13200
+ autocompleteLabels: await autocompleteLabelsFor(admin, row, state.dbSession)
12648
13201
  }),
12649
13202
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12650
13203
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
@@ -12658,27 +13211,47 @@ function makeAdminRouter(site, options) {
12658
13211
  guarded(async (req, res) => {
12659
13212
  const state = await authenticate(req, res);
12660
13213
  if (state === null) return;
12661
- const admin = resolveAdmin(req, res, state);
13214
+ const admin = await resolveAdmin(req, res, state, AdminPermission.EDIT);
12662
13215
  if (admin === null) return;
12663
13216
  const identity = String(req.params.identity);
12664
13217
  if (!admin.canEdit) {
12665
- html(res, renderNotFound(context(req, state.session)), 404);
13218
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13219
+ return;
13220
+ }
13221
+ let uploadError = null;
13222
+ let submission;
13223
+ try {
13224
+ submission = await readSubmission(req);
13225
+ } catch (error) {
13226
+ if (!(error instanceof MultipartLimitError)) throw error;
13227
+ submission = { fields: {}, files: [] };
13228
+ uploadError = error.message;
13229
+ }
13230
+ const body = submission.fields;
13231
+ if (uploadError === null && !csrfTokenMatches(state.session, body.csrf_token)) {
13232
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12666
13233
  return;
12667
13234
  }
12668
- if (!checkCsrf(req, res, state)) return;
12669
- const body = req.body;
12670
13235
  const parsed = parseFormBody(admin, body);
13236
+ await applyUploads(admin, parsed.data, parsed.errors, submission.files, false);
12671
13237
  const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
13238
+ const autocompleteLabels = await autocompleteLabelsFor(
13239
+ admin,
13240
+ body,
13241
+ state.dbSession
13242
+ );
12672
13243
  const rerender = (error, status) => {
12673
13244
  html(
12674
13245
  res,
12675
- renderFormPage(context(req, state.session), {
13246
+ renderFormPage(context(req, state.session, state.visible), {
12676
13247
  mode: "edit",
12677
13248
  title: admin.verboseName(),
12678
13249
  fields: buildFormFields(admin, {
12679
13250
  values: body,
12680
13251
  errors: parsed.errors,
12681
- foreignKeyOptions
13252
+ foreignKeyOptions,
13253
+ autocompleteUrls: autocompleteUrlsFor(admin),
13254
+ autocompleteLabels
12682
13255
  }),
12683
13256
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12684
13257
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
@@ -12687,14 +13260,24 @@ function makeAdminRouter(site, options) {
12687
13260
  status
12688
13261
  );
12689
13262
  };
13263
+ if (uploadError !== null) {
13264
+ rerender(uploadError, 400);
13265
+ return;
13266
+ }
12690
13267
  if (Object.keys(parsed.errors).length > 0) {
12691
13268
  rerender("Please fix the highlighted fields.", 400);
12692
13269
  return;
12693
13270
  }
13271
+ stampActor(
13272
+ admin,
13273
+ parsed.data,
13274
+ backend.principalId(state.principal),
13275
+ false
13276
+ );
12694
13277
  try {
12695
13278
  const changed = await admin.repository(state.dbSession).update({ [admin.identityField]: identity }, parsed.data);
12696
13279
  if (changed === 0) {
12697
- html(res, renderNotFound(context(req, state.session)), 404);
13280
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12698
13281
  return;
12699
13282
  }
12700
13283
  } catch (error) {
@@ -12709,10 +13292,10 @@ function makeAdminRouter(site, options) {
12709
13292
  guarded(async (req, res) => {
12710
13293
  const state = await authenticate(req, res);
12711
13294
  if (state === null) return;
12712
- const admin = resolveAdmin(req, res, state);
13295
+ const admin = await resolveAdmin(req, res, state, AdminPermission.DELETE);
12713
13296
  if (admin === null) return;
12714
13297
  if (!admin.canDelete) {
12715
- html(res, renderNotFound(context(req, state.session)), 404);
13298
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12716
13299
  return;
12717
13300
  }
12718
13301
  if (!checkCsrf(req, res, state)) return;
@@ -12720,10 +13303,96 @@ function makeAdminRouter(site, options) {
12720
13303
  res.redirect(`${prefix}/m/${admin.slug()}?ok=deleted`);
12721
13304
  })
12722
13305
  );
13306
+ async function actorLabel(actor, dbSession) {
13307
+ if (actor === null || actor === void 0 || actor === "") return "";
13308
+ const principal = await backend.loadPrincipal(dbSession, String(actor));
13309
+ return principal === null ? String(actor) : backend.displayName(principal);
13310
+ }
13311
+ async function buildAuditView(admin, row, dbSession) {
13312
+ const fields = [];
13313
+ for (const name of admin.auditFieldNames()) {
13314
+ const value = name === "createdBy" || name === "updatedBy" ? await actorLabel(row[name], dbSession) : formatCellValue(row[name]);
13315
+ fields.push({ label: humanizeField(name), value });
13316
+ }
13317
+ const history = [];
13318
+ if (admin.auditModel !== null) {
13319
+ const entity = admin.model.name;
13320
+ const entityId = String(row[admin.identityField]);
13321
+ const page2 = await new tempestDbJs.BaseRepository(admin.auditModel, dbSession).paginate({
13322
+ page: 1,
13323
+ pageSize: AUDIT_HISTORY_LIMIT,
13324
+ orderBy: "createdAt",
13325
+ ascending: false,
13326
+ filters: { entity, entityId }
13327
+ });
13328
+ for (const entry of page2.items) {
13329
+ const changes = entry.changes ?? {};
13330
+ history.push({
13331
+ action: String(entry.action ?? ""),
13332
+ at: formatCellValue(entry.createdAt),
13333
+ actor: await actorLabel(entry.actor, dbSession) || "\u2014",
13334
+ changes: Object.entries(changes).map(([field, change]) => ({
13335
+ field,
13336
+ before: formatCellValue(change?.before),
13337
+ after: formatCellValue(change?.after)
13338
+ })),
13339
+ context: entry.context === null || entry.context === void 0 ? null : JSON.stringify(entry.context, null, 2)
13340
+ });
13341
+ }
13342
+ }
13343
+ if (fields.length === 0 && history.length === 0) return null;
13344
+ return { fields, history };
13345
+ }
12723
13346
  async function findRow(admin, dbSession, identity) {
12724
13347
  const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
12725
13348
  return row ?? null;
12726
13349
  }
13350
+ function renderImport(req, state, admin, error, created, rowErrors) {
13351
+ return renderImportPage(context(req, state.session, state.visible), {
13352
+ title: admin.verboseNamePlural(),
13353
+ actionUrl: `${prefix}/m/${admin.slug()}/import`,
13354
+ backUrl: `${prefix}/m/${admin.slug()}`,
13355
+ columns: admin.editableFieldNames(),
13356
+ error,
13357
+ created,
13358
+ rowErrors
13359
+ });
13360
+ }
13361
+ async function readSubmission(req) {
13362
+ if (!isMultipart(req)) {
13363
+ return { fields: req.body ?? {}, files: [] };
13364
+ }
13365
+ const parsed = await parseMultipart(req, { maxFileBytes: maxUploadBytes });
13366
+ return { fields: parsed.fields, files: parsed.files };
13367
+ }
13368
+ async function applyUploads(admin, data, errors, files, creating) {
13369
+ if (admin.uploadFields.length === 0) return;
13370
+ const storage2 = admin.uploadStorage;
13371
+ if (storage2 === null) return;
13372
+ const columns = adminColumns(admin.model);
13373
+ for (const field of admin.uploadFields) {
13374
+ const file = files.find((candidate) => candidate.field === field);
13375
+ if (file === void 0) {
13376
+ const column6 = columns[field];
13377
+ if (creating && column6 !== void 0 && !isColumnOptional(column6)) {
13378
+ errors[field] = "This field is required.";
13379
+ }
13380
+ continue;
13381
+ }
13382
+ const extension = file.filename.includes(".") ? `.${file.filename.split(".").pop()}` : "";
13383
+ const key = `${admin.slug()}/${field}/${crypto.randomUUID()}${extension}`;
13384
+ const saved = await storage2.save(key, file.data, { contentType: file.contentType });
13385
+ data[field] = saved.key;
13386
+ }
13387
+ }
13388
+ async function permittedBulkActions(admin, principal) {
13389
+ const permitted = [];
13390
+ for (const option of bulkActionsFor(admin)) {
13391
+ const needed = option.value === "delete" ? AdminPermission.DELETE : AdminPermission.EDIT;
13392
+ if (await allows(principal, admin, needed)) permitted.push(option);
13393
+ }
13394
+ return permitted;
13395
+ }
12727
13396
  async function renderList(req, admin, state) {
12728
13397
  const columns = adminColumns(admin.model);
12729
13398
  const query = await resolveListQuery(req, admin, state.dbSession);
@@ -12778,21 +13447,37 @@ function makeAdminRouter(site, options) {
12778
13447
  searchValue: query.search,
12779
13448
  filters: query.filterViews,
12780
13449
  sort,
12781
- newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null,
12782
- bulkActions: bulkActionsFor(admin),
13450
+ newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null,
13451
+ importUrl: admin.canImport && await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/import` : null,
13452
+ bulkActions: await permittedBulkActions(admin, state.principal),
12783
13453
  bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
12784
13454
  exportCsvUrl: exportUrl("csv"),
12785
- exportJsonUrl: exportUrl("json")
13455
+ exportJsonUrl: exportUrl("json"),
13456
+ lenses: admin.lenses.length === 0 ? [] : [
13457
+ {
13458
+ label: "All",
13459
+ url: `?${buildQuery({ ...query.baseQuery, lens: void 0 })}`,
13460
+ active: query.lens === ""
13461
+ },
13462
+ ...admin.lenses.map((entry) => ({
13463
+ label: entry.label,
13464
+ url: `?${buildQuery({ ...query.baseQuery, lens: entry.slug })}`,
13465
+ active: query.lens === entry.slug
13466
+ }))
13467
+ ]
12786
13468
  };
12787
- return renderListPage(context(req, state.session), view);
13469
+ return renderListPage(context(req, state.session, state.visible), view);
12788
13470
  }
12789
13471
  async function resolveListQuery(req, admin, dbSession) {
12790
13472
  const columns = adminColumns(admin.model);
12791
13473
  const search = queryString(req.query.q);
12792
13474
  const sortField = queryString(req.query.sort);
12793
13475
  const sortColumn = sortField in columns ? sortField : null;
12794
- const ascending = sortColumn === null ? admin.orderAscending : queryString(req.query.dir) !== "desc";
13476
+ const lens = admin.getLens(queryString(req.query.lens));
12795
13477
  const conditions = [];
13478
+ if (lens !== null && Object.keys(lens.filters).length > 0) {
13479
+ conditions.push(lens.filters);
13480
+ }
12796
13481
  const filterViews = [];
12797
13482
  for (const field of admin.listFilter) {
12798
13483
  const column6 = columns[field];
@@ -12856,15 +13541,21 @@ function makeAdminRouter(site, options) {
12856
13541
  baseQuery[`filter_${view.field}`] = view.value;
12857
13542
  }
12858
13543
  }
13544
+ const lensOrder = lens?.orderBy ?? null;
13545
+ const lensDescending = lensOrder?.startsWith("-");
13546
+ const lensColumn = lensOrder === null ? null : lensOrder.replace(/^-/, "");
13547
+ const ascending = sortColumn !== null ? queryString(req.query.dir) !== "desc" : lensColumn !== null ? !lensDescending : admin.orderAscending;
13548
+ if (lens !== null) baseQuery.lens = lens.slug;
12859
13549
  return {
12860
13550
  search,
12861
13551
  searchable,
12862
13552
  where: conditions.length === 0 ? void 0 : tempestDbJs.and(...conditions),
12863
- orderBy: sortColumn ?? admin.orderKey ?? void 0,
13553
+ orderBy: sortColumn ?? lensColumn ?? admin.orderKey ?? void 0,
12864
13554
  ascending,
12865
13555
  sortColumn,
12866
13556
  filterViews,
12867
- baseQuery
13557
+ baseQuery,
13558
+ lens: lens?.slug ?? ""
12868
13559
  };
12869
13560
  }
12870
13561
  async function relatedOptions(column6, dbSession) {
@@ -12882,6 +13573,7 @@ function makeAdminRouter(site, options) {
12882
13573
  const columns = adminColumns(admin.model);
12883
13574
  const options2 = {};
12884
13575
  for (const field of Object.keys(foreignKeyFields(admin))) {
13576
+ if (admin.autocompleteFields.includes(field)) continue;
12885
13577
  const column6 = columns[field];
12886
13578
  if (column6 === void 0) continue;
12887
13579
  const related = await relatedOptions(column6, dbSession);
@@ -12889,8 +13581,101 @@ function makeAdminRouter(site, options) {
12889
13581
  }
12890
13582
  return options2;
12891
13583
  }
13584
+ function autocompleteUrlsFor(admin) {
13585
+ const urls = {};
13586
+ for (const field of admin.autocompleteFields) {
13587
+ urls[field] = `${prefix}/m/${admin.slug()}/autocomplete/${field}`;
13588
+ }
13589
+ return urls;
13590
+ }
13591
+ async function autocompleteLabelsFor(admin, row, dbSession) {
13592
+ const labels = {};
13593
+ if (row === null) return labels;
13594
+ const columns = adminColumns(admin.model);
13595
+ for (const field of admin.autocompleteFields) {
13596
+ const value = row[field];
13597
+ if (value === null || value === void 0 || value === "") continue;
13598
+ const column6 = columns[field];
13599
+ const table = column6 === void 0 ? null : foreignKeyTable(column6);
13600
+ const referenced = table === null ? null : site.get(table);
13601
+ if (referenced === null) continue;
13602
+ const related = await referenced.repository(dbSession).first({ [referenced.identityField]: value });
13603
+ if (related !== null) labels[field] = foreignKeyLabel(referenced, related);
13604
+ }
13605
+ return labels;
13606
+ }
12892
13607
  return router;
12893
13608
  }
13609
+ function flagAllows(admin, action) {
13610
+ if (action === AdminPermission.CREATE) return admin.canCreate;
13611
+ if (action === AdminPermission.EDIT) return admin.canEdit;
13612
+ if (action === AdminPermission.DELETE) return admin.canDelete;
13613
+ return true;
13614
+ }
13615
+ function stampActor(admin, data, actorId, creating) {
13616
+ const columns = adminColumns(admin.model);
13617
+ if (creating && "createdBy" in columns) data.createdBy = actorId;
13618
+ if ("updatedBy" in columns) data.updatedBy = actorId;
13619
+ }
13620
+ function formatMetric(value) {
13621
+ if (typeof value === "string") return value;
13622
+ return Number.isInteger(value) ? String(value) : value.toFixed(2);
13623
+ }
13624
+ async function computeBusinessCard(card, dbSession) {
13625
+ const base = {
13626
+ label: card.label,
13627
+ unit: null,
13628
+ direction: "flat",
13629
+ percent: null,
13630
+ previous: "",
13631
+ segments: [],
13632
+ helpText: card.helpText ?? null
13633
+ };
13634
+ let data;
13635
+ try {
13636
+ data = await card.compute(dbSession);
13637
+ } catch (error) {
13638
+ logger2.warning("Admin dashboard card failed", {
13639
+ card: card.label,
13640
+ error: error instanceof Error ? error.message : String(error)
13641
+ });
13642
+ return { ...base, kind: "value", value: "", error: "Could not compute this metric." };
13643
+ }
13644
+ if (data.kind === "partition") {
13645
+ const total = partitionTotal(data);
13646
+ return {
13647
+ ...base,
13648
+ kind: "partition",
13649
+ value: formatMetric(total),
13650
+ segments: data.segments.map((segment) => ({
13651
+ label: segment.label,
13652
+ value: formatMetric(segment.value),
13653
+ percent: total === 0 ? 0 : segment.value / total * 100
13654
+ })),
13655
+ error: null
13656
+ };
13657
+ }
13658
+ if (data.kind === "trend") {
13659
+ const percent = trendPercent(data);
13660
+ return {
13661
+ ...base,
13662
+ kind: "trend",
13663
+ value: formatMetric(data.value),
13664
+ unit: data.unit ?? null,
13665
+ direction: trendDirection(data),
13666
+ percent: percent === null ? null : `${percent >= 0 ? "+" : ""}${percent.toFixed(1)}%`,
13667
+ previous: formatMetric(data.previous),
13668
+ error: null
13669
+ };
13670
+ }
13671
+ return {
13672
+ ...base,
13673
+ kind: "value",
13674
+ value: formatMetric(data.value),
13675
+ unit: data.unit ?? null,
13676
+ error: null
13677
+ };
13678
+ }
12894
13679
  function bulkActionsFor(admin) {
12895
13680
  const actions = [];
12896
13681
  const hasActiveFlag = "isActive" in adminColumns(admin.model);
@@ -12910,6 +13695,55 @@ function bulkActionsFor(admin) {
12910
13695
  }
12911
13696
  return actions;
12912
13697
  }
13698
+ function parseCsv(text) {
13699
+ const source = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
13700
+ const rows = [];
13701
+ let row = [];
13702
+ let field = "";
13703
+ let quoted = false;
13704
+ for (let index = 0; index < source.length; index += 1) {
13705
+ const char = source[index];
13706
+ if (quoted) {
13707
+ if (char === '"') {
13708
+ if (source[index + 1] === '"') {
13709
+ field += '"';
13710
+ index += 1;
13711
+ } else {
13712
+ quoted = false;
13713
+ }
13714
+ } else {
13715
+ field += char;
13716
+ }
13717
+ continue;
13718
+ }
13719
+ if (char === '"') {
13720
+ quoted = true;
13721
+ } else if (char === ",") {
13722
+ row.push(field);
13723
+ field = "";
13724
+ } else if (char === "\n" || char === "\r") {
13725
+ if (char === "\r" && source[index + 1] === "\n") index += 1;
13726
+ row.push(field);
13727
+ rows.push(row);
13728
+ row = [];
13729
+ field = "";
13730
+ } else {
13731
+ field += char;
13732
+ }
13733
+ }
13734
+ if (field !== "" || row.length > 0) {
13735
+ row.push(field);
13736
+ rows.push(row);
13737
+ }
13738
+ const header = rows.shift();
13739
+ if (header === void 0 || header.length === 0) {
13740
+ throw new Error("The file has no header row.");
13741
+ }
13742
+ const keys = header.map((name) => name.trim());
13743
+ return rows.filter((entry) => entry.some((value) => value.trim() !== "")).map(
13744
+ (entry) => Object.fromEntries(keys.map((key, position) => [key, entry[position] ?? ""]))
13745
+ );
13746
+ }
12913
13747
  function exportValue(value) {
12914
13748
  if (value instanceof Date) return value.toISOString();
12915
13749
  if (typeof value === "bigint") return value.toString();
@@ -14943,7 +15777,7 @@ async function withTestDatabase(models, fn) {
14943
15777
  }
14944
15778
 
14945
15779
  // src/version.ts
14946
- var VERSION = "0.25.0";
15780
+ var VERSION = "0.27.0";
14947
15781
 
14948
15782
  Object.defineProperty(exports, "OpenAPIRegistry", {
14949
15783
  enumerable: true,
@@ -15101,6 +15935,7 @@ exports.ADMIN_CSS = ADMIN_CSS;
15101
15935
  exports.ActivationService = ActivationService;
15102
15936
  exports.AdminJsonSite = AdminJsonSite;
15103
15937
  exports.AdminModel = AdminModel;
15938
+ exports.AdminPermission = AdminPermission;
15104
15939
  exports.AdminSessionStore = AdminSessionStore;
15105
15940
  exports.AdminSite = AdminSite;
15106
15941
  exports.AppException = AppException;
@@ -15156,6 +15991,7 @@ exports.MessageCatalog = MessageCatalog;
15156
15991
  exports.MessagingHub = MessagingHub;
15157
15992
  exports.MetricsUtils = MetricsUtils;
15158
15993
  exports.MfaService = MfaService;
15994
+ exports.MultipartLimitError = MultipartLimitError;
15159
15995
  exports.NotFoundException = NotFoundException;
15160
15996
  exports.OAuthError = OAuthError;
15161
15997
  exports.OIDCProvider = OIDCProvider;
@@ -15201,6 +16037,7 @@ exports.activationSchema = activationSchema;
15201
16037
  exports.addLogSink = addLogSink;
15202
16038
  exports.adminAction = adminAction;
15203
16039
  exports.adminColumns = adminColumns;
16040
+ exports.adminLens = adminLens;
15204
16041
  exports.adminThemeCss = adminThemeCss;
15205
16042
  exports.attachWebSocketHub = attachWebSocketHub;
15206
16043
  exports.authResponseSchema = authResponseSchema;
@@ -15267,6 +16104,7 @@ exports.humanizeField = humanizeField;
15267
16104
  exports.idempotencyMiddleware = idempotencyMiddleware;
15268
16105
  exports.inboundMessageSchema = inboundMessageSchema;
15269
16106
  exports.isColumnOptional = isColumnOptional;
16107
+ exports.isMultipart = isMultipart;
15270
16108
  exports.isSearchableColumn = isSearchableColumn;
15271
16109
  exports.isValidCep = isValidCep;
15272
16110
  exports.isValidCity = isValidCity;
@@ -15302,6 +16140,7 @@ exports.makeToolSpecRouter = makeToolSpecRouter;
15302
16140
  exports.makeTwilioWebhookRouter = makeTwilioWebhookRouter;
15303
16141
  exports.makeUnhandledExceptionHandler = makeUnhandledExceptionHandler;
15304
16142
  exports.makeWhatsAppWebhookRouter = makeWhatsAppWebhookRouter;
16143
+ exports.metricCard = metricCard;
15305
16144
  exports.mfaChallengeSchema = mfaChallengeSchema;
15306
16145
  exports.mfaCodeSchema = mfaCodeSchema;
15307
16146
  exports.mfaEnrollResponseSchema = mfaEnrollResponseSchema;
@@ -15325,7 +16164,10 @@ exports.paginationFilterSchema = paginationFilterSchema;
15325
16164
  exports.paginationSchema = paginationSchema;
15326
16165
  exports.parseAcceptLanguage = parseAcceptLanguage;
15327
16166
  exports.parseCookies = parseCookies;
16167
+ exports.parseCsv = parseCsv;
15328
16168
  exports.parseFormBody = parseFormBody;
16169
+ exports.parseMultipart = parseMultipart;
16170
+ exports.partitionTotal = partitionTotal;
15329
16171
  exports.passwordResetConfirmSchema = passwordResetConfirmSchema;
15330
16172
  exports.passwordResetRequestSchema = passwordResetRequestSchema;
15331
16173
  exports.percentField = percentField;
@@ -15346,6 +16188,7 @@ exports.renderAuthResultPage = renderAuthResultPage;
15346
16188
  exports.renderDashboardPage = renderDashboardPage;
15347
16189
  exports.renderDetailPage = renderDetailPage;
15348
16190
  exports.renderFormPage = renderFormPage;
16191
+ exports.renderImportPage = renderImportPage;
15349
16192
  exports.renderLayout = renderLayout;
15350
16193
  exports.renderListPage = renderListPage;
15351
16194
  exports.renderLoginPage = renderLoginPage;
@@ -15378,6 +16221,8 @@ exports.toUtc = toUtc;
15378
16221
  exports.tokenFromUrl = tokenFromUrl;
15379
16222
  exports.tokenPairSchema = tokenPairSchema;
15380
16223
  exports.tokenSettingsShape = tokenSettingsShape;
16224
+ exports.trendDirection = trendDirection;
16225
+ exports.trendPercent = trendPercent;
15381
16226
  exports.ufField = ufField;
15382
16227
  exports.updatedByColumn = updatedByColumn;
15383
16228
  exports.uploadSettingsShape = uploadSettingsShape;