strapi-plugin-hubspot 0.13.1 → 0.15.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 (36) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +58 -32
  3. package/dist/_chunks/{Forms-CHB-sKem.mjs → Forms-B7eIm0Fi.mjs} +38 -3
  4. package/dist/_chunks/{Forms-lrzuWzoB.js → Forms-ClyI4MJ1.js} +38 -3
  5. package/dist/_chunks/{HubspotFormInput-Bb6it0nP.js → HubspotFormInput-Do7F83V6.js} +1 -1
  6. package/dist/_chunks/{HubspotFormInput-YI09keTC.mjs → HubspotFormInput-cZAiaAaq.mjs} +1 -1
  7. package/dist/_chunks/{HubspotObjectInput-D3A6xzb3.js → HubspotObjectInput-DWqk2C26.js} +3 -3
  8. package/dist/_chunks/{HubspotObjectInput-CzLKT9oj.mjs → HubspotObjectInput-ZZk_7hWq.mjs} +3 -3
  9. package/dist/_chunks/{HubspotPropertyInput-DicskLcN.mjs → HubspotPropertyInput-5bkDGmGo.mjs} +3 -3
  10. package/dist/_chunks/{HubspotPropertyInput-Dss3eUlL.js → HubspotPropertyInput-v9B3wb1a.js} +3 -3
  11. package/dist/_chunks/{Settings-4CbX3SPK.mjs → Settings-QcFbm3s5.mjs} +132 -36
  12. package/dist/_chunks/{Settings-Ch8tPZAV.js → Settings-X3EHnTaO.js} +131 -35
  13. package/dist/_chunks/{en-D8DJiSxd.mjs → en-DMJeF7EN.mjs} +19 -6
  14. package/dist/_chunks/{en-PYFibnDc.js → en-ZHvaZLi7.js} +19 -6
  15. package/dist/_chunks/{fr-BMjixfXn.mjs → fr-CVAntLmc.mjs} +18 -5
  16. package/dist/_chunks/{fr-yHk4sSEl.js → fr-Ds_2EAzZ.js} +18 -5
  17. package/dist/_chunks/{index-BE2ribUL.mjs → index-BSJAhj8r.mjs} +6 -6
  18. package/dist/_chunks/{index-BAvedd2m.js → index-BpcilDy1.js} +6 -6
  19. package/dist/_chunks/{objectLabels-CycoU0zY.js → objectLabels-CYXXFpNB.js} +1 -1
  20. package/dist/_chunks/{objectLabels-DGJxYjKz.mjs → objectLabels-CrYRzsqe.mjs} +1 -1
  21. package/dist/_chunks/{useHubspotSchema-DRfaI6mF.js → useHubspotSchema-Ba6xZUCL.js} +1 -1
  22. package/dist/_chunks/{useHubspotSchema-e31UsYb2.mjs → useHubspotSchema-CBF4GkmK.mjs} +1 -1
  23. package/dist/admin/index.js +1 -1
  24. package/dist/admin/index.mjs +1 -1
  25. package/dist/admin/src/builder/types.d.ts +2 -0
  26. package/dist/server/index.js +502 -197
  27. package/dist/server/index.mjs +502 -197
  28. package/dist/server/src/__tests__/hsforms.test.d.ts +1 -0
  29. package/dist/server/src/__tests__/settings.test.d.ts +1 -0
  30. package/dist/server/src/content-types.d.ts +8 -0
  31. package/dist/server/src/forms.d.ts +10 -6
  32. package/dist/server/src/hsforms.d.ts +75 -0
  33. package/dist/server/src/importHubspot.d.ts +1 -0
  34. package/dist/server/src/index.d.ts +17 -2
  35. package/dist/server/src/settings.d.ts +36 -5
  36. package/package.json +1 -1
@@ -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,13 @@ 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
+ };
169
179
  async function resolveApiKey(strapi) {
170
180
  const stored = await getStoredSettings(strapi);
171
181
  if (stored.apiKey) return { apiKey: stored.apiKey, source: "settings" };
@@ -175,14 +185,71 @@ async function resolveApiKey(strapi) {
175
185
  if (fromEnv) return { apiKey: fromEnv, source: "env" };
176
186
  return { apiKey: "", source: null };
177
187
  }
178
- async function publicSettings(strapi) {
179
- const { apiKey, source } = await resolveApiKey(strapi);
188
+ async function resolveAccount(strapi) {
189
+ const resolved = await resolveAccountSources(strapi);
190
+ return {
191
+ portalId: resolved.portalId,
192
+ region: resolved.region || "eu1",
193
+ defaultFormId: resolved.defaultFormId
194
+ };
195
+ }
196
+ async function resolveAccountSources(strapi) {
197
+ const stored = await getStoredSettings(strapi);
198
+ const formsCfg = strapi.plugin("hubspot").config("forms", {}) ?? {};
199
+ const portal = first([
200
+ { value: stored.portalId, source: "settings" },
201
+ { value: strapi.plugin("hubspot").config("portalId", ""), source: "config" },
202
+ { value: process.env[ENV_PORTAL], source: "env" }
203
+ ]);
204
+ const region = first(
205
+ [
206
+ { value: stored.region, source: "settings" },
207
+ { value: strapi.plugin("hubspot").config("region", ""), source: "config" },
208
+ { value: process.env[ENV_REGION], source: "env" }
209
+ ],
210
+ "eu1"
211
+ );
212
+ const form = first([
213
+ { value: stored.defaultFormId, source: "settings" },
214
+ { value: formsCfg.defaultFormId, source: "config" },
215
+ { value: process.env[ENV_DEFAULT_FORM], source: "env" }
216
+ ]);
217
+ const { apiKey, source: keySource } = await resolveApiKey(strapi);
180
218
  return {
181
219
  configured: Boolean(apiKey),
182
- keySource: source,
183
- hint: apiKey ? `…${apiKey.slice(-4)}` : ""
220
+ keySource,
221
+ hint: apiKey ? `…${apiKey.slice(-4)}` : "",
222
+ portalId: portal.value,
223
+ region: region.value || "eu1",
224
+ defaultFormId: form.value,
225
+ portalSource: portal.source,
226
+ regionSource: region.source,
227
+ formSource: form.source
184
228
  };
185
229
  }
230
+ async function publicSettings(strapi) {
231
+ return resolveAccountSources(strapi);
232
+ }
233
+ async function patchStoredSettings(strapi, body) {
234
+ const current = await getStoredSettings(strapi);
235
+ const next = { ...current };
236
+ if (typeof body.apiKey === "string" && body.apiKey.trim()) {
237
+ next.apiKey = body.apiKey.trim();
238
+ }
239
+ if ("portalId" in body) next.portalId = String(body.portalId ?? "").trim();
240
+ if ("region" in body) {
241
+ const region = String(body.region ?? "").trim().toLowerCase();
242
+ next.region = region || "eu1";
243
+ }
244
+ if ("defaultFormId" in body) next.defaultFormId = String(body.defaultFormId ?? "").trim();
245
+ await setStoredSettings(strapi, next);
246
+ }
247
+ async function clearStoredApiKey(strapi) {
248
+ const current = await getStoredSettings(strapi);
249
+ const { apiKey: _apiKey, ...rest } = current;
250
+ const empty = !rest.portalId && !rest.region && !rest.defaultFormId;
251
+ await setStoredSettings(strapi, empty ? null : rest);
252
+ }
186
253
  function collectMappings(node, target, found = []) {
187
254
  if (Array.isArray(node)) {
188
255
  for (const item of node) collectMappings(item, target, found);
@@ -1246,6 +1313,13 @@ const contentTypes = {
1246
1313
  submitLabel: localizedString(),
1247
1314
  successMessage: localizedString("text"),
1248
1315
  class: { type: "string" },
1316
+ // Marketing form GUID of the connected HubSpot portal. Test portal
1317
+ // today, production later — swap the private app + this GUID, no
1318
+ // code change. Empty = CRM upsert fallback (no native conversion).
1319
+ hubspotFormId: {
1320
+ type: "string",
1321
+ pluginOptions: { i18n: { localized: false } }
1322
+ },
1249
1323
  definition: {
1250
1324
  type: "json",
1251
1325
  required: true,
@@ -1449,6 +1523,312 @@ function resolveSubmission(definition, rawValues) {
1449
1523
  const unknown = Object.keys(rawValues).filter((key) => !declared.has(key));
1450
1524
  return { values, hidden, missingRequired, unknown };
1451
1525
  }
1526
+ const HS_BASE$2 = "https://api.hubapi.com";
1527
+ const OBJECT_BY_TYPE_ID = {
1528
+ "0-1": "contact",
1529
+ "0-2": "company"
1530
+ };
1531
+ const TYPE_MAP = {
1532
+ single_line_text: "text",
1533
+ multi_line_text: "textarea",
1534
+ email: "email",
1535
+ phone: "tel",
1536
+ mobile_phone: "tel",
1537
+ number: "number",
1538
+ dropdown: "select",
1539
+ select: "select",
1540
+ radio: "radio",
1541
+ multiple_checkboxes: "checkbox",
1542
+ single_checkbox: "checkbox",
1543
+ booleancheckbox: "checkbox"
1544
+ };
1545
+ let counter$1 = 0;
1546
+ const makeId$1 = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter$1 += 1).toString(36)}`;
1547
+ function convertCondition(parentFieldId, raw) {
1548
+ const operator = (raw.operator ?? "").toUpperCase();
1549
+ const values = (raw.values ?? []).map((v) => String(v)).filter((v) => v !== "");
1550
+ const spread = (op, logic) => values.length ? { logic, rules: values.map((value) => ({ field: parentFieldId, operator: op, value })) } : null;
1551
+ switch (operator) {
1552
+ case "EQ":
1553
+ case "SET_ANY":
1554
+ return spread("eq", "or");
1555
+ case "NEQ":
1556
+ case "NOT_SET_ANY":
1557
+ return spread("neq", "and");
1558
+ case "CONTAINS":
1559
+ return spread("contains", "or");
1560
+ case "GT":
1561
+ return spread("gt", "or");
1562
+ case "LT":
1563
+ return spread("lt", "or");
1564
+ case "SET":
1565
+ case "NOT_EMPTY":
1566
+ return { logic: "and", rules: [{ field: parentFieldId, operator: "notEmpty" }] };
1567
+ case "NOT_SET":
1568
+ case "EMPTY":
1569
+ return { logic: "and", rules: [{ field: parentFieldId, operator: "empty" }] };
1570
+ default:
1571
+ return null;
1572
+ }
1573
+ }
1574
+ function convertField(raw, ctx) {
1575
+ const label = raw.label || raw.name || "";
1576
+ const name = raw.name ?? "";
1577
+ if (raw.hidden) {
1578
+ ctx.skipped.push({ code: "hidden-field", label });
1579
+ return [];
1580
+ }
1581
+ const object = OBJECT_BY_TYPE_ID[raw.objectTypeId ?? "0-1"];
1582
+ if (!object) {
1583
+ ctx.skipped.push({ code: "object", label, detail: raw.objectTypeId });
1584
+ return [];
1585
+ }
1586
+ const type = TYPE_MAP[raw.fieldType ?? ""];
1587
+ if (!type || !name) {
1588
+ ctx.skipped.push({ code: "field-type", label, detail: raw.fieldType });
1589
+ return [];
1590
+ }
1591
+ if (ctx.usedNames.has(name)) {
1592
+ ctx.skipped.push({ code: "duplicate", label, detail: name });
1593
+ return [];
1594
+ }
1595
+ ctx.usedNames.add(name);
1596
+ const options = (raw.options ?? []).map((o) => ({ value: String(o.value ?? ""), label: o.label || void 0 })).filter((o) => o.value !== "");
1597
+ const field = {
1598
+ id: makeId$1("fld"),
1599
+ name,
1600
+ label,
1601
+ type,
1602
+ required: Boolean(raw.required),
1603
+ placeholder: raw.placeholder || void 0,
1604
+ helpText: raw.description || void 0,
1605
+ ...options.length ? { options } : {},
1606
+ hubspot: { object, property: name },
1607
+ ...ctx.parentCondition ? { visibleIf: ctx.parentCondition } : {}
1608
+ };
1609
+ const out = [field];
1610
+ for (const dependent of raw.dependentFields ?? []) {
1611
+ if (!dependent.field) continue;
1612
+ const childLabel = dependent.field.label || dependent.field.name || "";
1613
+ const condition = dependent.dependentCondition ? convertCondition(field.id, dependent.dependentCondition) : null;
1614
+ if (!condition) {
1615
+ ctx.skipped.push({
1616
+ code: "condition",
1617
+ label: childLabel,
1618
+ detail: dependent.dependentCondition?.operator
1619
+ });
1620
+ }
1621
+ out.push(...convertField(dependent.field, { ...ctx, parentCondition: condition }));
1622
+ }
1623
+ return out;
1624
+ }
1625
+ function convertHubspotForm(raw) {
1626
+ const skipped = [];
1627
+ const usedNames = /* @__PURE__ */ new Set();
1628
+ const fields = [];
1629
+ for (const group of raw.fieldGroups ?? []) {
1630
+ if (group.richText && !(group.fields ?? []).length) {
1631
+ skipped.push({ code: "rich-text", detail: group.richText.slice(0, 80) });
1632
+ continue;
1633
+ }
1634
+ const groupFields = (group.fields ?? []).flatMap(
1635
+ (f) => convertField(f, { usedNames, skipped })
1636
+ );
1637
+ const topLevel = (group.fields ?? []).length;
1638
+ if (topLevel === 2 && groupFields.length >= 2) {
1639
+ groupFields[0].width = "half";
1640
+ groupFields[1].width = "half";
1641
+ }
1642
+ fields.push(...groupFields);
1643
+ }
1644
+ if (raw.legalConsentOptions && Object.keys(raw.legalConsentOptions).length) {
1645
+ skipped.push({ code: "legal-consent" });
1646
+ }
1647
+ const postSubmit = raw.configuration?.postSubmitAction;
1648
+ return {
1649
+ name: raw.name ?? "",
1650
+ submitLabel: raw.displayOptions?.submitButtonText || null,
1651
+ successMessage: postSubmit?.type === "thank_you" && postSubmit.value ? postSubmit.value : null,
1652
+ hubspotFormId: raw.id ?? null,
1653
+ // HubSpot forms are single-page: one step, reorganizable in the builder.
1654
+ definition: { version: 1, steps: [{ id: makeId$1("stp"), fields }] },
1655
+ skipped
1656
+ };
1657
+ }
1658
+ async function hsGet(apiKey, path) {
1659
+ const res = await fetch(`${HS_BASE$2}${path}`, {
1660
+ headers: { Authorization: `Bearer ${apiKey}` }
1661
+ });
1662
+ if (!res.ok) {
1663
+ const body = await res.json().catch(() => ({}));
1664
+ throw Object.assign(new Error(body.message || `HubSpot ${res.status}`), {
1665
+ status: res.status
1666
+ });
1667
+ }
1668
+ return await res.json();
1669
+ }
1670
+ async function listHubspotForms(apiKey) {
1671
+ const out = [];
1672
+ let after;
1673
+ for (let page = 0; page < 10; page += 1) {
1674
+ const query2 = after ? `&after=${encodeURIComponent(after)}` : "";
1675
+ const res = await hsGet(apiKey, `/marketing/v3/forms/?limit=100&formTypes=hubspot${query2}`);
1676
+ for (const form of res.results ?? []) {
1677
+ if (form.id && !form.archived) {
1678
+ out.push({ id: form.id, name: form.name || form.id, updatedAt: form.updatedAt });
1679
+ }
1680
+ }
1681
+ after = res.paging?.next?.after;
1682
+ if (!after) break;
1683
+ }
1684
+ return out.sort((a, b) => a.name.localeCompare(b.name));
1685
+ }
1686
+ async function fetchHubspotForm(apiKey, formId) {
1687
+ return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
1688
+ }
1689
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1690
+ const PORTAL = /^\d{1,12}$/;
1691
+ const HUTK = /^[a-f0-9]{8,128}$/i;
1692
+ const SHAPE_TTL_MS = 5 * 60 * 1e3;
1693
+ const DEFAULT_CONSENT_TEXT = "J'accepte que mes données soient utilisées pour me recontacter.";
1694
+ function isFormGuid(value) {
1695
+ return GUID.test(value.trim());
1696
+ }
1697
+ function sanitizeHutk(value) {
1698
+ if (typeof value !== "string") return void 0;
1699
+ const trimmed = value.trim();
1700
+ return HUTK.test(trimmed) ? trimmed : void 0;
1701
+ }
1702
+ function sanitizePortalId(value) {
1703
+ if (typeof value !== "string" && typeof value !== "number") return void 0;
1704
+ const trimmed = String(value).trim();
1705
+ return PORTAL.test(trimmed) ? trimmed : void 0;
1706
+ }
1707
+ function sanitizeIp(value) {
1708
+ if (typeof value !== "string") return void 0;
1709
+ const trimmed = value.trim();
1710
+ return trimmed && trimmed.length <= 45 ? trimmed : void 0;
1711
+ }
1712
+ function formsHost(region) {
1713
+ const r = (region || "eu1").trim().toLowerCase();
1714
+ if (!r || r === "na1" || r === "na") return "api.hsforms.com";
1715
+ return `api-${r}.hsforms.com`;
1716
+ }
1717
+ function formsSubmitUrl(portalId, formGuid, region = "eu1") {
1718
+ return `https://${formsHost(region)}/submissions/v3/integration/secure/submit/${encodeURIComponent(portalId)}/${encodeURIComponent(formGuid)}`;
1719
+ }
1720
+ function toHsFields(props) {
1721
+ return Object.entries(props).filter(([, value]) => value !== "").map(([name, value]) => ({ name, value }));
1722
+ }
1723
+ function filterToFormFields(fields, allowed) {
1724
+ if (!allowed) return fields;
1725
+ return fields.filter((field) => allowed.has(field.name));
1726
+ }
1727
+ function formFieldNames(raw) {
1728
+ const names = [];
1729
+ for (const group of raw.fieldGroups ?? []) {
1730
+ for (const field of group.fields ?? []) {
1731
+ if (field.name) names.push(field.name);
1732
+ }
1733
+ }
1734
+ return names;
1735
+ }
1736
+ const shapeCache = /* @__PURE__ */ new Map();
1737
+ async function loadFormShape(apiKey, formGuid) {
1738
+ const cached = shapeCache.get(formGuid);
1739
+ if (cached && Date.now() - cached.at < SHAPE_TTL_MS) {
1740
+ return { names: cached.names, hasLegalConsent: cached.hasLegalConsent };
1741
+ }
1742
+ try {
1743
+ const raw = await fetchHubspotForm(apiKey, formGuid);
1744
+ const names = new Set(formFieldNames(raw));
1745
+ const hasLegalConsent = Boolean(
1746
+ raw.legalConsentOptions && typeof raw.legalConsentOptions === "object" && raw.legalConsentOptions.type && raw.legalConsentOptions.type !== "none"
1747
+ );
1748
+ shapeCache.set(formGuid, { at: Date.now(), names, hasLegalConsent });
1749
+ return { names, hasLegalConsent };
1750
+ } catch {
1751
+ return null;
1752
+ }
1753
+ }
1754
+ async function findContactIdByEmail(apiKey, email) {
1755
+ const res = await fetch("https://api.hubapi.com/crm/v3/objects/contacts/search", {
1756
+ method: "POST",
1757
+ headers: {
1758
+ Authorization: `Bearer ${apiKey}`,
1759
+ "Content-Type": "application/json"
1760
+ },
1761
+ body: JSON.stringify({
1762
+ filterGroups: [
1763
+ { filters: [{ propertyName: "email", operator: "EQ", value: email }] }
1764
+ ],
1765
+ properties: ["email"],
1766
+ limit: 1
1767
+ })
1768
+ });
1769
+ const body = await res.json().catch(() => ({}));
1770
+ if (!res.ok) return void 0;
1771
+ return body.results?.[0]?.id;
1772
+ }
1773
+ function contextPayload(ctx) {
1774
+ const out = {};
1775
+ const hutk = sanitizeHutk(ctx?.hutk);
1776
+ if (hutk) out.hutk = hutk;
1777
+ if (typeof ctx?.pageUri === "string" && ctx.pageUri.trim()) {
1778
+ out.pageUri = ctx.pageUri.trim().slice(0, 1e3);
1779
+ }
1780
+ if (typeof ctx?.pageName === "string" && ctx.pageName.trim()) {
1781
+ out.pageName = ctx.pageName.trim().slice(0, 200);
1782
+ }
1783
+ const ip = sanitizeIp(ctx?.ipAddress);
1784
+ if (ip) out.ipAddress = ip;
1785
+ return out;
1786
+ }
1787
+ async function postForm(url, apiKey, payload) {
1788
+ const res = await fetch(url, {
1789
+ method: "POST",
1790
+ headers: {
1791
+ Authorization: `Bearer ${apiKey}`,
1792
+ "Content-Type": "application/json"
1793
+ },
1794
+ body: JSON.stringify(payload)
1795
+ });
1796
+ if (res.ok) return { ok: true, status: res.status };
1797
+ const body = await res.json().catch(() => ({}));
1798
+ const error = body.errors?.[0]?.message || body.message || `HubSpot ${res.status}`;
1799
+ return { ok: false, status: res.status, error };
1800
+ }
1801
+ async function submitMarketingForm(input) {
1802
+ const portalId = sanitizePortalId(input.portalId);
1803
+ const formGuid = input.formGuid.trim();
1804
+ if (!portalId || !isFormGuid(formGuid) || !input.fields.length) {
1805
+ return { ok: false, status: 0, error: "Missing portal, form GUID or fields" };
1806
+ }
1807
+ const url = formsSubmitUrl(portalId, formGuid, input.region);
1808
+ const context = contextPayload(input.context);
1809
+ const base = {
1810
+ fields: input.fields,
1811
+ ...Object.keys(context).length ? { context } : {}
1812
+ };
1813
+ const withConsent = input.consent?.given && input.formHasLegalConsent !== false ? {
1814
+ ...base,
1815
+ legalConsentOptions: {
1816
+ consent: {
1817
+ consentToProcess: true,
1818
+ text: input.consent.text?.trim() || DEFAULT_CONSENT_TEXT
1819
+ }
1820
+ }
1821
+ } : base;
1822
+ let result = await postForm(url, input.apiKey, withConsent);
1823
+ if (!result.ok && result.status === 400 && withConsent !== base) {
1824
+ result = await postForm(url, input.apiKey, base);
1825
+ }
1826
+ if (!result.ok && result.status === 400 && input.fields.length > 1) {
1827
+ const email = input.fields.find((f) => f.name === "email");
1828
+ if (email) result = await postForm(url, input.apiKey, { ...base, fields: [email] });
1829
+ }
1830
+ return result;
1831
+ }
1452
1832
  function publicForm(entry) {
1453
1833
  return {
1454
1834
  slug: entry.slug,
@@ -1557,9 +1937,9 @@ function sanitizeRawValues(raw) {
1557
1937
  }
1558
1938
  return values;
1559
1939
  }
1560
- const HS_BASE$2 = "https://api.hubapi.com";
1561
- const HS_COMPANIES = `${HS_BASE$2}/crm/v3/objects/companies`;
1562
- const HS_NOTES = `${HS_BASE$2}/crm/v3/objects/notes`;
1940
+ const HS_BASE$1 = "https://api.hubapi.com";
1941
+ const HS_COMPANIES = `${HS_BASE$1}/crm/v3/objects/companies`;
1942
+ const HS_NOTES = `${HS_BASE$1}/crm/v3/objects/notes`;
1563
1943
  const ASSOC_NOTE_TO_CONTACT = 202;
1564
1944
  const ASSOC_NOTE_TO_COMPANY = 190;
1565
1945
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -1617,8 +1997,17 @@ function createFormsService(strapi, _opts = {}) {
1617
1997
  const formsConfig = () => ({
1618
1998
  companyFromDomain: true,
1619
1999
  timelineNote: true,
2000
+ defaultFormId: "",
1620
2001
  ...strapi.plugin("hubspot").config("forms", {})
1621
2002
  });
2003
+ const stringProps = (props) => {
2004
+ const out = {};
2005
+ for (const [key, value] of Object.entries(props)) {
2006
+ if (value === "" || value == null) continue;
2007
+ out[key] = typeof value === "boolean" ? value ? "true" : "false" : String(value);
2008
+ }
2009
+ return out;
2010
+ };
1622
2011
  async function partitionBySchema(apiKey, groups) {
1623
2012
  let schema;
1624
2013
  try {
@@ -1658,20 +2047,20 @@ function createFormsService(strapi, _opts = {}) {
1658
2047
  });
1659
2048
  const existingId = found.results?.[0]?.id;
1660
2049
  if (!existingId) return void 0;
1661
- const stringProps = {};
1662
- for (const [key, value] of Object.entries(props)) stringProps[key] = String(value);
2050
+ const stringProps2 = {};
2051
+ for (const [key, value] of Object.entries(props)) stringProps2[key] = String(value);
1663
2052
  await hsJson(apiKey, `${HS_COMPANIES}/${existingId}`, {
1664
2053
  method: "PATCH",
1665
- body: { properties: stringProps }
2054
+ body: { properties: stringProps2 }
1666
2055
  });
1667
2056
  return existingId;
1668
2057
  }
1669
2058
  async function createCompany(apiKey, props) {
1670
- const stringProps = {};
1671
- for (const [key, value] of Object.entries(props)) stringProps[key] = String(value);
2059
+ const stringProps2 = {};
2060
+ for (const [key, value] of Object.entries(props)) stringProps2[key] = String(value);
1672
2061
  const created = await hsJson(apiKey, HS_COMPANIES, {
1673
2062
  method: "POST",
1674
- body: { properties: stringProps }
2063
+ body: { properties: stringProps2 }
1675
2064
  });
1676
2065
  return created.id;
1677
2066
  }
@@ -1684,19 +2073,19 @@ function createFormsService(strapi, _opts = {}) {
1684
2073
  limit: 1
1685
2074
  }
1686
2075
  });
1687
- const stringProps = { domain };
1688
- for (const [key, value] of Object.entries(props)) stringProps[key] = String(value);
2076
+ const stringProps2 = { domain };
2077
+ for (const [key, value] of Object.entries(props)) stringProps2[key] = String(value);
1689
2078
  const existingId = found.results?.[0]?.id;
1690
2079
  if (existingId) {
1691
2080
  await hsJson(apiKey, `${HS_COMPANIES}/${existingId}`, {
1692
2081
  method: "PATCH",
1693
- body: { properties: stringProps }
2082
+ body: { properties: stringProps2 }
1694
2083
  });
1695
2084
  return existingId;
1696
2085
  }
1697
2086
  const created = await hsJson(apiKey, HS_COMPANIES, {
1698
2087
  method: "POST",
1699
- body: { properties: stringProps }
2088
+ body: { properties: stringProps2 }
1700
2089
  });
1701
2090
  return created.id;
1702
2091
  }
@@ -1806,6 +2195,7 @@ function createFormsService(strapi, _opts = {}) {
1806
2195
  });
1807
2196
  }
1808
2197
  }
2198
+ let usedFormsApi = false;
1809
2199
  if (email && apiKey) {
1810
2200
  const groups = groupByObject(form.definition, resolution.values);
1811
2201
  for (const [object, props] of Object.entries(companyGroups)) {
@@ -1813,14 +2203,63 @@ function createFormsService(strapi, _opts = {}) {
1813
2203
  }
1814
2204
  const partition = await partitionBySchema(apiKey, groups);
1815
2205
  rejected = partition.rejected;
1816
- const result = await strapi.plugin("hubspot").service("submit").upsert({
1817
- object: "contact",
1818
- idProperty: "email",
1819
- properties: { ...partition.accepted.contact ?? {}, email }
1820
- });
1821
- if (result.ok) {
1822
- hubspotSynced = true;
1823
- contactId = result.id;
2206
+ const account = await resolveAccount(strapi);
2207
+ const portalId = sanitizePortalId(account.portalId);
2208
+ const region = account.region || "eu1";
2209
+ const ownGuid = typeof form.hubspotFormId === "string" ? form.hubspotFormId.trim() : "";
2210
+ const formGuid = isFormGuid(ownGuid) ? ownGuid : isFormGuid(account.defaultFormId) ? account.defaultFormId.trim() : "";
2211
+ if (portalId && formGuid) {
2212
+ try {
2213
+ const shape = await loadFormShape(apiKey, formGuid);
2214
+ const fields = filterToFormFields(
2215
+ toHsFields({ ...stringProps(partition.accepted.contact ?? {}), email }),
2216
+ shape?.names ?? null
2217
+ );
2218
+ if (fields.length && !fields.some((f) => f.name === "email")) {
2219
+ fields.unshift({ name: "email", value: email });
2220
+ }
2221
+ const submitted = await submitMarketingForm({
2222
+ apiKey,
2223
+ portalId,
2224
+ formGuid,
2225
+ region,
2226
+ fields: fields.length ? fields : [{ name: "email", value: email }],
2227
+ context: {
2228
+ hutk: sanitizeHutk(meta.hutk),
2229
+ pageUri: typeof meta.pageUrl === "string" ? meta.pageUrl : void 0,
2230
+ pageName: typeof meta.pageName === "string" && meta.pageName || (form.title || form.name) || void 0,
2231
+ ipAddress: sanitizeIp(meta.ipAddress)
2232
+ },
2233
+ consent: { given: meta.consent === true },
2234
+ formHasLegalConsent: shape?.hasLegalConsent
2235
+ });
2236
+ if (submitted.ok) {
2237
+ hubspotSynced = true;
2238
+ usedFormsApi = true;
2239
+ try {
2240
+ contactId = await findContactIdByEmail(apiKey, email);
2241
+ } catch (err) {
2242
+ strapi.log.warn(
2243
+ `[hubspot] contact lookup after form submit failed — ${err.message}`
2244
+ );
2245
+ }
2246
+ } else {
2247
+ strapi.log.warn(`[hubspot] Forms API submit failed — ${submitted.error}`);
2248
+ }
2249
+ } catch (err) {
2250
+ strapi.log.warn(`[hubspot] Forms API submit failed — ${err.message}`);
2251
+ }
2252
+ }
2253
+ if (!usedFormsApi) {
2254
+ const result = await strapi.plugin("hubspot").service("submit").upsert({
2255
+ object: "contact",
2256
+ idProperty: "email",
2257
+ properties: { ...partition.accepted.contact ?? {}, email }
2258
+ });
2259
+ if (result.ok) {
2260
+ hubspotSynced = true;
2261
+ contactId = result.id;
2262
+ }
1824
2263
  }
1825
2264
  const domain = corporateDomain(email);
1826
2265
  const companyProps = partition.accepted.company ?? {};
@@ -1844,7 +2283,7 @@ function createFormsService(strapi, _opts = {}) {
1844
2283
  if (companyId) {
1845
2284
  await hsJson(
1846
2285
  apiKey,
1847
- `${HS_BASE$2}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
2286
+ `${HS_BASE$1}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
1848
2287
  { method: "PUT" }
1849
2288
  );
1850
2289
  }
@@ -1852,7 +2291,7 @@ function createFormsService(strapi, _opts = {}) {
1852
2291
  strapi.log.warn(`[hubspot] company sync failed (contact OK) — ${err.message}`);
1853
2292
  }
1854
2293
  }
1855
- if (contactId && config2.timelineNote) {
2294
+ if (contactId && config2.timelineNote && !usedFormsApi) {
1856
2295
  try {
1857
2296
  await createLeadNote(apiKey, {
1858
2297
  contactId,
@@ -1887,168 +2326,6 @@ function createFormsService(strapi, _opts = {}) {
1887
2326
  }
1888
2327
  return { submit };
1889
2328
  }
1890
- const HS_BASE$1 = "https://api.hubapi.com";
1891
- const OBJECT_BY_TYPE_ID = {
1892
- "0-1": "contact",
1893
- "0-2": "company"
1894
- };
1895
- const TYPE_MAP = {
1896
- single_line_text: "text",
1897
- multi_line_text: "textarea",
1898
- email: "email",
1899
- phone: "tel",
1900
- mobile_phone: "tel",
1901
- number: "number",
1902
- dropdown: "select",
1903
- select: "select",
1904
- radio: "radio",
1905
- multiple_checkboxes: "checkbox",
1906
- single_checkbox: "checkbox",
1907
- booleancheckbox: "checkbox"
1908
- };
1909
- let counter$1 = 0;
1910
- const makeId$1 = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter$1 += 1).toString(36)}`;
1911
- function convertCondition(parentFieldId, raw) {
1912
- const operator = (raw.operator ?? "").toUpperCase();
1913
- const values = (raw.values ?? []).map((v) => String(v)).filter((v) => v !== "");
1914
- const spread = (op, logic) => values.length ? { logic, rules: values.map((value) => ({ field: parentFieldId, operator: op, value })) } : null;
1915
- switch (operator) {
1916
- case "EQ":
1917
- case "SET_ANY":
1918
- return spread("eq", "or");
1919
- case "NEQ":
1920
- case "NOT_SET_ANY":
1921
- return spread("neq", "and");
1922
- case "CONTAINS":
1923
- return spread("contains", "or");
1924
- case "GT":
1925
- return spread("gt", "or");
1926
- case "LT":
1927
- return spread("lt", "or");
1928
- case "SET":
1929
- case "NOT_EMPTY":
1930
- return { logic: "and", rules: [{ field: parentFieldId, operator: "notEmpty" }] };
1931
- case "NOT_SET":
1932
- case "EMPTY":
1933
- return { logic: "and", rules: [{ field: parentFieldId, operator: "empty" }] };
1934
- default:
1935
- return null;
1936
- }
1937
- }
1938
- function convertField(raw, ctx) {
1939
- const label = raw.label || raw.name || "";
1940
- const name = raw.name ?? "";
1941
- if (raw.hidden) {
1942
- ctx.skipped.push({ code: "hidden-field", label });
1943
- return [];
1944
- }
1945
- const object = OBJECT_BY_TYPE_ID[raw.objectTypeId ?? "0-1"];
1946
- if (!object) {
1947
- ctx.skipped.push({ code: "object", label, detail: raw.objectTypeId });
1948
- return [];
1949
- }
1950
- const type = TYPE_MAP[raw.fieldType ?? ""];
1951
- if (!type || !name) {
1952
- ctx.skipped.push({ code: "field-type", label, detail: raw.fieldType });
1953
- return [];
1954
- }
1955
- if (ctx.usedNames.has(name)) {
1956
- ctx.skipped.push({ code: "duplicate", label, detail: name });
1957
- return [];
1958
- }
1959
- ctx.usedNames.add(name);
1960
- const options = (raw.options ?? []).map((o) => ({ value: String(o.value ?? ""), label: o.label || void 0 })).filter((o) => o.value !== "");
1961
- const field = {
1962
- id: makeId$1("fld"),
1963
- name,
1964
- label,
1965
- type,
1966
- required: Boolean(raw.required),
1967
- placeholder: raw.placeholder || void 0,
1968
- helpText: raw.description || void 0,
1969
- ...options.length ? { options } : {},
1970
- hubspot: { object, property: name },
1971
- ...ctx.parentCondition ? { visibleIf: ctx.parentCondition } : {}
1972
- };
1973
- const out = [field];
1974
- for (const dependent of raw.dependentFields ?? []) {
1975
- if (!dependent.field) continue;
1976
- const childLabel = dependent.field.label || dependent.field.name || "";
1977
- const condition = dependent.dependentCondition ? convertCondition(field.id, dependent.dependentCondition) : null;
1978
- if (!condition) {
1979
- ctx.skipped.push({
1980
- code: "condition",
1981
- label: childLabel,
1982
- detail: dependent.dependentCondition?.operator
1983
- });
1984
- }
1985
- out.push(...convertField(dependent.field, { ...ctx, parentCondition: condition }));
1986
- }
1987
- return out;
1988
- }
1989
- function convertHubspotForm(raw) {
1990
- const skipped = [];
1991
- const usedNames = /* @__PURE__ */ new Set();
1992
- const fields = [];
1993
- for (const group of raw.fieldGroups ?? []) {
1994
- if (group.richText && !(group.fields ?? []).length) {
1995
- skipped.push({ code: "rich-text", detail: group.richText.slice(0, 80) });
1996
- continue;
1997
- }
1998
- const groupFields = (group.fields ?? []).flatMap(
1999
- (f) => convertField(f, { usedNames, skipped })
2000
- );
2001
- const topLevel = (group.fields ?? []).length;
2002
- if (topLevel === 2 && groupFields.length >= 2) {
2003
- groupFields[0].width = "half";
2004
- groupFields[1].width = "half";
2005
- }
2006
- fields.push(...groupFields);
2007
- }
2008
- if (raw.legalConsentOptions && Object.keys(raw.legalConsentOptions).length) {
2009
- skipped.push({ code: "legal-consent" });
2010
- }
2011
- const postSubmit = raw.configuration?.postSubmitAction;
2012
- return {
2013
- name: raw.name ?? "",
2014
- submitLabel: raw.displayOptions?.submitButtonText || null,
2015
- successMessage: postSubmit?.type === "thank_you" && postSubmit.value ? postSubmit.value : null,
2016
- // HubSpot forms are single-page: one step, reorganizable in the builder.
2017
- definition: { version: 1, steps: [{ id: makeId$1("stp"), fields }] },
2018
- skipped
2019
- };
2020
- }
2021
- async function hsGet(apiKey, path) {
2022
- const res = await fetch(`${HS_BASE$1}${path}`, {
2023
- headers: { Authorization: `Bearer ${apiKey}` }
2024
- });
2025
- if (!res.ok) {
2026
- const body = await res.json().catch(() => ({}));
2027
- throw Object.assign(new Error(body.message || `HubSpot ${res.status}`), {
2028
- status: res.status
2029
- });
2030
- }
2031
- return await res.json();
2032
- }
2033
- async function listHubspotForms(apiKey) {
2034
- const out = [];
2035
- let after;
2036
- for (let page = 0; page < 10; page += 1) {
2037
- const query2 = after ? `&after=${encodeURIComponent(after)}` : "";
2038
- const res = await hsGet(apiKey, `/marketing/v3/forms/?limit=100&formTypes=hubspot${query2}`);
2039
- for (const form of res.results ?? []) {
2040
- if (form.id && !form.archived) {
2041
- out.push({ id: form.id, name: form.name || form.id, updatedAt: form.updatedAt });
2042
- }
2043
- }
2044
- after = res.paging?.next?.after;
2045
- if (!after) break;
2046
- }
2047
- return out.sort((a, b) => a.name.localeCompare(b.name));
2048
- }
2049
- async function fetchHubspotForm(apiKey, formId) {
2050
- return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
2051
- }
2052
2329
  let counter = 0;
2053
2330
  const makeId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter += 1).toString(36)}`;
2054
2331
  const slugifyName = (label) => label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
@@ -2127,7 +2404,8 @@ const WRITABLE = [
2127
2404
  "nextLabel",
2128
2405
  "submitLabel",
2129
2406
  "successMessage",
2130
- "class"
2407
+ "class",
2408
+ "hubspotFormId"
2131
2409
  ];
2132
2410
  const slugify = (value) => value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "form";
2133
2411
  function createFormsAdminController(strapi) {
@@ -2492,6 +2770,7 @@ function createFormsAdminController(strapi) {
2492
2770
  submitLabel: source.submitLabel,
2493
2771
  successMessage: source.successMessage,
2494
2772
  class: source.class,
2773
+ hubspotFormId: source.hubspotFormId ?? null,
2495
2774
  definition: source.definition
2496
2775
  };
2497
2776
  if (targetId) {
@@ -2726,17 +3005,27 @@ const FORMS_ACTION = "plugin::hubspot.forms";
2726
3005
  const config = {
2727
3006
  default: {
2728
3007
  apiKey: "",
3008
+ // HubSpot account that receives form submissions. A test portal id is
3009
+ // fine in development; production swaps this env var, not the code.
3010
+ portalId: "",
3011
+ // Hosting region of that portal (`eu1`, `na1`, …). Drives the Forms API
3012
+ // host (`api-eu1.hsforms.com`) — CRM stays on api.hubapi.com.
3013
+ region: "eu1",
2729
3014
  // Objects whose properties are offered. Names from the standard set, or
2730
3015
  // `{ name, path }` for a custom object type.
2731
3016
  objects: ["contact", "company"],
2732
3017
  validate: [],
2733
- // Submission pipeline of the built forms. Both default to true:
3018
+ // Submission pipeline of the built forms:
2734
3019
  // - companyFromDomain: a corporate email upserts the Company (deduped by
2735
3020
  // domain) and associates it to the contact;
2736
- // - timelineNote: a note recaps the submission on the contact's timeline.
3021
+ // - timelineNote: recap note on the CRM-upsert fallback only (off when
3022
+ // submissions go through the marketing Forms API);
3023
+ // - defaultFormId: portal-wide marketing form GUID when a builder form
3024
+ // doesn't set its own — swap with the portal at production cutover.
2737
3025
  forms: {
2738
3026
  companyFromDomain: true,
2739
3027
  timelineNote: true,
3028
+ defaultFormId: "",
2740
3029
  // Per-IP brakes on the public routes; 0 disables one.
2741
3030
  rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
2742
3031
  }
@@ -2892,7 +3181,7 @@ const controllers = {
2892
3181
  if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
2893
3182
  const rawMeta = ctx.request.body?.meta ?? {};
2894
3183
  const meta = {};
2895
- for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
3184
+ for (const key of ["pagePath", "pageUrl", "pageName", "originPath", "originLabel", "source", "ipAddress"]) {
2896
3185
  const v = rawMeta[key];
2897
3186
  if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
2898
3187
  }
@@ -2900,6 +3189,13 @@ const controllers = {
2900
3189
  if (typeof rawMeta.consentedAt === "string" && rawMeta.consentedAt.trim()) {
2901
3190
  meta.consentedAt = rawMeta.consentedAt.trim().slice(0, 40);
2902
3191
  }
3192
+ if (typeof rawMeta.hutk === "string" && rawMeta.hutk.trim()) {
3193
+ meta.hutk = rawMeta.hutk.trim().slice(0, 128);
3194
+ }
3195
+ if (!meta.ipAddress) {
3196
+ const ip = ctx.request.ip;
3197
+ if (ip) meta.ipAddress = ip.slice(0, 45);
3198
+ }
2903
3199
  const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
2904
3200
  if (!outcome.ok) {
2905
3201
  ctx.status = 422;
@@ -2912,15 +3208,24 @@ const controllers = {
2912
3208
  },
2913
3209
  settings: ({ strapi }) => ({
2914
3210
  async get(ctx) {
2915
- ctx.body = await publicSettings(strapi);
3211
+ const settings = await publicSettings(strapi);
3212
+ let forms = [];
3213
+ const { apiKey } = await resolveApiKey(strapi);
3214
+ if (apiKey) {
3215
+ try {
3216
+ forms = await listHubspotForms(apiKey);
3217
+ } catch {
3218
+ forms = [];
3219
+ }
3220
+ }
3221
+ ctx.body = { ...settings, forms };
2916
3222
  },
2917
3223
  async update(ctx) {
2918
- const apiKey = (ctx.request.body?.apiKey ?? "").trim();
2919
- await setStoredSettings(strapi, apiKey ? { apiKey } : null);
3224
+ await patchStoredSettings(strapi, ctx.request.body ?? {});
2920
3225
  ctx.body = await publicSettings(strapi);
2921
3226
  },
2922
3227
  async reset(ctx) {
2923
- await setStoredSettings(strapi, null);
3228
+ await clearStoredApiKey(strapi);
2924
3229
  ctx.body = await publicSettings(strapi);
2925
3230
  }
2926
3231
  })