strapi-plugin-hubspot 0.13.0 → 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 +36 -0
  2. package/README.md +59 -15
  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 +425 -189
  27. package/dist/server/index.mjs +425 -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
  }
@@ -1710,6 +2032,10 @@ function createFormsService(strapi, _opts = {}) {
1710
2032
  const path = opts.meta.originPath ? ` (${escapeHtml(String(opts.meta.originPath))})` : "";
1711
2033
  lines.push(`<strong>Sujet</strong> : ${escapeHtml(String(opts.meta.originLabel))}${path}`);
1712
2034
  }
2035
+ if (opts.meta.consent === true) {
2036
+ const when = typeof opts.meta.consentedAt === "string" && opts.meta.consentedAt ? ` (${escapeHtml(opts.meta.consentedAt)})` : "";
2037
+ lines.push(`<strong>Consentement RGPD</strong> : oui${when}`);
2038
+ }
1713
2039
  for (const record of opts.companies ?? []) {
1714
2040
  if (!record.name && !record.siret) continue;
1715
2041
  const bits = [record.name, record.siret ? `SIRET ${record.siret}` : ""].filter(Boolean);
@@ -1803,6 +2129,7 @@ function createFormsService(strapi, _opts = {}) {
1803
2129
  });
1804
2130
  }
1805
2131
  }
2132
+ let usedFormsApi = false;
1806
2133
  if (email && apiKey) {
1807
2134
  const groups = groupByObject(form.definition, resolution.values);
1808
2135
  for (const [object, props] of Object.entries(companyGroups)) {
@@ -1810,14 +2137,62 @@ function createFormsService(strapi, _opts = {}) {
1810
2137
  }
1811
2138
  const partition = await partitionBySchema(apiKey, groups);
1812
2139
  rejected = partition.rejected;
1813
- const result = await strapi.plugin("hubspot").service("submit").upsert({
1814
- object: "contact",
1815
- idProperty: "email",
1816
- properties: { ...partition.accepted.contact ?? {}, email }
1817
- });
1818
- if (result.ok) {
1819
- hubspotSynced = true;
1820
- 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
+ }
1821
2196
  }
1822
2197
  const domain = corporateDomain(email);
1823
2198
  const companyProps = partition.accepted.company ?? {};
@@ -1841,7 +2216,7 @@ function createFormsService(strapi, _opts = {}) {
1841
2216
  if (companyId) {
1842
2217
  await hsJson(
1843
2218
  apiKey,
1844
- `${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}`,
1845
2220
  { method: "PUT" }
1846
2221
  );
1847
2222
  }
@@ -1849,7 +2224,7 @@ function createFormsService(strapi, _opts = {}) {
1849
2224
  strapi.log.warn(`[hubspot] company sync failed (contact OK) — ${err.message}`);
1850
2225
  }
1851
2226
  }
1852
- if (contactId && config2.timelineNote) {
2227
+ if (contactId && config2.timelineNote && !usedFormsApi) {
1853
2228
  try {
1854
2229
  await createLeadNote(apiKey, {
1855
2230
  contactId,
@@ -1884,168 +2259,6 @@ function createFormsService(strapi, _opts = {}) {
1884
2259
  }
1885
2260
  return { submit };
1886
2261
  }
1887
- const HS_BASE$1 = "https://api.hubapi.com";
1888
- const OBJECT_BY_TYPE_ID = {
1889
- "0-1": "contact",
1890
- "0-2": "company"
1891
- };
1892
- const TYPE_MAP = {
1893
- single_line_text: "text",
1894
- multi_line_text: "textarea",
1895
- email: "email",
1896
- phone: "tel",
1897
- mobile_phone: "tel",
1898
- number: "number",
1899
- dropdown: "select",
1900
- select: "select",
1901
- radio: "radio",
1902
- multiple_checkboxes: "checkbox",
1903
- single_checkbox: "checkbox",
1904
- booleancheckbox: "checkbox"
1905
- };
1906
- let counter$1 = 0;
1907
- const makeId$1 = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter$1 += 1).toString(36)}`;
1908
- function convertCondition(parentFieldId, raw) {
1909
- const operator = (raw.operator ?? "").toUpperCase();
1910
- const values = (raw.values ?? []).map((v) => String(v)).filter((v) => v !== "");
1911
- const spread = (op, logic) => values.length ? { logic, rules: values.map((value) => ({ field: parentFieldId, operator: op, value })) } : null;
1912
- switch (operator) {
1913
- case "EQ":
1914
- case "SET_ANY":
1915
- return spread("eq", "or");
1916
- case "NEQ":
1917
- case "NOT_SET_ANY":
1918
- return spread("neq", "and");
1919
- case "CONTAINS":
1920
- return spread("contains", "or");
1921
- case "GT":
1922
- return spread("gt", "or");
1923
- case "LT":
1924
- return spread("lt", "or");
1925
- case "SET":
1926
- case "NOT_EMPTY":
1927
- return { logic: "and", rules: [{ field: parentFieldId, operator: "notEmpty" }] };
1928
- case "NOT_SET":
1929
- case "EMPTY":
1930
- return { logic: "and", rules: [{ field: parentFieldId, operator: "empty" }] };
1931
- default:
1932
- return null;
1933
- }
1934
- }
1935
- function convertField(raw, ctx) {
1936
- const label = raw.label || raw.name || "";
1937
- const name = raw.name ?? "";
1938
- if (raw.hidden) {
1939
- ctx.skipped.push({ code: "hidden-field", label });
1940
- return [];
1941
- }
1942
- const object = OBJECT_BY_TYPE_ID[raw.objectTypeId ?? "0-1"];
1943
- if (!object) {
1944
- ctx.skipped.push({ code: "object", label, detail: raw.objectTypeId });
1945
- return [];
1946
- }
1947
- const type = TYPE_MAP[raw.fieldType ?? ""];
1948
- if (!type || !name) {
1949
- ctx.skipped.push({ code: "field-type", label, detail: raw.fieldType });
1950
- return [];
1951
- }
1952
- if (ctx.usedNames.has(name)) {
1953
- ctx.skipped.push({ code: "duplicate", label, detail: name });
1954
- return [];
1955
- }
1956
- ctx.usedNames.add(name);
1957
- const options = (raw.options ?? []).map((o) => ({ value: String(o.value ?? ""), label: o.label || void 0 })).filter((o) => o.value !== "");
1958
- const field = {
1959
- id: makeId$1("fld"),
1960
- name,
1961
- label,
1962
- type,
1963
- required: Boolean(raw.required),
1964
- placeholder: raw.placeholder || void 0,
1965
- helpText: raw.description || void 0,
1966
- ...options.length ? { options } : {},
1967
- hubspot: { object, property: name },
1968
- ...ctx.parentCondition ? { visibleIf: ctx.parentCondition } : {}
1969
- };
1970
- const out = [field];
1971
- for (const dependent of raw.dependentFields ?? []) {
1972
- if (!dependent.field) continue;
1973
- const childLabel = dependent.field.label || dependent.field.name || "";
1974
- const condition = dependent.dependentCondition ? convertCondition(field.id, dependent.dependentCondition) : null;
1975
- if (!condition) {
1976
- ctx.skipped.push({
1977
- code: "condition",
1978
- label: childLabel,
1979
- detail: dependent.dependentCondition?.operator
1980
- });
1981
- }
1982
- out.push(...convertField(dependent.field, { ...ctx, parentCondition: condition }));
1983
- }
1984
- return out;
1985
- }
1986
- function convertHubspotForm(raw) {
1987
- const skipped = [];
1988
- const usedNames = /* @__PURE__ */ new Set();
1989
- const fields = [];
1990
- for (const group of raw.fieldGroups ?? []) {
1991
- if (group.richText && !(group.fields ?? []).length) {
1992
- skipped.push({ code: "rich-text", detail: group.richText.slice(0, 80) });
1993
- continue;
1994
- }
1995
- const groupFields = (group.fields ?? []).flatMap(
1996
- (f) => convertField(f, { usedNames, skipped })
1997
- );
1998
- const topLevel = (group.fields ?? []).length;
1999
- if (topLevel === 2 && groupFields.length >= 2) {
2000
- groupFields[0].width = "half";
2001
- groupFields[1].width = "half";
2002
- }
2003
- fields.push(...groupFields);
2004
- }
2005
- if (raw.legalConsentOptions && Object.keys(raw.legalConsentOptions).length) {
2006
- skipped.push({ code: "legal-consent" });
2007
- }
2008
- const postSubmit = raw.configuration?.postSubmitAction;
2009
- return {
2010
- name: raw.name ?? "",
2011
- submitLabel: raw.displayOptions?.submitButtonText || null,
2012
- successMessage: postSubmit?.type === "thank_you" && postSubmit.value ? postSubmit.value : null,
2013
- // HubSpot forms are single-page: one step, reorganizable in the builder.
2014
- definition: { version: 1, steps: [{ id: makeId$1("stp"), fields }] },
2015
- skipped
2016
- };
2017
- }
2018
- async function hsGet(apiKey, path) {
2019
- const res = await fetch(`${HS_BASE$1}${path}`, {
2020
- headers: { Authorization: `Bearer ${apiKey}` }
2021
- });
2022
- if (!res.ok) {
2023
- const body = await res.json().catch(() => ({}));
2024
- throw Object.assign(new Error(body.message || `HubSpot ${res.status}`), {
2025
- status: res.status
2026
- });
2027
- }
2028
- return await res.json();
2029
- }
2030
- async function listHubspotForms(apiKey) {
2031
- const out = [];
2032
- let after;
2033
- for (let page = 0; page < 10; page += 1) {
2034
- const query2 = after ? `&after=${encodeURIComponent(after)}` : "";
2035
- const res = await hsGet(apiKey, `/marketing/v3/forms/?limit=100&formTypes=hubspot${query2}`);
2036
- for (const form of res.results ?? []) {
2037
- if (form.id && !form.archived) {
2038
- out.push({ id: form.id, name: form.name || form.id, updatedAt: form.updatedAt });
2039
- }
2040
- }
2041
- after = res.paging?.next?.after;
2042
- if (!after) break;
2043
- }
2044
- return out.sort((a, b) => a.name.localeCompare(b.name));
2045
- }
2046
- async function fetchHubspotForm(apiKey, formId) {
2047
- return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
2048
- }
2049
2262
  let counter = 0;
2050
2263
  const makeId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter += 1).toString(36)}`;
2051
2264
  const slugifyName = (label) => label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
@@ -2124,7 +2337,8 @@ const WRITABLE = [
2124
2337
  "nextLabel",
2125
2338
  "submitLabel",
2126
2339
  "successMessage",
2127
- "class"
2340
+ "class",
2341
+ "hubspotFormId"
2128
2342
  ];
2129
2343
  const slugify = (value) => value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "form";
2130
2344
  function createFormsAdminController(strapi) {
@@ -2489,6 +2703,7 @@ function createFormsAdminController(strapi) {
2489
2703
  submitLabel: source.submitLabel,
2490
2704
  successMessage: source.successMessage,
2491
2705
  class: source.class,
2706
+ hubspotFormId: source.hubspotFormId ?? null,
2492
2707
  definition: source.definition
2493
2708
  };
2494
2709
  if (targetId) {
@@ -2723,17 +2938,27 @@ const FORMS_ACTION = "plugin::hubspot.forms";
2723
2938
  const config = {
2724
2939
  default: {
2725
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",
2726
2947
  // Objects whose properties are offered. Names from the standard set, or
2727
2948
  // `{ name, path }` for a custom object type.
2728
2949
  objects: ["contact", "company"],
2729
2950
  validate: [],
2730
- // Submission pipeline of the built forms. Both default to true:
2951
+ // Submission pipeline of the built forms:
2731
2952
  // - companyFromDomain: a corporate email upserts the Company (deduped by
2732
2953
  // domain) and associates it to the contact;
2733
- // - 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.
2734
2958
  forms: {
2735
2959
  companyFromDomain: true,
2736
2960
  timelineNote: true,
2961
+ defaultFormId: "",
2737
2962
  // Per-IP brakes on the public routes; 0 disables one.
2738
2963
  rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
2739
2964
  }
@@ -2889,10 +3114,21 @@ const controllers = {
2889
3114
  if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
2890
3115
  const rawMeta = ctx.request.body?.meta ?? {};
2891
3116
  const meta = {};
2892
- for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
3117
+ for (const key of ["pagePath", "pageUrl", "pageName", "originPath", "originLabel", "source", "ipAddress"]) {
2893
3118
  const v = rawMeta[key];
2894
3119
  if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
2895
3120
  }
3121
+ if (rawMeta.consent === true) meta.consent = true;
3122
+ if (typeof rawMeta.consentedAt === "string" && rawMeta.consentedAt.trim()) {
3123
+ meta.consentedAt = rawMeta.consentedAt.trim().slice(0, 40);
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
+ }
2896
3132
  const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
2897
3133
  if (!outcome.ok) {
2898
3134
  ctx.status = 422;