strapi-plugin-hubspot 0.14.0 → 0.16.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 +22 -0
- package/README.md +39 -15
- package/dist/_chunks/{Forms-ws6d8rdC.mjs → Forms-6EoEYzAc.mjs} +26 -5
- package/dist/_chunks/{Forms-Cch-7zei.js → Forms-BW_zGc1P.js} +26 -5
- package/dist/_chunks/{HubspotFormInput-18pWTITT.mjs → HubspotFormInput-V5w4y1fb.mjs} +1 -1
- package/dist/_chunks/{HubspotFormInput-M2l_8w_e.js → HubspotFormInput-oMhm1UJm.js} +1 -1
- package/dist/_chunks/{HubspotObjectInput-BDO99qXs.js → HubspotObjectInput-Bmo6YQgs.js} +3 -3
- package/dist/_chunks/{HubspotObjectInput-DshusXZP.mjs → HubspotObjectInput-DhsnSguG.mjs} +3 -3
- package/dist/_chunks/{HubspotPropertyInput-C-kHDgwx.mjs → HubspotPropertyInput-CrYP4pc0.mjs} +3 -3
- package/dist/_chunks/{HubspotPropertyInput-XGnH2tHn.js → HubspotPropertyInput-luFfnxqy.js} +3 -3
- package/dist/_chunks/{Settings-CRnsu6ob.js → Settings-B3A22i7Z.js} +204 -35
- package/dist/_chunks/{Settings-Bm7WkzK1.mjs → Settings-CjcWj9I6.mjs} +205 -36
- package/dist/_chunks/{en-BXMU5CjT.js → en-C7ls2EUl.js} +28 -6
- package/dist/_chunks/{en-DKV-8FWP.mjs → en-D6GHgMOt.mjs} +28 -6
- package/dist/_chunks/{fr-GB6T3ZyU.mjs → fr-CfohMY8n.mjs} +27 -5
- package/dist/_chunks/{fr-Dx4XmZ4X.js → fr-pOP8ZXqM.js} +27 -5
- package/dist/_chunks/{index-CPVfElYB.mjs → index-7cL9PRh1.mjs} +6 -6
- package/dist/_chunks/{index-BlaG8oIq.js → index-CodpLKZx.js} +6 -6
- package/dist/_chunks/{objectLabels-CPc21Tdd.js → objectLabels-CSWGIgNb.js} +1 -1
- package/dist/_chunks/{objectLabels-6OUnIDB5.mjs → objectLabels-Dnj14fsp.mjs} +1 -1
- package/dist/_chunks/{useHubspotSchema-qCUs2gIX.mjs → useHubspotSchema-BsmQgJgx.mjs} +1 -1
- package/dist/_chunks/{useHubspotSchema-0MvCM1AC.js → useHubspotSchema-DiRN3HrN.js} +1 -1
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +301 -15
- package/dist/server/index.mjs +301 -15
- package/dist/server/src/__tests__/formSync.test.d.ts +1 -0
- package/dist/server/src/__tests__/settings.test.d.ts +1 -0
- package/dist/server/src/formSync.d.ts +37 -0
- package/dist/server/src/forms.d.ts +6 -8
- package/dist/server/src/hsforms.d.ts +8 -0
- package/dist/server/src/importHubspot.d.ts +4 -0
- package/dist/server/src/index.d.ts +11 -1
- package/dist/server/src/settings.d.ts +65 -5
- package/package.json +1 -1
package/dist/server/index.mjs
CHANGED
|
@@ -158,6 +158,9 @@ function resolveObjects(configured) {
|
|
|
158
158
|
return out;
|
|
159
159
|
}
|
|
160
160
|
const ENV_VAR = "HUBSPOT_API_KEY";
|
|
161
|
+
const ENV_PORTAL = "HUBSPOT_PORTAL_ID";
|
|
162
|
+
const ENV_REGION = "HUBSPOT_REGION";
|
|
163
|
+
const ENV_DEFAULT_FORM = "HUBSPOT_DEFAULT_FORM_ID";
|
|
161
164
|
const store = (strapi) => strapi.store({ type: "plugin", name: "hubspot" });
|
|
162
165
|
async function getStoredSettings(strapi) {
|
|
163
166
|
return await store(strapi).get({ key: "settings" }) ?? {};
|
|
@@ -166,6 +169,33 @@ async function setStoredSettings(strapi, value) {
|
|
|
166
169
|
await store(strapi).set({ key: "settings", value });
|
|
167
170
|
clearCache();
|
|
168
171
|
}
|
|
172
|
+
const first = (values, fallback = "") => {
|
|
173
|
+
for (const item of values) {
|
|
174
|
+
const value = (item.value ?? "").trim();
|
|
175
|
+
if (value) return { value, source: item.source };
|
|
176
|
+
}
|
|
177
|
+
return { value: fallback, source: null };
|
|
178
|
+
};
|
|
179
|
+
const MODES = /* @__PURE__ */ new Set(["auto", "crm", "forms"]);
|
|
180
|
+
function asSubmissionMode(value) {
|
|
181
|
+
return typeof value === "string" && MODES.has(value) ? value : void 0;
|
|
182
|
+
}
|
|
183
|
+
function asBool(value) {
|
|
184
|
+
return typeof value === "boolean" ? value : void 0;
|
|
185
|
+
}
|
|
186
|
+
function formsCfg(strapi) {
|
|
187
|
+
return strapi.plugin("hubspot").config("forms", {}) ?? {};
|
|
188
|
+
}
|
|
189
|
+
function policyFrom(stored, forms) {
|
|
190
|
+
return {
|
|
191
|
+
submissionMode: asSubmissionMode(stored.submissionMode) ?? asSubmissionMode(forms.submissionMode) ?? "auto",
|
|
192
|
+
writeExtraProperties: asBool(stored.writeExtraProperties) ?? asBool(forms.writeExtraProperties) ?? true,
|
|
193
|
+
syncFieldsOnPublish: asBool(stored.syncFieldsOnPublish) ?? asBool(forms.syncFieldsOnPublish) ?? false
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
async function resolvePolicy(strapi) {
|
|
197
|
+
return policyFrom(await getStoredSettings(strapi), formsCfg(strapi));
|
|
198
|
+
}
|
|
169
199
|
async function resolveApiKey(strapi) {
|
|
170
200
|
const stored = await getStoredSettings(strapi);
|
|
171
201
|
if (stored.apiKey) return { apiKey: stored.apiKey, source: "settings" };
|
|
@@ -175,14 +205,83 @@ async function resolveApiKey(strapi) {
|
|
|
175
205
|
if (fromEnv) return { apiKey: fromEnv, source: "env" };
|
|
176
206
|
return { apiKey: "", source: null };
|
|
177
207
|
}
|
|
178
|
-
async function
|
|
179
|
-
const
|
|
208
|
+
async function resolveAccount(strapi) {
|
|
209
|
+
const resolved = await resolveAccountSources(strapi);
|
|
210
|
+
return {
|
|
211
|
+
portalId: resolved.portalId,
|
|
212
|
+
region: resolved.region || "eu1",
|
|
213
|
+
defaultFormId: resolved.defaultFormId
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
async function resolveAccountSources(strapi) {
|
|
217
|
+
const stored = await getStoredSettings(strapi);
|
|
218
|
+
const forms = formsCfg(strapi);
|
|
219
|
+
const portal = first([
|
|
220
|
+
{ value: stored.portalId, source: "settings" },
|
|
221
|
+
{ value: strapi.plugin("hubspot").config("portalId", ""), source: "config" },
|
|
222
|
+
{ value: process.env[ENV_PORTAL], source: "env" }
|
|
223
|
+
]);
|
|
224
|
+
const region = first(
|
|
225
|
+
[
|
|
226
|
+
{ value: stored.region, source: "settings" },
|
|
227
|
+
{ value: strapi.plugin("hubspot").config("region", ""), source: "config" },
|
|
228
|
+
{ value: process.env[ENV_REGION], source: "env" }
|
|
229
|
+
],
|
|
230
|
+
"eu1"
|
|
231
|
+
);
|
|
232
|
+
const form = first([
|
|
233
|
+
{ value: stored.defaultFormId, source: "settings" },
|
|
234
|
+
{ value: forms.defaultFormId, source: "config" },
|
|
235
|
+
{ value: process.env[ENV_DEFAULT_FORM], source: "env" }
|
|
236
|
+
]);
|
|
237
|
+
const { apiKey, source: keySource } = await resolveApiKey(strapi);
|
|
238
|
+
const policy = policyFrom(stored, forms);
|
|
180
239
|
return {
|
|
181
240
|
configured: Boolean(apiKey),
|
|
182
|
-
keySource
|
|
183
|
-
hint: apiKey ? `…${apiKey.slice(-4)}` : ""
|
|
241
|
+
keySource,
|
|
242
|
+
hint: apiKey ? `…${apiKey.slice(-4)}` : "",
|
|
243
|
+
portalId: portal.value,
|
|
244
|
+
region: region.value || "eu1",
|
|
245
|
+
defaultFormId: form.value,
|
|
246
|
+
portalSource: portal.source,
|
|
247
|
+
regionSource: region.source,
|
|
248
|
+
formSource: form.source,
|
|
249
|
+
...policy
|
|
184
250
|
};
|
|
185
251
|
}
|
|
252
|
+
async function publicSettings(strapi) {
|
|
253
|
+
return resolveAccountSources(strapi);
|
|
254
|
+
}
|
|
255
|
+
async function patchStoredSettings(strapi, body) {
|
|
256
|
+
const current = await getStoredSettings(strapi);
|
|
257
|
+
const next = { ...current };
|
|
258
|
+
if (typeof body.apiKey === "string" && body.apiKey.trim()) {
|
|
259
|
+
next.apiKey = body.apiKey.trim();
|
|
260
|
+
}
|
|
261
|
+
if ("portalId" in body) next.portalId = String(body.portalId ?? "").trim();
|
|
262
|
+
if ("region" in body) {
|
|
263
|
+
const region = String(body.region ?? "").trim().toLowerCase();
|
|
264
|
+
next.region = region || "eu1";
|
|
265
|
+
}
|
|
266
|
+
if ("defaultFormId" in body) next.defaultFormId = String(body.defaultFormId ?? "").trim();
|
|
267
|
+
if ("submissionMode" in body) {
|
|
268
|
+
const mode = asSubmissionMode(body.submissionMode);
|
|
269
|
+
if (mode) next.submissionMode = mode;
|
|
270
|
+
}
|
|
271
|
+
if ("writeExtraProperties" in body && typeof body.writeExtraProperties === "boolean") {
|
|
272
|
+
next.writeExtraProperties = body.writeExtraProperties;
|
|
273
|
+
}
|
|
274
|
+
if ("syncFieldsOnPublish" in body && typeof body.syncFieldsOnPublish === "boolean") {
|
|
275
|
+
next.syncFieldsOnPublish = body.syncFieldsOnPublish;
|
|
276
|
+
}
|
|
277
|
+
await setStoredSettings(strapi, next);
|
|
278
|
+
}
|
|
279
|
+
async function clearStoredApiKey(strapi) {
|
|
280
|
+
const current = await getStoredSettings(strapi);
|
|
281
|
+
const { apiKey: _apiKey, ...rest } = current;
|
|
282
|
+
const empty = !rest.portalId && !rest.region && !rest.defaultFormId;
|
|
283
|
+
await setStoredSettings(strapi, empty ? null : rest);
|
|
284
|
+
}
|
|
186
285
|
function collectMappings(node, target, found = []) {
|
|
187
286
|
if (Array.isArray(node)) {
|
|
188
287
|
for (const item of node) collectMappings(item, target, found);
|
|
@@ -1456,6 +1555,96 @@ function resolveSubmission(definition, rawValues) {
|
|
|
1456
1555
|
const unknown = Object.keys(rawValues).filter((key) => !declared.has(key));
|
|
1457
1556
|
return { values, hidden, missingRequired, unknown };
|
|
1458
1557
|
}
|
|
1558
|
+
const OBJECT_TYPE_ID = {
|
|
1559
|
+
contact: "0-1",
|
|
1560
|
+
company: "0-2"
|
|
1561
|
+
};
|
|
1562
|
+
function leftoverContactProps(contact, sentFieldNames) {
|
|
1563
|
+
const sent = new Set(sentFieldNames);
|
|
1564
|
+
const extra = {};
|
|
1565
|
+
for (const [key, value] of Object.entries(contact)) {
|
|
1566
|
+
if (key === "email" || sent.has(key) || value === "") continue;
|
|
1567
|
+
extra[key] = value;
|
|
1568
|
+
}
|
|
1569
|
+
return extra;
|
|
1570
|
+
}
|
|
1571
|
+
function mappedContactFields(definition) {
|
|
1572
|
+
const out = [];
|
|
1573
|
+
for (const step of definition.steps ?? []) {
|
|
1574
|
+
for (const fld of step.fields ?? []) {
|
|
1575
|
+
if (fld.type === "company") continue;
|
|
1576
|
+
const mapping = fld.hubspot ?? {};
|
|
1577
|
+
const object = mapping.object?.trim() || "contact";
|
|
1578
|
+
if (object !== "contact") continue;
|
|
1579
|
+
const property = mapping.property?.trim() || fld.name;
|
|
1580
|
+
if (!property) continue;
|
|
1581
|
+
out.push({
|
|
1582
|
+
name: fld.name,
|
|
1583
|
+
label: typeof fld.label === "string" ? fld.label : void 0,
|
|
1584
|
+
type: fld.type,
|
|
1585
|
+
required: Boolean(fld.required),
|
|
1586
|
+
object,
|
|
1587
|
+
property,
|
|
1588
|
+
options: fld.options
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return out;
|
|
1593
|
+
}
|
|
1594
|
+
function missingMappedFields(definition, existingNames) {
|
|
1595
|
+
const existing = new Set(existingNames);
|
|
1596
|
+
return mappedContactFields(definition).filter((field) => !existing.has(field.property));
|
|
1597
|
+
}
|
|
1598
|
+
function hsFieldType(type, options) {
|
|
1599
|
+
switch (type) {
|
|
1600
|
+
case "email":
|
|
1601
|
+
return "email";
|
|
1602
|
+
case "tel":
|
|
1603
|
+
return "phone";
|
|
1604
|
+
case "textarea":
|
|
1605
|
+
return "multi_line_text";
|
|
1606
|
+
case "number":
|
|
1607
|
+
return "number";
|
|
1608
|
+
case "select":
|
|
1609
|
+
return "dropdown";
|
|
1610
|
+
case "radio":
|
|
1611
|
+
return "radio";
|
|
1612
|
+
case "checkbox":
|
|
1613
|
+
return Array.isArray(options) && options.length ? "multiple_checkboxes" : "booleancheckbox";
|
|
1614
|
+
default:
|
|
1615
|
+
return "single_line_text";
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
function toHsFormField(field) {
|
|
1619
|
+
const options = (field.options ?? []).filter((option) => option.value).map((option) => ({
|
|
1620
|
+
value: String(option.value),
|
|
1621
|
+
label: option.label || String(option.value)
|
|
1622
|
+
}));
|
|
1623
|
+
return {
|
|
1624
|
+
objectTypeId: OBJECT_TYPE_ID[field.object || "contact"] || "0-1",
|
|
1625
|
+
name: field.property,
|
|
1626
|
+
label: field.label || field.property,
|
|
1627
|
+
required: Boolean(field.required),
|
|
1628
|
+
hidden: false,
|
|
1629
|
+
fieldType: hsFieldType(field.type, field.options),
|
|
1630
|
+
...options.length ? { options } : {}
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
function appendFieldGroups(existing, extra) {
|
|
1634
|
+
return [
|
|
1635
|
+
...existing.map((group) => ({
|
|
1636
|
+
groupType: group.groupType || "default_group",
|
|
1637
|
+
richTextType: group.richTextType || "text",
|
|
1638
|
+
...group.richText ? { richText: group.richText } : {},
|
|
1639
|
+
fields: group.fields ?? []
|
|
1640
|
+
})),
|
|
1641
|
+
...extra.map((field) => ({
|
|
1642
|
+
groupType: "default_group",
|
|
1643
|
+
richTextType: "text",
|
|
1644
|
+
fields: [toHsFormField(field)]
|
|
1645
|
+
}))
|
|
1646
|
+
];
|
|
1647
|
+
}
|
|
1459
1648
|
const HS_BASE$2 = "https://api.hubapi.com";
|
|
1460
1649
|
const OBJECT_BY_TYPE_ID = {
|
|
1461
1650
|
"0-1": "contact",
|
|
@@ -1619,6 +1808,23 @@ async function listHubspotForms(apiKey) {
|
|
|
1619
1808
|
async function fetchHubspotForm(apiKey, formId) {
|
|
1620
1809
|
return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
|
|
1621
1810
|
}
|
|
1811
|
+
async function patchHubspotForm(apiKey, formId, body) {
|
|
1812
|
+
const res = await fetch(`${HS_BASE$2}/marketing/v3/forms/${encodeURIComponent(formId)}`, {
|
|
1813
|
+
method: "PATCH",
|
|
1814
|
+
headers: {
|
|
1815
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1816
|
+
"Content-Type": "application/json"
|
|
1817
|
+
},
|
|
1818
|
+
body: JSON.stringify(body)
|
|
1819
|
+
});
|
|
1820
|
+
if (!res.ok) {
|
|
1821
|
+
const payload = await res.json().catch(() => ({}));
|
|
1822
|
+
throw Object.assign(new Error(payload.message || `HubSpot ${res.status}`), {
|
|
1823
|
+
status: res.status
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
return await res.json();
|
|
1827
|
+
}
|
|
1622
1828
|
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1623
1829
|
const PORTAL = /^\d{1,12}$/;
|
|
1624
1830
|
const HUTK = /^[a-f0-9]{8,128}$/i;
|
|
@@ -1667,6 +1873,10 @@ function formFieldNames(raw) {
|
|
|
1667
1873
|
return names;
|
|
1668
1874
|
}
|
|
1669
1875
|
const shapeCache = /* @__PURE__ */ new Map();
|
|
1876
|
+
function clearFormShapeCache(formGuid) {
|
|
1877
|
+
if (formGuid) shapeCache.delete(formGuid);
|
|
1878
|
+
else shapeCache.clear();
|
|
1879
|
+
}
|
|
1670
1880
|
async function loadFormShape(apiKey, formGuid) {
|
|
1671
1881
|
const cached = shapeCache.get(formGuid);
|
|
1672
1882
|
if (cached && Date.now() - cached.at < SHAPE_TTL_MS) {
|
|
@@ -1762,6 +1972,16 @@ async function submitMarketingForm(input) {
|
|
|
1762
1972
|
}
|
|
1763
1973
|
return result;
|
|
1764
1974
|
}
|
|
1975
|
+
async function syncFieldsToHubspotForm(apiKey, formGuid, definition) {
|
|
1976
|
+
const raw = await fetchHubspotForm(apiKey, formGuid);
|
|
1977
|
+
const extra = missingMappedFields(definition, formFieldNames(raw));
|
|
1978
|
+
if (!extra.length) return [];
|
|
1979
|
+
await patchHubspotForm(apiKey, formGuid, {
|
|
1980
|
+
fieldGroups: appendFieldGroups(raw.fieldGroups ?? [], extra)
|
|
1981
|
+
});
|
|
1982
|
+
clearFormShapeCache(formGuid);
|
|
1983
|
+
return extra.map((field) => field.property);
|
|
1984
|
+
}
|
|
1765
1985
|
function publicForm(entry) {
|
|
1766
1986
|
return {
|
|
1767
1987
|
slug: entry.slug,
|
|
@@ -2129,6 +2349,7 @@ function createFormsService(strapi, _opts = {}) {
|
|
|
2129
2349
|
}
|
|
2130
2350
|
}
|
|
2131
2351
|
let usedFormsApi = false;
|
|
2352
|
+
let sentFieldNames = [];
|
|
2132
2353
|
if (email && apiKey) {
|
|
2133
2354
|
const groups = groupByObject(form.definition, resolution.values);
|
|
2134
2355
|
for (const [object, props] of Object.entries(companyGroups)) {
|
|
@@ -2136,20 +2357,25 @@ function createFormsService(strapi, _opts = {}) {
|
|
|
2136
2357
|
}
|
|
2137
2358
|
const partition = await partitionBySchema(apiKey, groups);
|
|
2138
2359
|
rejected = partition.rejected;
|
|
2139
|
-
const
|
|
2140
|
-
const
|
|
2360
|
+
const contactProps = stringProps(partition.accepted.contact ?? {});
|
|
2361
|
+
const account = await resolveAccount(strapi);
|
|
2362
|
+
const policy = await resolvePolicy(strapi);
|
|
2363
|
+
const portalId = sanitizePortalId(account.portalId);
|
|
2364
|
+
const region = account.region || "eu1";
|
|
2141
2365
|
const ownGuid = typeof form.hubspotFormId === "string" ? form.hubspotFormId.trim() : "";
|
|
2142
|
-
const formGuid = isFormGuid(ownGuid) ? ownGuid : isFormGuid(
|
|
2143
|
-
|
|
2366
|
+
const formGuid = isFormGuid(ownGuid) ? ownGuid : isFormGuid(account.defaultFormId) ? account.defaultFormId.trim() : "";
|
|
2367
|
+
const tryFormsApi = policy.submissionMode !== "crm" && Boolean(portalId && formGuid);
|
|
2368
|
+
if (tryFormsApi && portalId && formGuid) {
|
|
2144
2369
|
try {
|
|
2145
2370
|
const shape = await loadFormShape(apiKey, formGuid);
|
|
2146
2371
|
const fields = filterToFormFields(
|
|
2147
|
-
toHsFields({ ...
|
|
2372
|
+
toHsFields({ ...contactProps, email }),
|
|
2148
2373
|
shape?.names ?? null
|
|
2149
2374
|
);
|
|
2150
2375
|
if (fields.length && !fields.some((f) => f.name === "email")) {
|
|
2151
2376
|
fields.unshift({ name: "email", value: email });
|
|
2152
2377
|
}
|
|
2378
|
+
sentFieldNames = fields.map((f) => f.name);
|
|
2153
2379
|
const submitted = await submitMarketingForm({
|
|
2154
2380
|
apiKey,
|
|
2155
2381
|
portalId,
|
|
@@ -2186,12 +2412,28 @@ function createFormsService(strapi, _opts = {}) {
|
|
|
2186
2412
|
const result = await strapi.plugin("hubspot").service("submit").upsert({
|
|
2187
2413
|
object: "contact",
|
|
2188
2414
|
idProperty: "email",
|
|
2189
|
-
properties: { ...
|
|
2415
|
+
properties: { ...contactProps, email }
|
|
2190
2416
|
});
|
|
2191
2417
|
if (result.ok) {
|
|
2192
2418
|
hubspotSynced = true;
|
|
2193
2419
|
contactId = result.id;
|
|
2194
2420
|
}
|
|
2421
|
+
} else if (policy.writeExtraProperties) {
|
|
2422
|
+
const extra = leftoverContactProps(contactProps, sentFieldNames);
|
|
2423
|
+
if (Object.keys(extra).length) {
|
|
2424
|
+
try {
|
|
2425
|
+
const result = await strapi.plugin("hubspot").service("submit").upsert({
|
|
2426
|
+
object: "contact",
|
|
2427
|
+
idProperty: "email",
|
|
2428
|
+
properties: { ...extra, email }
|
|
2429
|
+
});
|
|
2430
|
+
if (result.ok) contactId = result.id ?? contactId;
|
|
2431
|
+
} catch (err) {
|
|
2432
|
+
strapi.log.warn(
|
|
2433
|
+
`[hubspot] leftover contact properties failed (conversion OK) — ${err.message}`
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2195
2437
|
}
|
|
2196
2438
|
const domain = corporateDomain(email);
|
|
2197
2439
|
const companyProps = partition.accepted.company ?? {};
|
|
@@ -2490,7 +2732,32 @@ function createFormsAdminController(strapi) {
|
|
|
2490
2732
|
documentId: ctx.params.documentId,
|
|
2491
2733
|
locale: ctx.query.locale
|
|
2492
2734
|
});
|
|
2493
|
-
|
|
2735
|
+
const policy = await resolvePolicy(strapi);
|
|
2736
|
+
let addedFields = [];
|
|
2737
|
+
let syncWarning;
|
|
2738
|
+
if (policy.syncFieldsOnPublish) {
|
|
2739
|
+
const { apiKey } = await resolveApiKey(strapi);
|
|
2740
|
+
const account = await resolveAccount(strapi);
|
|
2741
|
+
const ownGuid = typeof entry.hubspotFormId === "string" ? entry.hubspotFormId.trim() : "";
|
|
2742
|
+
const formGuid = isFormGuid(ownGuid) ? ownGuid : isFormGuid(account.defaultFormId) ? account.defaultFormId.trim() : "";
|
|
2743
|
+
if (apiKey && formGuid) {
|
|
2744
|
+
try {
|
|
2745
|
+
addedFields = await syncFieldsToHubspotForm(
|
|
2746
|
+
apiKey,
|
|
2747
|
+
formGuid,
|
|
2748
|
+
entry.definition ?? EMPTY_DEFINITION
|
|
2749
|
+
);
|
|
2750
|
+
} catch (err) {
|
|
2751
|
+
syncWarning = err.message;
|
|
2752
|
+
strapi.log.warn(`[hubspot] field sync on publish failed — ${syncWarning}`);
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
ctx.body = {
|
|
2757
|
+
ok: true,
|
|
2758
|
+
...addedFields.length ? { addedFields } : {},
|
|
2759
|
+
...syncWarning ? { syncWarning } : {}
|
|
2760
|
+
};
|
|
2494
2761
|
},
|
|
2495
2762
|
async unpublish(ctx) {
|
|
2496
2763
|
await documents().unpublish({
|
|
@@ -2954,10 +3221,20 @@ const config = {
|
|
|
2954
3221
|
// submissions go through the marketing Forms API);
|
|
2955
3222
|
// - defaultFormId: portal-wide marketing form GUID when a builder form
|
|
2956
3223
|
// doesn't set its own — swap with the portal at production cutover.
|
|
3224
|
+
// - submissionMode / writeExtraProperties / syncFieldsOnPublish: see
|
|
3225
|
+
// Settings → HubSpot; each install picks its HubSpot will.
|
|
2957
3226
|
forms: {
|
|
2958
3227
|
companyFromDomain: true,
|
|
2959
3228
|
timelineNote: true,
|
|
2960
3229
|
defaultFormId: "",
|
|
3230
|
+
// How submissions reach HubSpot. `auto` = Forms API when a GUID is set,
|
|
3231
|
+
// else CRM upsert. `crm` skips conversions. `forms` prefers the Forms API.
|
|
3232
|
+
submissionMode: "auto",
|
|
3233
|
+
// After a conversion, CRM-write mapped contact props the HubSpot form dropped.
|
|
3234
|
+
writeExtraProperties: true,
|
|
3235
|
+
// On publish, PATCH missing mapped contact fields onto the HubSpot form.
|
|
3236
|
+
// Off by default: HubSpot-first installs must not have Strapi mutate forms.
|
|
3237
|
+
syncFieldsOnPublish: false,
|
|
2961
3238
|
// Per-IP brakes on the public routes; 0 disables one.
|
|
2962
3239
|
rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
|
|
2963
3240
|
}
|
|
@@ -3140,15 +3417,24 @@ const controllers = {
|
|
|
3140
3417
|
},
|
|
3141
3418
|
settings: ({ strapi }) => ({
|
|
3142
3419
|
async get(ctx) {
|
|
3143
|
-
|
|
3420
|
+
const settings = await publicSettings(strapi);
|
|
3421
|
+
let forms = [];
|
|
3422
|
+
const { apiKey } = await resolveApiKey(strapi);
|
|
3423
|
+
if (apiKey) {
|
|
3424
|
+
try {
|
|
3425
|
+
forms = await listHubspotForms(apiKey);
|
|
3426
|
+
} catch {
|
|
3427
|
+
forms = [];
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
ctx.body = { ...settings, forms };
|
|
3144
3431
|
},
|
|
3145
3432
|
async update(ctx) {
|
|
3146
|
-
|
|
3147
|
-
await setStoredSettings(strapi, apiKey ? { apiKey } : null);
|
|
3433
|
+
await patchStoredSettings(strapi, ctx.request.body ?? {});
|
|
3148
3434
|
ctx.body = await publicSettings(strapi);
|
|
3149
3435
|
},
|
|
3150
3436
|
async reset(ctx) {
|
|
3151
|
-
await
|
|
3437
|
+
await clearStoredApiKey(strapi);
|
|
3152
3438
|
ctx.body = await publicSettings(strapi);
|
|
3153
3439
|
}
|
|
3154
3440
|
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy helpers for HubSpot form field sync and leftover CRM writes.
|
|
3
|
+
*
|
|
4
|
+
* Two independent user wills, neither assumed:
|
|
5
|
+
* - Strapi-first: mapped contact fields missing on the HubSpot form can be
|
|
6
|
+
* PATCHed onto it at publish time (opt-in).
|
|
7
|
+
* - HubSpot-first: extras must not 400 the Forms API submit, but they can
|
|
8
|
+
* still land on the contact via a CRM upsert of the leftovers.
|
|
9
|
+
*
|
|
10
|
+
* CRM properties are never created here — the picker only maps to ones that
|
|
11
|
+
* already exist on the portal.
|
|
12
|
+
*/
|
|
13
|
+
import type { FormDefinition } from "./conditions";
|
|
14
|
+
export interface MappedFormField {
|
|
15
|
+
name: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
type: string;
|
|
18
|
+
required?: boolean;
|
|
19
|
+
object?: string;
|
|
20
|
+
property: string;
|
|
21
|
+
options?: {
|
|
22
|
+
value?: string;
|
|
23
|
+
label?: string;
|
|
24
|
+
}[];
|
|
25
|
+
}
|
|
26
|
+
export declare function leftoverContactProps(contact: Record<string, string>, sentFieldNames: Iterable<string>): Record<string, string>;
|
|
27
|
+
export declare function mappedContactFields(definition: FormDefinition): MappedFormField[];
|
|
28
|
+
export declare function missingMappedFields(definition: FormDefinition, existingNames: Iterable<string>): MappedFormField[];
|
|
29
|
+
export declare function hsFieldType(type: string, options?: unknown[]): string;
|
|
30
|
+
export declare function toHsFormField(field: MappedFormField): Record<string, unknown>;
|
|
31
|
+
export interface RawFieldGroupLike {
|
|
32
|
+
groupType?: string;
|
|
33
|
+
richTextType?: string;
|
|
34
|
+
richText?: string;
|
|
35
|
+
fields?: unknown[];
|
|
36
|
+
}
|
|
37
|
+
export declare function appendFieldGroups(existing: RawFieldGroupLike[], extra: MappedFormField[]): Record<string, unknown>[];
|
|
@@ -2,14 +2,12 @@
|
|
|
2
2
|
* The form-builder server side: shaping a published form for the public API,
|
|
3
3
|
* and turning a submission into a HubSpot marketing-form conversion.
|
|
4
4
|
*
|
|
5
|
-
* The pipeline
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* on that fallback. Company sync is best-effort; the form submission is
|
|
12
|
-
* the lead.
|
|
5
|
+
* The pipeline is a policy the install picks in Settings: native Forms API
|
|
6
|
+
* conversion (with leftover CRM writes for mapped fields the HubSpot form
|
|
7
|
+
* dropped), CRM upsert only, or automatic (Forms API when a GUID is linked).
|
|
8
|
+
* Company find-or-create still runs after a successful submit. Timeline notes
|
|
9
|
+
* are opt-in and only used on the CRM-upsert path. The form submission is
|
|
10
|
+
* the lead — a CRM outage never loses it.
|
|
13
11
|
*/
|
|
14
12
|
import type { Core } from "@strapi/strapi";
|
|
15
13
|
import { type FormDefinition, type Primitive } from "./conditions";
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Portal id, region and form GUIDs are configuration — a test portal today,
|
|
11
11
|
* production tomorrow, no code change.
|
|
12
12
|
*/
|
|
13
|
+
import type { FormDefinition } from "./conditions";
|
|
13
14
|
export declare function isFormGuid(value: string): boolean;
|
|
14
15
|
export declare function sanitizeHutk(value: unknown): string | undefined;
|
|
15
16
|
export declare function sanitizePortalId(value: unknown): string | undefined;
|
|
@@ -58,6 +59,7 @@ export interface FormSubmitInput {
|
|
|
58
59
|
};
|
|
59
60
|
formHasLegalConsent?: boolean;
|
|
60
61
|
}
|
|
62
|
+
export declare function clearFormShapeCache(formGuid?: string): void;
|
|
61
63
|
export declare function loadFormShape(apiKey: string, formGuid: string): Promise<{
|
|
62
64
|
names: Set<string>;
|
|
63
65
|
hasLegalConsent: boolean;
|
|
@@ -73,3 +75,9 @@ export declare function submitMarketingForm(input: FormSubmitInput): Promise<{
|
|
|
73
75
|
status: number;
|
|
74
76
|
error?: string;
|
|
75
77
|
}>;
|
|
78
|
+
/**
|
|
79
|
+
* Opt-in: add mapped contact fields that the HubSpot form doesn't already
|
|
80
|
+
* declare. Never creates CRM properties — only form fields pointing at ones
|
|
81
|
+
* that already exist. Returns the property names that were added.
|
|
82
|
+
*/
|
|
83
|
+
export declare function syncFieldsToHubspotForm(apiKey: string, formGuid: string, definition: FormDefinition): Promise<string[]>;
|
|
@@ -83,4 +83,8 @@ export declare function convertHubspotForm(raw: RawHubspotForm): ConvertedHubspo
|
|
|
83
83
|
export declare function listHubspotForms(apiKey: string): Promise<HubspotFormSummary[]>;
|
|
84
84
|
/** One form, full definition. */
|
|
85
85
|
export declare function fetchHubspotForm(apiKey: string, formId: string): Promise<RawHubspotForm>;
|
|
86
|
+
/** Replace `fieldGroups` (HubSpot PATCH is a full replacement of that array). */
|
|
87
|
+
export declare function patchHubspotForm(apiKey: string, formId: string, body: {
|
|
88
|
+
fieldGroups: unknown[];
|
|
89
|
+
}): Promise<RawHubspotForm>;
|
|
86
90
|
export {};
|
|
@@ -13,6 +13,9 @@ declare const _default: {
|
|
|
13
13
|
companyFromDomain: boolean;
|
|
14
14
|
timelineNote: boolean;
|
|
15
15
|
defaultFormId: string;
|
|
16
|
+
submissionMode: "auto" | "crm" | "forms";
|
|
17
|
+
writeExtraProperties: boolean;
|
|
18
|
+
syncFieldsOnPublish: boolean;
|
|
16
19
|
rateLimit: {
|
|
17
20
|
submitPerMinute: number;
|
|
18
21
|
searchPerMinute: number;
|
|
@@ -50,6 +53,7 @@ declare const _default: {
|
|
|
50
53
|
};
|
|
51
54
|
attributes: {
|
|
52
55
|
name: {
|
|
56
|
+
/** RBAC action gating the settings screen and its routes. */
|
|
53
57
|
type: string;
|
|
54
58
|
required: boolean;
|
|
55
59
|
pluginOptions: {
|
|
@@ -358,8 +362,14 @@ declare const _default: {
|
|
|
358
362
|
}): Promise<void>;
|
|
359
363
|
update(ctx: {
|
|
360
364
|
request: {
|
|
361
|
-
body
|
|
365
|
+
body?: {
|
|
362
366
|
apiKey?: string;
|
|
367
|
+
portalId?: string;
|
|
368
|
+
region?: string;
|
|
369
|
+
defaultFormId?: string;
|
|
370
|
+
submissionMode?: string;
|
|
371
|
+
writeExtraProperties?: boolean;
|
|
372
|
+
syncFieldsOnPublish?: boolean;
|
|
363
373
|
};
|
|
364
374
|
};
|
|
365
375
|
body: unknown;
|
|
@@ -1,28 +1,88 @@
|
|
|
1
1
|
import type { Core } from "@strapi/strapi";
|
|
2
2
|
/**
|
|
3
|
-
* API key resolution, in order of precedence:
|
|
3
|
+
* API key + portal resolution, in order of precedence:
|
|
4
4
|
* 1. saved from the admin UI (plugin core store)
|
|
5
5
|
* 2. plugin config (config/plugins of the host app)
|
|
6
|
-
* 3. environment variable
|
|
6
|
+
* 3. environment variable
|
|
7
7
|
*
|
|
8
8
|
* The key is only ever read server-side; `publicSettings` is what the admin UI
|
|
9
|
-
* receives, and it deliberately never carries the key itself.
|
|
9
|
+
* receives, and it deliberately never carries the key itself. Portal id, region
|
|
10
|
+
* and the default form GUID are not secrets — they round-trip so they can be
|
|
11
|
+
* edited in Settings → HubSpot.
|
|
10
12
|
*/
|
|
11
13
|
export declare const ENV_VAR = "HUBSPOT_API_KEY";
|
|
14
|
+
export declare const ENV_PORTAL = "HUBSPOT_PORTAL_ID";
|
|
15
|
+
export declare const ENV_REGION = "HUBSPOT_REGION";
|
|
16
|
+
export declare const ENV_DEFAULT_FORM = "HUBSPOT_DEFAULT_FORM_ID";
|
|
17
|
+
export type SettingSource = "settings" | "config" | "env" | null;
|
|
18
|
+
export type SubmissionMode = "auto" | "crm" | "forms";
|
|
12
19
|
export interface StoredSettings {
|
|
13
20
|
apiKey?: string;
|
|
21
|
+
portalId?: string;
|
|
22
|
+
region?: string;
|
|
23
|
+
defaultFormId?: string;
|
|
24
|
+
submissionMode?: SubmissionMode;
|
|
25
|
+
writeExtraProperties?: boolean;
|
|
26
|
+
syncFieldsOnPublish?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface HubspotPolicy {
|
|
29
|
+
/** How submissions reach HubSpot. `auto` uses Forms API when a GUID is set. */
|
|
30
|
+
submissionMode: SubmissionMode;
|
|
31
|
+
/** After a Forms API conversion, CRM-write mapped contact props the form dropped. */
|
|
32
|
+
writeExtraProperties: boolean;
|
|
33
|
+
/** On publish, PATCH missing mapped contact fields onto the HubSpot form. */
|
|
34
|
+
syncFieldsOnPublish: boolean;
|
|
14
35
|
}
|
|
15
36
|
export interface PublicSettings {
|
|
16
37
|
configured: boolean;
|
|
17
38
|
/** Where the key comes from, so the UI can explain why it can't be edited. */
|
|
18
|
-
keySource:
|
|
39
|
+
keySource: SettingSource;
|
|
19
40
|
/** Last four characters, enough to recognise a key without exposing it. */
|
|
20
41
|
hint: string;
|
|
42
|
+
portalId: string;
|
|
43
|
+
region: string;
|
|
44
|
+
defaultFormId: string;
|
|
45
|
+
portalSource: SettingSource;
|
|
46
|
+
regionSource: SettingSource;
|
|
47
|
+
formSource: SettingSource;
|
|
48
|
+
submissionMode: SubmissionMode;
|
|
49
|
+
writeExtraProperties: boolean;
|
|
50
|
+
syncFieldsOnPublish: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface HubspotAccount {
|
|
53
|
+
portalId: string;
|
|
54
|
+
region: string;
|
|
55
|
+
defaultFormId: string;
|
|
21
56
|
}
|
|
22
57
|
export declare function getStoredSettings(strapi: Core.Strapi): Promise<StoredSettings>;
|
|
23
58
|
export declare function setStoredSettings(strapi: Core.Strapi, value: StoredSettings | null): Promise<void>;
|
|
59
|
+
export declare function asSubmissionMode(value: unknown): SubmissionMode | undefined;
|
|
60
|
+
interface FormsCfg {
|
|
61
|
+
defaultFormId?: string;
|
|
62
|
+
submissionMode?: unknown;
|
|
63
|
+
writeExtraProperties?: unknown;
|
|
64
|
+
syncFieldsOnPublish?: unknown;
|
|
65
|
+
}
|
|
66
|
+
export declare function policyFrom(stored: StoredSettings, forms: FormsCfg): HubspotPolicy;
|
|
67
|
+
export declare function resolvePolicy(strapi: Core.Strapi): Promise<HubspotPolicy>;
|
|
24
68
|
export declare function resolveApiKey(strapi: Core.Strapi): Promise<{
|
|
25
69
|
apiKey: string;
|
|
26
|
-
source:
|
|
70
|
+
source: SettingSource;
|
|
27
71
|
}>;
|
|
72
|
+
/** Portal / region / default marketing form — settings UI overrides env. */
|
|
73
|
+
export declare function resolveAccount(strapi: Core.Strapi): Promise<HubspotAccount>;
|
|
28
74
|
export declare function publicSettings(strapi: Core.Strapi): Promise<PublicSettings>;
|
|
75
|
+
export interface SettingsPatch {
|
|
76
|
+
apiKey?: string;
|
|
77
|
+
portalId?: unknown;
|
|
78
|
+
region?: unknown;
|
|
79
|
+
defaultFormId?: unknown;
|
|
80
|
+
submissionMode?: unknown;
|
|
81
|
+
writeExtraProperties?: unknown;
|
|
82
|
+
syncFieldsOnPublish?: unknown;
|
|
83
|
+
}
|
|
84
|
+
/** Merge a PATCH-like body into the store without dropping the saved key. */
|
|
85
|
+
export declare function patchStoredSettings(strapi: Core.Strapi, body: SettingsPatch): Promise<void>;
|
|
86
|
+
/** Drop only the stored token; portal / form settings stay. */
|
|
87
|
+
export declare function clearStoredApiKey(strapi: Core.Strapi): Promise<void>;
|
|
88
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strapi-plugin-hubspot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "The form builder for HubSpot in Strapi — multi-step forms with conditional fields mapped to real CRM properties, a public submit pipeline with retries, and save-time mapping validation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"strapi",
|