strapi-plugin-hubspot 0.7.0 → 0.9.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 (40) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +28 -0
  3. package/dist/_chunks/{Forms-CKA6ukB6.mjs → Forms-BsKys-Hc.mjs} +404 -42
  4. package/dist/_chunks/{Forms-HMZbTk_B.js → Forms-JuzNfsbL.js} +401 -39
  5. package/dist/_chunks/{HubspotFormInput-CKYRcNNU.mjs → HubspotFormInput-CIvHS5rp.mjs} +1 -1
  6. package/dist/_chunks/{HubspotFormInput-BequMb12.js → HubspotFormInput-ph9pe6xY.js} +1 -1
  7. package/dist/_chunks/{HubspotObjectInput-V3uhGK8k.mjs → HubspotObjectInput-BX1qYD2c.mjs} +3 -3
  8. package/dist/_chunks/{HubspotObjectInput-D-FGwsyS.js → HubspotObjectInput-dAd-lMdD.js} +3 -3
  9. package/dist/_chunks/{HubspotPropertyInput-Dn1QvOcu.js → HubspotPropertyInput-BM3ZaY8f.js} +3 -3
  10. package/dist/_chunks/{HubspotPropertyInput-DrBsx9jQ.mjs → HubspotPropertyInput-C2Z6472q.mjs} +3 -3
  11. package/dist/_chunks/{Settings-Cgp7M1Cv.js → Settings-Cdcjy9DA.js} +2 -2
  12. package/dist/_chunks/{Settings-BjkNbyna.mjs → Settings-DnwGetcc.mjs} +2 -2
  13. package/dist/_chunks/{en-Cun5LoUP.mjs → en-CcQWnXra.mjs} +50 -3
  14. package/dist/_chunks/{en-D5Ch9YFo.js → en-Cqfair98.js} +50 -3
  15. package/dist/_chunks/{fr-ClOWflse.mjs → fr-CBRZ9q8U.mjs} +50 -3
  16. package/dist/_chunks/{fr-CqlhHltZ.js → fr-D117-3Ta.js} +50 -3
  17. package/dist/_chunks/{index-D2Q34BuH.js → index-BbRtXnAL.js} +6 -6
  18. package/dist/_chunks/{index-FKsiOEPB.mjs → index-LTaVoyWJ.mjs} +6 -6
  19. package/dist/_chunks/{objectLabels-DBKevJle.mjs → objectLabels-DE2xGq7f.mjs} +1 -1
  20. package/dist/_chunks/{objectLabels-LAQ0M6-a.js → objectLabels-DU0Cagt_.js} +1 -1
  21. package/dist/_chunks/{useHubspotSchema-BC0Od-Wl.mjs → useHubspotSchema-BSKow0X3.mjs} +1 -1
  22. package/dist/_chunks/{useHubspotSchema-C08qBAyy.js → useHubspotSchema-DzBMAxv7.js} +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 +26 -0
  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 +396 -21
  29. package/dist/server/index.mjs +396 -21
  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/content-types.d.ts +3 -0
  33. package/dist/server/src/forms.d.ts +2 -0
  34. package/dist/server/src/formsAdmin.d.ts +19 -0
  35. package/dist/server/src/importHubspot.d.ts +85 -0
  36. package/dist/server/src/importLegacy.d.ts +1 -0
  37. package/dist/server/src/index.d.ts +32 -1
  38. package/dist/server/src/submissions.d.ts +28 -0
  39. package/dist/shared/types.d.ts +2 -0
  40. package/package.json +2 -2
@@ -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
  );
@@ -397,6 +397,7 @@ const contentTypes = {
397
397
  nextLabel: localizedString(),
398
398
  submitLabel: localizedString(),
399
399
  successMessage: localizedString("text"),
400
+ class: { type: "string" },
400
401
  definition: {
401
402
  type: "json",
402
403
  required: true,
@@ -573,6 +574,7 @@ function publicForm(entry) {
573
574
  nextLabel: entry.nextLabel ?? null,
574
575
  submitLabel: entry.submitLabel ?? null,
575
576
  successMessage: entry.successMessage ?? null,
577
+ class: entry.class ?? null,
576
578
  locale: entry.locale ?? null,
577
579
  steps: (entry.definition?.steps ?? []).map((step) => ({
578
580
  ...step,
@@ -662,9 +664,9 @@ function sanitizeRawValues(raw) {
662
664
  }
663
665
  return values;
664
666
  }
665
- const HS_BASE$1 = "https://api.hubapi.com";
666
- const HS_COMPANIES = `${HS_BASE$1}/crm/v3/objects/companies`;
667
- const HS_NOTES = `${HS_BASE$1}/crm/v3/objects/notes`;
667
+ const HS_BASE$2 = "https://api.hubapi.com";
668
+ const HS_COMPANIES = `${HS_BASE$2}/crm/v3/objects/companies`;
669
+ const HS_NOTES = `${HS_BASE$2}/crm/v3/objects/notes`;
668
670
  const ASSOC_NOTE_TO_CONTACT = 202;
669
671
  const ASSOC_NOTE_TO_COMPANY = 190;
670
672
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -826,7 +828,7 @@ function createFormsService(strapi, _opts = {}) {
826
828
  if (companyId) {
827
829
  await hsJson(
828
830
  apiKey,
829
- `${HS_BASE$1}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
831
+ `${HS_BASE$2}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
830
832
  { method: "PUT" }
831
833
  );
832
834
  }
@@ -867,6 +869,168 @@ function createFormsService(strapi, _opts = {}) {
867
869
  }
868
870
  return { submit };
869
871
  }
872
+ const HS_BASE$1 = "https://api.hubapi.com";
873
+ const OBJECT_BY_TYPE_ID = {
874
+ "0-1": "contact",
875
+ "0-2": "company"
876
+ };
877
+ const TYPE_MAP = {
878
+ single_line_text: "text",
879
+ multi_line_text: "textarea",
880
+ email: "email",
881
+ phone: "tel",
882
+ mobile_phone: "tel",
883
+ number: "number",
884
+ dropdown: "select",
885
+ select: "select",
886
+ radio: "radio",
887
+ multiple_checkboxes: "checkbox",
888
+ single_checkbox: "checkbox",
889
+ booleancheckbox: "checkbox"
890
+ };
891
+ let counter$1 = 0;
892
+ const makeId$1 = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter$1 += 1).toString(36)}`;
893
+ function convertCondition(parentFieldId, raw) {
894
+ const operator = (raw.operator ?? "").toUpperCase();
895
+ const values = (raw.values ?? []).map((v) => String(v)).filter((v) => v !== "");
896
+ const spread = (op, logic) => values.length ? { logic, rules: values.map((value) => ({ field: parentFieldId, operator: op, value })) } : null;
897
+ switch (operator) {
898
+ case "EQ":
899
+ case "SET_ANY":
900
+ return spread("eq", "or");
901
+ case "NEQ":
902
+ case "NOT_SET_ANY":
903
+ return spread("neq", "and");
904
+ case "CONTAINS":
905
+ return spread("contains", "or");
906
+ case "GT":
907
+ return spread("gt", "or");
908
+ case "LT":
909
+ return spread("lt", "or");
910
+ case "SET":
911
+ case "NOT_EMPTY":
912
+ return { logic: "and", rules: [{ field: parentFieldId, operator: "notEmpty" }] };
913
+ case "NOT_SET":
914
+ case "EMPTY":
915
+ return { logic: "and", rules: [{ field: parentFieldId, operator: "empty" }] };
916
+ default:
917
+ return null;
918
+ }
919
+ }
920
+ function convertField(raw, ctx) {
921
+ const label = raw.label || raw.name || "";
922
+ const name = raw.name ?? "";
923
+ if (raw.hidden) {
924
+ ctx.skipped.push({ code: "hidden-field", label });
925
+ return [];
926
+ }
927
+ const object = OBJECT_BY_TYPE_ID[raw.objectTypeId ?? "0-1"];
928
+ if (!object) {
929
+ ctx.skipped.push({ code: "object", label, detail: raw.objectTypeId });
930
+ return [];
931
+ }
932
+ const type = TYPE_MAP[raw.fieldType ?? ""];
933
+ if (!type || !name) {
934
+ ctx.skipped.push({ code: "field-type", label, detail: raw.fieldType });
935
+ return [];
936
+ }
937
+ if (ctx.usedNames.has(name)) {
938
+ ctx.skipped.push({ code: "duplicate", label, detail: name });
939
+ return [];
940
+ }
941
+ ctx.usedNames.add(name);
942
+ const options = (raw.options ?? []).map((o) => ({ value: String(o.value ?? ""), label: o.label || void 0 })).filter((o) => o.value !== "");
943
+ const field = {
944
+ id: makeId$1("fld"),
945
+ name,
946
+ label,
947
+ type,
948
+ required: Boolean(raw.required),
949
+ placeholder: raw.placeholder || void 0,
950
+ helpText: raw.description || void 0,
951
+ ...options.length ? { options } : {},
952
+ hubspot: { object, property: name },
953
+ ...ctx.parentCondition ? { visibleIf: ctx.parentCondition } : {}
954
+ };
955
+ const out = [field];
956
+ for (const dependent of raw.dependentFields ?? []) {
957
+ if (!dependent.field) continue;
958
+ const childLabel = dependent.field.label || dependent.field.name || "";
959
+ const condition = dependent.dependentCondition ? convertCondition(field.id, dependent.dependentCondition) : null;
960
+ if (!condition) {
961
+ ctx.skipped.push({
962
+ code: "condition",
963
+ label: childLabel,
964
+ detail: dependent.dependentCondition?.operator
965
+ });
966
+ }
967
+ out.push(...convertField(dependent.field, { ...ctx, parentCondition: condition }));
968
+ }
969
+ return out;
970
+ }
971
+ function convertHubspotForm(raw) {
972
+ const skipped = [];
973
+ const usedNames = /* @__PURE__ */ new Set();
974
+ const fields = [];
975
+ for (const group of raw.fieldGroups ?? []) {
976
+ if (group.richText && !(group.fields ?? []).length) {
977
+ skipped.push({ code: "rich-text", detail: group.richText.slice(0, 80) });
978
+ continue;
979
+ }
980
+ const groupFields = (group.fields ?? []).flatMap(
981
+ (f) => convertField(f, { usedNames, skipped })
982
+ );
983
+ const topLevel = (group.fields ?? []).length;
984
+ if (topLevel === 2 && groupFields.length >= 2) {
985
+ groupFields[0].width = "half";
986
+ groupFields[1].width = "half";
987
+ }
988
+ fields.push(...groupFields);
989
+ }
990
+ if (raw.legalConsentOptions && Object.keys(raw.legalConsentOptions).length) {
991
+ skipped.push({ code: "legal-consent" });
992
+ }
993
+ const postSubmit = raw.configuration?.postSubmitAction;
994
+ return {
995
+ name: raw.name ?? "",
996
+ submitLabel: raw.displayOptions?.submitButtonText || null,
997
+ successMessage: postSubmit?.type === "thank_you" && postSubmit.value ? postSubmit.value : null,
998
+ // HubSpot forms are single-page: one step, reorganizable in the builder.
999
+ definition: { version: 1, steps: [{ id: makeId$1("stp"), fields }] },
1000
+ skipped
1001
+ };
1002
+ }
1003
+ async function hsGet(apiKey, path) {
1004
+ const res = await fetch(`${HS_BASE$1}${path}`, {
1005
+ headers: { Authorization: `Bearer ${apiKey}` }
1006
+ });
1007
+ if (!res.ok) {
1008
+ const body = await res.json().catch(() => ({}));
1009
+ throw Object.assign(new Error(body.message || `HubSpot ${res.status}`), {
1010
+ status: res.status
1011
+ });
1012
+ }
1013
+ return await res.json();
1014
+ }
1015
+ async function listHubspotForms(apiKey) {
1016
+ const out = [];
1017
+ let after;
1018
+ for (let page = 0; page < 10; page += 1) {
1019
+ const query = after ? `&after=${encodeURIComponent(after)}` : "";
1020
+ const res = await hsGet(apiKey, `/marketing/v3/forms/?limit=100&formTypes=hubspot${query}`);
1021
+ for (const form of res.results ?? []) {
1022
+ if (form.id && !form.archived) {
1023
+ out.push({ id: form.id, name: form.name || form.id, updatedAt: form.updatedAt });
1024
+ }
1025
+ }
1026
+ after = res.paging?.next?.after;
1027
+ if (!after) break;
1028
+ }
1029
+ return out.sort((a, b) => a.name.localeCompare(b.name));
1030
+ }
1031
+ async function fetchHubspotForm(apiKey, formId) {
1032
+ return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
1033
+ }
870
1034
  let counter = 0;
871
1035
  const makeId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter += 1).toString(36)}`;
872
1036
  const slugifyName = (label) => label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
@@ -932,6 +1096,7 @@ function convertLegacyForm(entry, map = {}) {
932
1096
  nextLabel: entry.nextLabel ?? null,
933
1097
  submitLabel: entry.submitLabel ?? null,
934
1098
  successMessage: entry.successMessage ?? null,
1099
+ class: entry.class ?? null,
935
1100
  definition: { version: 1, steps }
936
1101
  };
937
1102
  }
@@ -943,7 +1108,8 @@ const WRITABLE = [
943
1108
  "subtitle",
944
1109
  "nextLabel",
945
1110
  "submitLabel",
946
- "successMessage"
1111
+ "successMessage",
1112
+ "class"
947
1113
  ];
948
1114
  const slugify = (value) => value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "form";
949
1115
  function createFormsAdminController(strapi) {
@@ -962,6 +1128,15 @@ function createFormsAdminController(strapi) {
962
1128
  return null;
963
1129
  }
964
1130
  }
1131
+ async function uniqueSlug(name) {
1132
+ const base = slugify(name);
1133
+ let slug = base;
1134
+ for (let i = 2; ; i += 1) {
1135
+ const clash = await documents().findFirst({ filters: { slug } });
1136
+ if (!clash) return slug;
1137
+ slug = `${base}-${i}`;
1138
+ }
1139
+ }
965
1140
  function pickWritable(body) {
966
1141
  const data = {};
967
1142
  for (const key of WRITABLE) {
@@ -994,8 +1169,15 @@ function createFormsAdminController(strapi) {
994
1169
  fields: ["slug"]
995
1170
  });
996
1171
  const publishedIds = new Set(published.map((e) => e.documentId));
1172
+ const submissionCounts = await Promise.all(
1173
+ drafts.map(
1174
+ (entry) => strapi.documents(SUBMISSION_UID).count({
1175
+ filters: { form: entry.slug }
1176
+ })
1177
+ )
1178
+ );
997
1179
  ctx.body = {
998
- forms: drafts.map((entry) => ({
1180
+ forms: drafts.map((entry, i) => ({
999
1181
  documentId: entry.documentId,
1000
1182
  name: entry.name,
1001
1183
  slug: entry.slug,
@@ -1005,7 +1187,8 @@ function createFormsAdminController(strapi) {
1005
1187
  fields: (entry.definition?.steps ?? []).reduce(
1006
1188
  (n, step) => n + (step.fields?.length ?? 0),
1007
1189
  0
1008
- )
1190
+ ),
1191
+ submissions: submissionCounts[i] ?? 0
1009
1192
  }))
1010
1193
  };
1011
1194
  },
@@ -1027,13 +1210,7 @@ function createFormsAdminController(strapi) {
1027
1210
  const body = ctx.request.body ?? {};
1028
1211
  const name = typeof body.name === "string" ? body.name.trim() : "";
1029
1212
  if (!name) ctx.throw(400, "A form needs a name");
1030
- const base = slugify(name);
1031
- let slug = base;
1032
- for (let i = 2; ; i += 1) {
1033
- const clash = await documents().findFirst({ filters: { slug } });
1034
- if (!clash) break;
1035
- slug = `${base}-${i}`;
1036
- }
1213
+ const slug = await uniqueSlug(name);
1037
1214
  let definition;
1038
1215
  try {
1039
1216
  definition = requireValidDefinition(ctx, body.definition);
@@ -1202,6 +1379,117 @@ function createFormsAdminController(strapi) {
1202
1379
  if (!targetId) ctx.throw(404, "Source entry not found");
1203
1380
  ctx.body = { documentId: targetId, locales: imported };
1204
1381
  },
1382
+ /**
1383
+ * Forms of the connected HubSpot portal, offered for import. Needs the
1384
+ * `forms` read scope on the private app token — a token without it gets
1385
+ * a clear message rather than an empty list.
1386
+ */
1387
+ async listHubspotSources(ctx) {
1388
+ const { apiKey } = await resolveApiKey(strapi);
1389
+ if (!apiKey) {
1390
+ ctx.body = { configured: false, forms: [] };
1391
+ return;
1392
+ }
1393
+ try {
1394
+ ctx.body = { configured: true, forms: await listHubspotForms(apiKey) };
1395
+ } catch (err) {
1396
+ strapi.log.warn(`[hubspot] portal forms unavailable — ${err.message}`);
1397
+ ctx.throw(502, err.message || "Cannot reach HubSpot");
1398
+ }
1399
+ },
1400
+ /**
1401
+ * Converts one portal form into a plugin form draft, in the current
1402
+ * locale. Slug derived from the HubSpot name: re-importing the same form
1403
+ * overwrites the draft — never the published version, never the portal.
1404
+ * What the builder can't express is skipped and returned in `skipped`.
1405
+ */
1406
+ async runHubspotImport(ctx) {
1407
+ const formId = ctx.request.body?.formId;
1408
+ if (typeof formId !== "string" || !formId) ctx.throw(400, "formId is required");
1409
+ const { apiKey } = await resolveApiKey(strapi);
1410
+ if (!apiKey) ctx.throw(400, "No HubSpot API key configured");
1411
+ let converted;
1412
+ try {
1413
+ converted = convertHubspotForm(await fetchHubspotForm(apiKey, formId));
1414
+ } catch (err) {
1415
+ strapi.log.warn(`[hubspot] form import failed — ${err.message}`);
1416
+ ctx.throw(502, err.message || "Cannot reach HubSpot");
1417
+ return;
1418
+ }
1419
+ const name = converted.name || "HubSpot form";
1420
+ const { skipped, definition, ...meta } = converted;
1421
+ const slug = slugify(name);
1422
+ const existing = await documents().findFirst({
1423
+ filters: { slug }
1424
+ });
1425
+ let documentId;
1426
+ if (existing) {
1427
+ await documents().update({
1428
+ documentId: existing.documentId,
1429
+ locale: ctx.query.locale,
1430
+ data: { ...meta, name, definition }
1431
+ });
1432
+ documentId = existing.documentId;
1433
+ } else {
1434
+ const created = await documents().create({
1435
+ locale: ctx.query.locale,
1436
+ data: { ...meta, name, slug, definition }
1437
+ });
1438
+ documentId = created.documentId;
1439
+ }
1440
+ ctx.body = { documentId, skipped };
1441
+ },
1442
+ /**
1443
+ * Copies a form (every locale of its draft) into a new draft document —
1444
+ * the quickest way to A/B a variant or to start from an existing form.
1445
+ * Internal step/field ids are kept: they only need uniqueness per form.
1446
+ */
1447
+ async duplicate(ctx) {
1448
+ const sourceId = ctx.params.documentId;
1449
+ let locales = [void 0];
1450
+ try {
1451
+ const found = await strapi.plugin("i18n").service("locales").find();
1452
+ if (Array.isArray(found) && found.length) {
1453
+ locales = [...found].sort((a, b) => Number(b.isDefault ?? false) - Number(a.isDefault ?? false)).map((l) => l.code);
1454
+ }
1455
+ } catch {
1456
+ }
1457
+ let targetId = null;
1458
+ let slug = null;
1459
+ for (const locale of locales) {
1460
+ const source = await documents().findOne({
1461
+ documentId: sourceId,
1462
+ locale,
1463
+ status: "draft"
1464
+ });
1465
+ if (!source) continue;
1466
+ const name = `${source.name} (2)`;
1467
+ slug ??= await uniqueSlug(name);
1468
+ const data = {
1469
+ name,
1470
+ slug,
1471
+ title: source.title,
1472
+ subtitle: source.subtitle,
1473
+ nextLabel: source.nextLabel,
1474
+ submitLabel: source.submitLabel,
1475
+ successMessage: source.successMessage,
1476
+ class: source.class,
1477
+ definition: source.definition
1478
+ };
1479
+ if (targetId) {
1480
+ const { slug: _slug, name: _name, ...localized } = data;
1481
+ await documents().update({ documentId: targetId, locale, data: localized });
1482
+ } else {
1483
+ const created = await documents().create({
1484
+ locale,
1485
+ data
1486
+ });
1487
+ targetId = created.documentId;
1488
+ }
1489
+ }
1490
+ if (!targetId) ctx.throw(404, "Form not found");
1491
+ ctx.body = { documentId: targetId };
1492
+ },
1205
1493
  async remove(ctx) {
1206
1494
  await documents().delete({
1207
1495
  documentId: ctx.params.documentId
@@ -1211,6 +1499,45 @@ function createFormsAdminController(strapi) {
1211
1499
  }
1212
1500
  };
1213
1501
  }
1502
+ function fieldOrder(definition) {
1503
+ if (!definition) return [];
1504
+ return definition.steps.flatMap((step) => step.fields.map((field) => field.name));
1505
+ }
1506
+ const escapeCell = (value) => {
1507
+ if (value === null || value === void 0) return "";
1508
+ const text = String(value);
1509
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
1510
+ };
1511
+ function submissionsCsv(rows, knownFields) {
1512
+ const extra = /* @__PURE__ */ new Set();
1513
+ for (const row of rows) {
1514
+ for (const key of Object.keys(row.values ?? {})) {
1515
+ if (!knownFields.includes(key)) extra.add(key);
1516
+ }
1517
+ }
1518
+ const valueColumns = [...knownFields, ...[...extra].sort()];
1519
+ const header = [
1520
+ "submittedAt",
1521
+ ...valueColumns,
1522
+ "hubspotSynced",
1523
+ "locale",
1524
+ "pagePath",
1525
+ "source"
1526
+ ];
1527
+ const lines = [header.map(escapeCell).join(",")];
1528
+ for (const row of rows) {
1529
+ const cells = [
1530
+ row.createdAt ?? "",
1531
+ ...valueColumns.map((key) => row.values?.[key]),
1532
+ row.hubspotSynced ? "true" : "false",
1533
+ row.locale ?? "",
1534
+ row.meta?.pagePath ?? "",
1535
+ row.meta?.source ?? ""
1536
+ ];
1537
+ lines.push(cells.map(escapeCell).join(","));
1538
+ }
1539
+ return lines.join("\r\n");
1540
+ }
1214
1541
  const HS_BASE = "https://api.hubapi.com";
1215
1542
  const FAILURE_UID = "plugin::hubspot.failure";
1216
1543
  const RETRY_DELAYS_MS = [500, 2e3];
@@ -1421,6 +1748,49 @@ const controllers = {
1421
1748
  }
1422
1749
  }),
1423
1750
  formsAdmin: ({ strapi }) => createFormsAdminController(strapi),
1751
+ submissions: ({ strapi }) => ({
1752
+ /** Paged submissions, newest first, optionally narrowed to one form. */
1753
+ async list(ctx) {
1754
+ const filters = ctx.query.form ? { form: ctx.query.form } : void 0;
1755
+ const page = Math.max(1, Number(ctx.query.page) || 1);
1756
+ const pageSize = Math.min(100, Math.max(1, Number(ctx.query.pageSize) || 20));
1757
+ const [rows, total] = await Promise.all([
1758
+ strapi.documents(SUBMISSION_UID).findMany({
1759
+ filters,
1760
+ sort: "createdAt:desc",
1761
+ limit: pageSize,
1762
+ start: (page - 1) * pageSize
1763
+ }),
1764
+ strapi.documents(SUBMISSION_UID).count({ filters })
1765
+ ]);
1766
+ ctx.body = { submissions: rows, total, page, pageSize };
1767
+ },
1768
+ /**
1769
+ * Whole history of one form as CSV, definition columns first. Returned as
1770
+ * JSON (`{ csv, filename }`): the admin fetch client speaks JSON, and the
1771
+ * page turns it into a download — no auth-header gymnastics.
1772
+ */
1773
+ async export(ctx) {
1774
+ const slug = ctx.query.form;
1775
+ if (!slug) ctx.throw(400, "form is required");
1776
+ const [rows, entry] = await Promise.all([
1777
+ strapi.documents(SUBMISSION_UID).findMany({
1778
+ filters: { form: slug },
1779
+ sort: "createdAt:desc",
1780
+ limit: 1e4
1781
+ }),
1782
+ strapi.documents(FORM_UID).findFirst({
1783
+ filters: { slug },
1784
+ status: "draft"
1785
+ })
1786
+ ]);
1787
+ ctx.body = {
1788
+ csv: submissionsCsv(rows, fieldOrder(entry?.definition)),
1789
+ filename: `${slug}-submissions-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`,
1790
+ total: rows.length
1791
+ };
1792
+ }
1793
+ }),
1424
1794
  forms: ({ strapi }) => ({
1425
1795
  /** Published form for the host frontend — CRM mapping stripped. */
1426
1796
  async findOne(ctx) {
@@ -1502,11 +1872,16 @@ const routes = {
1502
1872
  adminRoute("POST", "/builder/forms/:documentId/publish", "formsAdmin.publish", [FORMS_ACTION]),
1503
1873
  adminRoute("POST", "/builder/forms/:documentId/unpublish", "formsAdmin.unpublish", [FORMS_ACTION]),
1504
1874
  adminRoute("DELETE", "/builder/forms/:documentId", "formsAdmin.remove", [FORMS_ACTION]),
1875
+ adminRoute("POST", "/builder/forms/:documentId/duplicate", "formsAdmin.duplicate", [FORMS_ACTION]),
1505
1876
  // No FORMS_ACTION: editors pick forms from the Content Manager, like
1506
1877
  // they pick properties — the builder itself stays gated.
1507
1878
  adminRoute("GET", "/forms-options", "formsAdmin.options"),
1508
1879
  adminRoute("GET", "/builder/import/sources", "formsAdmin.listSources", [FORMS_ACTION]),
1509
- adminRoute("POST", "/builder/import", "formsAdmin.runImport", [FORMS_ACTION])
1880
+ adminRoute("POST", "/builder/import", "formsAdmin.runImport", [FORMS_ACTION]),
1881
+ adminRoute("GET", "/builder/import/hubspot", "formsAdmin.listHubspotSources", [FORMS_ACTION]),
1882
+ adminRoute("POST", "/builder/import/hubspot", "formsAdmin.runHubspotImport", [FORMS_ACTION]),
1883
+ adminRoute("GET", "/builder/submissions", "submissions.list", [FORMS_ACTION]),
1884
+ adminRoute("GET", "/builder/submissions/export", "submissions.export", [FORMS_ACTION])
1510
1885
  ]
1511
1886
  },
1512
1887
  // Public form delivery + submission, under /api/hubspot/…. Like any