tempest-express-sdk 0.26.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
@@ -9498,6 +9498,85 @@ function adminAction(options, handler) {
9498
9498
  };
9499
9499
  }
9500
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
+
9501
9580
  // src/admin/columns.ts
9502
9581
  function humanizeField(name) {
9503
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(" ");
@@ -9591,6 +9670,8 @@ var NEVER_EDITABLE = [
9591
9670
  "id",
9592
9671
  "createdAt",
9593
9672
  "updatedAt",
9673
+ "createdBy",
9674
+ "updatedBy",
9594
9675
  "hashedPassword"
9595
9676
  ];
9596
9677
  var NEVER_LISTED = ["hashedPassword"];
@@ -9627,6 +9708,14 @@ var AdminModel = class {
9627
9708
  auditModel;
9628
9709
  /** Saved list-view presets, in declaration order. */
9629
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;
9630
9719
  actions = /* @__PURE__ */ new Map();
9631
9720
  slugOverride;
9632
9721
  listDisplayOverride;
@@ -9654,6 +9743,15 @@ var AdminModel = class {
9654
9743
  this.canDelete = options.canDelete ?? true;
9655
9744
  this.auditModel = options.auditModel ?? null;
9656
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
+ }
9657
9755
  for (const action of options.actions ?? []) {
9658
9756
  if (this.actions.has(action.name)) {
9659
9757
  throw new Error(
@@ -9668,7 +9766,9 @@ var AdminModel = class {
9668
9766
  ["listFilter", this.listFilter],
9669
9767
  ["searchFields", this.searchFields],
9670
9768
  ["readonlyFields", this.readonlyFields],
9671
- ["identityField", [this.identityField]]
9769
+ ["identityField", [this.identityField]],
9770
+ ["uploadFields", this.uploadFields],
9771
+ ["autocompleteFields", this.autocompleteFields]
9672
9772
  ]) {
9673
9773
  for (const name of names) {
9674
9774
  if (!known.has(name)) {
@@ -9850,11 +9950,15 @@ function buildFormFields(admin, options = {}) {
9850
9950
  const values = options.values ?? {};
9851
9951
  const errors = options.errors ?? {};
9852
9952
  const foreignKeys = options.foreignKeyOptions ?? {};
9953
+ const autocompleteUrls = options.autocompleteUrls ?? {};
9954
+ const autocompleteLabels = options.autocompleteLabels ?? {};
9955
+ const uploads = new Set(admin.uploadFields);
9853
9956
  return admin.editableFieldNames().flatMap((name) => {
9854
9957
  const column6 = columns[name];
9855
9958
  if (column6 === void 0) return [];
9856
9959
  const related = foreignKeys[name];
9857
- 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 };
9858
9962
  const raw = name in values ? values[name] : literalDefault(column6);
9859
9963
  return [
9860
9964
  {
@@ -9866,7 +9970,9 @@ function buildFormFields(admin, options = {}) {
9866
9970
  checked: spec.widget === "checkbox" && toBoolean(raw),
9867
9971
  step: spec.step,
9868
9972
  options: spec.options,
9869
- error: errors[name] ?? null
9973
+ error: errors[name] ?? null,
9974
+ autocompleteUrl: autocompleteUrl ?? null,
9975
+ displayLabel: autocompleteLabels[name] ?? ""
9870
9976
  }
9871
9977
  ];
9872
9978
  });
@@ -9914,13 +10020,15 @@ function coerceValue(column6, widget, raw) {
9914
10020
  return raw;
9915
10021
  }
9916
10022
  }
9917
- function parseFormBody(admin, body) {
10023
+ function parseFormBody(admin, body, options = {}) {
9918
10024
  const columns = adminColumns(admin.model);
9919
10025
  const data = {};
9920
10026
  const errors = {};
10027
+ const uploads = options.uploadsAsText === true ? /* @__PURE__ */ new Set() : new Set(admin.uploadFields);
9921
10028
  for (const name of admin.editableFieldNames()) {
9922
10029
  const column6 = columns[name];
9923
10030
  if (column6 === void 0) continue;
10031
+ if (uploads.has(name)) continue;
9924
10032
  const { widget } = widgetForColumn(column6);
9925
10033
  if (widget === "checkbox") {
9926
10034
  data[name] = toBoolean(body[name]);
@@ -12206,6 +12314,7 @@ function renderListPage(context, view) {
12206
12314
  </form>
12207
12315
  <div class="tempest-admin-list__actions">
12208
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>` : ""}
12209
12318
  <a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
12210
12319
  <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
12211
12320
  </div>
@@ -12305,6 +12414,18 @@ function renderFormField(field) {
12305
12414
  control = `<label><span>${label}${field.required ? " *" : ""}</span><select name="${name}"${required}>${blank}${options}</select></label>`;
12306
12415
  break;
12307
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;
12308
12429
  case "number":
12309
12430
  control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}></label>`;
12310
12431
  break;
@@ -12332,7 +12453,7 @@ function renderFormPage(context, view) {
12332
12453
  <a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
12333
12454
  </header>
12334
12455
  ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12335
- <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"' : ""}>
12336
12457
  <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12337
12458
  ${view.fields.map(renderFormField).join("")}
12338
12459
  <div class="tempest-admin-form__actions">
@@ -12340,10 +12461,97 @@ function renderFormPage(context, view) {
12340
12461
  <a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
12341
12462
  </div>
12342
12463
  </form>
12464
+ ${view.fields.some((field) => field.widget === "autocomplete") ? AUTOCOMPLETE_SCRIPT : ""}
12343
12465
  </section>`;
12344
12466
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12345
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>`;
12346
12553
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12554
+ var AUTOCOMPLETE_LIMIT = 20;
12347
12555
  var AUDIT_HISTORY_LIMIT = 50;
12348
12556
  var FK_OPTION_CAP = 1e3;
12349
12557
  var FLASH_MAX_LENGTH = 300;
@@ -12373,6 +12581,7 @@ function makeAdminRouter(site, options) {
12373
12581
  const theme = resolveAdminTheme(site.theme);
12374
12582
  const showMetrics = options.showMetrics ?? true;
12375
12583
  const exportMaxRows = options.exportMaxRows ?? 5e3;
12584
+ const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
12376
12585
  const sessions = new AdminSessionStore({
12377
12586
  secret: options.secretKey,
12378
12587
  ...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
@@ -12630,7 +12839,8 @@ function makeAdminRouter(site, options) {
12630
12839
  mode: "create",
12631
12840
  title: admin.verboseName(),
12632
12841
  fields: buildFormFields(admin, {
12633
- foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12842
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
12843
+ autocompleteUrls: autocompleteUrlsFor(admin)
12634
12844
  }),
12635
12845
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12636
12846
  backUrl: `${prefix}/m/${admin.slug()}`,
@@ -12650,10 +12860,28 @@ function makeAdminRouter(site, options) {
12650
12860
  html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12651
12861
  return;
12652
12862
  }
12653
- if (!checkCsrf(req, res, state)) return;
12654
- const body = req.body;
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);
12875
+ return;
12876
+ }
12655
12877
  const parsed = parseFormBody(admin, body);
12878
+ await applyUploads(admin, parsed.data, parsed.errors, submission.files, true);
12656
12879
  const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12880
+ const autocompleteLabels = await autocompleteLabelsFor(
12881
+ admin,
12882
+ body,
12883
+ state.dbSession
12884
+ );
12657
12885
  const rerender = (error, status) => {
12658
12886
  html(
12659
12887
  res,
@@ -12663,7 +12891,9 @@ function makeAdminRouter(site, options) {
12663
12891
  fields: buildFormFields(admin, {
12664
12892
  values: body,
12665
12893
  errors: parsed.errors,
12666
- foreignKeyOptions
12894
+ foreignKeyOptions,
12895
+ autocompleteUrls: autocompleteUrlsFor(admin),
12896
+ autocompleteLabels
12667
12897
  }),
12668
12898
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12669
12899
  backUrl: `${prefix}/m/${admin.slug()}`,
@@ -12672,6 +12902,10 @@ function makeAdminRouter(site, options) {
12672
12902
  status
12673
12903
  );
12674
12904
  };
12905
+ if (uploadError !== null) {
12906
+ rerender(uploadError, 400);
12907
+ return;
12908
+ }
12675
12909
  if (Object.keys(parsed.errors).length > 0) {
12676
12910
  rerender("Please fix the highlighted fields.", 400);
12677
12911
  return;
@@ -12791,6 +13025,129 @@ function makeAdminRouter(site, options) {
12791
13025
  );
12792
13026
  })
12793
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
+ );
12794
13151
  router.get(
12795
13152
  `${prefix}/m/:slug/:identity`,
12796
13153
  guarded(async (req, res) => {
@@ -12838,7 +13195,9 @@ function makeAdminRouter(site, options) {
12838
13195
  title: admin.verboseName(),
12839
13196
  fields: buildFormFields(admin, {
12840
13197
  values: row,
12841
- 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)
12842
13201
  }),
12843
13202
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12844
13203
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
@@ -12859,10 +13218,28 @@ function makeAdminRouter(site, options) {
12859
13218
  html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12860
13219
  return;
12861
13220
  }
12862
- if (!checkCsrf(req, res, state)) return;
12863
- const body = req.body;
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);
13233
+ return;
13234
+ }
12864
13235
  const parsed = parseFormBody(admin, body);
13236
+ await applyUploads(admin, parsed.data, parsed.errors, submission.files, false);
12865
13237
  const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
13238
+ const autocompleteLabels = await autocompleteLabelsFor(
13239
+ admin,
13240
+ body,
13241
+ state.dbSession
13242
+ );
12866
13243
  const rerender = (error, status) => {
12867
13244
  html(
12868
13245
  res,
@@ -12872,7 +13249,9 @@ function makeAdminRouter(site, options) {
12872
13249
  fields: buildFormFields(admin, {
12873
13250
  values: body,
12874
13251
  errors: parsed.errors,
12875
- foreignKeyOptions
13252
+ foreignKeyOptions,
13253
+ autocompleteUrls: autocompleteUrlsFor(admin),
13254
+ autocompleteLabels
12876
13255
  }),
12877
13256
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12878
13257
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
@@ -12881,6 +13260,10 @@ function makeAdminRouter(site, options) {
12881
13260
  status
12882
13261
  );
12883
13262
  };
13263
+ if (uploadError !== null) {
13264
+ rerender(uploadError, 400);
13265
+ return;
13266
+ }
12884
13267
  if (Object.keys(parsed.errors).length > 0) {
12885
13268
  rerender("Please fix the highlighted fields.", 400);
12886
13269
  return;
@@ -12964,6 +13347,44 @@ function makeAdminRouter(site, options) {
12964
13347
  const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
12965
13348
  return row ?? null;
12966
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
+ }
12967
13388
  async function permittedBulkActions(admin, principal) {
12968
13389
  const permitted = [];
12969
13390
  for (const option of bulkActionsFor(admin)) {
@@ -13027,6 +13448,7 @@ function makeAdminRouter(site, options) {
13027
13448
  filters: query.filterViews,
13028
13449
  sort,
13029
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,
13030
13452
  bulkActions: await permittedBulkActions(admin, state.principal),
13031
13453
  bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
13032
13454
  exportCsvUrl: exportUrl("csv"),
@@ -13151,6 +13573,7 @@ function makeAdminRouter(site, options) {
13151
13573
  const columns = adminColumns(admin.model);
13152
13574
  const options2 = {};
13153
13575
  for (const field of Object.keys(foreignKeyFields(admin))) {
13576
+ if (admin.autocompleteFields.includes(field)) continue;
13154
13577
  const column6 = columns[field];
13155
13578
  if (column6 === void 0) continue;
13156
13579
  const related = await relatedOptions(column6, dbSession);
@@ -13158,6 +13581,29 @@ function makeAdminRouter(site, options) {
13158
13581
  }
13159
13582
  return options2;
13160
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
+ }
13161
13607
  return router;
13162
13608
  }
13163
13609
  function flagAllows(admin, action) {
@@ -13249,6 +13695,55 @@ function bulkActionsFor(admin) {
13249
13695
  }
13250
13696
  return actions;
13251
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
+ }
13252
13747
  function exportValue(value) {
13253
13748
  if (value instanceof Date) return value.toISOString();
13254
13749
  if (typeof value === "bigint") return value.toString();
@@ -15282,7 +15777,7 @@ async function withTestDatabase(models, fn) {
15282
15777
  }
15283
15778
 
15284
15779
  // src/version.ts
15285
- var VERSION = "0.26.0";
15780
+ var VERSION = "0.27.0";
15286
15781
 
15287
15782
  Object.defineProperty(exports, "OpenAPIRegistry", {
15288
15783
  enumerable: true,
@@ -15496,6 +15991,7 @@ exports.MessageCatalog = MessageCatalog;
15496
15991
  exports.MessagingHub = MessagingHub;
15497
15992
  exports.MetricsUtils = MetricsUtils;
15498
15993
  exports.MfaService = MfaService;
15994
+ exports.MultipartLimitError = MultipartLimitError;
15499
15995
  exports.NotFoundException = NotFoundException;
15500
15996
  exports.OAuthError = OAuthError;
15501
15997
  exports.OIDCProvider = OIDCProvider;
@@ -15608,6 +16104,7 @@ exports.humanizeField = humanizeField;
15608
16104
  exports.idempotencyMiddleware = idempotencyMiddleware;
15609
16105
  exports.inboundMessageSchema = inboundMessageSchema;
15610
16106
  exports.isColumnOptional = isColumnOptional;
16107
+ exports.isMultipart = isMultipart;
15611
16108
  exports.isSearchableColumn = isSearchableColumn;
15612
16109
  exports.isValidCep = isValidCep;
15613
16110
  exports.isValidCity = isValidCity;
@@ -15667,7 +16164,9 @@ exports.paginationFilterSchema = paginationFilterSchema;
15667
16164
  exports.paginationSchema = paginationSchema;
15668
16165
  exports.parseAcceptLanguage = parseAcceptLanguage;
15669
16166
  exports.parseCookies = parseCookies;
16167
+ exports.parseCsv = parseCsv;
15670
16168
  exports.parseFormBody = parseFormBody;
16169
+ exports.parseMultipart = parseMultipart;
15671
16170
  exports.partitionTotal = partitionTotal;
15672
16171
  exports.passwordResetConfirmSchema = passwordResetConfirmSchema;
15673
16172
  exports.passwordResetRequestSchema = passwordResetRequestSchema;
@@ -15689,6 +16188,7 @@ exports.renderAuthResultPage = renderAuthResultPage;
15689
16188
  exports.renderDashboardPage = renderDashboardPage;
15690
16189
  exports.renderDetailPage = renderDetailPage;
15691
16190
  exports.renderFormPage = renderFormPage;
16191
+ exports.renderImportPage = renderImportPage;
15692
16192
  exports.renderLayout = renderLayout;
15693
16193
  exports.renderListPage = renderListPage;
15694
16194
  exports.renderLoginPage = renderLoginPage;