strapi-plugin-hubspot 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +28 -0
  3. package/dist/_chunks/{Forms-77Mornxt.mjs → Forms-BGdNbq-k.mjs} +348 -33
  4. package/dist/_chunks/{Forms-B8kkPY0c.js → Forms-BVkE1C46.js} +345 -30
  5. package/dist/_chunks/{HubspotFormInput-BVknnlmR.mjs → HubspotFormInput-QJldybwT.mjs} +1 -1
  6. package/dist/_chunks/{HubspotFormInput-kx3LlQYF.js → HubspotFormInput-ddrrD-GJ.js} +1 -1
  7. package/dist/_chunks/{HubspotObjectInput-B-Y1PZ6c.mjs → HubspotObjectInput-B_W9dpvD.mjs} +3 -3
  8. package/dist/_chunks/{HubspotObjectInput-BOO1PvOj.js → HubspotObjectInput-DZ4H-vju.js} +3 -3
  9. package/dist/_chunks/{HubspotPropertyInput-awfD1opH.js → HubspotPropertyInput-BJHUy5Zr.js} +3 -3
  10. package/dist/_chunks/{HubspotPropertyInput-Drxn_Ddt.mjs → HubspotPropertyInput-CiVxcnEt.mjs} +3 -3
  11. package/dist/_chunks/{Settings-DrujhMxS.mjs → Settings-B0NyQtsh.mjs} +2 -2
  12. package/dist/_chunks/{Settings-BT1NhvxO.js → Settings-DaFRUzjG.js} +2 -2
  13. package/dist/_chunks/{en-BYKKi1LK.mjs → en-D1sHFQdN.mjs} +48 -1
  14. package/dist/_chunks/{en-B8adhJhO.js → en-zJv-SuNb.js} +48 -1
  15. package/dist/_chunks/{fr-DzgArgi3.mjs → fr-E3zTW7pq.mjs} +48 -1
  16. package/dist/_chunks/{fr-DnKtGXaB.js → fr-fh0uH6Rp.js} +48 -1
  17. package/dist/_chunks/{index-LrCSAHoH.mjs → index-BpapRdHX.mjs} +6 -6
  18. package/dist/_chunks/{index-P7p8ASb6.js → index-rgjWxSn4.js} +6 -6
  19. package/dist/_chunks/{objectLabels-yNrnTOBo.mjs → objectLabels-DQYwGDr3.mjs} +1 -1
  20. package/dist/_chunks/{objectLabels-Dn-zriuZ.js → objectLabels-mBjwR5EE.js} +1 -1
  21. package/dist/_chunks/{useHubspotSchema-BottzdRR.js → useHubspotSchema-CJEiHbjz.js} +1 -1
  22. package/dist/_chunks/{useHubspotSchema-DccG2KbZ.mjs → useHubspotSchema-DX4JTPFt.mjs} +1 -1
  23. package/dist/admin/index.js +1 -1
  24. package/dist/admin/index.mjs +1 -1
  25. package/dist/admin/src/builder/types.d.ts +28 -1
  26. package/dist/admin/src/pages/Forms.d.ts +5 -1
  27. package/dist/admin/src/pages/Submissions.d.ts +8 -0
  28. package/dist/server/index.js +353 -14
  29. package/dist/server/index.mjs +353 -14
  30. package/dist/server/src/__tests__/importHubspot.test.d.ts +1 -0
  31. package/dist/server/src/__tests__/submissions.test.d.ts +1 -0
  32. package/dist/server/src/conditions.d.ts +3 -1
  33. package/dist/server/src/formsAdmin.d.ts +13 -0
  34. package/dist/server/src/importHubspot.d.ts +85 -0
  35. package/dist/server/src/index.d.ts +28 -1
  36. package/dist/server/src/submissions.d.ts +28 -0
  37. package/dist/shared/types.d.ts +15 -1
  38. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  const utils = require("@strapi/utils");
3
- const HS_BASE$2 = "https://api.hubapi.com";
3
+ const HS_BASE$3 = "https://api.hubapi.com";
4
4
  const TTL_MS = 10 * 60 * 1e3;
5
5
  const STANDARD_OBJECTS = [
6
6
  { name: "contact", path: "contacts" },
@@ -13,8 +13,8 @@ const STANDARD_OBJECTS = [
13
13
  ];
14
14
  let cache = null;
15
15
  let inFlight = null;
16
- async function hsGet(apiKey, path) {
17
- const res = await fetch(`${HS_BASE$2}${path}`, {
16
+ async function hsGet$1(apiKey, path) {
17
+ const res = await fetch(`${HS_BASE$3}${path}`, {
18
18
  headers: { Authorization: `Bearer ${apiKey}` }
19
19
  });
20
20
  if (!res.ok) {
@@ -27,7 +27,7 @@ async function hsGet(apiKey, path) {
27
27
  }
28
28
  async function fetchGroups(apiKey, path) {
29
29
  try {
30
- const res = await hsGet(
30
+ const res = await hsGet$1(
31
31
  apiKey,
32
32
  `/crm/v3/properties/${path}/groups`
33
33
  );
@@ -38,7 +38,7 @@ async function fetchGroups(apiKey, path) {
38
38
  }
39
39
  async function fetchObject(apiKey, object) {
40
40
  const [res, groups] = await Promise.all([
41
- hsGet(apiKey, `/crm/v3/properties/${object.path}`),
41
+ hsGet$1(apiKey, `/crm/v3/properties/${object.path}`),
42
42
  fetchGroups(apiKey, object.path)
43
43
  ]);
44
44
  return (res.results ?? []).filter((p) => !p.modificationMetadata?.readOnlyValue).map((p) => ({
@@ -52,7 +52,7 @@ async function fetchObject(apiKey, object) {
52
52
  }
53
53
  async function fetchAccount(apiKey) {
54
54
  try {
55
- return await hsGet(
55
+ return await hsGet$1(
56
56
  apiKey,
57
57
  "/account-info/v3/details"
58
58
  );
@@ -465,7 +465,17 @@ const contentTypes = {
465
465
  }
466
466
  }
467
467
  };
468
- const NEEDS_VALUE = /* @__PURE__ */ new Set(["eq", "neq", "contains", "gt", "lt"]);
468
+ const NEEDS_VALUE = /* @__PURE__ */ new Set([
469
+ "eq",
470
+ "neq",
471
+ "contains",
472
+ "notContains",
473
+ "startsWith",
474
+ "endsWith",
475
+ "gt",
476
+ "lt"
477
+ ]);
478
+ const NEEDS_VALUES = /* @__PURE__ */ new Set(["in", "notIn"]);
469
479
  function validateDefinition(definition) {
470
480
  const errors = [];
471
481
  const allIds = /* @__PURE__ */ new Set();
@@ -482,6 +492,9 @@ function validateDefinition(definition) {
482
492
  if (NEEDS_VALUE.has(rule.operator) && (rule.value === void 0 || rule.value === "")) {
483
493
  errors.push({ code: "missing-value", ...owner, target: rule.field });
484
494
  }
495
+ if (NEEDS_VALUES.has(rule.operator) && !rule.values?.length) {
496
+ errors.push({ code: "missing-value", ...owner, target: rule.field });
497
+ }
485
498
  }
486
499
  };
487
500
  const seen = /* @__PURE__ */ new Set();
@@ -515,6 +528,16 @@ function evaluateRule(rule, get) {
515
528
  return asString(value) !== asString(rule.value);
516
529
  case "contains":
517
530
  return asString(value).toLowerCase().includes(asString(rule.value).toLowerCase());
531
+ case "notContains":
532
+ return !asString(value).toLowerCase().includes(asString(rule.value).toLowerCase());
533
+ case "startsWith":
534
+ return asString(value).toLowerCase().startsWith(asString(rule.value).toLowerCase());
535
+ case "endsWith":
536
+ return asString(value).toLowerCase().endsWith(asString(rule.value).toLowerCase());
537
+ case "in":
538
+ return (rule.values ?? []).some((v) => asString(v) === asString(value));
539
+ case "notIn":
540
+ return !(rule.values ?? []).some((v) => asString(v) === asString(value));
518
541
  case "empty":
519
542
  return isEmpty(value);
520
543
  case "notEmpty":
@@ -664,9 +687,9 @@ function sanitizeRawValues(raw) {
664
687
  }
665
688
  return values;
666
689
  }
667
- const HS_BASE$1 = "https://api.hubapi.com";
668
- const HS_COMPANIES = `${HS_BASE$1}/crm/v3/objects/companies`;
669
- const HS_NOTES = `${HS_BASE$1}/crm/v3/objects/notes`;
690
+ const HS_BASE$2 = "https://api.hubapi.com";
691
+ const HS_COMPANIES = `${HS_BASE$2}/crm/v3/objects/companies`;
692
+ const HS_NOTES = `${HS_BASE$2}/crm/v3/objects/notes`;
670
693
  const ASSOC_NOTE_TO_CONTACT = 202;
671
694
  const ASSOC_NOTE_TO_COMPANY = 190;
672
695
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -828,7 +851,7 @@ function createFormsService(strapi, _opts = {}) {
828
851
  if (companyId) {
829
852
  await hsJson(
830
853
  apiKey,
831
- `${HS_BASE$1}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
854
+ `${HS_BASE$2}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
832
855
  { method: "PUT" }
833
856
  );
834
857
  }
@@ -869,6 +892,168 @@ function createFormsService(strapi, _opts = {}) {
869
892
  }
870
893
  return { submit };
871
894
  }
895
+ const HS_BASE$1 = "https://api.hubapi.com";
896
+ const OBJECT_BY_TYPE_ID = {
897
+ "0-1": "contact",
898
+ "0-2": "company"
899
+ };
900
+ const TYPE_MAP = {
901
+ single_line_text: "text",
902
+ multi_line_text: "textarea",
903
+ email: "email",
904
+ phone: "tel",
905
+ mobile_phone: "tel",
906
+ number: "number",
907
+ dropdown: "select",
908
+ select: "select",
909
+ radio: "radio",
910
+ multiple_checkboxes: "checkbox",
911
+ single_checkbox: "checkbox",
912
+ booleancheckbox: "checkbox"
913
+ };
914
+ let counter$1 = 0;
915
+ const makeId$1 = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter$1 += 1).toString(36)}`;
916
+ function convertCondition(parentFieldId, raw) {
917
+ const operator = (raw.operator ?? "").toUpperCase();
918
+ const values = (raw.values ?? []).map((v) => String(v)).filter((v) => v !== "");
919
+ const spread = (op, logic) => values.length ? { logic, rules: values.map((value) => ({ field: parentFieldId, operator: op, value })) } : null;
920
+ switch (operator) {
921
+ case "EQ":
922
+ case "SET_ANY":
923
+ return spread("eq", "or");
924
+ case "NEQ":
925
+ case "NOT_SET_ANY":
926
+ return spread("neq", "and");
927
+ case "CONTAINS":
928
+ return spread("contains", "or");
929
+ case "GT":
930
+ return spread("gt", "or");
931
+ case "LT":
932
+ return spread("lt", "or");
933
+ case "SET":
934
+ case "NOT_EMPTY":
935
+ return { logic: "and", rules: [{ field: parentFieldId, operator: "notEmpty" }] };
936
+ case "NOT_SET":
937
+ case "EMPTY":
938
+ return { logic: "and", rules: [{ field: parentFieldId, operator: "empty" }] };
939
+ default:
940
+ return null;
941
+ }
942
+ }
943
+ function convertField(raw, ctx) {
944
+ const label = raw.label || raw.name || "";
945
+ const name = raw.name ?? "";
946
+ if (raw.hidden) {
947
+ ctx.skipped.push({ code: "hidden-field", label });
948
+ return [];
949
+ }
950
+ const object = OBJECT_BY_TYPE_ID[raw.objectTypeId ?? "0-1"];
951
+ if (!object) {
952
+ ctx.skipped.push({ code: "object", label, detail: raw.objectTypeId });
953
+ return [];
954
+ }
955
+ const type = TYPE_MAP[raw.fieldType ?? ""];
956
+ if (!type || !name) {
957
+ ctx.skipped.push({ code: "field-type", label, detail: raw.fieldType });
958
+ return [];
959
+ }
960
+ if (ctx.usedNames.has(name)) {
961
+ ctx.skipped.push({ code: "duplicate", label, detail: name });
962
+ return [];
963
+ }
964
+ ctx.usedNames.add(name);
965
+ const options = (raw.options ?? []).map((o) => ({ value: String(o.value ?? ""), label: o.label || void 0 })).filter((o) => o.value !== "");
966
+ const field = {
967
+ id: makeId$1("fld"),
968
+ name,
969
+ label,
970
+ type,
971
+ required: Boolean(raw.required),
972
+ placeholder: raw.placeholder || void 0,
973
+ helpText: raw.description || void 0,
974
+ ...options.length ? { options } : {},
975
+ hubspot: { object, property: name },
976
+ ...ctx.parentCondition ? { visibleIf: ctx.parentCondition } : {}
977
+ };
978
+ const out = [field];
979
+ for (const dependent of raw.dependentFields ?? []) {
980
+ if (!dependent.field) continue;
981
+ const childLabel = dependent.field.label || dependent.field.name || "";
982
+ const condition = dependent.dependentCondition ? convertCondition(field.id, dependent.dependentCondition) : null;
983
+ if (!condition) {
984
+ ctx.skipped.push({
985
+ code: "condition",
986
+ label: childLabel,
987
+ detail: dependent.dependentCondition?.operator
988
+ });
989
+ }
990
+ out.push(...convertField(dependent.field, { ...ctx, parentCondition: condition }));
991
+ }
992
+ return out;
993
+ }
994
+ function convertHubspotForm(raw) {
995
+ const skipped = [];
996
+ const usedNames = /* @__PURE__ */ new Set();
997
+ const fields = [];
998
+ for (const group of raw.fieldGroups ?? []) {
999
+ if (group.richText && !(group.fields ?? []).length) {
1000
+ skipped.push({ code: "rich-text", detail: group.richText.slice(0, 80) });
1001
+ continue;
1002
+ }
1003
+ const groupFields = (group.fields ?? []).flatMap(
1004
+ (f) => convertField(f, { usedNames, skipped })
1005
+ );
1006
+ const topLevel = (group.fields ?? []).length;
1007
+ if (topLevel === 2 && groupFields.length >= 2) {
1008
+ groupFields[0].width = "half";
1009
+ groupFields[1].width = "half";
1010
+ }
1011
+ fields.push(...groupFields);
1012
+ }
1013
+ if (raw.legalConsentOptions && Object.keys(raw.legalConsentOptions).length) {
1014
+ skipped.push({ code: "legal-consent" });
1015
+ }
1016
+ const postSubmit = raw.configuration?.postSubmitAction;
1017
+ return {
1018
+ name: raw.name ?? "",
1019
+ submitLabel: raw.displayOptions?.submitButtonText || null,
1020
+ successMessage: postSubmit?.type === "thank_you" && postSubmit.value ? postSubmit.value : null,
1021
+ // HubSpot forms are single-page: one step, reorganizable in the builder.
1022
+ definition: { version: 1, steps: [{ id: makeId$1("stp"), fields }] },
1023
+ skipped
1024
+ };
1025
+ }
1026
+ async function hsGet(apiKey, path) {
1027
+ const res = await fetch(`${HS_BASE$1}${path}`, {
1028
+ headers: { Authorization: `Bearer ${apiKey}` }
1029
+ });
1030
+ if (!res.ok) {
1031
+ const body = await res.json().catch(() => ({}));
1032
+ throw Object.assign(new Error(body.message || `HubSpot ${res.status}`), {
1033
+ status: res.status
1034
+ });
1035
+ }
1036
+ return await res.json();
1037
+ }
1038
+ async function listHubspotForms(apiKey) {
1039
+ const out = [];
1040
+ let after;
1041
+ for (let page = 0; page < 10; page += 1) {
1042
+ const query = after ? `&after=${encodeURIComponent(after)}` : "";
1043
+ const res = await hsGet(apiKey, `/marketing/v3/forms/?limit=100&formTypes=hubspot${query}`);
1044
+ for (const form of res.results ?? []) {
1045
+ if (form.id && !form.archived) {
1046
+ out.push({ id: form.id, name: form.name || form.id, updatedAt: form.updatedAt });
1047
+ }
1048
+ }
1049
+ after = res.paging?.next?.after;
1050
+ if (!after) break;
1051
+ }
1052
+ return out.sort((a, b) => a.name.localeCompare(b.name));
1053
+ }
1054
+ async function fetchHubspotForm(apiKey, formId) {
1055
+ return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
1056
+ }
872
1057
  let counter = 0;
873
1058
  const makeId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter += 1).toString(36)}`;
874
1059
  const slugifyName = (label) => label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
@@ -1007,8 +1192,15 @@ function createFormsAdminController(strapi) {
1007
1192
  fields: ["slug"]
1008
1193
  });
1009
1194
  const publishedIds = new Set(published.map((e) => e.documentId));
1195
+ const submissionCounts = await Promise.all(
1196
+ drafts.map(
1197
+ (entry) => strapi.documents(SUBMISSION_UID).count({
1198
+ filters: { form: entry.slug }
1199
+ })
1200
+ )
1201
+ );
1010
1202
  ctx.body = {
1011
- forms: drafts.map((entry) => ({
1203
+ forms: drafts.map((entry, i) => ({
1012
1204
  documentId: entry.documentId,
1013
1205
  name: entry.name,
1014
1206
  slug: entry.slug,
@@ -1018,7 +1210,8 @@ function createFormsAdminController(strapi) {
1018
1210
  fields: (entry.definition?.steps ?? []).reduce(
1019
1211
  (n, step) => n + (step.fields?.length ?? 0),
1020
1212
  0
1021
- )
1213
+ ),
1214
+ submissions: submissionCounts[i] ?? 0
1022
1215
  }))
1023
1216
  };
1024
1217
  },
@@ -1209,6 +1402,66 @@ function createFormsAdminController(strapi) {
1209
1402
  if (!targetId) ctx.throw(404, "Source entry not found");
1210
1403
  ctx.body = { documentId: targetId, locales: imported };
1211
1404
  },
1405
+ /**
1406
+ * Forms of the connected HubSpot portal, offered for import. Needs the
1407
+ * `forms` read scope on the private app token — a token without it gets
1408
+ * a clear message rather than an empty list.
1409
+ */
1410
+ async listHubspotSources(ctx) {
1411
+ const { apiKey } = await resolveApiKey(strapi);
1412
+ if (!apiKey) {
1413
+ ctx.body = { configured: false, forms: [] };
1414
+ return;
1415
+ }
1416
+ try {
1417
+ ctx.body = { configured: true, forms: await listHubspotForms(apiKey) };
1418
+ } catch (err) {
1419
+ strapi.log.warn(`[hubspot] portal forms unavailable — ${err.message}`);
1420
+ ctx.throw(502, err.message || "Cannot reach HubSpot");
1421
+ }
1422
+ },
1423
+ /**
1424
+ * Converts one portal form into a plugin form draft, in the current
1425
+ * locale. Slug derived from the HubSpot name: re-importing the same form
1426
+ * overwrites the draft — never the published version, never the portal.
1427
+ * What the builder can't express is skipped and returned in `skipped`.
1428
+ */
1429
+ async runHubspotImport(ctx) {
1430
+ const formId = ctx.request.body?.formId;
1431
+ if (typeof formId !== "string" || !formId) ctx.throw(400, "formId is required");
1432
+ const { apiKey } = await resolveApiKey(strapi);
1433
+ if (!apiKey) ctx.throw(400, "No HubSpot API key configured");
1434
+ let converted;
1435
+ try {
1436
+ converted = convertHubspotForm(await fetchHubspotForm(apiKey, formId));
1437
+ } catch (err) {
1438
+ strapi.log.warn(`[hubspot] form import failed — ${err.message}`);
1439
+ ctx.throw(502, err.message || "Cannot reach HubSpot");
1440
+ return;
1441
+ }
1442
+ const name = converted.name || "HubSpot form";
1443
+ const { skipped, definition, ...meta } = converted;
1444
+ const slug = slugify(name);
1445
+ const existing = await documents().findFirst({
1446
+ filters: { slug }
1447
+ });
1448
+ let documentId;
1449
+ if (existing) {
1450
+ await documents().update({
1451
+ documentId: existing.documentId,
1452
+ locale: ctx.query.locale,
1453
+ data: { ...meta, name, definition }
1454
+ });
1455
+ documentId = existing.documentId;
1456
+ } else {
1457
+ const created = await documents().create({
1458
+ locale: ctx.query.locale,
1459
+ data: { ...meta, name, slug, definition }
1460
+ });
1461
+ documentId = created.documentId;
1462
+ }
1463
+ ctx.body = { documentId, skipped };
1464
+ },
1212
1465
  /**
1213
1466
  * Copies a form (every locale of its draft) into a new draft document —
1214
1467
  * the quickest way to A/B a variant or to start from an existing form.
@@ -1269,6 +1522,45 @@ function createFormsAdminController(strapi) {
1269
1522
  }
1270
1523
  };
1271
1524
  }
1525
+ function fieldOrder(definition) {
1526
+ if (!definition) return [];
1527
+ return definition.steps.flatMap((step) => step.fields.map((field) => field.name));
1528
+ }
1529
+ const escapeCell = (value) => {
1530
+ if (value === null || value === void 0) return "";
1531
+ const text = String(value);
1532
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
1533
+ };
1534
+ function submissionsCsv(rows, knownFields) {
1535
+ const extra = /* @__PURE__ */ new Set();
1536
+ for (const row of rows) {
1537
+ for (const key of Object.keys(row.values ?? {})) {
1538
+ if (!knownFields.includes(key)) extra.add(key);
1539
+ }
1540
+ }
1541
+ const valueColumns = [...knownFields, ...[...extra].sort()];
1542
+ const header = [
1543
+ "submittedAt",
1544
+ ...valueColumns,
1545
+ "hubspotSynced",
1546
+ "locale",
1547
+ "pagePath",
1548
+ "source"
1549
+ ];
1550
+ const lines = [header.map(escapeCell).join(",")];
1551
+ for (const row of rows) {
1552
+ const cells = [
1553
+ row.createdAt ?? "",
1554
+ ...valueColumns.map((key) => row.values?.[key]),
1555
+ row.hubspotSynced ? "true" : "false",
1556
+ row.locale ?? "",
1557
+ row.meta?.pagePath ?? "",
1558
+ row.meta?.source ?? ""
1559
+ ];
1560
+ lines.push(cells.map(escapeCell).join(","));
1561
+ }
1562
+ return lines.join("\r\n");
1563
+ }
1272
1564
  const HS_BASE = "https://api.hubapi.com";
1273
1565
  const FAILURE_UID = "plugin::hubspot.failure";
1274
1566
  const RETRY_DELAYS_MS = [500, 2e3];
@@ -1479,6 +1771,49 @@ const controllers = {
1479
1771
  }
1480
1772
  }),
1481
1773
  formsAdmin: ({ strapi }) => createFormsAdminController(strapi),
1774
+ submissions: ({ strapi }) => ({
1775
+ /** Paged submissions, newest first, optionally narrowed to one form. */
1776
+ async list(ctx) {
1777
+ const filters = ctx.query.form ? { form: ctx.query.form } : void 0;
1778
+ const page = Math.max(1, Number(ctx.query.page) || 1);
1779
+ const pageSize = Math.min(100, Math.max(1, Number(ctx.query.pageSize) || 20));
1780
+ const [rows, total] = await Promise.all([
1781
+ strapi.documents(SUBMISSION_UID).findMany({
1782
+ filters,
1783
+ sort: "createdAt:desc",
1784
+ limit: pageSize,
1785
+ start: (page - 1) * pageSize
1786
+ }),
1787
+ strapi.documents(SUBMISSION_UID).count({ filters })
1788
+ ]);
1789
+ ctx.body = { submissions: rows, total, page, pageSize };
1790
+ },
1791
+ /**
1792
+ * Whole history of one form as CSV, definition columns first. Returned as
1793
+ * JSON (`{ csv, filename }`): the admin fetch client speaks JSON, and the
1794
+ * page turns it into a download — no auth-header gymnastics.
1795
+ */
1796
+ async export(ctx) {
1797
+ const slug = ctx.query.form;
1798
+ if (!slug) ctx.throw(400, "form is required");
1799
+ const [rows, entry] = await Promise.all([
1800
+ strapi.documents(SUBMISSION_UID).findMany({
1801
+ filters: { form: slug },
1802
+ sort: "createdAt:desc",
1803
+ limit: 1e4
1804
+ }),
1805
+ strapi.documents(FORM_UID).findFirst({
1806
+ filters: { slug },
1807
+ status: "draft"
1808
+ })
1809
+ ]);
1810
+ ctx.body = {
1811
+ csv: submissionsCsv(rows, fieldOrder(entry?.definition)),
1812
+ filename: `${slug}-submissions-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`,
1813
+ total: rows.length
1814
+ };
1815
+ }
1816
+ }),
1482
1817
  forms: ({ strapi }) => ({
1483
1818
  /** Published form for the host frontend — CRM mapping stripped. */
1484
1819
  async findOne(ctx) {
@@ -1565,7 +1900,11 @@ const routes = {
1565
1900
  // they pick properties — the builder itself stays gated.
1566
1901
  adminRoute("GET", "/forms-options", "formsAdmin.options"),
1567
1902
  adminRoute("GET", "/builder/import/sources", "formsAdmin.listSources", [FORMS_ACTION]),
1568
- adminRoute("POST", "/builder/import", "formsAdmin.runImport", [FORMS_ACTION])
1903
+ adminRoute("POST", "/builder/import", "formsAdmin.runImport", [FORMS_ACTION]),
1904
+ adminRoute("GET", "/builder/import/hubspot", "formsAdmin.listHubspotSources", [FORMS_ACTION]),
1905
+ adminRoute("POST", "/builder/import/hubspot", "formsAdmin.runHubspotImport", [FORMS_ACTION]),
1906
+ adminRoute("GET", "/builder/submissions", "submissions.list", [FORMS_ACTION]),
1907
+ adminRoute("GET", "/builder/submissions/export", "submissions.export", [FORMS_ACTION])
1569
1908
  ]
1570
1909
  },
1571
1910
  // Public form delivery + submission, under /api/hubspot/…. Like any