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.
Files changed (35) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +39 -15
  3. package/dist/_chunks/{Forms-ws6d8rdC.mjs → Forms-6EoEYzAc.mjs} +26 -5
  4. package/dist/_chunks/{Forms-Cch-7zei.js → Forms-BW_zGc1P.js} +26 -5
  5. package/dist/_chunks/{HubspotFormInput-18pWTITT.mjs → HubspotFormInput-V5w4y1fb.mjs} +1 -1
  6. package/dist/_chunks/{HubspotFormInput-M2l_8w_e.js → HubspotFormInput-oMhm1UJm.js} +1 -1
  7. package/dist/_chunks/{HubspotObjectInput-BDO99qXs.js → HubspotObjectInput-Bmo6YQgs.js} +3 -3
  8. package/dist/_chunks/{HubspotObjectInput-DshusXZP.mjs → HubspotObjectInput-DhsnSguG.mjs} +3 -3
  9. package/dist/_chunks/{HubspotPropertyInput-C-kHDgwx.mjs → HubspotPropertyInput-CrYP4pc0.mjs} +3 -3
  10. package/dist/_chunks/{HubspotPropertyInput-XGnH2tHn.js → HubspotPropertyInput-luFfnxqy.js} +3 -3
  11. package/dist/_chunks/{Settings-CRnsu6ob.js → Settings-B3A22i7Z.js} +204 -35
  12. package/dist/_chunks/{Settings-Bm7WkzK1.mjs → Settings-CjcWj9I6.mjs} +205 -36
  13. package/dist/_chunks/{en-BXMU5CjT.js → en-C7ls2EUl.js} +28 -6
  14. package/dist/_chunks/{en-DKV-8FWP.mjs → en-D6GHgMOt.mjs} +28 -6
  15. package/dist/_chunks/{fr-GB6T3ZyU.mjs → fr-CfohMY8n.mjs} +27 -5
  16. package/dist/_chunks/{fr-Dx4XmZ4X.js → fr-pOP8ZXqM.js} +27 -5
  17. package/dist/_chunks/{index-CPVfElYB.mjs → index-7cL9PRh1.mjs} +6 -6
  18. package/dist/_chunks/{index-BlaG8oIq.js → index-CodpLKZx.js} +6 -6
  19. package/dist/_chunks/{objectLabels-CPc21Tdd.js → objectLabels-CSWGIgNb.js} +1 -1
  20. package/dist/_chunks/{objectLabels-6OUnIDB5.mjs → objectLabels-Dnj14fsp.mjs} +1 -1
  21. package/dist/_chunks/{useHubspotSchema-qCUs2gIX.mjs → useHubspotSchema-BsmQgJgx.mjs} +1 -1
  22. package/dist/_chunks/{useHubspotSchema-0MvCM1AC.js → useHubspotSchema-DiRN3HrN.js} +1 -1
  23. package/dist/admin/index.js +1 -1
  24. package/dist/admin/index.mjs +1 -1
  25. package/dist/server/index.js +301 -15
  26. package/dist/server/index.mjs +301 -15
  27. package/dist/server/src/__tests__/formSync.test.d.ts +1 -0
  28. package/dist/server/src/__tests__/settings.test.d.ts +1 -0
  29. package/dist/server/src/formSync.d.ts +37 -0
  30. package/dist/server/src/forms.d.ts +6 -8
  31. package/dist/server/src/hsforms.d.ts +8 -0
  32. package/dist/server/src/importHubspot.d.ts +4 -0
  33. package/dist/server/src/index.d.ts +11 -1
  34. package/dist/server/src/settings.d.ts +65 -5
  35. package/package.json +1 -1
@@ -25,7 +25,7 @@ const index = {
25
25
  icon: PaperPlane,
26
26
  intlLabel: { id: `${PLUGIN_ID}.menu.label`, defaultMessage: "HubSpot Forms" },
27
27
  permissions: [{ action: `plugin::${PLUGIN_ID}.forms`, subject: null }],
28
- Component: async () => (await import("./Forms-ws6d8rdC.mjs")).default
28
+ Component: async () => (await import("./Forms-6EoEYzAc.mjs")).default
29
29
  });
30
30
  app.customFields.register({
31
31
  name: "property",
@@ -40,7 +40,7 @@ const index = {
40
40
  defaultMessage: "Picked from the portal's real properties"
41
41
  },
42
42
  components: {
43
- Input: async () => import("./HubspotPropertyInput-C-kHDgwx.mjs")
43
+ Input: async () => import("./HubspotPropertyInput-CrYP4pc0.mjs")
44
44
  },
45
45
  // Exposed in the Content-Type Builder. Without this, `objectField` could
46
46
  // only be set by hand-editing schema.json — which is not how anyone
@@ -103,7 +103,7 @@ const index = {
103
103
  defaultMessage: "Picked from the portal's configured objects"
104
104
  },
105
105
  components: {
106
- Input: async () => import("./HubspotObjectInput-DshusXZP.mjs")
106
+ Input: async () => import("./HubspotObjectInput-DhsnSguG.mjs")
107
107
  }
108
108
  });
109
109
  app.customFields.register({
@@ -119,7 +119,7 @@ const index = {
119
119
  defaultMessage: "A form built in the HubSpot form builder, stored by slug"
120
120
  },
121
121
  components: {
122
- Input: async () => import("./HubspotFormInput-18pWTITT.mjs")
122
+ Input: async () => import("./HubspotFormInput-V5w4y1fb.mjs")
123
123
  }
124
124
  });
125
125
  app.createSettingSection(
@@ -135,7 +135,7 @@ const index = {
135
135
  // Mirrors the RBAC action registered server-side: a role without it
136
136
  // doesn't see the link at all — the routes refuse it anyway.
137
137
  permissions: [{ action: `plugin::${PLUGIN_ID}.settings`, subject: null }],
138
- Component: async () => (await import("./Settings-Bm7WkzK1.mjs")).default
138
+ Component: async () => (await import("./Settings-CjcWj9I6.mjs")).default
139
139
  }
140
140
  ]
141
141
  );
@@ -145,7 +145,7 @@ const index = {
145
145
  return Promise.all(
146
146
  locales.map(async (locale) => {
147
147
  try {
148
- const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-DKV-8FWP.mjs"), "./translations/fr.json": () => import("./fr-GB6T3ZyU.mjs") }), `./translations/${locale}.json`, 3);
148
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-D6GHgMOt.mjs"), "./translations/fr.json": () => import("./fr-CfohMY8n.mjs") }), `./translations/${locale}.json`, 3);
149
149
  return { data: prefixPluginTranslations(data), locale };
150
150
  } catch {
151
151
  return { data: {}, locale };
@@ -26,7 +26,7 @@ const index = {
26
26
  icon: icons.PaperPlane,
27
27
  intlLabel: { id: `${PLUGIN_ID}.menu.label`, defaultMessage: "HubSpot Forms" },
28
28
  permissions: [{ action: `plugin::${PLUGIN_ID}.forms`, subject: null }],
29
- Component: async () => (await Promise.resolve().then(() => require("./Forms-Cch-7zei.js"))).default
29
+ Component: async () => (await Promise.resolve().then(() => require("./Forms-BW_zGc1P.js"))).default
30
30
  });
31
31
  app.customFields.register({
32
32
  name: "property",
@@ -41,7 +41,7 @@ const index = {
41
41
  defaultMessage: "Picked from the portal's real properties"
42
42
  },
43
43
  components: {
44
- Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-XGnH2tHn.js"))
44
+ Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-luFfnxqy.js"))
45
45
  },
46
46
  // Exposed in the Content-Type Builder. Without this, `objectField` could
47
47
  // only be set by hand-editing schema.json — which is not how anyone
@@ -104,7 +104,7 @@ const index = {
104
104
  defaultMessage: "Picked from the portal's configured objects"
105
105
  },
106
106
  components: {
107
- Input: async () => Promise.resolve().then(() => require("./HubspotObjectInput-BDO99qXs.js"))
107
+ Input: async () => Promise.resolve().then(() => require("./HubspotObjectInput-Bmo6YQgs.js"))
108
108
  }
109
109
  });
110
110
  app.customFields.register({
@@ -120,7 +120,7 @@ const index = {
120
120
  defaultMessage: "A form built in the HubSpot form builder, stored by slug"
121
121
  },
122
122
  components: {
123
- Input: async () => Promise.resolve().then(() => require("./HubspotFormInput-M2l_8w_e.js"))
123
+ Input: async () => Promise.resolve().then(() => require("./HubspotFormInput-oMhm1UJm.js"))
124
124
  }
125
125
  });
126
126
  app.createSettingSection(
@@ -136,7 +136,7 @@ const index = {
136
136
  // Mirrors the RBAC action registered server-side: a role without it
137
137
  // doesn't see the link at all — the routes refuse it anyway.
138
138
  permissions: [{ action: `plugin::${PLUGIN_ID}.settings`, subject: null }],
139
- Component: async () => (await Promise.resolve().then(() => require("./Settings-CRnsu6ob.js"))).default
139
+ Component: async () => (await Promise.resolve().then(() => require("./Settings-B3A22i7Z.js"))).default
140
140
  }
141
141
  ]
142
142
  );
@@ -146,7 +146,7 @@ const index = {
146
146
  return Promise.all(
147
147
  locales.map(async (locale) => {
148
148
  try {
149
- const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-BXMU5CjT.js")), "./translations/fr.json": () => Promise.resolve().then(() => require("./fr-Dx4XmZ4X.js")) }), `./translations/${locale}.json`, 3);
149
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-C7ls2EUl.js")), "./translations/fr.json": () => Promise.resolve().then(() => require("./fr-pOP8ZXqM.js")) }), `./translations/${locale}.json`, 3);
150
150
  return { data: prefixPluginTranslations(data), locale };
151
151
  } catch {
152
152
  return { data: {}, locale };
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- const index = require("./index-BlaG8oIq.js");
2
+ const index = require("./index-CodpLKZx.js");
3
3
  const STANDARD = /* @__PURE__ */ new Set([
4
4
  "contact",
5
5
  "company",
@@ -1,4 +1,4 @@
1
- import { g as getTranslation } from "./index-CPVfElYB.mjs";
1
+ import { g as getTranslation } from "./index-7cL9PRh1.mjs";
2
2
  const STANDARD = /* @__PURE__ */ new Set([
3
3
  "contact",
4
4
  "company",
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { useFetchClient } from "@strapi/strapi/admin";
3
- import { P as PLUGIN_ID } from "./index-CPVfElYB.mjs";
3
+ import { P as PLUGIN_ID } from "./index-7cL9PRh1.mjs";
4
4
  let cached = null;
5
5
  let inFlight = null;
6
6
  const listeners = /* @__PURE__ */ new Set();
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  const React = require("react");
3
3
  const admin = require("@strapi/strapi/admin");
4
- const index = require("./index-BlaG8oIq.js");
4
+ const index = require("./index-CodpLKZx.js");
5
5
  function _interopNamespace(e) {
6
6
  if (e && e.__esModule) return e;
7
7
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-BlaG8oIq.js");
2
+ const index = require("../_chunks/index-CodpLKZx.js");
3
3
  require("@strapi/icons");
4
4
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-CPVfElYB.mjs";
1
+ import { i } from "../_chunks/index-7cL9PRh1.mjs";
2
2
  import "@strapi/icons";
3
3
  export {
4
4
  i as default
@@ -159,6 +159,9 @@ function resolveObjects(configured) {
159
159
  return out;
160
160
  }
161
161
  const ENV_VAR = "HUBSPOT_API_KEY";
162
+ const ENV_PORTAL = "HUBSPOT_PORTAL_ID";
163
+ const ENV_REGION = "HUBSPOT_REGION";
164
+ const ENV_DEFAULT_FORM = "HUBSPOT_DEFAULT_FORM_ID";
162
165
  const store = (strapi) => strapi.store({ type: "plugin", name: "hubspot" });
163
166
  async function getStoredSettings(strapi) {
164
167
  return await store(strapi).get({ key: "settings" }) ?? {};
@@ -167,6 +170,33 @@ async function setStoredSettings(strapi, value) {
167
170
  await store(strapi).set({ key: "settings", value });
168
171
  clearCache();
169
172
  }
173
+ const first = (values, fallback = "") => {
174
+ for (const item of values) {
175
+ const value = (item.value ?? "").trim();
176
+ if (value) return { value, source: item.source };
177
+ }
178
+ return { value: fallback, source: null };
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
+ }
170
200
  async function resolveApiKey(strapi) {
171
201
  const stored = await getStoredSettings(strapi);
172
202
  if (stored.apiKey) return { apiKey: stored.apiKey, source: "settings" };
@@ -176,14 +206,83 @@ async function resolveApiKey(strapi) {
176
206
  if (fromEnv) return { apiKey: fromEnv, source: "env" };
177
207
  return { apiKey: "", source: null };
178
208
  }
179
- async function publicSettings(strapi) {
180
- const { apiKey, source } = await resolveApiKey(strapi);
209
+ async function resolveAccount(strapi) {
210
+ const resolved = await resolveAccountSources(strapi);
211
+ return {
212
+ portalId: resolved.portalId,
213
+ region: resolved.region || "eu1",
214
+ defaultFormId: resolved.defaultFormId
215
+ };
216
+ }
217
+ async function resolveAccountSources(strapi) {
218
+ const stored = await getStoredSettings(strapi);
219
+ const forms = formsCfg(strapi);
220
+ const portal = first([
221
+ { value: stored.portalId, source: "settings" },
222
+ { value: strapi.plugin("hubspot").config("portalId", ""), source: "config" },
223
+ { value: process.env[ENV_PORTAL], source: "env" }
224
+ ]);
225
+ const region = first(
226
+ [
227
+ { value: stored.region, source: "settings" },
228
+ { value: strapi.plugin("hubspot").config("region", ""), source: "config" },
229
+ { value: process.env[ENV_REGION], source: "env" }
230
+ ],
231
+ "eu1"
232
+ );
233
+ const form = first([
234
+ { value: stored.defaultFormId, source: "settings" },
235
+ { value: forms.defaultFormId, source: "config" },
236
+ { value: process.env[ENV_DEFAULT_FORM], source: "env" }
237
+ ]);
238
+ const { apiKey, source: keySource } = await resolveApiKey(strapi);
239
+ const policy = policyFrom(stored, forms);
181
240
  return {
182
241
  configured: Boolean(apiKey),
183
- keySource: source,
184
- hint: apiKey ? `…${apiKey.slice(-4)}` : ""
242
+ keySource,
243
+ hint: apiKey ? `…${apiKey.slice(-4)}` : "",
244
+ portalId: portal.value,
245
+ region: region.value || "eu1",
246
+ defaultFormId: form.value,
247
+ portalSource: portal.source,
248
+ regionSource: region.source,
249
+ formSource: form.source,
250
+ ...policy
185
251
  };
186
252
  }
253
+ async function publicSettings(strapi) {
254
+ return resolveAccountSources(strapi);
255
+ }
256
+ async function patchStoredSettings(strapi, body) {
257
+ const current = await getStoredSettings(strapi);
258
+ const next = { ...current };
259
+ if (typeof body.apiKey === "string" && body.apiKey.trim()) {
260
+ next.apiKey = body.apiKey.trim();
261
+ }
262
+ if ("portalId" in body) next.portalId = String(body.portalId ?? "").trim();
263
+ if ("region" in body) {
264
+ const region = String(body.region ?? "").trim().toLowerCase();
265
+ next.region = region || "eu1";
266
+ }
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
+ }
278
+ await setStoredSettings(strapi, next);
279
+ }
280
+ async function clearStoredApiKey(strapi) {
281
+ const current = await getStoredSettings(strapi);
282
+ const { apiKey: _apiKey, ...rest } = current;
283
+ const empty = !rest.portalId && !rest.region && !rest.defaultFormId;
284
+ await setStoredSettings(strapi, empty ? null : rest);
285
+ }
187
286
  function collectMappings(node, target, found = []) {
188
287
  if (Array.isArray(node)) {
189
288
  for (const item of node) collectMappings(item, target, found);
@@ -1457,6 +1556,96 @@ function resolveSubmission(definition, rawValues) {
1457
1556
  const unknown = Object.keys(rawValues).filter((key) => !declared.has(key));
1458
1557
  return { values, hidden, missingRequired, unknown };
1459
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
+ }
1460
1649
  const HS_BASE$2 = "https://api.hubapi.com";
1461
1650
  const OBJECT_BY_TYPE_ID = {
1462
1651
  "0-1": "contact",
@@ -1620,6 +1809,23 @@ async function listHubspotForms(apiKey) {
1620
1809
  async function fetchHubspotForm(apiKey, formId) {
1621
1810
  return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
1622
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
+ }
1623
1829
  const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1624
1830
  const PORTAL = /^\d{1,12}$/;
1625
1831
  const HUTK = /^[a-f0-9]{8,128}$/i;
@@ -1668,6 +1874,10 @@ function formFieldNames(raw) {
1668
1874
  return names;
1669
1875
  }
1670
1876
  const shapeCache = /* @__PURE__ */ new Map();
1877
+ function clearFormShapeCache(formGuid) {
1878
+ if (formGuid) shapeCache.delete(formGuid);
1879
+ else shapeCache.clear();
1880
+ }
1671
1881
  async function loadFormShape(apiKey, formGuid) {
1672
1882
  const cached = shapeCache.get(formGuid);
1673
1883
  if (cached && Date.now() - cached.at < SHAPE_TTL_MS) {
@@ -1763,6 +1973,16 @@ async function submitMarketingForm(input) {
1763
1973
  }
1764
1974
  return result;
1765
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
+ }
1766
1986
  function publicForm(entry) {
1767
1987
  return {
1768
1988
  slug: entry.slug,
@@ -2130,6 +2350,7 @@ function createFormsService(strapi, _opts = {}) {
2130
2350
  }
2131
2351
  }
2132
2352
  let usedFormsApi = false;
2353
+ let sentFieldNames = [];
2133
2354
  if (email && apiKey) {
2134
2355
  const groups = groupByObject(form.definition, resolution.values);
2135
2356
  for (const [object, props] of Object.entries(companyGroups)) {
@@ -2137,20 +2358,25 @@ function createFormsService(strapi, _opts = {}) {
2137
2358
  }
2138
2359
  const partition = await partitionBySchema(apiKey, groups);
2139
2360
  rejected = partition.rejected;
2140
- const portalId = sanitizePortalId(strapi.plugin("hubspot").config("portalId", ""));
2141
- const region = String(strapi.plugin("hubspot").config("region", "eu1") || "eu1");
2361
+ const contactProps = stringProps(partition.accepted.contact ?? {});
2362
+ const account = await resolveAccount(strapi);
2363
+ const policy = await resolvePolicy(strapi);
2364
+ const portalId = sanitizePortalId(account.portalId);
2365
+ const region = account.region || "eu1";
2142
2366
  const ownGuid = typeof form.hubspotFormId === "string" ? form.hubspotFormId.trim() : "";
2143
- const formGuid = isFormGuid(ownGuid) ? ownGuid : isFormGuid(config2.defaultFormId) ? config2.defaultFormId.trim() : "";
2144
- if (portalId && formGuid) {
2367
+ const formGuid = isFormGuid(ownGuid) ? ownGuid : isFormGuid(account.defaultFormId) ? account.defaultFormId.trim() : "";
2368
+ const tryFormsApi = policy.submissionMode !== "crm" && Boolean(portalId && formGuid);
2369
+ if (tryFormsApi && portalId && formGuid) {
2145
2370
  try {
2146
2371
  const shape = await loadFormShape(apiKey, formGuid);
2147
2372
  const fields = filterToFormFields(
2148
- toHsFields({ ...stringProps(partition.accepted.contact ?? {}), email }),
2373
+ toHsFields({ ...contactProps, email }),
2149
2374
  shape?.names ?? null
2150
2375
  );
2151
2376
  if (fields.length && !fields.some((f) => f.name === "email")) {
2152
2377
  fields.unshift({ name: "email", value: email });
2153
2378
  }
2379
+ sentFieldNames = fields.map((f) => f.name);
2154
2380
  const submitted = await submitMarketingForm({
2155
2381
  apiKey,
2156
2382
  portalId,
@@ -2187,12 +2413,28 @@ function createFormsService(strapi, _opts = {}) {
2187
2413
  const result = await strapi.plugin("hubspot").service("submit").upsert({
2188
2414
  object: "contact",
2189
2415
  idProperty: "email",
2190
- properties: { ...partition.accepted.contact ?? {}, email }
2416
+ properties: { ...contactProps, email }
2191
2417
  });
2192
2418
  if (result.ok) {
2193
2419
  hubspotSynced = true;
2194
2420
  contactId = result.id;
2195
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
+ }
2196
2438
  }
2197
2439
  const domain = corporateDomain(email);
2198
2440
  const companyProps = partition.accepted.company ?? {};
@@ -2491,7 +2733,32 @@ function createFormsAdminController(strapi) {
2491
2733
  documentId: ctx.params.documentId,
2492
2734
  locale: ctx.query.locale
2493
2735
  });
2494
- 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
+ };
2495
2762
  },
2496
2763
  async unpublish(ctx) {
2497
2764
  await documents().unpublish({
@@ -2955,10 +3222,20 @@ const config = {
2955
3222
  // submissions go through the marketing Forms API);
2956
3223
  // - defaultFormId: portal-wide marketing form GUID when a builder form
2957
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.
2958
3227
  forms: {
2959
3228
  companyFromDomain: true,
2960
3229
  timelineNote: true,
2961
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,
2962
3239
  // Per-IP brakes on the public routes; 0 disables one.
2963
3240
  rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
2964
3241
  }
@@ -3141,15 +3418,24 @@ const controllers = {
3141
3418
  },
3142
3419
  settings: ({ strapi }) => ({
3143
3420
  async get(ctx) {
3144
- ctx.body = await publicSettings(strapi);
3421
+ const settings = await publicSettings(strapi);
3422
+ let forms = [];
3423
+ const { apiKey } = await resolveApiKey(strapi);
3424
+ if (apiKey) {
3425
+ try {
3426
+ forms = await listHubspotForms(apiKey);
3427
+ } catch {
3428
+ forms = [];
3429
+ }
3430
+ }
3431
+ ctx.body = { ...settings, forms };
3145
3432
  },
3146
3433
  async update(ctx) {
3147
- const apiKey = (ctx.request.body?.apiKey ?? "").trim();
3148
- await setStoredSettings(strapi, apiKey ? { apiKey } : null);
3434
+ await patchStoredSettings(strapi, ctx.request.body ?? {});
3149
3435
  ctx.body = await publicSettings(strapi);
3150
3436
  },
3151
3437
  async reset(ctx) {
3152
- await setStoredSettings(strapi, null);
3438
+ await clearStoredApiKey(strapi);
3153
3439
  ctx.body = await publicSettings(strapi);
3154
3440
  }
3155
3441
  })