strapi-plugin-hubspot 0.4.2 → 0.6.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 (54) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +116 -4
  3. package/dist/_chunks/Forms-BF4_zA-m.js +1007 -0
  4. package/dist/_chunks/Forms-CW1KFO9U.mjs +989 -0
  5. package/dist/_chunks/{HubspotObjectInput-DutelVp6.mjs → HubspotObjectInput-CpxeM2J0.mjs} +4 -16
  6. package/dist/_chunks/{HubspotObjectInput-Br2jTFkG.js → HubspotObjectInput-hjAYWtaU.js} +4 -16
  7. package/dist/_chunks/HubspotPropertyInput-C-52Lu24.mjs +227 -0
  8. package/dist/_chunks/HubspotPropertyInput-OSeIBaRC.js +245 -0
  9. package/dist/_chunks/Settings-2V7TH5CX.mjs +321 -0
  10. package/dist/_chunks/Settings-DcA9M5Tw.js +339 -0
  11. package/dist/_chunks/en-CJ9wYHgN.mjs +197 -0
  12. package/dist/_chunks/en-C_-N8cjv.js +197 -0
  13. package/dist/_chunks/fr-BHObPoUp.js +197 -0
  14. package/dist/_chunks/fr-DttDn-2Y.mjs +197 -0
  15. package/dist/_chunks/{Settings-CQsB0B2T.mjs → index-C1UJXE_o.mjs} +576 -317
  16. package/dist/_chunks/{index-BUlB1rK9.js → index-CMzg-x5i.js} +12 -4
  17. package/dist/_chunks/{Settings-CvbKVl-t.js → index-Dgbid2LK.js} +580 -322
  18. package/dist/_chunks/{index-CKoBcX1T.mjs → index-DkGOTp8p.mjs} +12 -4
  19. package/dist/_chunks/{objectLabels-dM4o67Fz.mjs → objectLabels-D9UEuOGr.mjs} +1 -1
  20. package/dist/_chunks/{objectLabels-B76t8qqg.js → objectLabels-Th7aYW-B.js} +1 -1
  21. package/dist/_chunks/useHubspotSchema-Bgrgg8Mt.js +75 -0
  22. package/dist/_chunks/useHubspotSchema-DQkQcA_V.mjs +58 -0
  23. package/dist/admin/index.js +2 -1
  24. package/dist/admin/index.mjs +2 -1
  25. package/dist/admin/src/builder/ConditionEditor.d.ts +17 -0
  26. package/dist/admin/src/builder/FieldPanel.d.ts +16 -0
  27. package/dist/admin/src/builder/types.d.ts +90 -0
  28. package/dist/admin/src/index.d.ts +1 -0
  29. package/dist/admin/src/pages/FormEditor.d.ts +9 -0
  30. package/dist/admin/src/pages/Forms.d.ts +4 -0
  31. package/dist/admin/src/pages/FormsList.d.ts +8 -0
  32. package/dist/admin/src/useHubspotSchema.d.ts +28 -0
  33. package/dist/server/index.js +880 -5
  34. package/dist/server/index.mjs +880 -5
  35. package/dist/server/src/__tests__/conditions.test.d.ts +1 -0
  36. package/dist/server/src/__tests__/forms.test.d.ts +1 -0
  37. package/dist/server/src/__tests__/formsSubmit.test.d.ts +1 -0
  38. package/dist/server/src/__tests__/importLegacy.test.d.ts +1 -0
  39. package/dist/server/src/conditions.d.ts +88 -0
  40. package/dist/server/src/content-types.d.ts +161 -9
  41. package/dist/server/src/forms.d.ts +81 -0
  42. package/dist/server/src/formsAdmin.d.ts +39 -0
  43. package/dist/server/src/importLegacy.d.ts +25 -0
  44. package/dist/server/src/index.d.ts +214 -16
  45. package/dist/shared/types.d.ts +103 -0
  46. package/dist/shared/types.js +2 -0
  47. package/dist/shared/types.mjs +2 -0
  48. package/package.json +23 -6
  49. package/dist/_chunks/HubspotPropertyInput-CK6Yj9rC.mjs +0 -177
  50. package/dist/_chunks/HubspotPropertyInput-DIPDPRXJ.js +0 -195
  51. package/dist/_chunks/en-D4ueeUcJ.mjs +0 -82
  52. package/dist/_chunks/en-PBG7-vMR.js +0 -82
  53. package/dist/_chunks/fr-DL4wdV8I.js +0 -82
  54. package/dist/_chunks/fr-DYnxVmIO.mjs +0 -82
@@ -1,5 +1,5 @@
1
1
  import { errors } from "@strapi/utils";
2
- const HS_BASE$1 = "https://api.hubapi.com";
2
+ const HS_BASE$2 = "https://api.hubapi.com";
3
3
  const TTL_MS = 10 * 60 * 1e3;
4
4
  const STANDARD_OBJECTS = [
5
5
  { name: "contact", path: "contacts" },
@@ -13,7 +13,7 @@ const STANDARD_OBJECTS = [
13
13
  let cache = null;
14
14
  let inFlight = null;
15
15
  async function hsGet(apiKey, path) {
16
- const res = await fetch(`${HS_BASE$1}${path}`, {
16
+ const res = await fetch(`${HS_BASE$2}${path}`, {
17
17
  headers: { Authorization: `Bearer ${apiKey}` }
18
18
  });
19
19
  if (!res.ok) {
@@ -352,7 +352,92 @@ async function runAudit(strapi, targets, schema) {
352
352
  }
353
353
  return reports;
354
354
  }
355
+ const localizedString = (type = "string") => ({
356
+ type,
357
+ pluginOptions: { i18n: { localized: true } }
358
+ });
355
359
  const contentTypes = {
360
+ /**
361
+ * A form built in the plugin's builder page. The flat, queryable identity
362
+ * lives as attributes; the structure (steps → fields → conditions) lives in
363
+ * `definition`, a versioned JSON document — plugins cannot ship components,
364
+ * and the builder is a better editor for it than the Content Manager, which
365
+ * is why the type is hidden there.
366
+ */
367
+ form: {
368
+ schema: {
369
+ kind: "collectionType",
370
+ collectionName: "hubspot_forms",
371
+ info: {
372
+ singularName: "form",
373
+ pluralName: "forms",
374
+ displayName: "HubSpot forms",
375
+ description: "Forms built in the HubSpot form builder"
376
+ },
377
+ options: { draftAndPublish: true },
378
+ pluginOptions: {
379
+ "content-manager": { visible: false },
380
+ "content-type-builder": { visible: false },
381
+ i18n: { localized: true }
382
+ },
383
+ attributes: {
384
+ name: {
385
+ type: "string",
386
+ required: true,
387
+ pluginOptions: { i18n: { localized: false } }
388
+ },
389
+ slug: {
390
+ type: "uid",
391
+ targetField: "name",
392
+ required: true
393
+ },
394
+ title: localizedString(),
395
+ subtitle: localizedString("text"),
396
+ nextLabel: localizedString(),
397
+ submitLabel: localizedString(),
398
+ successMessage: localizedString("text"),
399
+ definition: {
400
+ type: "json",
401
+ required: true,
402
+ pluginOptions: { i18n: { localized: true } }
403
+ }
404
+ }
405
+ }
406
+ },
407
+ /**
408
+ * One visitor submission of a built form — the source of truth for the
409
+ * lead, stored whatever the CRM's mood. Visible in the Content Manager so
410
+ * the team can browse and export; hidden from the Content-Type Builder.
411
+ */
412
+ submission: {
413
+ schema: {
414
+ kind: "collectionType",
415
+ collectionName: "hubspot_submissions",
416
+ info: {
417
+ singularName: "submission",
418
+ pluralName: "submissions",
419
+ displayName: "HubSpot form submissions",
420
+ description: "Visitor submissions of the forms built with the plugin"
421
+ },
422
+ options: { draftAndPublish: false },
423
+ pluginOptions: {
424
+ "content-manager": { visible: true },
425
+ "content-type-builder": { visible: false }
426
+ },
427
+ attributes: {
428
+ form: { type: "string", required: true },
429
+ formTitle: { type: "string" },
430
+ email: { type: "email" },
431
+ values: { type: "json", required: true },
432
+ meta: { type: "json" },
433
+ locale: { type: "string" },
434
+ hubspotSynced: { type: "boolean", default: false },
435
+ contactId: { type: "string" },
436
+ companyId: { type: "string" },
437
+ rejected: { type: "json" }
438
+ }
439
+ }
440
+ },
356
441
  failure: {
357
442
  schema: {
358
443
  kind: "collectionType",
@@ -378,6 +463,728 @@ const contentTypes = {
378
463
  }
379
464
  }
380
465
  };
466
+ const NEEDS_VALUE = /* @__PURE__ */ new Set(["eq", "neq", "contains", "gt", "lt"]);
467
+ function validateDefinition(definition) {
468
+ const errors2 = [];
469
+ const allIds = /* @__PURE__ */ new Set();
470
+ for (const step of definition.steps ?? []) {
471
+ for (const fld of step.fields ?? []) allIds.add(fld.id);
472
+ }
473
+ const checkCondition = (condition, seen2, owner) => {
474
+ for (const rule of condition?.rules ?? []) {
475
+ if (!allIds.has(rule.field)) {
476
+ errors2.push({ code: "unknown-field", ...owner, target: rule.field });
477
+ } else if (!seen2.has(rule.field)) {
478
+ errors2.push({ code: "forward-reference", ...owner, target: rule.field });
479
+ }
480
+ if (NEEDS_VALUE.has(rule.operator) && (rule.value === void 0 || rule.value === "")) {
481
+ errors2.push({ code: "missing-value", ...owner, target: rule.field });
482
+ }
483
+ }
484
+ };
485
+ const seen = /* @__PURE__ */ new Set();
486
+ const seenNames = /* @__PURE__ */ new Set();
487
+ for (const step of definition.steps ?? []) {
488
+ checkCondition(step.visibleIf, seen, { stepId: step.id });
489
+ for (const fld of step.fields ?? []) {
490
+ if (!fld.name?.trim()) {
491
+ errors2.push({ code: "missing-name", fieldId: fld.id, name: fld.name ?? "" });
492
+ } else if (seenNames.has(fld.name)) {
493
+ errors2.push({ code: "duplicate-name", fieldId: fld.id, name: fld.name });
494
+ } else {
495
+ seenNames.add(fld.name);
496
+ }
497
+ checkCondition(fld.visibleIf, seen, { fieldId: fld.id });
498
+ seen.add(fld.id);
499
+ }
500
+ }
501
+ return errors2;
502
+ }
503
+ const isPrimitive = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
504
+ const isEmpty = (v) => v === void 0 || v === null || v === false || typeof v === "string" && v.trim() === "";
505
+ const asString = (v) => typeof v === "string" ? v.trim() : v === void 0 || v === null ? "" : String(v);
506
+ const asNumber = (v) => typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
507
+ function evaluateRule(rule, get) {
508
+ const value = get(rule.field);
509
+ switch (rule.operator) {
510
+ case "eq":
511
+ return asString(value) === asString(rule.value);
512
+ case "neq":
513
+ return asString(value) !== asString(rule.value);
514
+ case "contains":
515
+ return asString(value).toLowerCase().includes(asString(rule.value).toLowerCase());
516
+ case "empty":
517
+ return isEmpty(value);
518
+ case "notEmpty":
519
+ return !isEmpty(value);
520
+ case "gt": {
521
+ const [a, b] = [asNumber(value), asNumber(rule.value)];
522
+ return Number.isFinite(a) && Number.isFinite(b) && a > b;
523
+ }
524
+ case "lt": {
525
+ const [a, b] = [asNumber(value), asNumber(rule.value)];
526
+ return Number.isFinite(a) && Number.isFinite(b) && a < b;
527
+ }
528
+ default:
529
+ return false;
530
+ }
531
+ }
532
+ function evaluateCondition(condition, get) {
533
+ if (!condition || !condition.rules?.length) return true;
534
+ const results = condition.rules.map((rule) => evaluateRule(rule, get));
535
+ return condition.logic === "or" ? results.some(Boolean) : results.every(Boolean);
536
+ }
537
+ function resolveSubmission(definition, rawValues) {
538
+ const visibleById = /* @__PURE__ */ new Map();
539
+ const get = (fieldId) => visibleById.get(fieldId);
540
+ const values = {};
541
+ const hidden = [];
542
+ const missingRequired = [];
543
+ const declared = /* @__PURE__ */ new Set();
544
+ for (const step of definition.steps ?? []) {
545
+ const stepVisible = evaluateCondition(step.visibleIf, get);
546
+ for (const fld of step.fields ?? []) {
547
+ declared.add(fld.name);
548
+ const visible = stepVisible && evaluateCondition(fld.visibleIf, get);
549
+ if (!visible) {
550
+ hidden.push(fld.name);
551
+ continue;
552
+ }
553
+ const raw = rawValues[fld.name];
554
+ const value = typeof raw === "string" ? raw.trim() : raw;
555
+ const submitted = isPrimitive(value) && !isEmpty(value) ? value : void 0;
556
+ visibleById.set(fld.id, submitted);
557
+ if (submitted !== void 0) {
558
+ values[fld.name] = submitted;
559
+ } else if (fld.required) {
560
+ missingRequired.push({ id: fld.id, name: fld.name, label: fld.label ?? fld.name });
561
+ }
562
+ }
563
+ }
564
+ const unknown = Object.keys(rawValues).filter((key) => !declared.has(key));
565
+ return { values, hidden, missingRequired, unknown };
566
+ }
567
+ function publicForm(entry) {
568
+ return {
569
+ slug: entry.slug,
570
+ title: entry.title ?? null,
571
+ subtitle: entry.subtitle ?? null,
572
+ nextLabel: entry.nextLabel ?? null,
573
+ submitLabel: entry.submitLabel ?? null,
574
+ successMessage: entry.successMessage ?? null,
575
+ locale: entry.locale ?? null,
576
+ steps: (entry.definition?.steps ?? []).map((step) => ({
577
+ ...step,
578
+ fields: (step.fields ?? []).map(({ hubspot: _hubspot, ...fld }) => fld)
579
+ }))
580
+ };
581
+ }
582
+ function groupByObject(definition, values) {
583
+ const groups = {};
584
+ for (const step of definition.steps ?? []) {
585
+ for (const fld of step.fields ?? []) {
586
+ const value = values[fld.name];
587
+ if (value === void 0) continue;
588
+ const mapping = fld.hubspot ?? {};
589
+ const object = mapping.object?.trim() || "contact";
590
+ const property = mapping.property?.trim() || fld.name;
591
+ (groups[object] ??= {})[property] = value;
592
+ }
593
+ }
594
+ return groups;
595
+ }
596
+ function mappingProblems(definition, portalProperties) {
597
+ const problems = [];
598
+ for (const step of definition.steps ?? []) {
599
+ for (const fld of step.fields ?? []) {
600
+ const mapping = fld.hubspot ?? {};
601
+ if (!mapping.property) continue;
602
+ const options = fld.options;
603
+ const problem = checkMapping(portalProperties, {
604
+ object: mapping.object?.trim() || "contact",
605
+ property: mapping.property,
606
+ values: options?.map((o) => o.value ?? "").filter(Boolean)
607
+ });
608
+ if (problem) problems.push({ ...problem, fieldId: fld.id });
609
+ }
610
+ }
611
+ return problems;
612
+ }
613
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
614
+ function findEmail(values) {
615
+ const candidates = [values.email, ...Object.values(values)];
616
+ for (const v of candidates) {
617
+ if (typeof v === "string" && EMAIL_RE.test(v.trim())) return v.trim().toLowerCase();
618
+ }
619
+ return void 0;
620
+ }
621
+ const FREEMAIL = /* @__PURE__ */ new Set([
622
+ "gmail.com",
623
+ "googlemail.com",
624
+ "yahoo.com",
625
+ "yahoo.fr",
626
+ "hotmail.com",
627
+ "hotmail.fr",
628
+ "outlook.com",
629
+ "outlook.fr",
630
+ "live.com",
631
+ "live.fr",
632
+ "msn.com",
633
+ "icloud.com",
634
+ "me.com",
635
+ "aol.com",
636
+ "free.fr",
637
+ "orange.fr",
638
+ "wanadoo.fr",
639
+ "sfr.fr",
640
+ "laposte.net",
641
+ "gmx.com",
642
+ "proton.me",
643
+ "protonmail.com",
644
+ "yopmail.com"
645
+ ]);
646
+ function corporateDomain(email) {
647
+ const domain = email?.split("@")[1]?.toLowerCase();
648
+ return domain && !FREEMAIL.has(domain) ? domain : void 0;
649
+ }
650
+ const SUBMISSION_UID = "plugin::hubspot.submission";
651
+ const MAX_FIELDS = 40;
652
+ const MAX_VALUE_LEN = 5e3;
653
+ function sanitizeRawValues(raw) {
654
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
655
+ const entries = Object.entries(raw);
656
+ if (entries.length > MAX_FIELDS) return null;
657
+ const values = {};
658
+ for (const [key, value] of entries) {
659
+ if (typeof value === "string") values[key] = value.slice(0, MAX_VALUE_LEN);
660
+ else if (typeof value === "boolean" || typeof value === "number") values[key] = value;
661
+ }
662
+ return values;
663
+ }
664
+ const HS_BASE$1 = "https://api.hubapi.com";
665
+ const HS_COMPANIES = `${HS_BASE$1}/crm/v3/objects/companies`;
666
+ const HS_NOTES = `${HS_BASE$1}/crm/v3/objects/notes`;
667
+ const ASSOC_NOTE_TO_CONTACT = 202;
668
+ const ASSOC_NOTE_TO_COMPANY = 190;
669
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
670
+ async function hsJson(apiKey, url, init) {
671
+ const res = await fetch(url, {
672
+ method: init.method,
673
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
674
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
675
+ });
676
+ const body = await res.json().catch(() => ({}));
677
+ if (!res.ok) throw new Error(body.message || `HubSpot ${res.status}`);
678
+ return body;
679
+ }
680
+ function summaryLines(definition, values) {
681
+ const lines = [];
682
+ for (const step of definition.steps ?? []) {
683
+ for (const fld of step.fields ?? []) {
684
+ const value = values[fld.name];
685
+ if (value === void 0) continue;
686
+ const options = fld.options;
687
+ const label = options?.find((o) => o.value === String(value))?.label;
688
+ lines.push(
689
+ `<strong>${escapeHtml(fld.label ?? fld.name)}</strong> : ${escapeHtml(String(label ?? value))}`
690
+ );
691
+ }
692
+ }
693
+ return lines;
694
+ }
695
+ function createFormsService(strapi, _opts = {}) {
696
+ const formsConfig = () => ({
697
+ companyFromDomain: true,
698
+ timelineNote: true,
699
+ ...strapi.plugin("hubspot").config("forms", {})
700
+ });
701
+ async function partitionBySchema(apiKey, groups) {
702
+ let schema;
703
+ try {
704
+ schema = await loadSchema(
705
+ strapi,
706
+ apiKey,
707
+ resolveObjects(strapi.plugin("hubspot").config("objects", []))
708
+ );
709
+ } catch {
710
+ return { accepted: groups, rejected: [] };
711
+ }
712
+ const accepted = {};
713
+ const rejected = [];
714
+ for (const [object, props] of Object.entries(groups)) {
715
+ for (const [property, value] of Object.entries(props)) {
716
+ const problem = checkMapping(schema.properties, {
717
+ object,
718
+ property,
719
+ values: String(value).split(";").map((v) => v.trim()).filter(Boolean)
720
+ });
721
+ if (problem) rejected.push(problem);
722
+ else (accepted[object] ??= {})[property] = value;
723
+ }
724
+ }
725
+ return { accepted, rejected };
726
+ }
727
+ async function resolveCompanyByDomain(apiKey, domain, props) {
728
+ const found = await hsJson(apiKey, `${HS_COMPANIES}/search`, {
729
+ method: "POST",
730
+ body: {
731
+ filterGroups: [{ filters: [{ propertyName: "domain", operator: "EQ", value: domain }] }],
732
+ properties: ["domain"],
733
+ limit: 1
734
+ }
735
+ });
736
+ const stringProps = { domain };
737
+ for (const [key, value] of Object.entries(props)) stringProps[key] = String(value);
738
+ const existingId = found.results?.[0]?.id;
739
+ if (existingId) {
740
+ await hsJson(apiKey, `${HS_COMPANIES}/${existingId}`, {
741
+ method: "PATCH",
742
+ body: { properties: stringProps }
743
+ });
744
+ return existingId;
745
+ }
746
+ const created = await hsJson(apiKey, HS_COMPANIES, {
747
+ method: "POST",
748
+ body: { properties: stringProps }
749
+ });
750
+ return created.id;
751
+ }
752
+ async function createLeadNote(apiKey, opts) {
753
+ const lines = [];
754
+ const title = opts.form.title || opts.form.name;
755
+ lines.push(`<strong>Nouveau lead${title ? ` — ${escapeHtml(String(title))}` : ""}</strong>`);
756
+ if (opts.meta.pageUrl) lines.push(`Page : ${escapeHtml(String(opts.meta.pageUrl))}`);
757
+ if (opts.meta.originLabel) {
758
+ const path = opts.meta.originPath ? ` (${escapeHtml(String(opts.meta.originPath))})` : "";
759
+ lines.push(`<strong>Sujet</strong> : ${escapeHtml(String(opts.meta.originLabel))}${path}`);
760
+ }
761
+ lines.push("", ...summaryLines(opts.form.definition, opts.values));
762
+ if (opts.rejected.length) {
763
+ lines.push("", "<strong>⚠ Champs non enregistrés (mapping à corriger)</strong>");
764
+ for (const r of opts.rejected) {
765
+ lines.push(`${escapeHtml(r.object)}.${escapeHtml(r.property)} — ${escapeHtml(r.code)}`);
766
+ }
767
+ }
768
+ const associations = [
769
+ {
770
+ to: { id: opts.contactId },
771
+ types: [
772
+ { associationCategory: "HUBSPOT_DEFINED", associationTypeId: ASSOC_NOTE_TO_CONTACT }
773
+ ]
774
+ }
775
+ ];
776
+ if (opts.companyId) {
777
+ associations.push({
778
+ to: { id: opts.companyId },
779
+ types: [
780
+ { associationCategory: "HUBSPOT_DEFINED", associationTypeId: ASSOC_NOTE_TO_COMPANY }
781
+ ]
782
+ });
783
+ }
784
+ await hsJson(apiKey, HS_NOTES, {
785
+ method: "POST",
786
+ body: {
787
+ properties: { hs_timestamp: (/* @__PURE__ */ new Date()).toISOString(), hs_note_body: lines.join("<br>") },
788
+ associations
789
+ }
790
+ });
791
+ }
792
+ async function submit(form, rawValues, meta = {}) {
793
+ const resolution = resolveSubmission(form.definition, rawValues);
794
+ if (resolution.missingRequired.length) {
795
+ return { ok: false, missingRequired: resolution.missingRequired };
796
+ }
797
+ const email = findEmail(resolution.values);
798
+ const { apiKey } = await resolveApiKey(strapi);
799
+ const config2 = formsConfig();
800
+ let hubspotSynced = false;
801
+ let contactId;
802
+ let companyId;
803
+ let rejected = [];
804
+ if (email && apiKey) {
805
+ const groups = groupByObject(form.definition, resolution.values);
806
+ const partition = await partitionBySchema(apiKey, groups);
807
+ rejected = partition.rejected;
808
+ const result = await strapi.plugin("hubspot").service("submit").upsert({
809
+ object: "contact",
810
+ idProperty: "email",
811
+ properties: { ...partition.accepted.contact ?? {}, email }
812
+ });
813
+ if (result.ok) {
814
+ hubspotSynced = true;
815
+ contactId = result.id;
816
+ }
817
+ const domain = corporateDomain(email);
818
+ if (contactId && domain && config2.companyFromDomain) {
819
+ try {
820
+ companyId = await resolveCompanyByDomain(
821
+ apiKey,
822
+ domain,
823
+ partition.accepted.company ?? {}
824
+ );
825
+ if (companyId) {
826
+ await hsJson(
827
+ apiKey,
828
+ `${HS_BASE$1}/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
829
+ { method: "PUT" }
830
+ );
831
+ }
832
+ } catch (err) {
833
+ strapi.log.warn(`[hubspot] company sync failed (contact OK) — ${err.message}`);
834
+ }
835
+ }
836
+ if (contactId && config2.timelineNote) {
837
+ try {
838
+ await createLeadNote(apiKey, {
839
+ contactId,
840
+ companyId,
841
+ form,
842
+ meta,
843
+ values: resolution.values,
844
+ rejected
845
+ });
846
+ } catch (err) {
847
+ strapi.log.warn(`[hubspot] lead note failed (contact OK) — ${err.message}`);
848
+ }
849
+ }
850
+ }
851
+ await strapi.documents(SUBMISSION_UID).create({
852
+ data: {
853
+ form: form.slug,
854
+ formTitle: form.title || form.name,
855
+ email,
856
+ values: resolution.values,
857
+ meta,
858
+ locale: form.locale ?? null,
859
+ hubspotSynced,
860
+ contactId,
861
+ companyId,
862
+ rejected
863
+ }
864
+ });
865
+ return { ok: true, hubspotSynced };
866
+ }
867
+ return { submit };
868
+ }
869
+ let counter = 0;
870
+ const makeId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter += 1).toString(36)}`;
871
+ const slugifyName = (label) => label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
872
+ const FIELD_DEFAULTS = {
873
+ label: "label",
874
+ name: "name",
875
+ object: "hsObject",
876
+ property: "hsProperty",
877
+ type: "type",
878
+ placeholder: "placeholder",
879
+ required: "required",
880
+ icon: "icon",
881
+ options: "options",
882
+ width: "width",
883
+ helpText: "helpText",
884
+ class: "class",
885
+ persist: "persist"
886
+ };
887
+ const STEP_DEFAULTS = {
888
+ title: "title",
889
+ description: "description",
890
+ class: "class"
891
+ };
892
+ function convertLegacyForm(entry, map = {}) {
893
+ const stepMap = { ...STEP_DEFAULTS, ...map.step };
894
+ const fieldMap = { ...FIELD_DEFAULTS, ...map.field };
895
+ const rawSteps = entry[map.steps ?? "steps"] ?? [];
896
+ const steps = rawSteps.map((rawStep) => {
897
+ const rawFields = rawStep[map.fields ?? "fields"] ?? [];
898
+ return {
899
+ id: makeId("stp"),
900
+ title: rawStep[stepMap.title] ?? void 0,
901
+ description: rawStep[stepMap.description] ?? void 0,
902
+ class: rawStep[stepMap.class] ?? void 0,
903
+ fields: rawFields.map((raw) => {
904
+ const label = raw[fieldMap.label] ?? "";
905
+ const object = raw[fieldMap.object] ?? void 0;
906
+ const property = raw[fieldMap.property] ?? void 0;
907
+ const options = raw[fieldMap.options] ?? [];
908
+ return {
909
+ id: makeId("fld"),
910
+ label,
911
+ name: (raw[fieldMap.name] || slugifyName(label)) ?? "",
912
+ type: raw[fieldMap.type] ?? "text",
913
+ placeholder: raw[fieldMap.placeholder] ?? void 0,
914
+ required: Boolean(raw[fieldMap.required]),
915
+ icon: raw[fieldMap.icon] ?? void 0,
916
+ width: raw[fieldMap.width] ?? void 0,
917
+ helpText: raw[fieldMap.helpText] ?? void 0,
918
+ class: raw[fieldMap.class] ?? void 0,
919
+ persist: raw[fieldMap.persist] ?? void 0,
920
+ ...options.length ? { options: options.map((o) => ({ value: o.value ?? "", label: o.label ?? void 0 })) } : {},
921
+ ...object || property ? { hubspot: { object, property } } : {}
922
+ };
923
+ })
924
+ };
925
+ });
926
+ return {
927
+ name: entry.name ?? "",
928
+ slug: entry.slug ?? "",
929
+ title: entry.title ?? null,
930
+ subtitle: entry.subtitle ?? null,
931
+ nextLabel: entry.nextLabel ?? null,
932
+ submitLabel: entry.submitLabel ?? null,
933
+ successMessage: entry.successMessage ?? null,
934
+ definition: { version: 1, steps }
935
+ };
936
+ }
937
+ const FORM_UID = "plugin::hubspot.form";
938
+ const EMPTY_DEFINITION = { version: 1, steps: [] };
939
+ const WRITABLE = [
940
+ "name",
941
+ "title",
942
+ "subtitle",
943
+ "nextLabel",
944
+ "submitLabel",
945
+ "successMessage"
946
+ ];
947
+ const slugify = (value) => value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "form";
948
+ function createFormsAdminController(strapi) {
949
+ const documents = () => strapi.documents(FORM_UID);
950
+ async function checkMappings(definition) {
951
+ const { apiKey } = await resolveApiKey(strapi);
952
+ if (!apiKey) return null;
953
+ try {
954
+ const schema = await loadSchema(
955
+ strapi,
956
+ apiKey,
957
+ resolveObjects(strapi.plugin("hubspot").config("objects", []))
958
+ );
959
+ return mappingProblems(definition, schema.properties);
960
+ } catch {
961
+ return null;
962
+ }
963
+ }
964
+ function pickWritable(body) {
965
+ const data = {};
966
+ for (const key of WRITABLE) {
967
+ if (key in body) data[key] = body[key];
968
+ }
969
+ return data;
970
+ }
971
+ function requireValidDefinition(ctx, definition) {
972
+ const def = definition ?? EMPTY_DEFINITION;
973
+ const errors2 = validateDefinition(def);
974
+ if (errors2.length) {
975
+ ctx.status = 400;
976
+ ctx.body = { errors: errors2 };
977
+ throw Object.assign(new Error("invalid definition"), { handled: true });
978
+ }
979
+ return def;
980
+ }
981
+ const handled = (err) => err?.handled === true;
982
+ return {
983
+ async list(ctx) {
984
+ const locale = ctx.query.locale;
985
+ const drafts = await documents().findMany({
986
+ locale,
987
+ status: "draft",
988
+ sort: "updatedAt:desc"
989
+ });
990
+ const published = await documents().findMany({
991
+ locale,
992
+ status: "published",
993
+ fields: ["slug"]
994
+ });
995
+ const publishedIds = new Set(published.map((e) => e.documentId));
996
+ ctx.body = {
997
+ forms: drafts.map((entry) => ({
998
+ documentId: entry.documentId,
999
+ name: entry.name,
1000
+ slug: entry.slug,
1001
+ updatedAt: entry.updatedAt,
1002
+ published: publishedIds.has(entry.documentId),
1003
+ steps: (entry.definition?.steps ?? []).length,
1004
+ fields: (entry.definition?.steps ?? []).reduce(
1005
+ (n, step) => n + (step.fields?.length ?? 0),
1006
+ 0
1007
+ )
1008
+ }))
1009
+ };
1010
+ },
1011
+ async findOne(ctx) {
1012
+ const entry = await documents().findOne({
1013
+ documentId: ctx.params.documentId,
1014
+ locale: ctx.query.locale,
1015
+ status: "draft"
1016
+ });
1017
+ if (!entry) ctx.throw(404, "Form not found");
1018
+ const published = await documents().findOne({
1019
+ documentId: ctx.params.documentId,
1020
+ locale: ctx.query.locale,
1021
+ status: "published"
1022
+ });
1023
+ ctx.body = { form: entry, published: Boolean(published) };
1024
+ },
1025
+ async create(ctx) {
1026
+ const body = ctx.request.body ?? {};
1027
+ const name = typeof body.name === "string" ? body.name.trim() : "";
1028
+ if (!name) ctx.throw(400, "A form needs a name");
1029
+ const base = slugify(name);
1030
+ let slug = base;
1031
+ for (let i = 2; ; i += 1) {
1032
+ const clash = await documents().findFirst({ filters: { slug } });
1033
+ if (!clash) break;
1034
+ slug = `${base}-${i}`;
1035
+ }
1036
+ let definition;
1037
+ try {
1038
+ definition = requireValidDefinition(ctx, body.definition);
1039
+ } catch (err) {
1040
+ if (handled(err)) return;
1041
+ throw err;
1042
+ }
1043
+ const entry = await documents().create({
1044
+ locale: ctx.query.locale,
1045
+ data: { ...pickWritable(body), name, slug, definition }
1046
+ });
1047
+ ctx.body = { form: entry };
1048
+ },
1049
+ async update(ctx) {
1050
+ const body = ctx.request.body ?? {};
1051
+ let definition;
1052
+ if ("definition" in body) {
1053
+ try {
1054
+ definition = requireValidDefinition(ctx, body.definition);
1055
+ } catch (err) {
1056
+ if (handled(err)) return;
1057
+ throw err;
1058
+ }
1059
+ }
1060
+ const entry = await documents().update({
1061
+ documentId: ctx.params.documentId,
1062
+ locale: ctx.query.locale,
1063
+ data: { ...pickWritable(body), ...definition ? { definition } : {} }
1064
+ });
1065
+ if (!entry) ctx.throw(404, "Form not found");
1066
+ const problems = definition ? await checkMappings(definition) : null;
1067
+ ctx.body = { form: entry, problems: problems ?? [] };
1068
+ },
1069
+ async publish(ctx) {
1070
+ const entry = await documents().findOne({
1071
+ documentId: ctx.params.documentId,
1072
+ locale: ctx.query.locale,
1073
+ status: "draft"
1074
+ });
1075
+ if (!entry) ctx.throw(404, "Form not found");
1076
+ const structural = validateDefinition(entry.definition ?? EMPTY_DEFINITION);
1077
+ const mappings = await checkMappings(entry.definition ?? EMPTY_DEFINITION) ?? [];
1078
+ if (structural.length || mappings.length) {
1079
+ ctx.status = 400;
1080
+ ctx.body = { errors: structural, problems: mappings };
1081
+ return;
1082
+ }
1083
+ await documents().publish({
1084
+ documentId: ctx.params.documentId,
1085
+ locale: ctx.query.locale
1086
+ });
1087
+ ctx.body = { ok: true };
1088
+ },
1089
+ async unpublish(ctx) {
1090
+ await documents().unpublish({
1091
+ documentId: ctx.params.documentId,
1092
+ locale: ctx.query.locale
1093
+ });
1094
+ ctx.body = { ok: true };
1095
+ },
1096
+ /** Entries of the configured legacy content type, offered for import. */
1097
+ async listSources(ctx) {
1098
+ const config2 = strapi.plugin("hubspot").config("forms", {}).import;
1099
+ if (!config2?.uid) {
1100
+ ctx.body = { configured: false, sources: [] };
1101
+ return;
1102
+ }
1103
+ try {
1104
+ const entries = await strapi.documents(config2.uid).findMany({
1105
+ fields: ["name", "slug"],
1106
+ sort: "name:asc"
1107
+ });
1108
+ ctx.body = {
1109
+ configured: true,
1110
+ sources: entries.map((e) => ({
1111
+ documentId: e.documentId,
1112
+ name: e.name ?? e.slug ?? e.documentId,
1113
+ slug: e.slug ?? ""
1114
+ }))
1115
+ };
1116
+ } catch (err) {
1117
+ strapi.log.warn(`[hubspot] import sources unavailable — ${err.message}`);
1118
+ ctx.body = { configured: true, sources: [] };
1119
+ }
1120
+ },
1121
+ /**
1122
+ * Converts one legacy entry (every locale of it) into a plugin form
1123
+ * draft. Same slug: re-importing overwrites the draft, never touches the
1124
+ * published version, and never modifies the source.
1125
+ */
1126
+ async runImport(ctx) {
1127
+ const config2 = strapi.plugin("hubspot").config("forms", {}).import;
1128
+ if (!config2?.uid) ctx.throw(400, "No import source configured (forms.import.uid)");
1129
+ const sourceId = ctx.request.body?.documentId;
1130
+ if (typeof sourceId !== "string" || !sourceId) ctx.throw(400, "documentId is required");
1131
+ const { uid, ...map } = config2;
1132
+ const populate = {
1133
+ [map.steps ?? "steps"]: {
1134
+ populate: {
1135
+ [map.fields ?? "fields"]: { populate: [map.field?.options ?? "options"] }
1136
+ }
1137
+ }
1138
+ };
1139
+ let locales = [void 0];
1140
+ try {
1141
+ const found = await strapi.plugin("i18n").service("locales").find();
1142
+ if (Array.isArray(found) && found.length) {
1143
+ locales = [...found].sort((a, b) => Number(b.isDefault ?? false) - Number(a.isDefault ?? false)).map((l) => l.code);
1144
+ }
1145
+ } catch {
1146
+ }
1147
+ let targetId = null;
1148
+ let imported = 0;
1149
+ for (const locale of locales) {
1150
+ const source = await strapi.documents(uid).findOne({
1151
+ documentId: sourceId,
1152
+ locale,
1153
+ populate
1154
+ });
1155
+ if (!source) continue;
1156
+ const converted = convertLegacyForm(source, map);
1157
+ if (!targetId) {
1158
+ const existing = await documents().findFirst({
1159
+ filters: { slug: converted.slug }
1160
+ });
1161
+ targetId = existing?.documentId ?? null;
1162
+ }
1163
+ const data = { ...converted };
1164
+ if (targetId) {
1165
+ delete data.slug;
1166
+ await documents().update({ documentId: targetId, locale, data });
1167
+ } else {
1168
+ const created = await documents().create({
1169
+ locale,
1170
+ data
1171
+ });
1172
+ targetId = created.documentId;
1173
+ }
1174
+ imported += 1;
1175
+ }
1176
+ if (!targetId) ctx.throw(404, "Source entry not found");
1177
+ ctx.body = { documentId: targetId, locales: imported };
1178
+ },
1179
+ async remove(ctx) {
1180
+ await documents().delete({
1181
+ documentId: ctx.params.documentId
1182
+ // No locale: deleting a form deletes all its locales.
1183
+ });
1184
+ ctx.body = { ok: true };
1185
+ }
1186
+ };
1187
+ }
381
1188
  const HS_BASE = "https://api.hubapi.com";
382
1189
  const FAILURE_UID = "plugin::hubspot.failure";
383
1190
  const RETRY_DELAYS_MS = [500, 2e3];
@@ -514,13 +1321,19 @@ function createSubmitService(strapi, { sleep = sleepFor } = {}) {
514
1321
  return { upsert, retryFailures };
515
1322
  }
516
1323
  const SETTINGS_ACTION = "plugin::hubspot.settings";
1324
+ const FORMS_ACTION = "plugin::hubspot.forms";
517
1325
  const config = {
518
1326
  default: {
519
1327
  apiKey: "",
520
1328
  // Objects whose properties are offered. Names from the standard set, or
521
1329
  // `{ name, path }` for a custom object type.
522
1330
  objects: ["contact", "company"],
523
- validate: []
1331
+ validate: [],
1332
+ // Submission pipeline of the built forms. Both default to true:
1333
+ // - companyFromDomain: a corporate email upserts the Company (deduped by
1334
+ // domain) and associates it to the contact;
1335
+ // - timelineNote: a note recaps the submission on the contact's timeline.
1336
+ forms: { companyFromDomain: true, timelineNote: true }
524
1337
  },
525
1338
  validator(cfg) {
526
1339
  if (cfg.validate && !Array.isArray(cfg.validate)) {
@@ -581,6 +1394,42 @@ const controllers = {
581
1394
  ctx.body = await strapi.plugin("hubspot").service("submit").retryFailures();
582
1395
  }
583
1396
  }),
1397
+ formsAdmin: ({ strapi }) => createFormsAdminController(strapi),
1398
+ forms: ({ strapi }) => ({
1399
+ /** Published form for the host frontend — CRM mapping stripped. */
1400
+ async findOne(ctx) {
1401
+ const entry = await strapi.documents(FORM_UID).findFirst({
1402
+ filters: { slug: ctx.params.slug },
1403
+ status: "published",
1404
+ locale: ctx.query.locale
1405
+ });
1406
+ if (!entry?.definition) ctx.throw(404, "Form not found");
1407
+ ctx.body = publicForm(entry);
1408
+ },
1409
+ async submit(ctx) {
1410
+ const entry = await strapi.documents(FORM_UID).findFirst({
1411
+ filters: { slug: ctx.params.slug },
1412
+ status: "published",
1413
+ locale: ctx.query.locale
1414
+ });
1415
+ if (!entry?.definition) ctx.throw(404, "Form not found");
1416
+ const values = sanitizeRawValues(ctx.request.body?.values);
1417
+ if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
1418
+ const rawMeta = ctx.request.body?.meta ?? {};
1419
+ const meta = {};
1420
+ for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
1421
+ const v = rawMeta[key];
1422
+ if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
1423
+ }
1424
+ const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
1425
+ if (!outcome.ok) {
1426
+ ctx.status = 422;
1427
+ ctx.body = { ok: false, missingRequired: outcome.missingRequired ?? [] };
1428
+ return;
1429
+ }
1430
+ ctx.body = { ok: true, hubspotSynced: outcome.hubspotSynced };
1431
+ }
1432
+ }),
584
1433
  settings: ({ strapi }) => ({
585
1434
  async get(ctx) {
586
1435
  ctx.body = await publicSettings(strapi);
@@ -619,7 +1468,26 @@ const routes = {
619
1468
  adminRoute("POST", "/failures/retry", "failures.retry", [SETTINGS_ACTION]),
620
1469
  adminRoute("GET", "/settings", "settings.get", [SETTINGS_ACTION]),
621
1470
  adminRoute("PUT", "/settings", "settings.update", [SETTINGS_ACTION]),
622
- adminRoute("DELETE", "/settings", "settings.reset", [SETTINGS_ACTION])
1471
+ adminRoute("DELETE", "/settings", "settings.reset", [SETTINGS_ACTION]),
1472
+ adminRoute("GET", "/builder/forms", "formsAdmin.list", [FORMS_ACTION]),
1473
+ adminRoute("POST", "/builder/forms", "formsAdmin.create", [FORMS_ACTION]),
1474
+ adminRoute("GET", "/builder/forms/:documentId", "formsAdmin.findOne", [FORMS_ACTION]),
1475
+ adminRoute("PUT", "/builder/forms/:documentId", "formsAdmin.update", [FORMS_ACTION]),
1476
+ adminRoute("POST", "/builder/forms/:documentId/publish", "formsAdmin.publish", [FORMS_ACTION]),
1477
+ adminRoute("POST", "/builder/forms/:documentId/unpublish", "formsAdmin.unpublish", [FORMS_ACTION]),
1478
+ adminRoute("DELETE", "/builder/forms/:documentId", "formsAdmin.remove", [FORMS_ACTION]),
1479
+ adminRoute("GET", "/builder/import/sources", "formsAdmin.listSources", [FORMS_ACTION]),
1480
+ adminRoute("POST", "/builder/import", "formsAdmin.runImport", [FORMS_ACTION])
1481
+ ]
1482
+ },
1483
+ // Public form delivery + submission, under /api/hubspot/…. Like any
1484
+ // content-api route, they must be granted to the Public role in
1485
+ // Settings → Users & Permissions before the frontend can call them.
1486
+ "content-api": {
1487
+ type: "content-api",
1488
+ routes: [
1489
+ { method: "GET", path: "/forms/:slug", handler: "forms.findOne", config: { policies: [] } },
1490
+ { method: "POST", path: "/forms/:slug/submit", handler: "forms.submit", config: { policies: [] } }
623
1491
  ]
624
1492
  }
625
1493
  };
@@ -629,7 +1497,8 @@ const index = {
629
1497
  controllers,
630
1498
  routes,
631
1499
  services: {
632
- submit: ({ strapi }) => createSubmitService(strapi)
1500
+ submit: ({ strapi }) => createSubmitService(strapi),
1501
+ forms: ({ strapi }) => createFormsService(strapi)
633
1502
  },
634
1503
  register({ strapi }) {
635
1504
  strapi.customFields.register({
@@ -650,6 +1519,12 @@ const index = {
650
1519
  displayName: "Access the HubSpot settings",
651
1520
  uid: "settings",
652
1521
  pluginName: "hubspot"
1522
+ },
1523
+ {
1524
+ section: "plugins",
1525
+ displayName: "Use the HubSpot form builder",
1526
+ uid: "forms",
1527
+ pluginName: "hubspot"
653
1528
  }
654
1529
  ]);
655
1530
  const targets = strapi.plugin("hubspot").config("validate", []);