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