strapi-plugin-hubspot 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +28 -13
  3. package/dist/_chunks/{Forms-ClyI4MJ1.js → Forms-D0QXkYAN.js} +216 -112
  4. package/dist/_chunks/{Forms-B7eIm0Fi.mjs → Forms-DEoZGrEt.mjs} +218 -114
  5. package/dist/_chunks/{HubspotFormInput-Do7F83V6.js → HubspotFormInput-4ZMYXWM_.js} +1 -1
  6. package/dist/_chunks/{HubspotFormInput-cZAiaAaq.mjs → HubspotFormInput-CPep55GL.mjs} +1 -1
  7. package/dist/_chunks/{HubspotObjectInput-DWqk2C26.js → HubspotObjectInput-BTqYXJIa.js} +3 -3
  8. package/dist/_chunks/{HubspotObjectInput-ZZk_7hWq.mjs → HubspotObjectInput-CQszIx1W.mjs} +3 -3
  9. package/dist/_chunks/{HubspotPropertyInput-v9B3wb1a.js → HubspotPropertyInput-2MgIKlR_.js} +3 -3
  10. package/dist/_chunks/{HubspotPropertyInput-5bkDGmGo.mjs → HubspotPropertyInput-ClNph0f1.mjs} +3 -3
  11. package/dist/_chunks/{Settings-QcFbm3s5.mjs → Settings-CZ-qQSKT.mjs} +78 -5
  12. package/dist/_chunks/{Settings-X3EHnTaO.js → Settings-DhiHQC6U.js} +77 -4
  13. package/dist/_chunks/{en-ZHvaZLi7.js → en-CSgFmKmM.js} +16 -3
  14. package/dist/_chunks/{en-DMJeF7EN.mjs → en-Dh3bMS6N.mjs} +16 -3
  15. package/dist/_chunks/{fr-Ds_2EAzZ.js → fr-D_EhZS0P.js} +16 -3
  16. package/dist/_chunks/{fr-CVAntLmc.mjs → fr-aWOIOlS9.mjs} +16 -3
  17. package/dist/_chunks/{index-BpcilDy1.js → index-ciliGv0E.js} +6 -6
  18. package/dist/_chunks/{index-BSJAhj8r.mjs → index-gKM4jVf8.mjs} +6 -6
  19. package/dist/_chunks/{objectLabels-CYXXFpNB.js → objectLabels-BjYfdhRB.js} +1 -1
  20. package/dist/_chunks/{objectLabels-CrYRzsqe.mjs → objectLabels-DQpSLUaJ.mjs} +1 -1
  21. package/dist/_chunks/{useHubspotSchema-CBF4GkmK.mjs → useHubspotSchema-CE7tJgOM.mjs} +1 -1
  22. package/dist/_chunks/{useHubspotSchema-Ba6xZUCL.js → useHubspotSchema-KKVppjC2.js} +1 -1
  23. package/dist/admin/index.js +1 -1
  24. package/dist/admin/index.mjs +1 -1
  25. package/dist/admin/src/builder/Sortable.d.ts +25 -12
  26. package/dist/admin/src/builder/__tests__/relocateField.test.d.ts +1 -0
  27. package/dist/admin/src/builder/relocateField.d.ts +14 -0
  28. package/dist/server/index.js +216 -7
  29. package/dist/server/index.mjs +216 -7
  30. package/dist/server/src/__tests__/formSync.test.d.ts +1 -0
  31. package/dist/server/src/formSync.d.ts +37 -0
  32. package/dist/server/src/forms.d.ts +6 -8
  33. package/dist/server/src/hsforms.d.ts +8 -0
  34. package/dist/server/src/importHubspot.d.ts +4 -0
  35. package/dist/server/src/index.d.ts +6 -0
  36. package/dist/server/src/settings.d.ts +32 -3
  37. package/package.json +1 -1
@@ -177,6 +177,26 @@ const first = (values, fallback = "") => {
177
177
  }
178
178
  return { value: fallback, source: null };
179
179
  };
180
+ const MODES = /* @__PURE__ */ new Set(["auto", "crm", "forms"]);
181
+ function asSubmissionMode(value) {
182
+ return typeof value === "string" && MODES.has(value) ? value : void 0;
183
+ }
184
+ function asBool(value) {
185
+ return typeof value === "boolean" ? value : void 0;
186
+ }
187
+ function formsCfg(strapi) {
188
+ return strapi.plugin("hubspot").config("forms", {}) ?? {};
189
+ }
190
+ function policyFrom(stored, forms) {
191
+ return {
192
+ submissionMode: asSubmissionMode(stored.submissionMode) ?? asSubmissionMode(forms.submissionMode) ?? "auto",
193
+ writeExtraProperties: asBool(stored.writeExtraProperties) ?? asBool(forms.writeExtraProperties) ?? true,
194
+ syncFieldsOnPublish: asBool(stored.syncFieldsOnPublish) ?? asBool(forms.syncFieldsOnPublish) ?? false
195
+ };
196
+ }
197
+ async function resolvePolicy(strapi) {
198
+ return policyFrom(await getStoredSettings(strapi), formsCfg(strapi));
199
+ }
180
200
  async function resolveApiKey(strapi) {
181
201
  const stored = await getStoredSettings(strapi);
182
202
  if (stored.apiKey) return { apiKey: stored.apiKey, source: "settings" };
@@ -196,7 +216,7 @@ async function resolveAccount(strapi) {
196
216
  }
197
217
  async function resolveAccountSources(strapi) {
198
218
  const stored = await getStoredSettings(strapi);
199
- const formsCfg = strapi.plugin("hubspot").config("forms", {}) ?? {};
219
+ const forms = formsCfg(strapi);
200
220
  const portal = first([
201
221
  { value: stored.portalId, source: "settings" },
202
222
  { value: strapi.plugin("hubspot").config("portalId", ""), source: "config" },
@@ -212,10 +232,11 @@ async function resolveAccountSources(strapi) {
212
232
  );
213
233
  const form = first([
214
234
  { value: stored.defaultFormId, source: "settings" },
215
- { value: formsCfg.defaultFormId, source: "config" },
235
+ { value: forms.defaultFormId, source: "config" },
216
236
  { value: process.env[ENV_DEFAULT_FORM], source: "env" }
217
237
  ]);
218
238
  const { apiKey, source: keySource } = await resolveApiKey(strapi);
239
+ const policy = policyFrom(stored, forms);
219
240
  return {
220
241
  configured: Boolean(apiKey),
221
242
  keySource,
@@ -225,7 +246,8 @@ async function resolveAccountSources(strapi) {
225
246
  defaultFormId: form.value,
226
247
  portalSource: portal.source,
227
248
  regionSource: region.source,
228
- formSource: form.source
249
+ formSource: form.source,
250
+ ...policy
229
251
  };
230
252
  }
231
253
  async function publicSettings(strapi) {
@@ -243,6 +265,16 @@ async function patchStoredSettings(strapi, body) {
243
265
  next.region = region || "eu1";
244
266
  }
245
267
  if ("defaultFormId" in body) next.defaultFormId = String(body.defaultFormId ?? "").trim();
268
+ if ("submissionMode" in body) {
269
+ const mode = asSubmissionMode(body.submissionMode);
270
+ if (mode) next.submissionMode = mode;
271
+ }
272
+ if ("writeExtraProperties" in body && typeof body.writeExtraProperties === "boolean") {
273
+ next.writeExtraProperties = body.writeExtraProperties;
274
+ }
275
+ if ("syncFieldsOnPublish" in body && typeof body.syncFieldsOnPublish === "boolean") {
276
+ next.syncFieldsOnPublish = body.syncFieldsOnPublish;
277
+ }
246
278
  await setStoredSettings(strapi, next);
247
279
  }
248
280
  async function clearStoredApiKey(strapi) {
@@ -1524,6 +1556,96 @@ function resolveSubmission(definition, rawValues) {
1524
1556
  const unknown = Object.keys(rawValues).filter((key) => !declared.has(key));
1525
1557
  return { values, hidden, missingRequired, unknown };
1526
1558
  }
1559
+ const OBJECT_TYPE_ID = {
1560
+ contact: "0-1",
1561
+ company: "0-2"
1562
+ };
1563
+ function leftoverContactProps(contact, sentFieldNames) {
1564
+ const sent = new Set(sentFieldNames);
1565
+ const extra = {};
1566
+ for (const [key, value] of Object.entries(contact)) {
1567
+ if (key === "email" || sent.has(key) || value === "") continue;
1568
+ extra[key] = value;
1569
+ }
1570
+ return extra;
1571
+ }
1572
+ function mappedContactFields(definition) {
1573
+ const out = [];
1574
+ for (const step of definition.steps ?? []) {
1575
+ for (const fld of step.fields ?? []) {
1576
+ if (fld.type === "company") continue;
1577
+ const mapping = fld.hubspot ?? {};
1578
+ const object = mapping.object?.trim() || "contact";
1579
+ if (object !== "contact") continue;
1580
+ const property = mapping.property?.trim() || fld.name;
1581
+ if (!property) continue;
1582
+ out.push({
1583
+ name: fld.name,
1584
+ label: typeof fld.label === "string" ? fld.label : void 0,
1585
+ type: fld.type,
1586
+ required: Boolean(fld.required),
1587
+ object,
1588
+ property,
1589
+ options: fld.options
1590
+ });
1591
+ }
1592
+ }
1593
+ return out;
1594
+ }
1595
+ function missingMappedFields(definition, existingNames) {
1596
+ const existing = new Set(existingNames);
1597
+ return mappedContactFields(definition).filter((field) => !existing.has(field.property));
1598
+ }
1599
+ function hsFieldType(type, options) {
1600
+ switch (type) {
1601
+ case "email":
1602
+ return "email";
1603
+ case "tel":
1604
+ return "phone";
1605
+ case "textarea":
1606
+ return "multi_line_text";
1607
+ case "number":
1608
+ return "number";
1609
+ case "select":
1610
+ return "dropdown";
1611
+ case "radio":
1612
+ return "radio";
1613
+ case "checkbox":
1614
+ return Array.isArray(options) && options.length ? "multiple_checkboxes" : "booleancheckbox";
1615
+ default:
1616
+ return "single_line_text";
1617
+ }
1618
+ }
1619
+ function toHsFormField(field) {
1620
+ const options = (field.options ?? []).filter((option) => option.value).map((option) => ({
1621
+ value: String(option.value),
1622
+ label: option.label || String(option.value)
1623
+ }));
1624
+ return {
1625
+ objectTypeId: OBJECT_TYPE_ID[field.object || "contact"] || "0-1",
1626
+ name: field.property,
1627
+ label: field.label || field.property,
1628
+ required: Boolean(field.required),
1629
+ hidden: false,
1630
+ fieldType: hsFieldType(field.type, field.options),
1631
+ ...options.length ? { options } : {}
1632
+ };
1633
+ }
1634
+ function appendFieldGroups(existing, extra) {
1635
+ return [
1636
+ ...existing.map((group) => ({
1637
+ groupType: group.groupType || "default_group",
1638
+ richTextType: group.richTextType || "text",
1639
+ ...group.richText ? { richText: group.richText } : {},
1640
+ fields: group.fields ?? []
1641
+ })),
1642
+ ...extra.map((field) => ({
1643
+ groupType: "default_group",
1644
+ richTextType: "text",
1645
+ fields: [toHsFormField(field)]
1646
+ }))
1647
+ ];
1648
+ }
1527
1649
  const HS_BASE$2 = "https://api.hubapi.com";
1528
1650
  const OBJECT_BY_TYPE_ID = {
1529
1651
  "0-1": "contact",
@@ -1687,6 +1809,23 @@ async function listHubspotForms(apiKey) {
1687
1809
  async function fetchHubspotForm(apiKey, formId) {
1688
1810
  return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
1689
1811
  }
1812
+ async function patchHubspotForm(apiKey, formId, body) {
1813
+ const res = await fetch(`${HS_BASE$2}/marketing/v3/forms/${encodeURIComponent(formId)}`, {
1814
+ method: "PATCH",
1815
+ headers: {
1816
+ Authorization: `Bearer ${apiKey}`,
1817
+ "Content-Type": "application/json"
1818
+ },
1819
+ body: JSON.stringify(body)
1820
+ });
1821
+ if (!res.ok) {
1822
+ const payload = await res.json().catch(() => ({}));
1823
+ throw Object.assign(new Error(payload.message || `HubSpot ${res.status}`), {
1824
+ status: res.status
1825
+ });
1826
+ }
1827
+ return await res.json();
1828
+ }
1690
1829
  const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1691
1830
  const PORTAL = /^\d{1,12}$/;
1692
1831
  const HUTK = /^[a-f0-9]{8,128}$/i;
@@ -1735,6 +1874,10 @@ function formFieldNames(raw) {
1735
1874
  return names;
1736
1875
  }
1737
1876
  const shapeCache = /* @__PURE__ */ new Map();
1877
+ function clearFormShapeCache(formGuid) {
1878
+ if (formGuid) shapeCache.delete(formGuid);
1879
+ else shapeCache.clear();
1880
+ }
1738
1881
  async function loadFormShape(apiKey, formGuid) {
1739
1882
  const cached = shapeCache.get(formGuid);
1740
1883
  if (cached && Date.now() - cached.at < SHAPE_TTL_MS) {
@@ -1830,6 +1973,16 @@ async function submitMarketingForm(input) {
1830
1973
  }
1831
1974
  return result;
1832
1975
  }
1976
+ async function syncFieldsToHubspotForm(apiKey, formGuid, definition) {
1977
+ const raw = await fetchHubspotForm(apiKey, formGuid);
1978
+ const extra = missingMappedFields(definition, formFieldNames(raw));
1979
+ if (!extra.length) return [];
1980
+ await patchHubspotForm(apiKey, formGuid, {
1981
+ fieldGroups: appendFieldGroups(raw.fieldGroups ?? [], extra)
1982
+ });
1983
+ clearFormShapeCache(formGuid);
1984
+ return extra.map((field) => field.property);
1985
+ }
1833
1986
  function publicForm(entry) {
1834
1987
  return {
1835
1988
  slug: entry.slug,
@@ -2197,6 +2350,7 @@ function createFormsService(strapi, _opts = {}) {
2197
2350
  }
2198
2351
  }
2199
2352
  let usedFormsApi = false;
2353
+ let sentFieldNames = [];
2200
2354
  if (email && apiKey) {
2201
2355
  const groups = groupByObject(form.definition, resolution.values);
2202
2356
  for (const [object, props] of Object.entries(companyGroups)) {
@@ -2204,21 +2358,25 @@ function createFormsService(strapi, _opts = {}) {
2204
2358
  }
2205
2359
  const partition = await partitionBySchema(apiKey, groups);
2206
2360
  rejected = partition.rejected;
2361
+ const contactProps = stringProps(partition.accepted.contact ?? {});
2207
2362
  const account = await resolveAccount(strapi);
2363
+ const policy = await resolvePolicy(strapi);
2208
2364
  const portalId = sanitizePortalId(account.portalId);
2209
2365
  const region = account.region || "eu1";
2210
2366
  const ownGuid = typeof form.hubspotFormId === "string" ? form.hubspotFormId.trim() : "";
2211
2367
  const formGuid = isFormGuid(ownGuid) ? ownGuid : isFormGuid(account.defaultFormId) ? account.defaultFormId.trim() : "";
2212
- if (portalId && formGuid) {
2368
+ const tryFormsApi = policy.submissionMode !== "crm" && Boolean(portalId && formGuid);
2369
+ if (tryFormsApi && portalId && formGuid) {
2213
2370
  try {
2214
2371
  const shape = await loadFormShape(apiKey, formGuid);
2215
2372
  const fields = filterToFormFields(
2216
- toHsFields({ ...stringProps(partition.accepted.contact ?? {}), email }),
2373
+ toHsFields({ ...contactProps, email }),
2217
2374
  shape?.names ?? null
2218
2375
  );
2219
2376
  if (fields.length && !fields.some((f) => f.name === "email")) {
2220
2377
  fields.unshift({ name: "email", value: email });
2221
2378
  }
2379
+ sentFieldNames = fields.map((f) => f.name);
2222
2380
  const submitted = await submitMarketingForm({
2223
2381
  apiKey,
2224
2382
  portalId,
@@ -2255,12 +2413,28 @@ function createFormsService(strapi, _opts = {}) {
2255
2413
  const result = await strapi.plugin("hubspot").service("submit").upsert({
2256
2414
  object: "contact",
2257
2415
  idProperty: "email",
2258
- properties: { ...partition.accepted.contact ?? {}, email }
2416
+ properties: { ...contactProps, email }
2259
2417
  });
2260
2418
  if (result.ok) {
2261
2419
  hubspotSynced = true;
2262
2420
  contactId = result.id;
2263
2421
  }
2422
+ } else if (policy.writeExtraProperties) {
2423
+ const extra = leftoverContactProps(contactProps, sentFieldNames);
2424
+ if (Object.keys(extra).length) {
2425
+ try {
2426
+ const result = await strapi.plugin("hubspot").service("submit").upsert({
2427
+ object: "contact",
2428
+ idProperty: "email",
2429
+ properties: { ...extra, email }
2430
+ });
2431
+ if (result.ok) contactId = result.id ?? contactId;
2432
+ } catch (err) {
2433
+ strapi.log.warn(
2434
+ `[hubspot] leftover contact properties failed (conversion OK) — ${err.message}`
2435
+ );
2436
+ }
2437
+ }
2264
2438
  }
2265
2439
  const domain = corporateDomain(email);
2266
2440
  const companyProps = partition.accepted.company ?? {};
@@ -2559,7 +2733,32 @@ function createFormsAdminController(strapi) {
2559
2733
  documentId: ctx.params.documentId,
2560
2734
  locale: ctx.query.locale
2561
2735
  });
2562
- ctx.body = { ok: true };
2736
+ const policy = await resolvePolicy(strapi);
2737
+ let addedFields = [];
2738
+ let syncWarning;
2739
+ if (policy.syncFieldsOnPublish) {
2740
+ const { apiKey } = await resolveApiKey(strapi);
2741
+ const account = await resolveAccount(strapi);
2742
+ const ownGuid = typeof entry.hubspotFormId === "string" ? entry.hubspotFormId.trim() : "";
2743
+ const formGuid = isFormGuid(ownGuid) ? ownGuid : isFormGuid(account.defaultFormId) ? account.defaultFormId.trim() : "";
2744
+ if (apiKey && formGuid) {
2745
+ try {
2746
+ addedFields = await syncFieldsToHubspotForm(
2747
+ apiKey,
2748
+ formGuid,
2749
+ entry.definition ?? EMPTY_DEFINITION
2750
+ );
2751
+ } catch (err) {
2752
+ syncWarning = err.message;
2753
+ strapi.log.warn(`[hubspot] field sync on publish failed — ${syncWarning}`);
2754
+ }
2755
+ }
2756
+ }
2757
+ ctx.body = {
2758
+ ok: true,
2759
+ ...addedFields.length ? { addedFields } : {},
2760
+ ...syncWarning ? { syncWarning } : {}
2761
+ };
2563
2762
  },
2564
2763
  async unpublish(ctx) {
2565
2764
  await documents().unpublish({
@@ -3023,10 +3222,20 @@ const config = {
3023
3222
  // submissions go through the marketing Forms API);
3024
3223
  // - defaultFormId: portal-wide marketing form GUID when a builder form
3025
3224
  // doesn't set its own — swap with the portal at production cutover.
3225
+ // - submissionMode / writeExtraProperties / syncFieldsOnPublish: see
3226
+ // Settings → HubSpot; each install picks its HubSpot will.
3026
3227
  forms: {
3027
3228
  companyFromDomain: true,
3028
3229
  timelineNote: true,
3029
3230
  defaultFormId: "",
3231
+ // How submissions reach HubSpot. `auto` = Forms API when a GUID is set,
3232
+ // else CRM upsert. `crm` skips conversions. `forms` prefers the Forms API.
3233
+ submissionMode: "auto",
3234
+ // After a conversion, CRM-write mapped contact props the HubSpot form dropped.
3235
+ writeExtraProperties: true,
3236
+ // On publish, PATCH missing mapped contact fields onto the HubSpot form.
3237
+ // Off by default: HubSpot-first installs must not have Strapi mutate forms.
3238
+ syncFieldsOnPublish: false,
3030
3239
  // Per-IP brakes on the public routes; 0 disables one.
3031
3240
  rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
3032
3241
  }
@@ -176,6 +176,26 @@ const first = (values, fallback = "") => {
176
176
  }
177
177
  return { value: fallback, source: null };
178
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
+ }
179
199
  async function resolveApiKey(strapi) {
180
200
  const stored = await getStoredSettings(strapi);
181
201
  if (stored.apiKey) return { apiKey: stored.apiKey, source: "settings" };
@@ -195,7 +215,7 @@ async function resolveAccount(strapi) {
195
215
  }
196
216
  async function resolveAccountSources(strapi) {
197
217
  const stored = await getStoredSettings(strapi);
198
- const formsCfg = strapi.plugin("hubspot").config("forms", {}) ?? {};
218
+ const forms = formsCfg(strapi);
199
219
  const portal = first([
200
220
  { value: stored.portalId, source: "settings" },
201
221
  { value: strapi.plugin("hubspot").config("portalId", ""), source: "config" },
@@ -211,10 +231,11 @@ async function resolveAccountSources(strapi) {
211
231
  );
212
232
  const form = first([
213
233
  { value: stored.defaultFormId, source: "settings" },
214
- { value: formsCfg.defaultFormId, source: "config" },
234
+ { value: forms.defaultFormId, source: "config" },
215
235
  { value: process.env[ENV_DEFAULT_FORM], source: "env" }
216
236
  ]);
217
237
  const { apiKey, source: keySource } = await resolveApiKey(strapi);
238
+ const policy = policyFrom(stored, forms);
218
239
  return {
219
240
  configured: Boolean(apiKey),
220
241
  keySource,
@@ -224,7 +245,8 @@ async function resolveAccountSources(strapi) {
224
245
  defaultFormId: form.value,
225
246
  portalSource: portal.source,
226
247
  regionSource: region.source,
227
- formSource: form.source
248
+ formSource: form.source,
249
+ ...policy
228
250
  };
229
251
  }
230
252
  async function publicSettings(strapi) {
@@ -242,6 +264,16 @@ async function patchStoredSettings(strapi, body) {
242
264
  next.region = region || "eu1";
243
265
  }
244
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
+ }
245
277
  await setStoredSettings(strapi, next);
246
278
  }
247
279
  async function clearStoredApiKey(strapi) {
@@ -1523,6 +1555,96 @@ function resolveSubmission(definition, rawValues) {
1523
1555
  const unknown = Object.keys(rawValues).filter((key) => !declared.has(key));
1524
1556
  return { values, hidden, missingRequired, unknown };
1525
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
+ }
1526
1648
  const HS_BASE$2 = "https://api.hubapi.com";
1527
1649
  const OBJECT_BY_TYPE_ID = {
1528
1650
  "0-1": "contact",
@@ -1686,6 +1808,23 @@ async function listHubspotForms(apiKey) {
1686
1808
  async function fetchHubspotForm(apiKey, formId) {
1687
1809
  return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
1688
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
+ }
1689
1828
  const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1690
1829
  const PORTAL = /^\d{1,12}$/;
1691
1830
  const HUTK = /^[a-f0-9]{8,128}$/i;
@@ -1734,6 +1873,10 @@ function formFieldNames(raw) {
1734
1873
  return names;
1735
1874
  }
1736
1875
  const shapeCache = /* @__PURE__ */ new Map();
1876
+ function clearFormShapeCache(formGuid) {
1877
+ if (formGuid) shapeCache.delete(formGuid);
1878
+ else shapeCache.clear();
1879
+ }
1737
1880
  async function loadFormShape(apiKey, formGuid) {
1738
1881
  const cached = shapeCache.get(formGuid);
1739
1882
  if (cached && Date.now() - cached.at < SHAPE_TTL_MS) {
@@ -1829,6 +1972,16 @@ async function submitMarketingForm(input) {
1829
1972
  }
1830
1973
  return result;
1831
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
+ }
1832
1985
  function publicForm(entry) {
1833
1986
  return {
1834
1987
  slug: entry.slug,
@@ -2196,6 +2349,7 @@ function createFormsService(strapi, _opts = {}) {
2196
2349
  }
2197
2350
  }
2198
2351
  let usedFormsApi = false;
2352
+ let sentFieldNames = [];
2199
2353
  if (email && apiKey) {
2200
2354
  const groups = groupByObject(form.definition, resolution.values);
2201
2355
  for (const [object, props] of Object.entries(companyGroups)) {
@@ -2203,21 +2357,25 @@ function createFormsService(strapi, _opts = {}) {
2203
2357
  }
2204
2358
  const partition = await partitionBySchema(apiKey, groups);
2205
2359
  rejected = partition.rejected;
2360
+ const contactProps = stringProps(partition.accepted.contact ?? {});
2206
2361
  const account = await resolveAccount(strapi);
2362
+ const policy = await resolvePolicy(strapi);
2207
2363
  const portalId = sanitizePortalId(account.portalId);
2208
2364
  const region = account.region || "eu1";
2209
2365
  const ownGuid = typeof form.hubspotFormId === "string" ? form.hubspotFormId.trim() : "";
2210
2366
  const formGuid = isFormGuid(ownGuid) ? ownGuid : isFormGuid(account.defaultFormId) ? account.defaultFormId.trim() : "";
2211
- if (portalId && formGuid) {
2367
+ const tryFormsApi = policy.submissionMode !== "crm" && Boolean(portalId && formGuid);
2368
+ if (tryFormsApi && portalId && formGuid) {
2212
2369
  try {
2213
2370
  const shape = await loadFormShape(apiKey, formGuid);
2214
2371
  const fields = filterToFormFields(
2215
- toHsFields({ ...stringProps(partition.accepted.contact ?? {}), email }),
2372
+ toHsFields({ ...contactProps, email }),
2216
2373
  shape?.names ?? null
2217
2374
  );
2218
2375
  if (fields.length && !fields.some((f) => f.name === "email")) {
2219
2376
  fields.unshift({ name: "email", value: email });
2220
2377
  }
2378
+ sentFieldNames = fields.map((f) => f.name);
2221
2379
  const submitted = await submitMarketingForm({
2222
2380
  apiKey,
2223
2381
  portalId,
@@ -2254,12 +2412,28 @@ function createFormsService(strapi, _opts = {}) {
2254
2412
  const result = await strapi.plugin("hubspot").service("submit").upsert({
2255
2413
  object: "contact",
2256
2414
  idProperty: "email",
2257
- properties: { ...partition.accepted.contact ?? {}, email }
2415
+ properties: { ...contactProps, email }
2258
2416
  });
2259
2417
  if (result.ok) {
2260
2418
  hubspotSynced = true;
2261
2419
  contactId = result.id;
2262
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
+ }
2263
2437
  }
2264
2438
  const domain = corporateDomain(email);
2265
2439
  const companyProps = partition.accepted.company ?? {};
@@ -2558,7 +2732,32 @@ function createFormsAdminController(strapi) {
2558
2732
  documentId: ctx.params.documentId,
2559
2733
  locale: ctx.query.locale
2560
2734
  });
2561
- ctx.body = { ok: true };
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
+ };
2562
2761
  },
2563
2762
  async unpublish(ctx) {
2564
2763
  await documents().unpublish({
@@ -3022,10 +3221,20 @@ const config = {
3022
3221
  // submissions go through the marketing Forms API);
3023
3222
  // - defaultFormId: portal-wide marketing form GUID when a builder form
3024
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.
3025
3226
  forms: {
3026
3227
  companyFromDomain: true,
3027
3228
  timelineNote: true,
3028
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,
3029
3238
  // Per-IP brakes on the public routes; 0 disables one.
3030
3239
  rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
3031
3240
  }
@@ -0,0 +1 @@
1
+ export {};