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