strapi-plugin-hubspot 0.13.1 → 0.14.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 (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +42 -25
  3. package/dist/_chunks/{Forms-lrzuWzoB.js → Forms-Cch-7zei.js} +38 -3
  4. package/dist/_chunks/{Forms-CHB-sKem.mjs → Forms-ws6d8rdC.mjs} +38 -3
  5. package/dist/_chunks/{HubspotFormInput-YI09keTC.mjs → HubspotFormInput-18pWTITT.mjs} +1 -1
  6. package/dist/_chunks/{HubspotFormInput-Bb6it0nP.js → HubspotFormInput-M2l_8w_e.js} +1 -1
  7. package/dist/_chunks/{HubspotObjectInput-D3A6xzb3.js → HubspotObjectInput-BDO99qXs.js} +3 -3
  8. package/dist/_chunks/{HubspotObjectInput-CzLKT9oj.mjs → HubspotObjectInput-DshusXZP.mjs} +3 -3
  9. package/dist/_chunks/{HubspotPropertyInput-DicskLcN.mjs → HubspotPropertyInput-C-kHDgwx.mjs} +3 -3
  10. package/dist/_chunks/{HubspotPropertyInput-Dss3eUlL.js → HubspotPropertyInput-XGnH2tHn.js} +3 -3
  11. package/dist/_chunks/{Settings-4CbX3SPK.mjs → Settings-Bm7WkzK1.mjs} +2 -2
  12. package/dist/_chunks/{Settings-Ch8tPZAV.js → Settings-CRnsu6ob.js} +2 -2
  13. package/dist/_chunks/{en-PYFibnDc.js → en-BXMU5CjT.js} +4 -0
  14. package/dist/_chunks/{en-D8DJiSxd.mjs → en-DKV-8FWP.mjs} +4 -0
  15. package/dist/_chunks/{fr-yHk4sSEl.js → fr-Dx4XmZ4X.js} +4 -0
  16. package/dist/_chunks/{fr-BMjixfXn.mjs → fr-GB6T3ZyU.mjs} +4 -0
  17. package/dist/_chunks/{index-BAvedd2m.js → index-BlaG8oIq.js} +6 -6
  18. package/dist/_chunks/{index-BE2ribUL.mjs → index-CPVfElYB.mjs} +6 -6
  19. package/dist/_chunks/{objectLabels-DGJxYjKz.mjs → objectLabels-6OUnIDB5.mjs} +1 -1
  20. package/dist/_chunks/{objectLabels-CycoU0zY.js → objectLabels-CPc21Tdd.js} +1 -1
  21. package/dist/_chunks/{useHubspotSchema-DRfaI6mF.js → useHubspotSchema-0MvCM1AC.js} +1 -1
  22. package/dist/_chunks/{useHubspotSchema-e31UsYb2.mjs → useHubspotSchema-qCUs2gIX.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 +417 -189
  27. package/dist/server/index.mjs +417 -189
  28. package/dist/server/src/__tests__/hsforms.test.d.ts +1 -0
  29. package/dist/server/src/content-types.d.ts +8 -0
  30. package/dist/server/src/forms.d.ts +10 -6
  31. package/dist/server/src/hsforms.d.ts +75 -0
  32. package/dist/server/src/importHubspot.d.ts +1 -0
  33. package/dist/server/src/index.d.ts +12 -1
  34. package/package.json +1 -1
@@ -1247,6 +1247,13 @@ const contentTypes = {
1247
1247
  submitLabel: localizedString(),
1248
1248
  successMessage: localizedString("text"),
1249
1249
  class: { type: "string" },
1250
+ // Marketing form GUID of the connected HubSpot portal. Test portal
1251
+ // today, production later — swap the private app + this GUID, no
1252
+ // code change. Empty = CRM upsert fallback (no native conversion).
1253
+ hubspotFormId: {
1254
+ type: "string",
1255
+ pluginOptions: { i18n: { localized: false } }
1256
+ },
1250
1257
  definition: {
1251
1258
  type: "json",
1252
1259
  required: true,
@@ -1450,6 +1457,312 @@ function resolveSubmission(definition, rawValues) {
1450
1457
  const unknown = Object.keys(rawValues).filter((key) => !declared.has(key));
1451
1458
  return { values, hidden, missingRequired, unknown };
1452
1459
  }
1460
+ const HS_BASE$2 = "https://api.hubapi.com";
1461
+ const OBJECT_BY_TYPE_ID = {
1462
+ "0-1": "contact",
1463
+ "0-2": "company"
1464
+ };
1465
+ const TYPE_MAP = {
1466
+ single_line_text: "text",
1467
+ multi_line_text: "textarea",
1468
+ email: "email",
1469
+ phone: "tel",
1470
+ mobile_phone: "tel",
1471
+ number: "number",
1472
+ dropdown: "select",
1473
+ select: "select",
1474
+ radio: "radio",
1475
+ multiple_checkboxes: "checkbox",
1476
+ single_checkbox: "checkbox",
1477
+ booleancheckbox: "checkbox"
1478
+ };
1479
+ let counter$1 = 0;
1480
+ const makeId$1 = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter$1 += 1).toString(36)}`;
1481
+ function convertCondition(parentFieldId, raw) {
1482
+ const operator = (raw.operator ?? "").toUpperCase();
1483
+ const values = (raw.values ?? []).map((v) => String(v)).filter((v) => v !== "");
1484
+ const spread = (op, logic) => values.length ? { logic, rules: values.map((value) => ({ field: parentFieldId, operator: op, value })) } : null;
1485
+ switch (operator) {
1486
+ case "EQ":
1487
+ case "SET_ANY":
1488
+ return spread("eq", "or");
1489
+ case "NEQ":
1490
+ case "NOT_SET_ANY":
1491
+ return spread("neq", "and");
1492
+ case "CONTAINS":
1493
+ return spread("contains", "or");
1494
+ case "GT":
1495
+ return spread("gt", "or");
1496
+ case "LT":
1497
+ return spread("lt", "or");
1498
+ case "SET":
1499
+ case "NOT_EMPTY":
1500
+ return { logic: "and", rules: [{ field: parentFieldId, operator: "notEmpty" }] };
1501
+ case "NOT_SET":
1502
+ case "EMPTY":
1503
+ return { logic: "and", rules: [{ field: parentFieldId, operator: "empty" }] };
1504
+ default:
1505
+ return null;
1506
+ }
1507
+ }
1508
+ function convertField(raw, ctx) {
1509
+ const label = raw.label || raw.name || "";
1510
+ const name = raw.name ?? "";
1511
+ if (raw.hidden) {
1512
+ ctx.skipped.push({ code: "hidden-field", label });
1513
+ return [];
1514
+ }
1515
+ const object = OBJECT_BY_TYPE_ID[raw.objectTypeId ?? "0-1"];
1516
+ if (!object) {
1517
+ ctx.skipped.push({ code: "object", label, detail: raw.objectTypeId });
1518
+ return [];
1519
+ }
1520
+ const type = TYPE_MAP[raw.fieldType ?? ""];
1521
+ if (!type || !name) {
1522
+ ctx.skipped.push({ code: "field-type", label, detail: raw.fieldType });
1523
+ return [];
1524
+ }
1525
+ if (ctx.usedNames.has(name)) {
1526
+ ctx.skipped.push({ code: "duplicate", label, detail: name });
1527
+ return [];
1528
+ }
1529
+ ctx.usedNames.add(name);
1530
+ const options = (raw.options ?? []).map((o) => ({ value: String(o.value ?? ""), label: o.label || void 0 })).filter((o) => o.value !== "");
1531
+ const field = {
1532
+ id: makeId$1("fld"),
1533
+ name,
1534
+ label,
1535
+ type,
1536
+ required: Boolean(raw.required),
1537
+ placeholder: raw.placeholder || void 0,
1538
+ helpText: raw.description || void 0,
1539
+ ...options.length ? { options } : {},
1540
+ hubspot: { object, property: name },
1541
+ ...ctx.parentCondition ? { visibleIf: ctx.parentCondition } : {}
1542
+ };
1543
+ const out = [field];
1544
+ for (const dependent of raw.dependentFields ?? []) {
1545
+ if (!dependent.field) continue;
1546
+ const childLabel = dependent.field.label || dependent.field.name || "";
1547
+ const condition = dependent.dependentCondition ? convertCondition(field.id, dependent.dependentCondition) : null;
1548
+ if (!condition) {
1549
+ ctx.skipped.push({
1550
+ code: "condition",
1551
+ label: childLabel,
1552
+ detail: dependent.dependentCondition?.operator
1553
+ });
1554
+ }
1555
+ out.push(...convertField(dependent.field, { ...ctx, parentCondition: condition }));
1556
+ }
1557
+ return out;
1558
+ }
1559
+ function convertHubspotForm(raw) {
1560
+ const skipped = [];
1561
+ const usedNames = /* @__PURE__ */ new Set();
1562
+ const fields = [];
1563
+ for (const group of raw.fieldGroups ?? []) {
1564
+ if (group.richText && !(group.fields ?? []).length) {
1565
+ skipped.push({ code: "rich-text", detail: group.richText.slice(0, 80) });
1566
+ continue;
1567
+ }
1568
+ const groupFields = (group.fields ?? []).flatMap(
1569
+ (f) => convertField(f, { usedNames, skipped })
1570
+ );
1571
+ const topLevel = (group.fields ?? []).length;
1572
+ if (topLevel === 2 && groupFields.length >= 2) {
1573
+ groupFields[0].width = "half";
1574
+ groupFields[1].width = "half";
1575
+ }
1576
+ fields.push(...groupFields);
1577
+ }
1578
+ if (raw.legalConsentOptions && Object.keys(raw.legalConsentOptions).length) {
1579
+ skipped.push({ code: "legal-consent" });
1580
+ }
1581
+ const postSubmit = raw.configuration?.postSubmitAction;
1582
+ return {
1583
+ name: raw.name ?? "",
1584
+ submitLabel: raw.displayOptions?.submitButtonText || null,
1585
+ successMessage: postSubmit?.type === "thank_you" && postSubmit.value ? postSubmit.value : null,
1586
+ hubspotFormId: raw.id ?? null,
1587
+ // HubSpot forms are single-page: one step, reorganizable in the builder.
1588
+ definition: { version: 1, steps: [{ id: makeId$1("stp"), fields }] },
1589
+ skipped
1590
+ };
1591
+ }
1592
+ async function hsGet(apiKey, path) {
1593
+ const res = await fetch(`${HS_BASE$2}${path}`, {
1594
+ headers: { Authorization: `Bearer ${apiKey}` }
1595
+ });
1596
+ if (!res.ok) {
1597
+ const body = await res.json().catch(() => ({}));
1598
+ throw Object.assign(new Error(body.message || `HubSpot ${res.status}`), {
1599
+ status: res.status
1600
+ });
1601
+ }
1602
+ return await res.json();
1603
+ }
1604
+ async function listHubspotForms(apiKey) {
1605
+ const out = [];
1606
+ let after;
1607
+ for (let page = 0; page < 10; page += 1) {
1608
+ const query2 = after ? `&after=${encodeURIComponent(after)}` : "";
1609
+ const res = await hsGet(apiKey, `/marketing/v3/forms/?limit=100&formTypes=hubspot${query2}`);
1610
+ for (const form of res.results ?? []) {
1611
+ if (form.id && !form.archived) {
1612
+ out.push({ id: form.id, name: form.name || form.id, updatedAt: form.updatedAt });
1613
+ }
1614
+ }
1615
+ after = res.paging?.next?.after;
1616
+ if (!after) break;
1617
+ }
1618
+ return out.sort((a, b) => a.name.localeCompare(b.name));
1619
+ }
1620
+ async function fetchHubspotForm(apiKey, formId) {
1621
+ return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
1622
+ }
1623
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1624
+ const PORTAL = /^\d{1,12}$/;
1625
+ const HUTK = /^[a-f0-9]{8,128}$/i;
1626
+ const SHAPE_TTL_MS = 5 * 60 * 1e3;
1627
+ const DEFAULT_CONSENT_TEXT = "J'accepte que mes données soient utilisées pour me recontacter.";
1628
+ function isFormGuid(value) {
1629
+ return GUID.test(value.trim());
1630
+ }
1631
+ function sanitizeHutk(value) {
1632
+ if (typeof value !== "string") return void 0;
1633
+ const trimmed = value.trim();
1634
+ return HUTK.test(trimmed) ? trimmed : void 0;
1635
+ }
1636
+ function sanitizePortalId(value) {
1637
+ if (typeof value !== "string" && typeof value !== "number") return void 0;
1638
+ const trimmed = String(value).trim();
1639
+ return PORTAL.test(trimmed) ? trimmed : void 0;
1640
+ }
1641
+ function sanitizeIp(value) {
1642
+ if (typeof value !== "string") return void 0;
1643
+ const trimmed = value.trim();
1644
+ return trimmed && trimmed.length <= 45 ? trimmed : void 0;
1645
+ }
1646
+ function formsHost(region) {
1647
+ const r = (region || "eu1").trim().toLowerCase();
1648
+ if (!r || r === "na1" || r === "na") return "api.hsforms.com";
1649
+ return `api-${r}.hsforms.com`;
1650
+ }
1651
+ function formsSubmitUrl(portalId, formGuid, region = "eu1") {
1652
+ return `https://${formsHost(region)}/submissions/v3/integration/secure/submit/${encodeURIComponent(portalId)}/${encodeURIComponent(formGuid)}`;
1653
+ }
1654
+ function toHsFields(props) {
1655
+ return Object.entries(props).filter(([, value]) => value !== "").map(([name, value]) => ({ name, value }));
1656
+ }
1657
+ function filterToFormFields(fields, allowed) {
1658
+ if (!allowed) return fields;
1659
+ return fields.filter((field) => allowed.has(field.name));
1660
+ }
1661
+ function formFieldNames(raw) {
1662
+ const names = [];
1663
+ for (const group of raw.fieldGroups ?? []) {
1664
+ for (const field of group.fields ?? []) {
1665
+ if (field.name) names.push(field.name);
1666
+ }
1667
+ }
1668
+ return names;
1669
+ }
1670
+ const shapeCache = /* @__PURE__ */ new Map();
1671
+ async function loadFormShape(apiKey, formGuid) {
1672
+ const cached = shapeCache.get(formGuid);
1673
+ if (cached && Date.now() - cached.at < SHAPE_TTL_MS) {
1674
+ return { names: cached.names, hasLegalConsent: cached.hasLegalConsent };
1675
+ }
1676
+ try {
1677
+ const raw = await fetchHubspotForm(apiKey, formGuid);
1678
+ const names = new Set(formFieldNames(raw));
1679
+ const hasLegalConsent = Boolean(
1680
+ raw.legalConsentOptions && typeof raw.legalConsentOptions === "object" && raw.legalConsentOptions.type && raw.legalConsentOptions.type !== "none"
1681
+ );
1682
+ shapeCache.set(formGuid, { at: Date.now(), names, hasLegalConsent });
1683
+ return { names, hasLegalConsent };
1684
+ } catch {
1685
+ return null;
1686
+ }
1687
+ }
1688
+ async function findContactIdByEmail(apiKey, email) {
1689
+ const res = await fetch("https://api.hubapi.com/crm/v3/objects/contacts/search", {
1690
+ method: "POST",
1691
+ headers: {
1692
+ Authorization: `Bearer ${apiKey}`,
1693
+ "Content-Type": "application/json"
1694
+ },
1695
+ body: JSON.stringify({
1696
+ filterGroups: [
1697
+ { filters: [{ propertyName: "email", operator: "EQ", value: email }] }
1698
+ ],
1699
+ properties: ["email"],
1700
+ limit: 1
1701
+ })
1702
+ });
1703
+ const body = await res.json().catch(() => ({}));
1704
+ if (!res.ok) return void 0;
1705
+ return body.results?.[0]?.id;
1706
+ }
1707
+ function contextPayload(ctx) {
1708
+ const out = {};
1709
+ const hutk = sanitizeHutk(ctx?.hutk);
1710
+ if (hutk) out.hutk = hutk;
1711
+ if (typeof ctx?.pageUri === "string" && ctx.pageUri.trim()) {
1712
+ out.pageUri = ctx.pageUri.trim().slice(0, 1e3);
1713
+ }
1714
+ if (typeof ctx?.pageName === "string" && ctx.pageName.trim()) {
1715
+ out.pageName = ctx.pageName.trim().slice(0, 200);
1716
+ }
1717
+ const ip = sanitizeIp(ctx?.ipAddress);
1718
+ if (ip) out.ipAddress = ip;
1719
+ return out;
1720
+ }
1721
+ async function postForm(url, apiKey, payload) {
1722
+ const res = await fetch(url, {
1723
+ method: "POST",
1724
+ headers: {
1725
+ Authorization: `Bearer ${apiKey}`,
1726
+ "Content-Type": "application/json"
1727
+ },
1728
+ body: JSON.stringify(payload)
1729
+ });
1730
+ if (res.ok) return { ok: true, status: res.status };
1731
+ const body = await res.json().catch(() => ({}));
1732
+ const error = body.errors?.[0]?.message || body.message || `HubSpot ${res.status}`;
1733
+ return { ok: false, status: res.status, error };
1734
+ }
1735
+ async function submitMarketingForm(input) {
1736
+ const portalId = sanitizePortalId(input.portalId);
1737
+ const formGuid = input.formGuid.trim();
1738
+ if (!portalId || !isFormGuid(formGuid) || !input.fields.length) {
1739
+ return { ok: false, status: 0, error: "Missing portal, form GUID or fields" };
1740
+ }
1741
+ const url = formsSubmitUrl(portalId, formGuid, input.region);
1742
+ const context = contextPayload(input.context);
1743
+ const base = {
1744
+ fields: input.fields,
1745
+ ...Object.keys(context).length ? { context } : {}
1746
+ };
1747
+ const withConsent = input.consent?.given && input.formHasLegalConsent !== false ? {
1748
+ ...base,
1749
+ legalConsentOptions: {
1750
+ consent: {
1751
+ consentToProcess: true,
1752
+ text: input.consent.text?.trim() || DEFAULT_CONSENT_TEXT
1753
+ }
1754
+ }
1755
+ } : base;
1756
+ let result = await postForm(url, input.apiKey, withConsent);
1757
+ if (!result.ok && result.status === 400 && withConsent !== base) {
1758
+ result = await postForm(url, input.apiKey, base);
1759
+ }
1760
+ if (!result.ok && result.status === 400 && input.fields.length > 1) {
1761
+ const email = input.fields.find((f) => f.name === "email");
1762
+ if (email) result = await postForm(url, input.apiKey, { ...base, fields: [email] });
1763
+ }
1764
+ return result;
1765
+ }
1453
1766
  function publicForm(entry) {
1454
1767
  return {
1455
1768
  slug: entry.slug,
@@ -1558,9 +1871,9 @@ function sanitizeRawValues(raw) {
1558
1871
  }
1559
1872
  return values;
1560
1873
  }
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`;
1874
+ const HS_BASE$1 = "https://api.hubapi.com";
1875
+ const HS_COMPANIES = `${HS_BASE$1}/crm/v3/objects/companies`;
1876
+ const HS_NOTES = `${HS_BASE$1}/crm/v3/objects/notes`;
1564
1877
  const ASSOC_NOTE_TO_CONTACT = 202;
1565
1878
  const ASSOC_NOTE_TO_COMPANY = 190;
1566
1879
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -1618,8 +1931,17 @@ function createFormsService(strapi, _opts = {}) {
1618
1931
  const formsConfig = () => ({
1619
1932
  companyFromDomain: true,
1620
1933
  timelineNote: true,
1934
+ defaultFormId: "",
1621
1935
  ...strapi.plugin("hubspot").config("forms", {})
1622
1936
  });
1937
+ const stringProps = (props) => {
1938
+ const out = {};
1939
+ for (const [key, value] of Object.entries(props)) {
1940
+ if (value === "" || value == null) continue;
1941
+ out[key] = typeof value === "boolean" ? value ? "true" : "false" : String(value);
1942
+ }
1943
+ return out;
1944
+ };
1623
1945
  async function partitionBySchema(apiKey, groups) {
1624
1946
  let schema;
1625
1947
  try {
@@ -1659,20 +1981,20 @@ function createFormsService(strapi, _opts = {}) {
1659
1981
  });
1660
1982
  const existingId = found.results?.[0]?.id;
1661
1983
  if (!existingId) return void 0;
1662
- const stringProps = {};
1663
- for (const [key, value] of Object.entries(props)) stringProps[key] = String(value);
1984
+ const stringProps2 = {};
1985
+ for (const [key, value] of Object.entries(props)) stringProps2[key] = String(value);
1664
1986
  await hsJson(apiKey, `${HS_COMPANIES}/${existingId}`, {
1665
1987
  method: "PATCH",
1666
- body: { properties: stringProps }
1988
+ body: { properties: stringProps2 }
1667
1989
  });
1668
1990
  return existingId;
1669
1991
  }
1670
1992
  async function createCompany(apiKey, props) {
1671
- const stringProps = {};
1672
- for (const [key, value] of Object.entries(props)) stringProps[key] = String(value);
1993
+ const stringProps2 = {};
1994
+ for (const [key, value] of Object.entries(props)) stringProps2[key] = String(value);
1673
1995
  const created = await hsJson(apiKey, HS_COMPANIES, {
1674
1996
  method: "POST",
1675
- body: { properties: stringProps }
1997
+ body: { properties: stringProps2 }
1676
1998
  });
1677
1999
  return created.id;
1678
2000
  }
@@ -1685,19 +2007,19 @@ function createFormsService(strapi, _opts = {}) {
1685
2007
  limit: 1
1686
2008
  }
1687
2009
  });
1688
- const stringProps = { domain };
1689
- for (const [key, value] of Object.entries(props)) stringProps[key] = String(value);
2010
+ const stringProps2 = { domain };
2011
+ for (const [key, value] of Object.entries(props)) stringProps2[key] = String(value);
1690
2012
  const existingId = found.results?.[0]?.id;
1691
2013
  if (existingId) {
1692
2014
  await hsJson(apiKey, `${HS_COMPANIES}/${existingId}`, {
1693
2015
  method: "PATCH",
1694
- body: { properties: stringProps }
2016
+ body: { properties: stringProps2 }
1695
2017
  });
1696
2018
  return existingId;
1697
2019
  }
1698
2020
  const created = await hsJson(apiKey, HS_COMPANIES, {
1699
2021
  method: "POST",
1700
- body: { properties: stringProps }
2022
+ body: { properties: stringProps2 }
1701
2023
  });
1702
2024
  return created.id;
1703
2025
  }
@@ -1807,6 +2129,7 @@ function createFormsService(strapi, _opts = {}) {
1807
2129
  });
1808
2130
  }
1809
2131
  }
2132
+ let usedFormsApi = false;
1810
2133
  if (email && apiKey) {
1811
2134
  const groups = groupByObject(form.definition, resolution.values);
1812
2135
  for (const [object, props] of Object.entries(companyGroups)) {
@@ -1814,14 +2137,62 @@ function createFormsService(strapi, _opts = {}) {
1814
2137
  }
1815
2138
  const partition = await partitionBySchema(apiKey, groups);
1816
2139
  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;
2140
+ const portalId = sanitizePortalId(strapi.plugin("hubspot").config("portalId", ""));
2141
+ const region = String(strapi.plugin("hubspot").config("region", "eu1") || "eu1");
2142
+ 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) {
2145
+ try {
2146
+ const shape = await loadFormShape(apiKey, formGuid);
2147
+ const fields = filterToFormFields(
2148
+ toHsFields({ ...stringProps(partition.accepted.contact ?? {}), email }),
2149
+ shape?.names ?? null
2150
+ );
2151
+ if (fields.length && !fields.some((f) => f.name === "email")) {
2152
+ fields.unshift({ name: "email", value: email });
2153
+ }
2154
+ const submitted = await submitMarketingForm({
2155
+ apiKey,
2156
+ portalId,
2157
+ formGuid,
2158
+ region,
2159
+ fields: fields.length ? fields : [{ name: "email", value: email }],
2160
+ context: {
2161
+ hutk: sanitizeHutk(meta.hutk),
2162
+ pageUri: typeof meta.pageUrl === "string" ? meta.pageUrl : void 0,
2163
+ pageName: typeof meta.pageName === "string" && meta.pageName || (form.title || form.name) || void 0,
2164
+ ipAddress: sanitizeIp(meta.ipAddress)
2165
+ },
2166
+ consent: { given: meta.consent === true },
2167
+ formHasLegalConsent: shape?.hasLegalConsent
2168
+ });
2169
+ if (submitted.ok) {
2170
+ hubspotSynced = true;
2171
+ usedFormsApi = true;
2172
+ try {
2173
+ contactId = await findContactIdByEmail(apiKey, email);
2174
+ } catch (err) {
2175
+ strapi.log.warn(
2176
+ `[hubspot] contact lookup after form submit failed — ${err.message}`
2177
+ );
2178
+ }
2179
+ } else {
2180
+ strapi.log.warn(`[hubspot] Forms API submit failed — ${submitted.error}`);
2181
+ }
2182
+ } catch (err) {
2183
+ strapi.log.warn(`[hubspot] Forms API submit failed — ${err.message}`);
2184
+ }
2185
+ }
2186
+ if (!usedFormsApi) {
2187
+ const result = await strapi.plugin("hubspot").service("submit").upsert({
2188
+ object: "contact",
2189
+ idProperty: "email",
2190
+ properties: { ...partition.accepted.contact ?? {}, email }
2191
+ });
2192
+ if (result.ok) {
2193
+ hubspotSynced = true;
2194
+ contactId = result.id;
2195
+ }
1825
2196
  }
1826
2197
  const domain = corporateDomain(email);
1827
2198
  const companyProps = partition.accepted.company ?? {};
@@ -1845,7 +2216,7 @@ function createFormsService(strapi, _opts = {}) {
1845
2216
  if (companyId) {
1846
2217
  await hsJson(
1847
2218
  apiKey,
1848
- `${HS_BASE$2}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
2219
+ `${HS_BASE$1}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
1849
2220
  { method: "PUT" }
1850
2221
  );
1851
2222
  }
@@ -1853,7 +2224,7 @@ function createFormsService(strapi, _opts = {}) {
1853
2224
  strapi.log.warn(`[hubspot] company sync failed (contact OK) — ${err.message}`);
1854
2225
  }
1855
2226
  }
1856
- if (contactId && config2.timelineNote) {
2227
+ if (contactId && config2.timelineNote && !usedFormsApi) {
1857
2228
  try {
1858
2229
  await createLeadNote(apiKey, {
1859
2230
  contactId,
@@ -1888,168 +2259,6 @@ function createFormsService(strapi, _opts = {}) {
1888
2259
  }
1889
2260
  return { submit };
1890
2261
  }
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
2262
  let counter = 0;
2054
2263
  const makeId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter += 1).toString(36)}`;
2055
2264
  const slugifyName = (label) => label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
@@ -2128,7 +2337,8 @@ const WRITABLE = [
2128
2337
  "nextLabel",
2129
2338
  "submitLabel",
2130
2339
  "successMessage",
2131
- "class"
2340
+ "class",
2341
+ "hubspotFormId"
2132
2342
  ];
2133
2343
  const slugify = (value) => value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "form";
2134
2344
  function createFormsAdminController(strapi) {
@@ -2493,6 +2703,7 @@ function createFormsAdminController(strapi) {
2493
2703
  submitLabel: source.submitLabel,
2494
2704
  successMessage: source.successMessage,
2495
2705
  class: source.class,
2706
+ hubspotFormId: source.hubspotFormId ?? null,
2496
2707
  definition: source.definition
2497
2708
  };
2498
2709
  if (targetId) {
@@ -2727,17 +2938,27 @@ const FORMS_ACTION = "plugin::hubspot.forms";
2727
2938
  const config = {
2728
2939
  default: {
2729
2940
  apiKey: "",
2941
+ // HubSpot account that receives form submissions. A test portal id is
2942
+ // fine in development; production swaps this env var, not the code.
2943
+ portalId: "",
2944
+ // Hosting region of that portal (`eu1`, `na1`, …). Drives the Forms API
2945
+ // host (`api-eu1.hsforms.com`) — CRM stays on api.hubapi.com.
2946
+ region: "eu1",
2730
2947
  // Objects whose properties are offered. Names from the standard set, or
2731
2948
  // `{ name, path }` for a custom object type.
2732
2949
  objects: ["contact", "company"],
2733
2950
  validate: [],
2734
- // Submission pipeline of the built forms. Both default to true:
2951
+ // Submission pipeline of the built forms:
2735
2952
  // - companyFromDomain: a corporate email upserts the Company (deduped by
2736
2953
  // domain) and associates it to the contact;
2737
- // - timelineNote: a note recaps the submission on the contact's timeline.
2954
+ // - timelineNote: recap note on the CRM-upsert fallback only (off when
2955
+ // submissions go through the marketing Forms API);
2956
+ // - defaultFormId: portal-wide marketing form GUID when a builder form
2957
+ // doesn't set its own — swap with the portal at production cutover.
2738
2958
  forms: {
2739
2959
  companyFromDomain: true,
2740
2960
  timelineNote: true,
2961
+ defaultFormId: "",
2741
2962
  // Per-IP brakes on the public routes; 0 disables one.
2742
2963
  rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
2743
2964
  }
@@ -2893,7 +3114,7 @@ const controllers = {
2893
3114
  if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
2894
3115
  const rawMeta = ctx.request.body?.meta ?? {};
2895
3116
  const meta = {};
2896
- for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
3117
+ for (const key of ["pagePath", "pageUrl", "pageName", "originPath", "originLabel", "source", "ipAddress"]) {
2897
3118
  const v = rawMeta[key];
2898
3119
  if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
2899
3120
  }
@@ -2901,6 +3122,13 @@ const controllers = {
2901
3122
  if (typeof rawMeta.consentedAt === "string" && rawMeta.consentedAt.trim()) {
2902
3123
  meta.consentedAt = rawMeta.consentedAt.trim().slice(0, 40);
2903
3124
  }
3125
+ if (typeof rawMeta.hutk === "string" && rawMeta.hutk.trim()) {
3126
+ meta.hutk = rawMeta.hutk.trim().slice(0, 128);
3127
+ }
3128
+ if (!meta.ipAddress) {
3129
+ const ip = ctx.request.ip;
3130
+ if (ip) meta.ipAddress = ip.slice(0, 45);
3131
+ }
2904
3132
  const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
2905
3133
  if (!outcome.ok) {
2906
3134
  ctx.status = 422;