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