strapi-plugin-hubspot 0.16.1 → 0.16.3

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 (32) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +12 -9
  3. package/dist/_chunks/{Forms-DEoZGrEt.mjs → Forms-BBaw53Tz.mjs} +48 -5
  4. package/dist/_chunks/{Forms-D0QXkYAN.js → Forms-CIJrUB_n.js} +48 -5
  5. package/dist/_chunks/{HubspotFormInput-CPep55GL.mjs → HubspotFormInput-DIo6hCNz.mjs} +1 -1
  6. package/dist/_chunks/{HubspotFormInput-4ZMYXWM_.js → HubspotFormInput-DSTuQ_bx.js} +1 -1
  7. package/dist/_chunks/{HubspotObjectInput-CQszIx1W.mjs → HubspotObjectInput-BA16dswz.mjs} +3 -3
  8. package/dist/_chunks/{HubspotObjectInput-BTqYXJIa.js → HubspotObjectInput-BIqC8tl2.js} +3 -3
  9. package/dist/_chunks/{HubspotPropertyInput-2MgIKlR_.js → HubspotPropertyInput-C77Op1Wm.js} +3 -3
  10. package/dist/_chunks/{HubspotPropertyInput-ClNph0f1.mjs → HubspotPropertyInput-Dsn7ikop.mjs} +3 -3
  11. package/dist/_chunks/{Settings-CZ-qQSKT.mjs → Settings-DxX9Uh_2.mjs} +21 -3
  12. package/dist/_chunks/{Settings-DhiHQC6U.js → Settings-PVhJVPqq.js} +21 -3
  13. package/dist/_chunks/{en-Dh3bMS6N.mjs → en-Djd3TID0.mjs} +6 -2
  14. package/dist/_chunks/{en-CSgFmKmM.js → en-sfzopB5K.js} +6 -2
  15. package/dist/_chunks/{fr-aWOIOlS9.mjs → fr-C7t1elmR.mjs} +6 -2
  16. package/dist/_chunks/{fr-D_EhZS0P.js → fr-sF5Josfh.js} +6 -2
  17. package/dist/_chunks/{index-gKM4jVf8.mjs → index-B8GYi6sw.mjs} +6 -6
  18. package/dist/_chunks/{index-ciliGv0E.js → index-XEuSPuUj.js} +6 -6
  19. package/dist/_chunks/{objectLabels-DQpSLUaJ.mjs → objectLabels-DybqhBdT.mjs} +1 -1
  20. package/dist/_chunks/{objectLabels-BjYfdhRB.js → objectLabels-wBwfLdZt.js} +1 -1
  21. package/dist/_chunks/{useHubspotSchema-CE7tJgOM.mjs → useHubspotSchema-DtcHHUOb.mjs} +1 -1
  22. package/dist/_chunks/{useHubspotSchema-KKVppjC2.js → useHubspotSchema-WvgW3od8.js} +1 -1
  23. package/dist/admin/index.js +1 -1
  24. package/dist/admin/index.mjs +1 -1
  25. package/dist/server/index.js +138 -17
  26. package/dist/server/index.mjs +138 -17
  27. package/dist/server/src/forms.d.ts +3 -2
  28. package/dist/server/src/formsAdmin.d.ts +8 -3
  29. package/dist/server/src/importHubspot.d.ts +12 -0
  30. package/dist/server/src/index.d.ts +2 -0
  31. package/dist/server/src/settings.d.ts +6 -0
  32. package/package.json +1 -1
@@ -26,7 +26,7 @@ const index = {
26
26
  icon: icons.PaperPlane,
27
27
  intlLabel: { id: `${PLUGIN_ID}.menu.label`, defaultMessage: "HubSpot Forms" },
28
28
  permissions: [{ action: `plugin::${PLUGIN_ID}.forms`, subject: null }],
29
- Component: async () => (await Promise.resolve().then(() => require("./Forms-D0QXkYAN.js"))).default
29
+ Component: async () => (await Promise.resolve().then(() => require("./Forms-CIJrUB_n.js"))).default
30
30
  });
31
31
  app.customFields.register({
32
32
  name: "property",
@@ -41,7 +41,7 @@ const index = {
41
41
  defaultMessage: "Picked from the portal's real properties"
42
42
  },
43
43
  components: {
44
- Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-2MgIKlR_.js"))
44
+ Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-C77Op1Wm.js"))
45
45
  },
46
46
  // Exposed in the Content-Type Builder. Without this, `objectField` could
47
47
  // only be set by hand-editing schema.json — which is not how anyone
@@ -104,7 +104,7 @@ const index = {
104
104
  defaultMessage: "Picked from the portal's configured objects"
105
105
  },
106
106
  components: {
107
- Input: async () => Promise.resolve().then(() => require("./HubspotObjectInput-BTqYXJIa.js"))
107
+ Input: async () => Promise.resolve().then(() => require("./HubspotObjectInput-BIqC8tl2.js"))
108
108
  }
109
109
  });
110
110
  app.customFields.register({
@@ -120,7 +120,7 @@ const index = {
120
120
  defaultMessage: "A form built in the HubSpot form builder, stored by slug"
121
121
  },
122
122
  components: {
123
- Input: async () => Promise.resolve().then(() => require("./HubspotFormInput-4ZMYXWM_.js"))
123
+ Input: async () => Promise.resolve().then(() => require("./HubspotFormInput-DSTuQ_bx.js"))
124
124
  }
125
125
  });
126
126
  app.createSettingSection(
@@ -136,7 +136,7 @@ const index = {
136
136
  // Mirrors the RBAC action registered server-side: a role without it
137
137
  // doesn't see the link at all — the routes refuse it anyway.
138
138
  permissions: [{ action: `plugin::${PLUGIN_ID}.settings`, subject: null }],
139
- Component: async () => (await Promise.resolve().then(() => require("./Settings-DhiHQC6U.js"))).default
139
+ Component: async () => (await Promise.resolve().then(() => require("./Settings-PVhJVPqq.js"))).default
140
140
  }
141
141
  ]
142
142
  );
@@ -146,7 +146,7 @@ const index = {
146
146
  return Promise.all(
147
147
  locales.map(async (locale) => {
148
148
  try {
149
- const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-CSgFmKmM.js")), "./translations/fr.json": () => Promise.resolve().then(() => require("./fr-D_EhZS0P.js")) }), `./translations/${locale}.json`, 3);
149
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-sfzopB5K.js")), "./translations/fr.json": () => Promise.resolve().then(() => require("./fr-sF5Josfh.js")) }), `./translations/${locale}.json`, 3);
150
150
  return { data: prefixPluginTranslations(data), locale };
151
151
  } catch {
152
152
  return { data: {}, locale };
@@ -1,4 +1,4 @@
1
- import { g as getTranslation } from "./index-gKM4jVf8.mjs";
1
+ import { g as getTranslation } from "./index-B8GYi6sw.mjs";
2
2
  const STANDARD = /* @__PURE__ */ new Set([
3
3
  "contact",
4
4
  "company",
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- const index = require("./index-ciliGv0E.js");
2
+ const index = require("./index-XEuSPuUj.js");
3
3
  const STANDARD = /* @__PURE__ */ new Set([
4
4
  "contact",
5
5
  "company",
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { useFetchClient } from "@strapi/strapi/admin";
3
- import { P as PLUGIN_ID } from "./index-gKM4jVf8.mjs";
3
+ import { P as PLUGIN_ID } from "./index-B8GYi6sw.mjs";
4
4
  let cached = null;
5
5
  let inFlight = null;
6
6
  const listeners = /* @__PURE__ */ new Set();
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  const React = require("react");
3
3
  const admin = require("@strapi/strapi/admin");
4
- const index = require("./index-ciliGv0E.js");
4
+ const index = require("./index-XEuSPuUj.js");
5
5
  function _interopNamespace(e) {
6
6
  if (e && e.__esModule) return e;
7
7
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-ciliGv0E.js");
2
+ const index = require("../_chunks/index-XEuSPuUj.js");
3
3
  require("@strapi/icons");
4
4
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-gKM4jVf8.mjs";
1
+ import { i } from "../_chunks/index-B8GYi6sw.mjs";
2
2
  import "@strapi/icons";
3
3
  export {
4
4
  i as default
@@ -191,7 +191,8 @@ function policyFrom(stored, forms) {
191
191
  return {
192
192
  submissionMode: asSubmissionMode(stored.submissionMode) ?? asSubmissionMode(forms.submissionMode) ?? "auto",
193
193
  writeExtraProperties: asBool(stored.writeExtraProperties) ?? asBool(forms.writeExtraProperties) ?? true,
194
- syncFieldsOnPublish: asBool(stored.syncFieldsOnPublish) ?? asBool(forms.syncFieldsOnPublish) ?? false
194
+ syncFieldsOnPublish: asBool(stored.syncFieldsOnPublish) ?? asBool(forms.syncFieldsOnPublish) ?? false,
195
+ timelineNote: asBool(stored.timelineNote) ?? asBool(forms.timelineNote) ?? true
195
196
  };
196
197
  }
197
198
  async function resolvePolicy(strapi) {
@@ -275,6 +276,9 @@ async function patchStoredSettings(strapi, body) {
275
276
  if ("syncFieldsOnPublish" in body && typeof body.syncFieldsOnPublish === "boolean") {
276
277
  next.syncFieldsOnPublish = body.syncFieldsOnPublish;
277
278
  }
279
+ if ("timelineNote" in body && typeof body.timelineNote === "boolean") {
280
+ next.timelineNote = body.timelineNote;
281
+ }
278
282
  await setStoredSettings(strapi, next);
279
283
  }
280
284
  async function clearStoredApiKey(strapi) {
@@ -1778,6 +1782,65 @@ function convertHubspotForm(raw) {
1778
1782
  skipped
1779
1783
  };
1780
1784
  }
1785
+ function mappingKey(field) {
1786
+ const mapping = field.hubspot ?? {};
1787
+ const property = mapping.property?.trim();
1788
+ if (!property) return null;
1789
+ return `${mapping.object?.trim() || "contact"}:${property}`;
1790
+ }
1791
+ function uniqueName(base, used) {
1792
+ if (!used.has(base)) return base;
1793
+ let n = 2;
1794
+ while (used.has(`${base}_${n}`)) n += 1;
1795
+ return `${base}_${n}`;
1796
+ }
1797
+ function mergeHubspotImport(current, incoming) {
1798
+ const steps = (current.steps ?? []).map((step) => ({
1799
+ ...step,
1800
+ fields: [...step.fields ?? []]
1801
+ }));
1802
+ if (!steps.length) {
1803
+ steps.push({ id: makeId$1("stp"), fields: [] });
1804
+ }
1805
+ const byKey = /* @__PURE__ */ new Map();
1806
+ const usedNames = /* @__PURE__ */ new Set();
1807
+ for (const step of steps) {
1808
+ for (const field of step.fields) {
1809
+ usedNames.add(field.name);
1810
+ const key = mappingKey(field);
1811
+ if (key) byKey.set(key, field);
1812
+ }
1813
+ }
1814
+ const added = [];
1815
+ const updated = [];
1816
+ const incomingFields = (incoming.steps ?? []).flatMap((step) => step.fields ?? []);
1817
+ for (const next of incomingFields) {
1818
+ const key = mappingKey(next);
1819
+ const existing = key ? byKey.get(key) : void 0;
1820
+ if (existing) {
1821
+ existing.label = next.label;
1822
+ existing.type = next.type;
1823
+ existing.required = next.required;
1824
+ existing.placeholder = next.placeholder;
1825
+ existing.helpText = next.helpText;
1826
+ if (next.options) existing.options = next.options;
1827
+ else delete existing.options;
1828
+ if (!existing.visibleIf && next.visibleIf) existing.visibleIf = next.visibleIf;
1829
+ updated.push(key);
1830
+ continue;
1831
+ }
1832
+ const field = {
1833
+ ...next,
1834
+ id: makeId$1("fld"),
1835
+ name: uniqueName(next.name, usedNames)
1836
+ };
1837
+ usedNames.add(field.name);
1838
+ if (key) byKey.set(key, field);
1839
+ steps[steps.length - 1].fields.push(field);
1840
+ added.push(key || field.name);
1841
+ }
1842
+ return { definition: { version: 1, steps }, added, updated };
1843
+ }
1781
1844
  async function hsGet(apiKey, path) {
1782
1845
  const res = await fetch(`${HS_BASE$2}${path}`, {
1783
1846
  headers: { Authorization: `Bearer ${apiKey}` }
@@ -2386,7 +2449,7 @@ function createFormsService(strapi, _opts = {}) {
2386
2449
  context: {
2387
2450
  hutk: sanitizeHutk(meta.hutk),
2388
2451
  pageUri: typeof meta.pageUrl === "string" ? meta.pageUrl : void 0,
2389
- pageName: typeof meta.pageName === "string" && meta.pageName || (form.title || form.name) || void 0,
2452
+ pageName: typeof meta.pageName === "string" && meta.pageName || typeof meta.originLabel === "string" && meta.originLabel || (form.title || form.name) || void 0,
2390
2453
  ipAddress: sanitizeIp(meta.ipAddress)
2391
2454
  },
2392
2455
  consent: { given: meta.consent === true },
@@ -2466,7 +2529,7 @@ function createFormsService(strapi, _opts = {}) {
2466
2529
  strapi.log.warn(`[hubspot] company sync failed (contact OK) — ${err.message}`);
2467
2530
  }
2468
2531
  }
2469
- if (contactId && config2.timelineNote && !usedFormsApi) {
2532
+ if (contactId && policy.timelineNote) {
2470
2533
  try {
2471
2534
  await createLeadNote(apiKey, {
2472
2535
  contactId,
@@ -2895,9 +2958,9 @@ function createFormsAdminController(strapi) {
2895
2958
  },
2896
2959
  /**
2897
2960
  * Converts one portal form into a plugin form draft, in the current
2898
- * locale. Slug derived from the HubSpot name: re-importing the same form
2899
- * overwrites the draft — never the published version, never the portal.
2900
- * What the builder can't express is skipped and returned in `skipped`.
2961
+ * locale. Matching prefers the linked HubSpot GUID, then the slug.
2962
+ * Re-importing merges: mapped fields update in place, new ones append,
2963
+ * Strapi steps stay. Never the published version, never the portal.
2901
2964
  */
2902
2965
  async runHubspotImport(ctx) {
2903
2966
  const formId = ctx.request.body?.formId;
@@ -2916,24 +2979,81 @@ function createFormsAdminController(strapi) {
2916
2979
  const { skipped, definition, ...meta } = converted;
2917
2980
  const slug = slugify(name);
2918
2981
  const existing = await documents().findFirst({
2982
+ filters: { hubspotFormId: formId }
2983
+ }) ?? await documents().findFirst({
2919
2984
  filters: { slug }
2920
2985
  });
2921
- let documentId;
2922
2986
  if (existing) {
2923
- await documents().update({
2987
+ const current = await documents().findOne({
2924
2988
  documentId: existing.documentId,
2925
2989
  locale: ctx.query.locale,
2926
- data: { ...meta, name, definition }
2990
+ status: "draft"
2927
2991
  });
2928
- documentId = existing.documentId;
2929
- } else {
2930
- const created = await documents().create({
2992
+ const merged = mergeHubspotImport(current?.definition ?? EMPTY_DEFINITION, definition);
2993
+ await documents().update({
2994
+ documentId: existing.documentId,
2931
2995
  locale: ctx.query.locale,
2932
- data: { ...meta, name, slug, definition }
2996
+ data: {
2997
+ hubspotFormId: meta.hubspotFormId ?? formId,
2998
+ definition: merged.definition
2999
+ }
2933
3000
  });
2934
- documentId = created.documentId;
3001
+ ctx.body = {
3002
+ documentId: existing.documentId,
3003
+ skipped,
3004
+ added: merged.added,
3005
+ updated: merged.updated
3006
+ };
3007
+ return;
2935
3008
  }
2936
- ctx.body = { documentId, skipped };
3009
+ const created = await documents().create({
3010
+ locale: ctx.query.locale,
3011
+ data: { ...meta, name, slug, definition }
3012
+ });
3013
+ ctx.body = { documentId: created.documentId, skipped, added: [], updated: [] };
3014
+ },
3015
+ /**
3016
+ * Pull the linked HubSpot marketing form back into this draft: mapped
3017
+ * fields update in place, new ones append, Strapi steps and extras stay.
3018
+ */
3019
+ async resync(ctx) {
3020
+ const entry = await documents().findOne({
3021
+ documentId: ctx.params.documentId,
3022
+ locale: ctx.query.locale,
3023
+ status: "draft"
3024
+ });
3025
+ if (!entry) ctx.throw(404, "Form not found");
3026
+ const guid = typeof entry.hubspotFormId === "string" ? entry.hubspotFormId.trim() : "";
3027
+ if (!isFormGuid(guid)) {
3028
+ ctx.throw(400, "This form is not linked to a HubSpot marketing form.");
3029
+ }
3030
+ const { apiKey } = await resolveApiKey(strapi);
3031
+ if (!apiKey) ctx.throw(400, "No HubSpot API key configured");
3032
+ let converted;
3033
+ try {
3034
+ converted = convertHubspotForm(await fetchHubspotForm(apiKey, guid));
3035
+ } catch (err) {
3036
+ strapi.log.warn(`[hubspot] form resync failed — ${err.message}`);
3037
+ ctx.throw(502, err.message || "Cannot reach HubSpot");
3038
+ return;
3039
+ }
3040
+ const merged = mergeHubspotImport(entry.definition ?? EMPTY_DEFINITION, converted.definition);
3041
+ await documents().update({
3042
+ documentId: ctx.params.documentId,
3043
+ locale: ctx.query.locale,
3044
+ data: { definition: merged.definition }
3045
+ });
3046
+ const form = await documents().findOne({
3047
+ documentId: ctx.params.documentId,
3048
+ locale: ctx.query.locale,
3049
+ status: "draft"
3050
+ });
3051
+ ctx.body = {
3052
+ form,
3053
+ added: merged.added,
3054
+ updated: merged.updated,
3055
+ skipped: converted.skipped
3056
+ };
2937
3057
  },
2938
3058
  /**
2939
3059
  * Copies a form (every locale of its draft) into a new draft document —
@@ -3218,8 +3338,8 @@ const config = {
3218
3338
  // Submission pipeline of the built forms:
3219
3339
  // - companyFromDomain: a corporate email upserts the Company (deduped by
3220
3340
  // domain) and associates it to the contact;
3221
- // - timelineNote: recap note on the CRM-upsert fallback only (off when
3222
- // submissions go through the marketing Forms API);
3341
+ // - timelineNote: recap note on the contact after a successful sync
3342
+ // (page URL, subject, answers) — alongside a Forms API conversion;
3223
3343
  // - defaultFormId: portal-wide marketing form GUID when a builder form
3224
3344
  // doesn't set its own — swap with the portal at production cutover.
3225
3345
  // - submissionMode / writeExtraProperties / syncFieldsOnPublish: see
@@ -3472,6 +3592,7 @@ const routes = {
3472
3592
  adminRoute("POST", "/builder/forms/:documentId/unpublish", "formsAdmin.unpublish", [FORMS_ACTION]),
3473
3593
  adminRoute("DELETE", "/builder/forms/:documentId", "formsAdmin.remove", [FORMS_ACTION]),
3474
3594
  adminRoute("POST", "/builder/forms/:documentId/duplicate", "formsAdmin.duplicate", [FORMS_ACTION]),
3595
+ adminRoute("POST", "/builder/forms/:documentId/resync", "formsAdmin.resync", [FORMS_ACTION]),
3475
3596
  // No FORMS_ACTION: editors pick forms from the Content Manager, like
3476
3597
  // they pick properties — the builder itself stays gated.
3477
3598
  adminRoute("GET", "/forms-options", "formsAdmin.options"),
@@ -190,7 +190,8 @@ function policyFrom(stored, forms) {
190
190
  return {
191
191
  submissionMode: asSubmissionMode(stored.submissionMode) ?? asSubmissionMode(forms.submissionMode) ?? "auto",
192
192
  writeExtraProperties: asBool(stored.writeExtraProperties) ?? asBool(forms.writeExtraProperties) ?? true,
193
- syncFieldsOnPublish: asBool(stored.syncFieldsOnPublish) ?? asBool(forms.syncFieldsOnPublish) ?? false
193
+ syncFieldsOnPublish: asBool(stored.syncFieldsOnPublish) ?? asBool(forms.syncFieldsOnPublish) ?? false,
194
+ timelineNote: asBool(stored.timelineNote) ?? asBool(forms.timelineNote) ?? true
194
195
  };
195
196
  }
196
197
  async function resolvePolicy(strapi) {
@@ -274,6 +275,9 @@ async function patchStoredSettings(strapi, body) {
274
275
  if ("syncFieldsOnPublish" in body && typeof body.syncFieldsOnPublish === "boolean") {
275
276
  next.syncFieldsOnPublish = body.syncFieldsOnPublish;
276
277
  }
278
+ if ("timelineNote" in body && typeof body.timelineNote === "boolean") {
279
+ next.timelineNote = body.timelineNote;
280
+ }
277
281
  await setStoredSettings(strapi, next);
278
282
  }
279
283
  async function clearStoredApiKey(strapi) {
@@ -1777,6 +1781,65 @@ function convertHubspotForm(raw) {
1777
1781
  skipped
1778
1782
  };
1779
1783
  }
1784
+ function mappingKey(field) {
1785
+ const mapping = field.hubspot ?? {};
1786
+ const property = mapping.property?.trim();
1787
+ if (!property) return null;
1788
+ return `${mapping.object?.trim() || "contact"}:${property}`;
1789
+ }
1790
+ function uniqueName(base, used) {
1791
+ if (!used.has(base)) return base;
1792
+ let n = 2;
1793
+ while (used.has(`${base}_${n}`)) n += 1;
1794
+ return `${base}_${n}`;
1795
+ }
1796
+ function mergeHubspotImport(current, incoming) {
1797
+ const steps = (current.steps ?? []).map((step) => ({
1798
+ ...step,
1799
+ fields: [...step.fields ?? []]
1800
+ }));
1801
+ if (!steps.length) {
1802
+ steps.push({ id: makeId$1("stp"), fields: [] });
1803
+ }
1804
+ const byKey = /* @__PURE__ */ new Map();
1805
+ const usedNames = /* @__PURE__ */ new Set();
1806
+ for (const step of steps) {
1807
+ for (const field of step.fields) {
1808
+ usedNames.add(field.name);
1809
+ const key = mappingKey(field);
1810
+ if (key) byKey.set(key, field);
1811
+ }
1812
+ }
1813
+ const added = [];
1814
+ const updated = [];
1815
+ const incomingFields = (incoming.steps ?? []).flatMap((step) => step.fields ?? []);
1816
+ for (const next of incomingFields) {
1817
+ const key = mappingKey(next);
1818
+ const existing = key ? byKey.get(key) : void 0;
1819
+ if (existing) {
1820
+ existing.label = next.label;
1821
+ existing.type = next.type;
1822
+ existing.required = next.required;
1823
+ existing.placeholder = next.placeholder;
1824
+ existing.helpText = next.helpText;
1825
+ if (next.options) existing.options = next.options;
1826
+ else delete existing.options;
1827
+ if (!existing.visibleIf && next.visibleIf) existing.visibleIf = next.visibleIf;
1828
+ updated.push(key);
1829
+ continue;
1830
+ }
1831
+ const field = {
1832
+ ...next,
1833
+ id: makeId$1("fld"),
1834
+ name: uniqueName(next.name, usedNames)
1835
+ };
1836
+ usedNames.add(field.name);
1837
+ if (key) byKey.set(key, field);
1838
+ steps[steps.length - 1].fields.push(field);
1839
+ added.push(key || field.name);
1840
+ }
1841
+ return { definition: { version: 1, steps }, added, updated };
1842
+ }
1780
1843
  async function hsGet(apiKey, path) {
1781
1844
  const res = await fetch(`${HS_BASE$2}${path}`, {
1782
1845
  headers: { Authorization: `Bearer ${apiKey}` }
@@ -2385,7 +2448,7 @@ function createFormsService(strapi, _opts = {}) {
2385
2448
  context: {
2386
2449
  hutk: sanitizeHutk(meta.hutk),
2387
2450
  pageUri: typeof meta.pageUrl === "string" ? meta.pageUrl : void 0,
2388
- pageName: typeof meta.pageName === "string" && meta.pageName || (form.title || form.name) || void 0,
2451
+ pageName: typeof meta.pageName === "string" && meta.pageName || typeof meta.originLabel === "string" && meta.originLabel || (form.title || form.name) || void 0,
2389
2452
  ipAddress: sanitizeIp(meta.ipAddress)
2390
2453
  },
2391
2454
  consent: { given: meta.consent === true },
@@ -2465,7 +2528,7 @@ function createFormsService(strapi, _opts = {}) {
2465
2528
  strapi.log.warn(`[hubspot] company sync failed (contact OK) — ${err.message}`);
2466
2529
  }
2467
2530
  }
2468
- if (contactId && config2.timelineNote && !usedFormsApi) {
2531
+ if (contactId && policy.timelineNote) {
2469
2532
  try {
2470
2533
  await createLeadNote(apiKey, {
2471
2534
  contactId,
@@ -2894,9 +2957,9 @@ function createFormsAdminController(strapi) {
2894
2957
  },
2895
2958
  /**
2896
2959
  * Converts one portal form into a plugin form draft, in the current
2897
- * locale. Slug derived from the HubSpot name: re-importing the same form
2898
- * overwrites the draft — never the published version, never the portal.
2899
- * What the builder can't express is skipped and returned in `skipped`.
2960
+ * locale. Matching prefers the linked HubSpot GUID, then the slug.
2961
+ * Re-importing merges: mapped fields update in place, new ones append,
2962
+ * Strapi steps stay. Never the published version, never the portal.
2900
2963
  */
2901
2964
  async runHubspotImport(ctx) {
2902
2965
  const formId = ctx.request.body?.formId;
@@ -2915,24 +2978,81 @@ function createFormsAdminController(strapi) {
2915
2978
  const { skipped, definition, ...meta } = converted;
2916
2979
  const slug = slugify(name);
2917
2980
  const existing = await documents().findFirst({
2981
+ filters: { hubspotFormId: formId }
2982
+ }) ?? await documents().findFirst({
2918
2983
  filters: { slug }
2919
2984
  });
2920
- let documentId;
2921
2985
  if (existing) {
2922
- await documents().update({
2986
+ const current = await documents().findOne({
2923
2987
  documentId: existing.documentId,
2924
2988
  locale: ctx.query.locale,
2925
- data: { ...meta, name, definition }
2989
+ status: "draft"
2926
2990
  });
2927
- documentId = existing.documentId;
2928
- } else {
2929
- const created = await documents().create({
2991
+ const merged = mergeHubspotImport(current?.definition ?? EMPTY_DEFINITION, definition);
2992
+ await documents().update({
2993
+ documentId: existing.documentId,
2930
2994
  locale: ctx.query.locale,
2931
- data: { ...meta, name, slug, definition }
2995
+ data: {
2996
+ hubspotFormId: meta.hubspotFormId ?? formId,
2997
+ definition: merged.definition
2998
+ }
2932
2999
  });
2933
- documentId = created.documentId;
3000
+ ctx.body = {
3001
+ documentId: existing.documentId,
3002
+ skipped,
3003
+ added: merged.added,
3004
+ updated: merged.updated
3005
+ };
3006
+ return;
2934
3007
  }
2935
- ctx.body = { documentId, skipped };
3008
+ const created = await documents().create({
3009
+ locale: ctx.query.locale,
3010
+ data: { ...meta, name, slug, definition }
3011
+ });
3012
+ ctx.body = { documentId: created.documentId, skipped, added: [], updated: [] };
3013
+ },
3014
+ /**
3015
+ * Pull the linked HubSpot marketing form back into this draft: mapped
3016
+ * fields update in place, new ones append, Strapi steps and extras stay.
3017
+ */
3018
+ async resync(ctx) {
3019
+ const entry = await documents().findOne({
3020
+ documentId: ctx.params.documentId,
3021
+ locale: ctx.query.locale,
3022
+ status: "draft"
3023
+ });
3024
+ if (!entry) ctx.throw(404, "Form not found");
3025
+ const guid = typeof entry.hubspotFormId === "string" ? entry.hubspotFormId.trim() : "";
3026
+ if (!isFormGuid(guid)) {
3027
+ ctx.throw(400, "This form is not linked to a HubSpot marketing form.");
3028
+ }
3029
+ const { apiKey } = await resolveApiKey(strapi);
3030
+ if (!apiKey) ctx.throw(400, "No HubSpot API key configured");
3031
+ let converted;
3032
+ try {
3033
+ converted = convertHubspotForm(await fetchHubspotForm(apiKey, guid));
3034
+ } catch (err) {
3035
+ strapi.log.warn(`[hubspot] form resync failed — ${err.message}`);
3036
+ ctx.throw(502, err.message || "Cannot reach HubSpot");
3037
+ return;
3038
+ }
3039
+ const merged = mergeHubspotImport(entry.definition ?? EMPTY_DEFINITION, converted.definition);
3040
+ await documents().update({
3041
+ documentId: ctx.params.documentId,
3042
+ locale: ctx.query.locale,
3043
+ data: { definition: merged.definition }
3044
+ });
3045
+ const form = await documents().findOne({
3046
+ documentId: ctx.params.documentId,
3047
+ locale: ctx.query.locale,
3048
+ status: "draft"
3049
+ });
3050
+ ctx.body = {
3051
+ form,
3052
+ added: merged.added,
3053
+ updated: merged.updated,
3054
+ skipped: converted.skipped
3055
+ };
2936
3056
  },
2937
3057
  /**
2938
3058
  * Copies a form (every locale of its draft) into a new draft document —
@@ -3217,8 +3337,8 @@ const config = {
3217
3337
  // Submission pipeline of the built forms:
3218
3338
  // - companyFromDomain: a corporate email upserts the Company (deduped by
3219
3339
  // domain) and associates it to the contact;
3220
- // - timelineNote: recap note on the CRM-upsert fallback only (off when
3221
- // submissions go through the marketing Forms API);
3340
+ // - timelineNote: recap note on the contact after a successful sync
3341
+ // (page URL, subject, answers) — alongside a Forms API conversion;
3222
3342
  // - defaultFormId: portal-wide marketing form GUID when a builder form
3223
3343
  // doesn't set its own — swap with the portal at production cutover.
3224
3344
  // - submissionMode / writeExtraProperties / syncFieldsOnPublish: see
@@ -3471,6 +3591,7 @@ const routes = {
3471
3591
  adminRoute("POST", "/builder/forms/:documentId/unpublish", "formsAdmin.unpublish", [FORMS_ACTION]),
3472
3592
  adminRoute("DELETE", "/builder/forms/:documentId", "formsAdmin.remove", [FORMS_ACTION]),
3473
3593
  adminRoute("POST", "/builder/forms/:documentId/duplicate", "formsAdmin.duplicate", [FORMS_ACTION]),
3594
+ adminRoute("POST", "/builder/forms/:documentId/resync", "formsAdmin.resync", [FORMS_ACTION]),
3474
3595
  // No FORMS_ACTION: editors pick forms from the Content Manager, like
3475
3596
  // they pick properties — the builder itself stays gated.
3476
3597
  adminRoute("GET", "/forms-options", "formsAdmin.options"),
@@ -6,8 +6,9 @@
6
6
  * conversion (with leftover CRM writes for mapped fields the HubSpot form
7
7
  * dropped), CRM upsert only, or automatic (Forms API when a GUID is linked).
8
8
  * Company find-or-create still runs after a successful submit. Timeline notes
9
- * are opt-in and only used on the CRM-upsert path. The form submission is
10
- * the lead — a CRM outage never loses it.
9
+ * (page URL, subject, answers) are optional extra context on the contact,
10
+ * alongside a native conversion. The form submission is the lead — a CRM
11
+ * outage never loses it.
11
12
  */
12
13
  import type { Core } from "@strapi/strapi";
13
14
  import { type FormDefinition, type Primitive } from "./conditions";
@@ -50,11 +50,16 @@ export declare function createFormsAdminController(strapi: Core.Strapi): {
50
50
  listHubspotSources(ctx: Ctx): Promise<void>;
51
51
  /**
52
52
  * Converts one portal form into a plugin form draft, in the current
53
- * locale. Slug derived from the HubSpot name: re-importing the same form
54
- * overwrites the draft — never the published version, never the portal.
55
- * What the builder can't express is skipped and returned in `skipped`.
53
+ * locale. Matching prefers the linked HubSpot GUID, then the slug.
54
+ * Re-importing merges: mapped fields update in place, new ones append,
55
+ * Strapi steps stay. Never the published version, never the portal.
56
56
  */
57
57
  runHubspotImport(ctx: Ctx): Promise<void>;
58
+ /**
59
+ * Pull the linked HubSpot marketing form back into this draft: mapped
60
+ * fields update in place, new ones append, Strapi steps and extras stay.
61
+ */
62
+ resync(ctx: Ctx): Promise<void>;
58
63
  /**
59
64
  * Copies a form (every locale of its draft) into a new draft document —
60
65
  * the quickest way to A/B a variant or to start from an existing form.
@@ -79,6 +79,18 @@ export interface RawHubspotForm {
79
79
  } | null;
80
80
  }
81
81
  export declare function convertHubspotForm(raw: RawHubspotForm): ConvertedHubspotForm;
82
+ export interface HubspotMergeResult {
83
+ definition: FormDefinition;
84
+ added: string[];
85
+ updated: string[];
86
+ }
87
+ /**
88
+ * Fold a freshly converted HubSpot form into an existing builder definition.
89
+ * Mapped fields are updated in place (label, type, required, options…) so
90
+ * Strapi steps, ids, conditions and extra fields survive. New HubSpot fields
91
+ * are appended to the last step. Nothing is deleted.
92
+ */
93
+ export declare function mergeHubspotImport(current: FormDefinition, incoming: FormDefinition): HubspotMergeResult;
82
94
  /** Every regular form of the portal, name-sorted. Needs the `forms` scope. */
83
95
  export declare function listHubspotForms(apiKey: string): Promise<HubspotFormSummary[]>;
84
96
  /** One form, full definition. */
@@ -280,6 +280,7 @@ declare const _default: {
280
280
  runImport(ctx: import("./formsAdmin").Ctx): Promise<void>;
281
281
  listHubspotSources(ctx: import("./formsAdmin").Ctx): Promise<void>;
282
282
  runHubspotImport(ctx: import("./formsAdmin").Ctx): Promise<void>;
283
+ resync(ctx: import("./formsAdmin").Ctx): Promise<void>;
283
284
  duplicate(ctx: import("./formsAdmin").Ctx): Promise<void>;
284
285
  remove(ctx: import("./formsAdmin").Ctx): Promise<void>;
285
286
  };
@@ -370,6 +371,7 @@ declare const _default: {
370
371
  submissionMode?: string;
371
372
  writeExtraProperties?: boolean;
372
373
  syncFieldsOnPublish?: boolean;
374
+ timelineNote?: boolean;
373
375
  };
374
376
  };
375
377
  body: unknown;
@@ -24,6 +24,7 @@ export interface StoredSettings {
24
24
  submissionMode?: SubmissionMode;
25
25
  writeExtraProperties?: boolean;
26
26
  syncFieldsOnPublish?: boolean;
27
+ timelineNote?: boolean;
27
28
  }
28
29
  export interface HubspotPolicy {
29
30
  /** How submissions reach HubSpot. `auto` uses Forms API when a GUID is set. */
@@ -32,6 +33,8 @@ export interface HubspotPolicy {
32
33
  writeExtraProperties: boolean;
33
34
  /** On publish, PATCH missing mapped contact fields onto the HubSpot form. */
34
35
  syncFieldsOnPublish: boolean;
36
+ /** Recap note on the contact (page URL, subject, answers) after a successful sync. */
37
+ timelineNote: boolean;
35
38
  }
36
39
  export interface PublicSettings {
37
40
  configured: boolean;
@@ -48,6 +51,7 @@ export interface PublicSettings {
48
51
  submissionMode: SubmissionMode;
49
52
  writeExtraProperties: boolean;
50
53
  syncFieldsOnPublish: boolean;
54
+ timelineNote: boolean;
51
55
  }
52
56
  export interface HubspotAccount {
53
57
  portalId: string;
@@ -62,6 +66,7 @@ interface FormsCfg {
62
66
  submissionMode?: unknown;
63
67
  writeExtraProperties?: unknown;
64
68
  syncFieldsOnPublish?: unknown;
69
+ timelineNote?: unknown;
65
70
  }
66
71
  export declare function policyFrom(stored: StoredSettings, forms: FormsCfg): HubspotPolicy;
67
72
  export declare function resolvePolicy(strapi: Core.Strapi): Promise<HubspotPolicy>;
@@ -80,6 +85,7 @@ export interface SettingsPatch {
80
85
  submissionMode?: unknown;
81
86
  writeExtraProperties?: unknown;
82
87
  syncFieldsOnPublish?: unknown;
88
+ timelineNote?: unknown;
83
89
  }
84
90
  /** Merge a PATCH-like body into the store without dropping the saved key. */
85
91
  export declare function patchStoredSettings(strapi: Core.Strapi, body: SettingsPatch): Promise<void>;