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