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
@@ -1246,6 +1246,13 @@ const contentTypes = {
1246
1246
  submitLabel: localizedString(),
1247
1247
  successMessage: localizedString("text"),
1248
1248
  class: { type: "string" },
1249
+ // Marketing form GUID of the connected HubSpot portal. Test portal
1250
+ // today, production later — swap the private app + this GUID, no
1251
+ // code change. Empty = CRM upsert fallback (no native conversion).
1252
+ hubspotFormId: {
1253
+ type: "string",
1254
+ pluginOptions: { i18n: { localized: false } }
1255
+ },
1249
1256
  definition: {
1250
1257
  type: "json",
1251
1258
  required: true,
@@ -1449,6 +1456,312 @@ function resolveSubmission(definition, rawValues) {
1449
1456
  const unknown = Object.keys(rawValues).filter((key) => !declared.has(key));
1450
1457
  return { values, hidden, missingRequired, unknown };
1451
1458
  }
1459
+ const HS_BASE$2 = "https://api.hubapi.com";
1460
+ const OBJECT_BY_TYPE_ID = {
1461
+ "0-1": "contact",
1462
+ "0-2": "company"
1463
+ };
1464
+ const TYPE_MAP = {
1465
+ single_line_text: "text",
1466
+ multi_line_text: "textarea",
1467
+ email: "email",
1468
+ phone: "tel",
1469
+ mobile_phone: "tel",
1470
+ number: "number",
1471
+ dropdown: "select",
1472
+ select: "select",
1473
+ radio: "radio",
1474
+ multiple_checkboxes: "checkbox",
1475
+ single_checkbox: "checkbox",
1476
+ booleancheckbox: "checkbox"
1477
+ };
1478
+ let counter$1 = 0;
1479
+ const makeId$1 = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter$1 += 1).toString(36)}`;
1480
+ function convertCondition(parentFieldId, raw) {
1481
+ const operator = (raw.operator ?? "").toUpperCase();
1482
+ const values = (raw.values ?? []).map((v) => String(v)).filter((v) => v !== "");
1483
+ const spread = (op, logic) => values.length ? { logic, rules: values.map((value) => ({ field: parentFieldId, operator: op, value })) } : null;
1484
+ switch (operator) {
1485
+ case "EQ":
1486
+ case "SET_ANY":
1487
+ return spread("eq", "or");
1488
+ case "NEQ":
1489
+ case "NOT_SET_ANY":
1490
+ return spread("neq", "and");
1491
+ case "CONTAINS":
1492
+ return spread("contains", "or");
1493
+ case "GT":
1494
+ return spread("gt", "or");
1495
+ case "LT":
1496
+ return spread("lt", "or");
1497
+ case "SET":
1498
+ case "NOT_EMPTY":
1499
+ return { logic: "and", rules: [{ field: parentFieldId, operator: "notEmpty" }] };
1500
+ case "NOT_SET":
1501
+ case "EMPTY":
1502
+ return { logic: "and", rules: [{ field: parentFieldId, operator: "empty" }] };
1503
+ default:
1504
+ return null;
1505
+ }
1506
+ }
1507
+ function convertField(raw, ctx) {
1508
+ const label = raw.label || raw.name || "";
1509
+ const name = raw.name ?? "";
1510
+ if (raw.hidden) {
1511
+ ctx.skipped.push({ code: "hidden-field", label });
1512
+ return [];
1513
+ }
1514
+ const object = OBJECT_BY_TYPE_ID[raw.objectTypeId ?? "0-1"];
1515
+ if (!object) {
1516
+ ctx.skipped.push({ code: "object", label, detail: raw.objectTypeId });
1517
+ return [];
1518
+ }
1519
+ const type = TYPE_MAP[raw.fieldType ?? ""];
1520
+ if (!type || !name) {
1521
+ ctx.skipped.push({ code: "field-type", label, detail: raw.fieldType });
1522
+ return [];
1523
+ }
1524
+ if (ctx.usedNames.has(name)) {
1525
+ ctx.skipped.push({ code: "duplicate", label, detail: name });
1526
+ return [];
1527
+ }
1528
+ ctx.usedNames.add(name);
1529
+ const options = (raw.options ?? []).map((o) => ({ value: String(o.value ?? ""), label: o.label || void 0 })).filter((o) => o.value !== "");
1530
+ const field = {
1531
+ id: makeId$1("fld"),
1532
+ name,
1533
+ label,
1534
+ type,
1535
+ required: Boolean(raw.required),
1536
+ placeholder: raw.placeholder || void 0,
1537
+ helpText: raw.description || void 0,
1538
+ ...options.length ? { options } : {},
1539
+ hubspot: { object, property: name },
1540
+ ...ctx.parentCondition ? { visibleIf: ctx.parentCondition } : {}
1541
+ };
1542
+ const out = [field];
1543
+ for (const dependent of raw.dependentFields ?? []) {
1544
+ if (!dependent.field) continue;
1545
+ const childLabel = dependent.field.label || dependent.field.name || "";
1546
+ const condition = dependent.dependentCondition ? convertCondition(field.id, dependent.dependentCondition) : null;
1547
+ if (!condition) {
1548
+ ctx.skipped.push({
1549
+ code: "condition",
1550
+ label: childLabel,
1551
+ detail: dependent.dependentCondition?.operator
1552
+ });
1553
+ }
1554
+ out.push(...convertField(dependent.field, { ...ctx, parentCondition: condition }));
1555
+ }
1556
+ return out;
1557
+ }
1558
+ function convertHubspotForm(raw) {
1559
+ const skipped = [];
1560
+ const usedNames = /* @__PURE__ */ new Set();
1561
+ const fields = [];
1562
+ for (const group of raw.fieldGroups ?? []) {
1563
+ if (group.richText && !(group.fields ?? []).length) {
1564
+ skipped.push({ code: "rich-text", detail: group.richText.slice(0, 80) });
1565
+ continue;
1566
+ }
1567
+ const groupFields = (group.fields ?? []).flatMap(
1568
+ (f) => convertField(f, { usedNames, skipped })
1569
+ );
1570
+ const topLevel = (group.fields ?? []).length;
1571
+ if (topLevel === 2 && groupFields.length >= 2) {
1572
+ groupFields[0].width = "half";
1573
+ groupFields[1].width = "half";
1574
+ }
1575
+ fields.push(...groupFields);
1576
+ }
1577
+ if (raw.legalConsentOptions && Object.keys(raw.legalConsentOptions).length) {
1578
+ skipped.push({ code: "legal-consent" });
1579
+ }
1580
+ const postSubmit = raw.configuration?.postSubmitAction;
1581
+ return {
1582
+ name: raw.name ?? "",
1583
+ submitLabel: raw.displayOptions?.submitButtonText || null,
1584
+ successMessage: postSubmit?.type === "thank_you" && postSubmit.value ? postSubmit.value : null,
1585
+ hubspotFormId: raw.id ?? null,
1586
+ // HubSpot forms are single-page: one step, reorganizable in the builder.
1587
+ definition: { version: 1, steps: [{ id: makeId$1("stp"), fields }] },
1588
+ skipped
1589
+ };
1590
+ }
1591
+ async function hsGet(apiKey, path) {
1592
+ const res = await fetch(`${HS_BASE$2}${path}`, {
1593
+ headers: { Authorization: `Bearer ${apiKey}` }
1594
+ });
1595
+ if (!res.ok) {
1596
+ const body = await res.json().catch(() => ({}));
1597
+ throw Object.assign(new Error(body.message || `HubSpot ${res.status}`), {
1598
+ status: res.status
1599
+ });
1600
+ }
1601
+ return await res.json();
1602
+ }
1603
+ async function listHubspotForms(apiKey) {
1604
+ const out = [];
1605
+ let after;
1606
+ for (let page = 0; page < 10; page += 1) {
1607
+ const query2 = after ? `&after=${encodeURIComponent(after)}` : "";
1608
+ const res = await hsGet(apiKey, `/marketing/v3/forms/?limit=100&formTypes=hubspot${query2}`);
1609
+ for (const form of res.results ?? []) {
1610
+ if (form.id && !form.archived) {
1611
+ out.push({ id: form.id, name: form.name || form.id, updatedAt: form.updatedAt });
1612
+ }
1613
+ }
1614
+ after = res.paging?.next?.after;
1615
+ if (!after) break;
1616
+ }
1617
+ return out.sort((a, b) => a.name.localeCompare(b.name));
1618
+ }
1619
+ async function fetchHubspotForm(apiKey, formId) {
1620
+ return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
1621
+ }
1622
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1623
+ const PORTAL = /^\d{1,12}$/;
1624
+ const HUTK = /^[a-f0-9]{8,128}$/i;
1625
+ const SHAPE_TTL_MS = 5 * 60 * 1e3;
1626
+ const DEFAULT_CONSENT_TEXT = "J'accepte que mes données soient utilisées pour me recontacter.";
1627
+ function isFormGuid(value) {
1628
+ return GUID.test(value.trim());
1629
+ }
1630
+ function sanitizeHutk(value) {
1631
+ if (typeof value !== "string") return void 0;
1632
+ const trimmed = value.trim();
1633
+ return HUTK.test(trimmed) ? trimmed : void 0;
1634
+ }
1635
+ function sanitizePortalId(value) {
1636
+ if (typeof value !== "string" && typeof value !== "number") return void 0;
1637
+ const trimmed = String(value).trim();
1638
+ return PORTAL.test(trimmed) ? trimmed : void 0;
1639
+ }
1640
+ function sanitizeIp(value) {
1641
+ if (typeof value !== "string") return void 0;
1642
+ const trimmed = value.trim();
1643
+ return trimmed && trimmed.length <= 45 ? trimmed : void 0;
1644
+ }
1645
+ function formsHost(region) {
1646
+ const r = (region || "eu1").trim().toLowerCase();
1647
+ if (!r || r === "na1" || r === "na") return "api.hsforms.com";
1648
+ return `api-${r}.hsforms.com`;
1649
+ }
1650
+ function formsSubmitUrl(portalId, formGuid, region = "eu1") {
1651
+ return `https://${formsHost(region)}/submissions/v3/integration/secure/submit/${encodeURIComponent(portalId)}/${encodeURIComponent(formGuid)}`;
1652
+ }
1653
+ function toHsFields(props) {
1654
+ return Object.entries(props).filter(([, value]) => value !== "").map(([name, value]) => ({ name, value }));
1655
+ }
1656
+ function filterToFormFields(fields, allowed) {
1657
+ if (!allowed) return fields;
1658
+ return fields.filter((field) => allowed.has(field.name));
1659
+ }
1660
+ function formFieldNames(raw) {
1661
+ const names = [];
1662
+ for (const group of raw.fieldGroups ?? []) {
1663
+ for (const field of group.fields ?? []) {
1664
+ if (field.name) names.push(field.name);
1665
+ }
1666
+ }
1667
+ return names;
1668
+ }
1669
+ const shapeCache = /* @__PURE__ */ new Map();
1670
+ async function loadFormShape(apiKey, formGuid) {
1671
+ const cached = shapeCache.get(formGuid);
1672
+ if (cached && Date.now() - cached.at < SHAPE_TTL_MS) {
1673
+ return { names: cached.names, hasLegalConsent: cached.hasLegalConsent };
1674
+ }
1675
+ try {
1676
+ const raw = await fetchHubspotForm(apiKey, formGuid);
1677
+ const names = new Set(formFieldNames(raw));
1678
+ const hasLegalConsent = Boolean(
1679
+ raw.legalConsentOptions && typeof raw.legalConsentOptions === "object" && raw.legalConsentOptions.type && raw.legalConsentOptions.type !== "none"
1680
+ );
1681
+ shapeCache.set(formGuid, { at: Date.now(), names, hasLegalConsent });
1682
+ return { names, hasLegalConsent };
1683
+ } catch {
1684
+ return null;
1685
+ }
1686
+ }
1687
+ async function findContactIdByEmail(apiKey, email) {
1688
+ const res = await fetch("https://api.hubapi.com/crm/v3/objects/contacts/search", {
1689
+ method: "POST",
1690
+ headers: {
1691
+ Authorization: `Bearer ${apiKey}`,
1692
+ "Content-Type": "application/json"
1693
+ },
1694
+ body: JSON.stringify({
1695
+ filterGroups: [
1696
+ { filters: [{ propertyName: "email", operator: "EQ", value: email }] }
1697
+ ],
1698
+ properties: ["email"],
1699
+ limit: 1
1700
+ })
1701
+ });
1702
+ const body = await res.json().catch(() => ({}));
1703
+ if (!res.ok) return void 0;
1704
+ return body.results?.[0]?.id;
1705
+ }
1706
+ function contextPayload(ctx) {
1707
+ const out = {};
1708
+ const hutk = sanitizeHutk(ctx?.hutk);
1709
+ if (hutk) out.hutk = hutk;
1710
+ if (typeof ctx?.pageUri === "string" && ctx.pageUri.trim()) {
1711
+ out.pageUri = ctx.pageUri.trim().slice(0, 1e3);
1712
+ }
1713
+ if (typeof ctx?.pageName === "string" && ctx.pageName.trim()) {
1714
+ out.pageName = ctx.pageName.trim().slice(0, 200);
1715
+ }
1716
+ const ip = sanitizeIp(ctx?.ipAddress);
1717
+ if (ip) out.ipAddress = ip;
1718
+ return out;
1719
+ }
1720
+ async function postForm(url, apiKey, payload) {
1721
+ const res = await fetch(url, {
1722
+ method: "POST",
1723
+ headers: {
1724
+ Authorization: `Bearer ${apiKey}`,
1725
+ "Content-Type": "application/json"
1726
+ },
1727
+ body: JSON.stringify(payload)
1728
+ });
1729
+ if (res.ok) return { ok: true, status: res.status };
1730
+ const body = await res.json().catch(() => ({}));
1731
+ const error = body.errors?.[0]?.message || body.message || `HubSpot ${res.status}`;
1732
+ return { ok: false, status: res.status, error };
1733
+ }
1734
+ async function submitMarketingForm(input) {
1735
+ const portalId = sanitizePortalId(input.portalId);
1736
+ const formGuid = input.formGuid.trim();
1737
+ if (!portalId || !isFormGuid(formGuid) || !input.fields.length) {
1738
+ return { ok: false, status: 0, error: "Missing portal, form GUID or fields" };
1739
+ }
1740
+ const url = formsSubmitUrl(portalId, formGuid, input.region);
1741
+ const context = contextPayload(input.context);
1742
+ const base = {
1743
+ fields: input.fields,
1744
+ ...Object.keys(context).length ? { context } : {}
1745
+ };
1746
+ const withConsent = input.consent?.given && input.formHasLegalConsent !== false ? {
1747
+ ...base,
1748
+ legalConsentOptions: {
1749
+ consent: {
1750
+ consentToProcess: true,
1751
+ text: input.consent.text?.trim() || DEFAULT_CONSENT_TEXT
1752
+ }
1753
+ }
1754
+ } : base;
1755
+ let result = await postForm(url, input.apiKey, withConsent);
1756
+ if (!result.ok && result.status === 400 && withConsent !== base) {
1757
+ result = await postForm(url, input.apiKey, base);
1758
+ }
1759
+ if (!result.ok && result.status === 400 && input.fields.length > 1) {
1760
+ const email = input.fields.find((f) => f.name === "email");
1761
+ if (email) result = await postForm(url, input.apiKey, { ...base, fields: [email] });
1762
+ }
1763
+ return result;
1764
+ }
1452
1765
  function publicForm(entry) {
1453
1766
  return {
1454
1767
  slug: entry.slug,
@@ -1557,9 +1870,9 @@ function sanitizeRawValues(raw) {
1557
1870
  }
1558
1871
  return values;
1559
1872
  }
1560
- const HS_BASE$2 = "https://api.hubapi.com";
1561
- const HS_COMPANIES = `${HS_BASE$2}/crm/v3/objects/companies`;
1562
- const HS_NOTES = `${HS_BASE$2}/crm/v3/objects/notes`;
1873
+ const HS_BASE$1 = "https://api.hubapi.com";
1874
+ const HS_COMPANIES = `${HS_BASE$1}/crm/v3/objects/companies`;
1875
+ const HS_NOTES = `${HS_BASE$1}/crm/v3/objects/notes`;
1563
1876
  const ASSOC_NOTE_TO_CONTACT = 202;
1564
1877
  const ASSOC_NOTE_TO_COMPANY = 190;
1565
1878
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -1617,8 +1930,17 @@ function createFormsService(strapi, _opts = {}) {
1617
1930
  const formsConfig = () => ({
1618
1931
  companyFromDomain: true,
1619
1932
  timelineNote: true,
1933
+ defaultFormId: "",
1620
1934
  ...strapi.plugin("hubspot").config("forms", {})
1621
1935
  });
1936
+ const stringProps = (props) => {
1937
+ const out = {};
1938
+ for (const [key, value] of Object.entries(props)) {
1939
+ if (value === "" || value == null) continue;
1940
+ out[key] = typeof value === "boolean" ? value ? "true" : "false" : String(value);
1941
+ }
1942
+ return out;
1943
+ };
1622
1944
  async function partitionBySchema(apiKey, groups) {
1623
1945
  let schema;
1624
1946
  try {
@@ -1658,20 +1980,20 @@ function createFormsService(strapi, _opts = {}) {
1658
1980
  });
1659
1981
  const existingId = found.results?.[0]?.id;
1660
1982
  if (!existingId) return void 0;
1661
- const stringProps = {};
1662
- for (const [key, value] of Object.entries(props)) stringProps[key] = String(value);
1983
+ const stringProps2 = {};
1984
+ for (const [key, value] of Object.entries(props)) stringProps2[key] = String(value);
1663
1985
  await hsJson(apiKey, `${HS_COMPANIES}/${existingId}`, {
1664
1986
  method: "PATCH",
1665
- body: { properties: stringProps }
1987
+ body: { properties: stringProps2 }
1666
1988
  });
1667
1989
  return existingId;
1668
1990
  }
1669
1991
  async function createCompany(apiKey, props) {
1670
- const stringProps = {};
1671
- for (const [key, value] of Object.entries(props)) stringProps[key] = String(value);
1992
+ const stringProps2 = {};
1993
+ for (const [key, value] of Object.entries(props)) stringProps2[key] = String(value);
1672
1994
  const created = await hsJson(apiKey, HS_COMPANIES, {
1673
1995
  method: "POST",
1674
- body: { properties: stringProps }
1996
+ body: { properties: stringProps2 }
1675
1997
  });
1676
1998
  return created.id;
1677
1999
  }
@@ -1684,19 +2006,19 @@ function createFormsService(strapi, _opts = {}) {
1684
2006
  limit: 1
1685
2007
  }
1686
2008
  });
1687
- const stringProps = { domain };
1688
- for (const [key, value] of Object.entries(props)) stringProps[key] = String(value);
2009
+ const stringProps2 = { domain };
2010
+ for (const [key, value] of Object.entries(props)) stringProps2[key] = String(value);
1689
2011
  const existingId = found.results?.[0]?.id;
1690
2012
  if (existingId) {
1691
2013
  await hsJson(apiKey, `${HS_COMPANIES}/${existingId}`, {
1692
2014
  method: "PATCH",
1693
- body: { properties: stringProps }
2015
+ body: { properties: stringProps2 }
1694
2016
  });
1695
2017
  return existingId;
1696
2018
  }
1697
2019
  const created = await hsJson(apiKey, HS_COMPANIES, {
1698
2020
  method: "POST",
1699
- body: { properties: stringProps }
2021
+ body: { properties: stringProps2 }
1700
2022
  });
1701
2023
  return created.id;
1702
2024
  }
@@ -1709,6 +2031,10 @@ function createFormsService(strapi, _opts = {}) {
1709
2031
  const path = opts.meta.originPath ? ` (${escapeHtml(String(opts.meta.originPath))})` : "";
1710
2032
  lines.push(`<strong>Sujet</strong> : ${escapeHtml(String(opts.meta.originLabel))}${path}`);
1711
2033
  }
2034
+ if (opts.meta.consent === true) {
2035
+ const when = typeof opts.meta.consentedAt === "string" && opts.meta.consentedAt ? ` (${escapeHtml(opts.meta.consentedAt)})` : "";
2036
+ lines.push(`<strong>Consentement RGPD</strong> : oui${when}`);
2037
+ }
1712
2038
  for (const record of opts.companies ?? []) {
1713
2039
  if (!record.name && !record.siret) continue;
1714
2040
  const bits = [record.name, record.siret ? `SIRET ${record.siret}` : ""].filter(Boolean);
@@ -1802,6 +2128,7 @@ function createFormsService(strapi, _opts = {}) {
1802
2128
  });
1803
2129
  }
1804
2130
  }
2131
+ let usedFormsApi = false;
1805
2132
  if (email && apiKey) {
1806
2133
  const groups = groupByObject(form.definition, resolution.values);
1807
2134
  for (const [object, props] of Object.entries(companyGroups)) {
@@ -1809,14 +2136,62 @@ function createFormsService(strapi, _opts = {}) {
1809
2136
  }
1810
2137
  const partition = await partitionBySchema(apiKey, groups);
1811
2138
  rejected = partition.rejected;
1812
- const result = await strapi.plugin("hubspot").service("submit").upsert({
1813
- object: "contact",
1814
- idProperty: "email",
1815
- properties: { ...partition.accepted.contact ?? {}, email }
1816
- });
1817
- if (result.ok) {
1818
- hubspotSynced = true;
1819
- contactId = result.id;
2139
+ const portalId = sanitizePortalId(strapi.plugin("hubspot").config("portalId", ""));
2140
+ const region = String(strapi.plugin("hubspot").config("region", "eu1") || "eu1");
2141
+ const ownGuid = typeof form.hubspotFormId === "string" ? form.hubspotFormId.trim() : "";
2142
+ const formGuid = isFormGuid(ownGuid) ? ownGuid : isFormGuid(config2.defaultFormId) ? config2.defaultFormId.trim() : "";
2143
+ if (portalId && formGuid) {
2144
+ try {
2145
+ const shape = await loadFormShape(apiKey, formGuid);
2146
+ const fields = filterToFormFields(
2147
+ toHsFields({ ...stringProps(partition.accepted.contact ?? {}), email }),
2148
+ shape?.names ?? null
2149
+ );
2150
+ if (fields.length && !fields.some((f) => f.name === "email")) {
2151
+ fields.unshift({ name: "email", value: email });
2152
+ }
2153
+ const submitted = await submitMarketingForm({
2154
+ apiKey,
2155
+ portalId,
2156
+ formGuid,
2157
+ region,
2158
+ fields: fields.length ? fields : [{ name: "email", value: email }],
2159
+ context: {
2160
+ hutk: sanitizeHutk(meta.hutk),
2161
+ pageUri: typeof meta.pageUrl === "string" ? meta.pageUrl : void 0,
2162
+ pageName: typeof meta.pageName === "string" && meta.pageName || (form.title || form.name) || void 0,
2163
+ ipAddress: sanitizeIp(meta.ipAddress)
2164
+ },
2165
+ consent: { given: meta.consent === true },
2166
+ formHasLegalConsent: shape?.hasLegalConsent
2167
+ });
2168
+ if (submitted.ok) {
2169
+ hubspotSynced = true;
2170
+ usedFormsApi = true;
2171
+ try {
2172
+ contactId = await findContactIdByEmail(apiKey, email);
2173
+ } catch (err) {
2174
+ strapi.log.warn(
2175
+ `[hubspot] contact lookup after form submit failed — ${err.message}`
2176
+ );
2177
+ }
2178
+ } else {
2179
+ strapi.log.warn(`[hubspot] Forms API submit failed — ${submitted.error}`);
2180
+ }
2181
+ } catch (err) {
2182
+ strapi.log.warn(`[hubspot] Forms API submit failed — ${err.message}`);
2183
+ }
2184
+ }
2185
+ if (!usedFormsApi) {
2186
+ const result = await strapi.plugin("hubspot").service("submit").upsert({
2187
+ object: "contact",
2188
+ idProperty: "email",
2189
+ properties: { ...partition.accepted.contact ?? {}, email }
2190
+ });
2191
+ if (result.ok) {
2192
+ hubspotSynced = true;
2193
+ contactId = result.id;
2194
+ }
1820
2195
  }
1821
2196
  const domain = corporateDomain(email);
1822
2197
  const companyProps = partition.accepted.company ?? {};
@@ -1840,7 +2215,7 @@ function createFormsService(strapi, _opts = {}) {
1840
2215
  if (companyId) {
1841
2216
  await hsJson(
1842
2217
  apiKey,
1843
- `${HS_BASE$2}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
2218
+ `${HS_BASE$1}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
1844
2219
  { method: "PUT" }
1845
2220
  );
1846
2221
  }
@@ -1848,7 +2223,7 @@ function createFormsService(strapi, _opts = {}) {
1848
2223
  strapi.log.warn(`[hubspot] company sync failed (contact OK) — ${err.message}`);
1849
2224
  }
1850
2225
  }
1851
- if (contactId && config2.timelineNote) {
2226
+ if (contactId && config2.timelineNote && !usedFormsApi) {
1852
2227
  try {
1853
2228
  await createLeadNote(apiKey, {
1854
2229
  contactId,
@@ -1883,168 +2258,6 @@ function createFormsService(strapi, _opts = {}) {
1883
2258
  }
1884
2259
  return { submit };
1885
2260
  }
1886
- const HS_BASE$1 = "https://api.hubapi.com";
1887
- const OBJECT_BY_TYPE_ID = {
1888
- "0-1": "contact",
1889
- "0-2": "company"
1890
- };
1891
- const TYPE_MAP = {
1892
- single_line_text: "text",
1893
- multi_line_text: "textarea",
1894
- email: "email",
1895
- phone: "tel",
1896
- mobile_phone: "tel",
1897
- number: "number",
1898
- dropdown: "select",
1899
- select: "select",
1900
- radio: "radio",
1901
- multiple_checkboxes: "checkbox",
1902
- single_checkbox: "checkbox",
1903
- booleancheckbox: "checkbox"
1904
- };
1905
- let counter$1 = 0;
1906
- const makeId$1 = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter$1 += 1).toString(36)}`;
1907
- function convertCondition(parentFieldId, raw) {
1908
- const operator = (raw.operator ?? "").toUpperCase();
1909
- const values = (raw.values ?? []).map((v) => String(v)).filter((v) => v !== "");
1910
- const spread = (op, logic) => values.length ? { logic, rules: values.map((value) => ({ field: parentFieldId, operator: op, value })) } : null;
1911
- switch (operator) {
1912
- case "EQ":
1913
- case "SET_ANY":
1914
- return spread("eq", "or");
1915
- case "NEQ":
1916
- case "NOT_SET_ANY":
1917
- return spread("neq", "and");
1918
- case "CONTAINS":
1919
- return spread("contains", "or");
1920
- case "GT":
1921
- return spread("gt", "or");
1922
- case "LT":
1923
- return spread("lt", "or");
1924
- case "SET":
1925
- case "NOT_EMPTY":
1926
- return { logic: "and", rules: [{ field: parentFieldId, operator: "notEmpty" }] };
1927
- case "NOT_SET":
1928
- case "EMPTY":
1929
- return { logic: "and", rules: [{ field: parentFieldId, operator: "empty" }] };
1930
- default:
1931
- return null;
1932
- }
1933
- }
1934
- function convertField(raw, ctx) {
1935
- const label = raw.label || raw.name || "";
1936
- const name = raw.name ?? "";
1937
- if (raw.hidden) {
1938
- ctx.skipped.push({ code: "hidden-field", label });
1939
- return [];
1940
- }
1941
- const object = OBJECT_BY_TYPE_ID[raw.objectTypeId ?? "0-1"];
1942
- if (!object) {
1943
- ctx.skipped.push({ code: "object", label, detail: raw.objectTypeId });
1944
- return [];
1945
- }
1946
- const type = TYPE_MAP[raw.fieldType ?? ""];
1947
- if (!type || !name) {
1948
- ctx.skipped.push({ code: "field-type", label, detail: raw.fieldType });
1949
- return [];
1950
- }
1951
- if (ctx.usedNames.has(name)) {
1952
- ctx.skipped.push({ code: "duplicate", label, detail: name });
1953
- return [];
1954
- }
1955
- ctx.usedNames.add(name);
1956
- const options = (raw.options ?? []).map((o) => ({ value: String(o.value ?? ""), label: o.label || void 0 })).filter((o) => o.value !== "");
1957
- const field = {
1958
- id: makeId$1("fld"),
1959
- name,
1960
- label,
1961
- type,
1962
- required: Boolean(raw.required),
1963
- placeholder: raw.placeholder || void 0,
1964
- helpText: raw.description || void 0,
1965
- ...options.length ? { options } : {},
1966
- hubspot: { object, property: name },
1967
- ...ctx.parentCondition ? { visibleIf: ctx.parentCondition } : {}
1968
- };
1969
- const out = [field];
1970
- for (const dependent of raw.dependentFields ?? []) {
1971
- if (!dependent.field) continue;
1972
- const childLabel = dependent.field.label || dependent.field.name || "";
1973
- const condition = dependent.dependentCondition ? convertCondition(field.id, dependent.dependentCondition) : null;
1974
- if (!condition) {
1975
- ctx.skipped.push({
1976
- code: "condition",
1977
- label: childLabel,
1978
- detail: dependent.dependentCondition?.operator
1979
- });
1980
- }
1981
- out.push(...convertField(dependent.field, { ...ctx, parentCondition: condition }));
1982
- }
1983
- return out;
1984
- }
1985
- function convertHubspotForm(raw) {
1986
- const skipped = [];
1987
- const usedNames = /* @__PURE__ */ new Set();
1988
- const fields = [];
1989
- for (const group of raw.fieldGroups ?? []) {
1990
- if (group.richText && !(group.fields ?? []).length) {
1991
- skipped.push({ code: "rich-text", detail: group.richText.slice(0, 80) });
1992
- continue;
1993
- }
1994
- const groupFields = (group.fields ?? []).flatMap(
1995
- (f) => convertField(f, { usedNames, skipped })
1996
- );
1997
- const topLevel = (group.fields ?? []).length;
1998
- if (topLevel === 2 && groupFields.length >= 2) {
1999
- groupFields[0].width = "half";
2000
- groupFields[1].width = "half";
2001
- }
2002
- fields.push(...groupFields);
2003
- }
2004
- if (raw.legalConsentOptions && Object.keys(raw.legalConsentOptions).length) {
2005
- skipped.push({ code: "legal-consent" });
2006
- }
2007
- const postSubmit = raw.configuration?.postSubmitAction;
2008
- return {
2009
- name: raw.name ?? "",
2010
- submitLabel: raw.displayOptions?.submitButtonText || null,
2011
- successMessage: postSubmit?.type === "thank_you" && postSubmit.value ? postSubmit.value : null,
2012
- // HubSpot forms are single-page: one step, reorganizable in the builder.
2013
- definition: { version: 1, steps: [{ id: makeId$1("stp"), fields }] },
2014
- skipped
2015
- };
2016
- }
2017
- async function hsGet(apiKey, path) {
2018
- const res = await fetch(`${HS_BASE$1}${path}`, {
2019
- headers: { Authorization: `Bearer ${apiKey}` }
2020
- });
2021
- if (!res.ok) {
2022
- const body = await res.json().catch(() => ({}));
2023
- throw Object.assign(new Error(body.message || `HubSpot ${res.status}`), {
2024
- status: res.status
2025
- });
2026
- }
2027
- return await res.json();
2028
- }
2029
- async function listHubspotForms(apiKey) {
2030
- const out = [];
2031
- let after;
2032
- for (let page = 0; page < 10; page += 1) {
2033
- const query2 = after ? `&after=${encodeURIComponent(after)}` : "";
2034
- const res = await hsGet(apiKey, `/marketing/v3/forms/?limit=100&formTypes=hubspot${query2}`);
2035
- for (const form of res.results ?? []) {
2036
- if (form.id && !form.archived) {
2037
- out.push({ id: form.id, name: form.name || form.id, updatedAt: form.updatedAt });
2038
- }
2039
- }
2040
- after = res.paging?.next?.after;
2041
- if (!after) break;
2042
- }
2043
- return out.sort((a, b) => a.name.localeCompare(b.name));
2044
- }
2045
- async function fetchHubspotForm(apiKey, formId) {
2046
- return hsGet(apiKey, `/marketing/v3/forms/${encodeURIComponent(formId)}`);
2047
- }
2048
2261
  let counter = 0;
2049
2262
  const makeId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter += 1).toString(36)}`;
2050
2263
  const slugifyName = (label) => label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
@@ -2123,7 +2336,8 @@ const WRITABLE = [
2123
2336
  "nextLabel",
2124
2337
  "submitLabel",
2125
2338
  "successMessage",
2126
- "class"
2339
+ "class",
2340
+ "hubspotFormId"
2127
2341
  ];
2128
2342
  const slugify = (value) => value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "form";
2129
2343
  function createFormsAdminController(strapi) {
@@ -2488,6 +2702,7 @@ function createFormsAdminController(strapi) {
2488
2702
  submitLabel: source.submitLabel,
2489
2703
  successMessage: source.successMessage,
2490
2704
  class: source.class,
2705
+ hubspotFormId: source.hubspotFormId ?? null,
2491
2706
  definition: source.definition
2492
2707
  };
2493
2708
  if (targetId) {
@@ -2722,17 +2937,27 @@ const FORMS_ACTION = "plugin::hubspot.forms";
2722
2937
  const config = {
2723
2938
  default: {
2724
2939
  apiKey: "",
2940
+ // HubSpot account that receives form submissions. A test portal id is
2941
+ // fine in development; production swaps this env var, not the code.
2942
+ portalId: "",
2943
+ // Hosting region of that portal (`eu1`, `na1`, …). Drives the Forms API
2944
+ // host (`api-eu1.hsforms.com`) — CRM stays on api.hubapi.com.
2945
+ region: "eu1",
2725
2946
  // Objects whose properties are offered. Names from the standard set, or
2726
2947
  // `{ name, path }` for a custom object type.
2727
2948
  objects: ["contact", "company"],
2728
2949
  validate: [],
2729
- // Submission pipeline of the built forms. Both default to true:
2950
+ // Submission pipeline of the built forms:
2730
2951
  // - companyFromDomain: a corporate email upserts the Company (deduped by
2731
2952
  // domain) and associates it to the contact;
2732
- // - timelineNote: a note recaps the submission on the contact's timeline.
2953
+ // - timelineNote: recap note on the CRM-upsert fallback only (off when
2954
+ // submissions go through the marketing Forms API);
2955
+ // - defaultFormId: portal-wide marketing form GUID when a builder form
2956
+ // doesn't set its own — swap with the portal at production cutover.
2733
2957
  forms: {
2734
2958
  companyFromDomain: true,
2735
2959
  timelineNote: true,
2960
+ defaultFormId: "",
2736
2961
  // Per-IP brakes on the public routes; 0 disables one.
2737
2962
  rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
2738
2963
  }
@@ -2888,10 +3113,21 @@ const controllers = {
2888
3113
  if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
2889
3114
  const rawMeta = ctx.request.body?.meta ?? {};
2890
3115
  const meta = {};
2891
- for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
3116
+ for (const key of ["pagePath", "pageUrl", "pageName", "originPath", "originLabel", "source", "ipAddress"]) {
2892
3117
  const v = rawMeta[key];
2893
3118
  if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
2894
3119
  }
3120
+ if (rawMeta.consent === true) meta.consent = true;
3121
+ if (typeof rawMeta.consentedAt === "string" && rawMeta.consentedAt.trim()) {
3122
+ meta.consentedAt = rawMeta.consentedAt.trim().slice(0, 40);
3123
+ }
3124
+ if (typeof rawMeta.hutk === "string" && rawMeta.hutk.trim()) {
3125
+ meta.hutk = rawMeta.hutk.trim().slice(0, 128);
3126
+ }
3127
+ if (!meta.ipAddress) {
3128
+ const ip = ctx.request.ip;
3129
+ if (ip) meta.ipAddress = ip.slice(0, 45);
3130
+ }
2895
3131
  const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
2896
3132
  if (!outcome.ok) {
2897
3133
  ctx.status = 422;