strapi-plugin-hubspot 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -35,8 +35,7 @@ This plugin makes all three impossible to save.
35
35
  ### A property picker instead of a text field
36
36
 
37
37
  Replace your property field's type with the `hubspot.property` custom field.
38
- Editors then search the real, writable properties of your portal, each labelled
39
- with the object it belongs to:
38
+ Editors then search the real, writable properties of your portal:
40
39
 
41
40
  ```
42
41
  Contact · Rôle (hs_role)
@@ -44,9 +43,24 @@ Contact · Prénom (firstname)
44
43
  Société · Nombre d'employés (numberofemployees)
45
44
  ```
46
45
 
46
+ **The list narrows to the object you picked.** Point the field at the sibling
47
+ that holds the object and choosing *Contact* leaves only contact properties —
48
+ the prefix disappears, since it is no longer telling you anything:
49
+
50
+ ```json
51
+ {
52
+ "hsProperty": {
53
+ "type": "customField",
54
+ "customField": "plugin::hubspot.property",
55
+ "options": { "objectField": "hsObject" }
56
+ }
57
+ }
58
+ ```
59
+
47
60
  Read-only properties — the ones HubSpot computes and always refuses to
48
61
  accept — are filtered out, so the list only ever offers things that will
49
- actually work.
62
+ actually work. When the portal id is readable, each selected property gets a
63
+ **Voir dans HubSpot** link straight to its settings page.
50
64
 
51
65
  ### Validation on save
52
66
 
@@ -64,6 +78,11 @@ never returned to the browser: the UI only receives whether a key exists, where
64
78
  it comes from, and its last four characters. A "Test connection" button
65
79
  round-trips to HubSpot and reports how many properties it can read.
66
80
 
81
+ > **On storage:** the key is kept in Strapi's core store, which is **not
82
+ > encrypted at rest** — it is readable by anyone with database access. It never
83
+ > reaches the browser, but treat it like any other secret in your database. Use
84
+ > `HUBSPOT_API_KEY` if your deployment already manages secrets properly.
85
+
67
86
  ## Install
68
87
 
69
88
  ```bash
@@ -80,6 +99,12 @@ export default ({ env }) => ({
80
99
  // Optional — the key can also be set from Settings → HubSpot.
81
100
  apiKey: env("HUBSPOT_API_KEY", ""),
82
101
 
102
+ // Optional — objects whose properties are offered.
103
+ // Defaults to ["contact", "company"]. Standard names: contact, company,
104
+ // deal, ticket, product, line_item, quote. A custom object is either its
105
+ // type id, or { name, path } when the two differ.
106
+ objects: ["contact", "company", "deal"],
107
+
83
108
  // Optional — entries whose mappings are validated on save.
84
109
  validate: [
85
110
  {
@@ -94,13 +119,15 @@ export default ({ env }) => ({
94
119
  ```
95
120
 
96
121
  Then point your property field at the custom field, in the component or content
97
- type that holds it:
122
+ type that holds it. `options.objectField` names the sibling holding the object —
123
+ omit it and the picker simply lists every object's properties:
98
124
 
99
125
  ```json
100
126
  {
101
127
  "hsProperty": {
102
128
  "type": "customField",
103
- "customField": "plugin::hubspot.property"
129
+ "customField": "plugin::hubspot.property",
130
+ "options": { "objectField": "hsObject" }
104
131
  }
105
132
  }
106
133
  ```
@@ -109,10 +136,16 @@ Restart Strapi and hard-refresh the admin.
109
136
 
110
137
  ### The token
111
138
 
112
- Create a **private app** in HubSpot with these scopes:
139
+ Create a **private app** in HubSpot with a read scope per object you list:
113
140
 
114
141
  - `crm.schemas.contacts.read`
115
142
  - `crm.schemas.companies.read`
143
+ - `crm.schemas.deals.read`, `crm.schemas.custom.read`… as needed
144
+
145
+ An object the token can't read is **skipped, not fatal**: the picker keeps
146
+ working for the others and explains which one is missing a scope. `oauth` is
147
+ worth adding too — it exposes the portal id, which turns on the *view in HubSpot*
148
+ links.
116
149
 
117
150
  The key is resolved in this order, first match wins:
118
151
 
@@ -140,6 +173,8 @@ This one never blocks work:
140
173
  | No API key configured | The field falls back to a plain text input, with a note explaining why |
141
174
  | HubSpot unreachable | Saving proceeds; validation is skipped and a warning is logged |
142
175
  | Property staged in HubSpot but not created yet | The picker accepts a typed value (`creatable`) |
176
+ | An object's scope is missing | That object is skipped; the others still work |
177
+ | Portal id unreadable | Deep links are omitted; everything else is unaffected |
143
178
  | Plugin uninstalled | Values remain as strings — nothing to undo |
144
179
 
145
180
  The property schema is cached for 10 minutes and de-duplicated across concurrent
@@ -152,7 +187,7 @@ All routes require an authenticated admin.
152
187
 
153
188
  | Method | Path | Purpose |
154
189
  |---|---|---|
155
- | `GET` | `/hubspot/properties` | Writable properties of both objects. `?refresh=1` bypasses the cache |
190
+ | `GET` | `/hubspot/properties` | Writable properties, readable objects, unreachable ones and the portal id. `?refresh=1` bypasses the cache |
156
191
  | `GET` | `/hubspot/settings` | Whether a key exists, its source and hint — never the key |
157
192
  | `PUT` | `/hubspot/settings` | Save a key (`{ apiKey }`) |
158
193
  | `DELETE` | `/hubspot/settings` | Remove the stored key |
@@ -4,7 +4,7 @@ const jsxRuntime = require("react/jsx-runtime");
4
4
  const React = require("react");
5
5
  const designSystem = require("@strapi/design-system");
6
6
  const admin = require("@strapi/strapi/admin");
7
- const index = require("./index-DVRLoCa1.js");
7
+ const index = require("./index-sOi9wC_y.js");
8
8
  function _interopNamespace(e) {
9
9
  if (e && e.__esModule) return e;
10
10
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
@@ -23,54 +23,75 @@ function _interopNamespace(e) {
23
23
  return Object.freeze(n);
24
24
  }
25
25
  const React__namespace = /* @__PURE__ */ _interopNamespace(React);
26
- const OBJECT_LABEL = {
26
+ const LABELS = {
27
27
  contact: "Contact",
28
- company: "Société"
28
+ company: "Société",
29
+ deal: "Transaction",
30
+ ticket: "Ticket",
31
+ product: "Produit",
32
+ line_item: "Ligne de commande",
33
+ quote: "Devis"
29
34
  };
35
+ function objectLabel(object) {
36
+ return LABELS[object] ?? object;
37
+ }
30
38
  const HubspotPropertyInput = React__namespace.forwardRef(
31
- ({ name, value, onChange, disabled, error, required, label, hint, labelAction }, ref) => {
39
+ ({ name, value, onChange, attribute, disabled, error, required, label, hint, labelAction }, ref) => {
32
40
  const { get } = admin.useFetchClient();
33
- const [properties, setProperties] = React__namespace.useState([]);
34
- const [configured, setConfigured] = React__namespace.useState(null);
41
+ const [schema, setSchema] = React__namespace.useState(null);
35
42
  const [loadError, setLoadError] = React__namespace.useState("");
43
+ const objectFieldName = attribute?.options?.objectField || "hsObject";
44
+ const siblingPath = React__namespace.useMemo(
45
+ () => name.replace(/[^.]+$/, objectFieldName),
46
+ [name, objectFieldName]
47
+ );
48
+ const siblingField = admin.useField(siblingPath);
49
+ const selectedObject = siblingField?.value;
36
50
  React__namespace.useEffect(() => {
37
51
  let cancelled = false;
38
52
  get(`/${index.PLUGIN_ID}/properties`).then(({ data }) => {
39
- if (cancelled) return;
40
- setConfigured(data.configured);
41
- setProperties(data.properties ?? []);
53
+ if (!cancelled) setSchema(data);
42
54
  }).catch((err) => {
43
55
  if (cancelled) return;
44
- setConfigured(false);
45
- setLoadError(
46
- err?.response?.data?.error?.message ?? "Propriétés HubSpot indisponibles"
47
- );
56
+ setSchema({ configured: false, properties: [], objects: [], unavailable: [] });
57
+ setLoadError(err?.response?.data?.error?.message ?? "Propriétés HubSpot indisponibles");
48
58
  });
49
59
  return () => {
50
60
  cancelled = true;
51
61
  };
52
62
  }, [get]);
53
63
  const emit = (next) => onChange({ target: { name, value: next ?? "", type: "string" } });
64
+ const options = React__namespace.useMemo(() => {
65
+ const all = schema?.properties ?? [];
66
+ const scoped = selectedObject ? all.filter((p) => p.object === selectedObject) : all;
67
+ const shown = scoped.map((p) => ({
68
+ value: p.name,
69
+ // The object prefix is redundant once filtered, and noisy.
70
+ label: selectedObject ? `${p.label} (${p.name})` : `${objectLabel(p.object)} · ${p.label} (${p.name})`
71
+ }));
72
+ if (value && !scoped.some((p) => p.name === value)) {
73
+ const elsewhere = all.find((p) => p.name === value);
74
+ shown.unshift({
75
+ value,
76
+ label: elsewhere ? `${value} — appartient à ${objectLabel(elsewhere.object)}` : `${value} — inconnue du portail`
77
+ });
78
+ }
79
+ return shown;
80
+ }, [schema, selectedObject, value]);
54
81
  const describe = () => {
55
82
  if (loadError) return loadError;
56
- if (configured === false) {
83
+ if (schema && !schema.configured) {
57
84
  return "Aucune clé API HubSpot — saisie libre. Renseignez-la dans Réglages → HubSpot.";
58
85
  }
86
+ if (selectedObject && schema?.unavailable.some((u) => u.object === selectedObject)) {
87
+ return `Propriétés de « ${objectLabel(selectedObject)} » illisibles — portée manquante sur le jeton.`;
88
+ }
59
89
  return hint;
60
90
  };
61
- const options = React__namespace.useMemo(() => {
62
- const known = properties.map((p) => ({
63
- value: p.name,
64
- label: `${OBJECT_LABEL[p.object]} · ${p.label} (${p.name})`
65
- }));
66
- if (value && !properties.some((p) => p.name === value)) {
67
- known.unshift({ value, label: `${value} — inconnue du portail` });
68
- }
69
- return known;
70
- }, [properties, value]);
91
+ const crmLink = schema?.portalId && value && schema.properties.some((p) => p.name === value) ? `https://app.hubspot.com/property-settings/${schema.portalId}/properties?search=${encodeURIComponent(value)}` : null;
71
92
  return /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { name, error, hint: describe(), required, children: [
72
93
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { action: labelAction, children: label }),
73
- configured === null ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { paddingTop: 2, paddingBottom: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Loader, { small: true, children: "Chargement des propriétés HubSpot…" }) }) : configured ? /* @__PURE__ */ jsxRuntime.jsx(
94
+ !schema ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { paddingTop: 2, paddingBottom: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Loader, { small: true, children: "Chargement des propriétés HubSpot…" }) }) : schema.configured ? /* @__PURE__ */ jsxRuntime.jsx(
74
95
  designSystem.Combobox,
75
96
  {
76
97
  ref,
@@ -93,6 +114,7 @@ const HubspotPropertyInput = React__namespace.forwardRef(
93
114
  placeholder: "ex. hs_role, numberofemployees"
94
115
  }
95
116
  ),
117
+ crmLink ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { paddingTop: 1, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Link, { href: crmLink, isExternal: true, children: "Voir dans HubSpot" }) }) : null,
96
118
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Hint, {}),
97
119
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Error, {})
98
120
  ] });
@@ -0,0 +1,106 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Field, Flex, Loader, Combobox, ComboboxOption, Link } from "@strapi/design-system";
4
+ import { useFetchClient, useField } from "@strapi/strapi/admin";
5
+ import { P as PLUGIN_ID } from "./index-DIwNOVQ0.mjs";
6
+ const LABELS = {
7
+ contact: "Contact",
8
+ company: "Société",
9
+ deal: "Transaction",
10
+ ticket: "Ticket",
11
+ product: "Produit",
12
+ line_item: "Ligne de commande",
13
+ quote: "Devis"
14
+ };
15
+ function objectLabel(object) {
16
+ return LABELS[object] ?? object;
17
+ }
18
+ const HubspotPropertyInput = React.forwardRef(
19
+ ({ name, value, onChange, attribute, disabled, error, required, label, hint, labelAction }, ref) => {
20
+ const { get } = useFetchClient();
21
+ const [schema, setSchema] = React.useState(null);
22
+ const [loadError, setLoadError] = React.useState("");
23
+ const objectFieldName = attribute?.options?.objectField || "hsObject";
24
+ const siblingPath = React.useMemo(
25
+ () => name.replace(/[^.]+$/, objectFieldName),
26
+ [name, objectFieldName]
27
+ );
28
+ const siblingField = useField(siblingPath);
29
+ const selectedObject = siblingField?.value;
30
+ React.useEffect(() => {
31
+ let cancelled = false;
32
+ get(`/${PLUGIN_ID}/properties`).then(({ data }) => {
33
+ if (!cancelled) setSchema(data);
34
+ }).catch((err) => {
35
+ if (cancelled) return;
36
+ setSchema({ configured: false, properties: [], objects: [], unavailable: [] });
37
+ setLoadError(err?.response?.data?.error?.message ?? "Propriétés HubSpot indisponibles");
38
+ });
39
+ return () => {
40
+ cancelled = true;
41
+ };
42
+ }, [get]);
43
+ const emit = (next) => onChange({ target: { name, value: next ?? "", type: "string" } });
44
+ const options = React.useMemo(() => {
45
+ const all = schema?.properties ?? [];
46
+ const scoped = selectedObject ? all.filter((p) => p.object === selectedObject) : all;
47
+ const shown = scoped.map((p) => ({
48
+ value: p.name,
49
+ // The object prefix is redundant once filtered, and noisy.
50
+ label: selectedObject ? `${p.label} (${p.name})` : `${objectLabel(p.object)} · ${p.label} (${p.name})`
51
+ }));
52
+ if (value && !scoped.some((p) => p.name === value)) {
53
+ const elsewhere = all.find((p) => p.name === value);
54
+ shown.unshift({
55
+ value,
56
+ label: elsewhere ? `${value} — appartient à ${objectLabel(elsewhere.object)}` : `${value} — inconnue du portail`
57
+ });
58
+ }
59
+ return shown;
60
+ }, [schema, selectedObject, value]);
61
+ const describe = () => {
62
+ if (loadError) return loadError;
63
+ if (schema && !schema.configured) {
64
+ return "Aucune clé API HubSpot — saisie libre. Renseignez-la dans Réglages → HubSpot.";
65
+ }
66
+ if (selectedObject && schema?.unavailable.some((u) => u.object === selectedObject)) {
67
+ return `Propriétés de « ${objectLabel(selectedObject)} » illisibles — portée manquante sur le jeton.`;
68
+ }
69
+ return hint;
70
+ };
71
+ const crmLink = schema?.portalId && value && schema.properties.some((p) => p.name === value) ? `https://app.hubspot.com/property-settings/${schema.portalId}/properties?search=${encodeURIComponent(value)}` : null;
72
+ return /* @__PURE__ */ jsxs(Field.Root, { name, error, hint: describe(), required, children: [
73
+ /* @__PURE__ */ jsx(Field.Label, { action: labelAction, children: label }),
74
+ !schema ? /* @__PURE__ */ jsx(Flex, { paddingTop: 2, paddingBottom: 2, children: /* @__PURE__ */ jsx(Loader, { small: true, children: "Chargement des propriétés HubSpot…" }) }) : schema.configured ? /* @__PURE__ */ jsx(
75
+ Combobox,
76
+ {
77
+ ref,
78
+ value: value || "",
79
+ onChange: emit,
80
+ onClear: () => emit(""),
81
+ disabled,
82
+ placeholder: "Rechercher une propriété…",
83
+ creatable: true,
84
+ onCreateOption: emit,
85
+ children: options.map((o) => /* @__PURE__ */ jsx(ComboboxOption, { value: o.value, children: o.label }, o.value))
86
+ }
87
+ ) : /* @__PURE__ */ jsx(
88
+ Field.Input,
89
+ {
90
+ ref,
91
+ value: value || "",
92
+ onChange: (e) => emit(e.target.value),
93
+ disabled,
94
+ placeholder: "ex. hs_role, numberofemployees"
95
+ }
96
+ ),
97
+ crmLink ? /* @__PURE__ */ jsx(Flex, { paddingTop: 1, children: /* @__PURE__ */ jsx(Link, { href: crmLink, isExternal: true, children: "Voir dans HubSpot" }) }) : null,
98
+ /* @__PURE__ */ jsx(Field.Hint, {}),
99
+ /* @__PURE__ */ jsx(Field.Error, {})
100
+ ] });
101
+ }
102
+ );
103
+ HubspotPropertyInput.displayName = "HubspotPropertyInput";
104
+ export {
105
+ HubspotPropertyInput as default
106
+ };
@@ -4,7 +4,7 @@ const jsxRuntime = require("react/jsx-runtime");
4
4
  const React = require("react");
5
5
  const designSystem = require("@strapi/design-system");
6
6
  const admin = require("@strapi/strapi/admin");
7
- const index = require("./index-DVRLoCa1.js");
7
+ const index = require("./index-sOi9wC_y.js");
8
8
  function _interopNamespace(e) {
9
9
  if (e && e.__esModule) return e;
10
10
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
@@ -2,7 +2,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { Box, Loader, Flex, Typography, Badge, Field, Button } from "@strapi/design-system";
4
4
  import { useFetchClient } from "@strapi/strapi/admin";
5
- import { P as PLUGIN_ID } from "./index-CER4BKjS.mjs";
5
+ import { P as PLUGIN_ID } from "./index-DIwNOVQ0.mjs";
6
6
  const SOURCE_LABEL = {
7
7
  settings: "saisie ici",
8
8
  config: "config/plugins.ts",
@@ -14,7 +14,7 @@ const index = {
14
14
  defaultMessage: "Choisie dans les propriétés réelles du portail"
15
15
  },
16
16
  components: {
17
- Input: async () => import("./HubspotPropertyInput-CTZYZS_7.mjs")
17
+ Input: async () => import("./HubspotPropertyInput-D0gijgli.mjs")
18
18
  },
19
19
  options: {}
20
20
  });
@@ -29,7 +29,7 @@ const index = {
29
29
  id: `${PLUGIN_ID}-settings`,
30
30
  to: `/settings/${PLUGIN_ID}`,
31
31
  permissions: [],
32
- Component: async () => (await import("./Settings-Byupubny.mjs")).default
32
+ Component: async () => (await import("./Settings-DuYUpD8L.mjs")).default
33
33
  }
34
34
  ]
35
35
  );
@@ -15,7 +15,7 @@ const index = {
15
15
  defaultMessage: "Choisie dans les propriétés réelles du portail"
16
16
  },
17
17
  components: {
18
- Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-B0HpXoqz.js"))
18
+ Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-BcdqfAfv.js"))
19
19
  },
20
20
  options: {}
21
21
  });
@@ -30,7 +30,7 @@ const index = {
30
30
  id: `${PLUGIN_ID}-settings`,
31
31
  to: `/settings/${PLUGIN_ID}`,
32
32
  permissions: [],
33
- Component: async () => (await Promise.resolve().then(() => require("./Settings-BgRpG_a6.js"))).default
33
+ Component: async () => (await Promise.resolve().then(() => require("./Settings-DedA0z6w.js"))).default
34
34
  }
35
35
  ]
36
36
  );
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-DVRLoCa1.js");
2
+ const index = require("../_chunks/index-sOi9wC_y.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-CER4BKjS.mjs";
1
+ import { i } from "../_chunks/index-DIwNOVQ0.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -9,26 +9,30 @@ interface InputProps {
9
9
  type: string;
10
10
  };
11
11
  }) => void;
12
- attribute?: unknown;
12
+ attribute?: {
13
+ options?: {
14
+ objectField?: string;
15
+ };
16
+ };
13
17
  disabled?: boolean;
14
18
  error?: string;
15
19
  required?: boolean;
16
20
  labelAction?: React.ReactNode;
17
21
  hint?: React.ReactNode;
18
22
  label?: string;
19
- placeholder?: string;
20
23
  }
21
24
  /**
22
- * Picker over the portal's writable properties.
25
+ * Picker over the portal's writable properties, narrowed to the object the
26
+ * sibling field selects.
23
27
  *
24
- * Contact and company properties share one list, each prefixed by its object,
25
- * rather than being filtered by a sibling `hsObject` field: reading a sibling
26
- * value from inside a repeatable component nested in a dynamic zone is brittle,
27
- * and showing the object inline is arguably clearer anyway the editor sees at
28
- * a glance which record the answer lands on.
28
+ * The sibling is found by path arithmetic on our own `name`: inside a repeatable
29
+ * component in a dynamic zone it looks like
30
+ * `blocks.3.form.steps.0.fields.2.hsProperty`, so swapping the last segment
31
+ * yields the neighbour. Which segment to swap is configurable per attribute
32
+ * (`options.objectField`) so the plugin isn't tied to a field called `hsObject`.
29
33
  *
30
- * Falls back to a plain text input when no API key is configured, so the field
31
- * never blocks authoring on a fresh install.
34
+ * Before an object is chosen the full list shows, each entry prefixed by its
35
+ * object an empty picker would read as broken.
32
36
  */
33
37
  declare const HubspotPropertyInput: React.ForwardRefExoticComponent<InputProps & React.RefAttributes<HTMLInputElement>>;
34
38
  export default HubspotPropertyInput;
@@ -0,0 +1 @@
1
+ export declare function objectLabel(object: string): string;
@@ -2,42 +2,91 @@
2
2
  const utils = require("@strapi/utils");
3
3
  const HS_BASE = "https://api.hubapi.com";
4
4
  const TTL_MS = 10 * 60 * 1e3;
5
- const PLURAL = {
6
- contact: "contacts",
7
- company: "companies"
8
- };
5
+ const STANDARD_OBJECTS = [
6
+ { name: "contact", path: "contacts" },
7
+ { name: "company", path: "companies" },
8
+ { name: "deal", path: "deals" },
9
+ { name: "ticket", path: "tickets" },
10
+ { name: "product", path: "products" },
11
+ { name: "line_item", path: "line_items" },
12
+ { name: "quote", path: "quotes" }
13
+ ];
9
14
  let cache = null;
10
15
  let inFlight = null;
11
- async function fetchObject(apiKey, object) {
12
- const res = await fetch(`${HS_BASE}/crm/v3/properties/${PLURAL[object]}`, {
16
+ async function hsGet(apiKey, path) {
17
+ const res = await fetch(`${HS_BASE}${path}`, {
13
18
  headers: { Authorization: `Bearer ${apiKey}` }
14
- }).then(async (r) => {
15
- if (!r.ok) throw new Error(`HubSpot ${r.status} sur ${PLURAL[object]}`);
16
- return r.json();
17
19
  });
20
+ if (!res.ok) {
21
+ const body = await res.json().catch(() => ({}));
22
+ throw Object.assign(new Error(body.message || `HubSpot ${res.status}`), {
23
+ status: res.status
24
+ });
25
+ }
26
+ return await res.json();
27
+ }
28
+ async function fetchObject(apiKey, object) {
29
+ const res = await hsGet(
30
+ apiKey,
31
+ `/crm/v3/properties/${object.path}`
32
+ );
18
33
  return (res.results ?? []).filter((p) => !p.modificationMetadata?.readOnlyValue).map((p) => ({
19
34
  name: p.name,
20
35
  label: p.label || p.name,
21
- object,
36
+ object: object.name,
22
37
  type: p.type,
23
38
  options: (p.options ?? []).map((o) => o.value),
24
39
  group: p.groupName
25
40
  }));
26
41
  }
27
- async function listProperties(strapi, apiKey, { force = false } = {}) {
28
- if (!force && cache && Date.now() - cache.at < TTL_MS) return cache.properties;
42
+ async function fetchPortalId(apiKey) {
43
+ try {
44
+ const { portalId } = await hsGet(apiKey, "/account-info/v3/details");
45
+ return portalId;
46
+ } catch {
47
+ return void 0;
48
+ }
49
+ }
50
+ async function loadSchema(strapi, apiKey, objects, { force = false } = {}) {
51
+ if (!force && cache && Date.now() - cache.at < TTL_MS) return cache.schema;
29
52
  if (!force && inFlight) return inFlight;
30
53
  inFlight = (async () => {
31
54
  try {
32
- const [contact, company] = await Promise.all([
33
- fetchObject(apiKey, "contact"),
34
- fetchObject(apiKey, "company")
35
- ]);
36
- const properties = [...contact, ...company].sort(
55
+ const settled = await Promise.all(
56
+ objects.map(async (object) => {
57
+ try {
58
+ return { object, properties: await fetchObject(apiKey, object) };
59
+ } catch (err) {
60
+ return { object, error: err.message };
61
+ }
62
+ })
63
+ );
64
+ const properties = [];
65
+ const available = [];
66
+ const unavailable = [];
67
+ for (const entry of settled) {
68
+ if ("error" in entry && entry.error) {
69
+ unavailable.push({ object: entry.object.name, reason: entry.error });
70
+ strapi.log.warn(`[hubspot] ${entry.object.name} illisible — ${entry.error}`);
71
+ continue;
72
+ }
73
+ available.push(entry.object.name);
74
+ properties.push(...entry.properties ?? []);
75
+ }
76
+ if (!available.length) {
77
+ throw new Error(unavailable[0]?.reason ?? "Aucun objet lisible");
78
+ }
79
+ properties.sort(
37
80
  (a, b) => a.object.localeCompare(b.object) || a.label.localeCompare(b.label)
38
81
  );
39
- cache = { at: Date.now(), properties };
40
- return properties;
82
+ const schema = {
83
+ properties,
84
+ objects: available,
85
+ unavailable,
86
+ portalId: await fetchPortalId(apiKey)
87
+ };
88
+ cache = { at: Date.now(), schema };
89
+ return schema;
41
90
  } finally {
42
91
  inFlight = null;
43
92
  }
@@ -52,12 +101,28 @@ function checkProperty(properties, object, name) {
52
101
  if (!trimmed) return null;
53
102
  const match = properties.find((p) => p.object === object && p.name === trimmed);
54
103
  if (match) return trimmed === name ? null : `« ${name} » contient un espace superflu`;
55
- const onOther = properties.find((p) => p.object !== object && p.name === trimmed);
104
+ const onOther = properties.find((p) => p.name === trimmed);
56
105
  if (onOther) {
57
- return `« ${trimmed} » existe sur l'objet ${onOther.object === "contact" ? "Contact" : "Société"}, pas sur ${object === "contact" ? "Contact" : "Société"}`;
106
+ return `« ${trimmed} » existe sur l'objet ${onOther.object}, pas sur ${object}`;
58
107
  }
59
108
  return `« ${trimmed} » n'existe pas dans ce portail HubSpot`;
60
109
  }
110
+ function resolveObjects(configured) {
111
+ if (!Array.isArray(configured) || !configured.length) {
112
+ return STANDARD_OBJECTS.filter((o) => o.name === "contact" || o.name === "company");
113
+ }
114
+ const out = [];
115
+ for (const entry of configured) {
116
+ if (typeof entry === "string") {
117
+ const known = STANDARD_OBJECTS.find((o) => o.name === entry || o.path === entry);
118
+ out.push(known ?? { name: entry, path: entry });
119
+ } else if (entry && typeof entry === "object" && "name" in entry) {
120
+ const e = entry;
121
+ out.push({ name: e.name, path: e.path || e.name });
122
+ }
123
+ }
124
+ return out;
125
+ }
61
126
  const ENV_VAR = "HUBSPOT_API_KEY";
62
127
  const store = (strapi) => strapi.store({ type: "plugin", name: "hubspot" });
63
128
  async function getStoredSettings(strapi) {
@@ -87,6 +152,9 @@ async function publicSettings(strapi) {
87
152
  const config = {
88
153
  default: {
89
154
  apiKey: "",
155
+ // Objects whose properties are offered. Names from the standard set, or
156
+ // `{ name, path }` for a custom object type.
157
+ objects: ["contact", "company"],
90
158
  validate: []
91
159
  },
92
160
  validator(cfg) {
@@ -100,14 +168,17 @@ const controllers = {
100
168
  async list(ctx) {
101
169
  const { apiKey } = await resolveApiKey(strapi);
102
170
  if (!apiKey) {
103
- ctx.body = { configured: false, properties: [] };
171
+ ctx.body = { configured: false, properties: [], objects: [], unavailable: [] };
104
172
  return;
105
173
  }
106
174
  try {
107
- const properties = await listProperties(strapi, apiKey, {
108
- force: ctx.query.refresh === "1"
109
- });
110
- ctx.body = { configured: true, properties };
175
+ const schema = await loadSchema(
176
+ strapi,
177
+ apiKey,
178
+ resolveObjects(strapi.plugin("hubspot").config("objects", [])),
179
+ { force: ctx.query.refresh === "1" }
180
+ );
181
+ ctx.body = { configured: true, ...schema };
111
182
  } catch (err) {
112
183
  strapi.log.error(`[hubspot] ${err.message}`);
113
184
  ctx.throw(502, "Impossible de joindre HubSpot — vérifiez la clé API.");
@@ -155,8 +226,9 @@ function collectMappings(node, target, found = []) {
155
226
  const obj = node;
156
227
  const property = obj[target.propertyField];
157
228
  if (typeof property === "string" && property.trim()) {
229
+ const object = obj[target.objectField];
158
230
  found.push({
159
- object: obj[target.objectField] === "company" ? "company" : "contact",
231
+ object: typeof object === "string" && object ? object : "contact",
160
232
  property
161
233
  });
162
234
  }
@@ -185,14 +257,18 @@ const index = {
185
257
  if (!mappings.length) return next();
186
258
  const { apiKey } = await resolveApiKey(strapi);
187
259
  if (!apiKey) return next();
188
- let properties;
260
+ let schema;
189
261
  try {
190
- properties = await listProperties(strapi, apiKey);
262
+ schema = await loadSchema(
263
+ strapi,
264
+ apiKey,
265
+ resolveObjects(strapi.plugin("hubspot").config("objects", []))
266
+ );
191
267
  } catch {
192
268
  strapi.log.warn("[hubspot] schéma indisponible — validation ignorée");
193
269
  return next();
194
270
  }
195
- const problems = mappings.map((m) => checkProperty(properties, m.object, m.property)).filter((reason) => Boolean(reason));
271
+ const problems = mappings.map((m) => checkProperty(schema.properties, m.object, m.property)).filter((reason) => Boolean(reason));
196
272
  if (problems.length) {
197
273
  throw new utils.errors.ValidationError(
198
274
  `Mapping HubSpot invalide — ${[...new Set(problems)].join(" ; ")}`
@@ -1,42 +1,91 @@
1
1
  import { errors } from "@strapi/utils";
2
2
  const HS_BASE = "https://api.hubapi.com";
3
3
  const TTL_MS = 10 * 60 * 1e3;
4
- const PLURAL = {
5
- contact: "contacts",
6
- company: "companies"
7
- };
4
+ const STANDARD_OBJECTS = [
5
+ { name: "contact", path: "contacts" },
6
+ { name: "company", path: "companies" },
7
+ { name: "deal", path: "deals" },
8
+ { name: "ticket", path: "tickets" },
9
+ { name: "product", path: "products" },
10
+ { name: "line_item", path: "line_items" },
11
+ { name: "quote", path: "quotes" }
12
+ ];
8
13
  let cache = null;
9
14
  let inFlight = null;
10
- async function fetchObject(apiKey, object) {
11
- const res = await fetch(`${HS_BASE}/crm/v3/properties/${PLURAL[object]}`, {
15
+ async function hsGet(apiKey, path) {
16
+ const res = await fetch(`${HS_BASE}${path}`, {
12
17
  headers: { Authorization: `Bearer ${apiKey}` }
13
- }).then(async (r) => {
14
- if (!r.ok) throw new Error(`HubSpot ${r.status} sur ${PLURAL[object]}`);
15
- return r.json();
16
18
  });
19
+ if (!res.ok) {
20
+ const body = await res.json().catch(() => ({}));
21
+ throw Object.assign(new Error(body.message || `HubSpot ${res.status}`), {
22
+ status: res.status
23
+ });
24
+ }
25
+ return await res.json();
26
+ }
27
+ async function fetchObject(apiKey, object) {
28
+ const res = await hsGet(
29
+ apiKey,
30
+ `/crm/v3/properties/${object.path}`
31
+ );
17
32
  return (res.results ?? []).filter((p) => !p.modificationMetadata?.readOnlyValue).map((p) => ({
18
33
  name: p.name,
19
34
  label: p.label || p.name,
20
- object,
35
+ object: object.name,
21
36
  type: p.type,
22
37
  options: (p.options ?? []).map((o) => o.value),
23
38
  group: p.groupName
24
39
  }));
25
40
  }
26
- async function listProperties(strapi, apiKey, { force = false } = {}) {
27
- if (!force && cache && Date.now() - cache.at < TTL_MS) return cache.properties;
41
+ async function fetchPortalId(apiKey) {
42
+ try {
43
+ const { portalId } = await hsGet(apiKey, "/account-info/v3/details");
44
+ return portalId;
45
+ } catch {
46
+ return void 0;
47
+ }
48
+ }
49
+ async function loadSchema(strapi, apiKey, objects, { force = false } = {}) {
50
+ if (!force && cache && Date.now() - cache.at < TTL_MS) return cache.schema;
28
51
  if (!force && inFlight) return inFlight;
29
52
  inFlight = (async () => {
30
53
  try {
31
- const [contact, company] = await Promise.all([
32
- fetchObject(apiKey, "contact"),
33
- fetchObject(apiKey, "company")
34
- ]);
35
- const properties = [...contact, ...company].sort(
54
+ const settled = await Promise.all(
55
+ objects.map(async (object) => {
56
+ try {
57
+ return { object, properties: await fetchObject(apiKey, object) };
58
+ } catch (err) {
59
+ return { object, error: err.message };
60
+ }
61
+ })
62
+ );
63
+ const properties = [];
64
+ const available = [];
65
+ const unavailable = [];
66
+ for (const entry of settled) {
67
+ if ("error" in entry && entry.error) {
68
+ unavailable.push({ object: entry.object.name, reason: entry.error });
69
+ strapi.log.warn(`[hubspot] ${entry.object.name} illisible — ${entry.error}`);
70
+ continue;
71
+ }
72
+ available.push(entry.object.name);
73
+ properties.push(...entry.properties ?? []);
74
+ }
75
+ if (!available.length) {
76
+ throw new Error(unavailable[0]?.reason ?? "Aucun objet lisible");
77
+ }
78
+ properties.sort(
36
79
  (a, b) => a.object.localeCompare(b.object) || a.label.localeCompare(b.label)
37
80
  );
38
- cache = { at: Date.now(), properties };
39
- return properties;
81
+ const schema = {
82
+ properties,
83
+ objects: available,
84
+ unavailable,
85
+ portalId: await fetchPortalId(apiKey)
86
+ };
87
+ cache = { at: Date.now(), schema };
88
+ return schema;
40
89
  } finally {
41
90
  inFlight = null;
42
91
  }
@@ -51,12 +100,28 @@ function checkProperty(properties, object, name) {
51
100
  if (!trimmed) return null;
52
101
  const match = properties.find((p) => p.object === object && p.name === trimmed);
53
102
  if (match) return trimmed === name ? null : `« ${name} » contient un espace superflu`;
54
- const onOther = properties.find((p) => p.object !== object && p.name === trimmed);
103
+ const onOther = properties.find((p) => p.name === trimmed);
55
104
  if (onOther) {
56
- return `« ${trimmed} » existe sur l'objet ${onOther.object === "contact" ? "Contact" : "Société"}, pas sur ${object === "contact" ? "Contact" : "Société"}`;
105
+ return `« ${trimmed} » existe sur l'objet ${onOther.object}, pas sur ${object}`;
57
106
  }
58
107
  return `« ${trimmed} » n'existe pas dans ce portail HubSpot`;
59
108
  }
109
+ function resolveObjects(configured) {
110
+ if (!Array.isArray(configured) || !configured.length) {
111
+ return STANDARD_OBJECTS.filter((o) => o.name === "contact" || o.name === "company");
112
+ }
113
+ const out = [];
114
+ for (const entry of configured) {
115
+ if (typeof entry === "string") {
116
+ const known = STANDARD_OBJECTS.find((o) => o.name === entry || o.path === entry);
117
+ out.push(known ?? { name: entry, path: entry });
118
+ } else if (entry && typeof entry === "object" && "name" in entry) {
119
+ const e = entry;
120
+ out.push({ name: e.name, path: e.path || e.name });
121
+ }
122
+ }
123
+ return out;
124
+ }
60
125
  const ENV_VAR = "HUBSPOT_API_KEY";
61
126
  const store = (strapi) => strapi.store({ type: "plugin", name: "hubspot" });
62
127
  async function getStoredSettings(strapi) {
@@ -86,6 +151,9 @@ async function publicSettings(strapi) {
86
151
  const config = {
87
152
  default: {
88
153
  apiKey: "",
154
+ // Objects whose properties are offered. Names from the standard set, or
155
+ // `{ name, path }` for a custom object type.
156
+ objects: ["contact", "company"],
89
157
  validate: []
90
158
  },
91
159
  validator(cfg) {
@@ -99,14 +167,17 @@ const controllers = {
99
167
  async list(ctx) {
100
168
  const { apiKey } = await resolveApiKey(strapi);
101
169
  if (!apiKey) {
102
- ctx.body = { configured: false, properties: [] };
170
+ ctx.body = { configured: false, properties: [], objects: [], unavailable: [] };
103
171
  return;
104
172
  }
105
173
  try {
106
- const properties = await listProperties(strapi, apiKey, {
107
- force: ctx.query.refresh === "1"
108
- });
109
- ctx.body = { configured: true, properties };
174
+ const schema = await loadSchema(
175
+ strapi,
176
+ apiKey,
177
+ resolveObjects(strapi.plugin("hubspot").config("objects", [])),
178
+ { force: ctx.query.refresh === "1" }
179
+ );
180
+ ctx.body = { configured: true, ...schema };
110
181
  } catch (err) {
111
182
  strapi.log.error(`[hubspot] ${err.message}`);
112
183
  ctx.throw(502, "Impossible de joindre HubSpot — vérifiez la clé API.");
@@ -154,8 +225,9 @@ function collectMappings(node, target, found = []) {
154
225
  const obj = node;
155
226
  const property = obj[target.propertyField];
156
227
  if (typeof property === "string" && property.trim()) {
228
+ const object = obj[target.objectField];
157
229
  found.push({
158
- object: obj[target.objectField] === "company" ? "company" : "contact",
230
+ object: typeof object === "string" && object ? object : "contact",
159
231
  property
160
232
  });
161
233
  }
@@ -184,14 +256,18 @@ const index = {
184
256
  if (!mappings.length) return next();
185
257
  const { apiKey } = await resolveApiKey(strapi);
186
258
  if (!apiKey) return next();
187
- let properties;
259
+ let schema;
188
260
  try {
189
- properties = await listProperties(strapi, apiKey);
261
+ schema = await loadSchema(
262
+ strapi,
263
+ apiKey,
264
+ resolveObjects(strapi.plugin("hubspot").config("objects", []))
265
+ );
190
266
  } catch {
191
267
  strapi.log.warn("[hubspot] schéma indisponible — validation ignorée");
192
268
  return next();
193
269
  }
194
- const problems = mappings.map((m) => checkProperty(properties, m.object, m.property)).filter((reason) => Boolean(reason));
270
+ const problems = mappings.map((m) => checkProperty(schema.properties, m.object, m.property)).filter((reason) => Boolean(reason));
195
271
  if (problems.length) {
196
272
  throw new errors.ValidationError(
197
273
  `Mapping HubSpot invalide — ${[...new Set(problems)].join(" ; ")}`
@@ -23,6 +23,7 @@ declare const _default: {
23
23
  config: {
24
24
  default: {
25
25
  apiKey: string;
26
+ objects: unknown[];
26
27
  validate: ValidateTarget[];
27
28
  };
28
29
  validator(cfg: {
@@ -1,23 +1,52 @@
1
1
  import type { Core } from "@strapi/strapi";
2
- export type HsObject = "contact" | "company";
2
+ export interface HsObjectDef {
3
+ /** Value stored in the entry's object field, e.g. `contact`. */
4
+ name: string;
5
+ /** HubSpot's URL segment for that object, e.g. `contacts`. */
6
+ path: string;
7
+ }
8
+ /** Objects HubSpot ships with; anything else is declared in the plugin config. */
9
+ export declare const STANDARD_OBJECTS: HsObjectDef[];
3
10
  export interface HsProperty {
4
11
  name: string;
5
12
  label: string;
6
- object: HsObject;
13
+ /** Object this property belongs to (`HsObjectDef.name`). */
14
+ object: string;
7
15
  type?: string;
8
16
  /** Allowed values, for enumeration properties. */
9
17
  options: string[];
10
18
  /** HubSpot's own grouping, shown to help editors locate a property. */
11
19
  group?: string;
12
20
  }
13
- /** Writable properties of both objects, cached and de-duplicated across calls. */
14
- export declare function listProperties(strapi: Core.Strapi, apiKey: string, { force }?: {
21
+ export interface Schema {
22
+ properties: HsProperty[];
23
+ /** Objects actually readable with the current token. */
24
+ objects: string[];
25
+ /** Objects that were configured but refused — almost always a missing scope. */
26
+ unavailable: {
27
+ object: string;
28
+ reason: string;
29
+ }[];
30
+ /** Enables deep links into the CRM; derived from the token, never configured. */
31
+ portalId?: number;
32
+ }
33
+ /**
34
+ * Writable properties of every configured object, cached and de-duplicated
35
+ * across concurrent calls.
36
+ *
37
+ * An object the token can't read (missing scope) is reported in `unavailable`
38
+ * instead of failing the whole call: a portal that only granted contact scopes
39
+ * must still get a working contact picker.
40
+ */
41
+ export declare function loadSchema(strapi: Core.Strapi, apiKey: string, objects: HsObjectDef[], { force }?: {
15
42
  force?: boolean;
16
- }): Promise<HsProperty[]>;
17
- /** Drop the cache — used after the API key changes. */
43
+ }): Promise<Schema>;
44
+ /** Drop the cache — used after the API key or the object list changes. */
18
45
  export declare function clearCache(): void;
19
46
  /**
20
47
  * Why a property can't be written, or `null` when it's fine. Returning a reason
21
48
  * rather than a boolean lets the caller tell an editor what to fix.
22
49
  */
23
- export declare function checkProperty(properties: HsProperty[], object: HsObject, name: string): string | null;
50
+ export declare function checkProperty(properties: HsProperty[], object: string, name: string): string | null;
51
+ /** Resolve the configured object list, accepting names or full definitions. */
52
+ export declare function resolveObjects(configured: unknown): HsObjectDef[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "strapi-plugin-hubspot",
3
- "version": "0.1.0",
4
- "description": "HubSpot integration for Strapi — pick CRM properties from a searchable list instead of typing them, and catch bad mappings before they reach the API.",
3
+ "version": "0.2.0",
4
+ "description": "HubSpot integration for Strapi — pick CRM properties from a searchable list filtered by object, and catch bad mappings before they reach the API.",
5
5
  "keywords": [
6
6
  "strapi",
7
7
  "plugin",
@@ -1,84 +0,0 @@
1
- import { jsxs, jsx } from "react/jsx-runtime";
2
- import * as React from "react";
3
- import { Field, Flex, Loader, Combobox, ComboboxOption } from "@strapi/design-system";
4
- import { useFetchClient } from "@strapi/strapi/admin";
5
- import { P as PLUGIN_ID } from "./index-CER4BKjS.mjs";
6
- const OBJECT_LABEL = {
7
- contact: "Contact",
8
- company: "Société"
9
- };
10
- const HubspotPropertyInput = React.forwardRef(
11
- ({ name, value, onChange, disabled, error, required, label, hint, labelAction }, ref) => {
12
- const { get } = useFetchClient();
13
- const [properties, setProperties] = React.useState([]);
14
- const [configured, setConfigured] = React.useState(null);
15
- const [loadError, setLoadError] = React.useState("");
16
- React.useEffect(() => {
17
- let cancelled = false;
18
- get(`/${PLUGIN_ID}/properties`).then(({ data }) => {
19
- if (cancelled) return;
20
- setConfigured(data.configured);
21
- setProperties(data.properties ?? []);
22
- }).catch((err) => {
23
- if (cancelled) return;
24
- setConfigured(false);
25
- setLoadError(
26
- err?.response?.data?.error?.message ?? "Propriétés HubSpot indisponibles"
27
- );
28
- });
29
- return () => {
30
- cancelled = true;
31
- };
32
- }, [get]);
33
- const emit = (next) => onChange({ target: { name, value: next ?? "", type: "string" } });
34
- const describe = () => {
35
- if (loadError) return loadError;
36
- if (configured === false) {
37
- return "Aucune clé API HubSpot — saisie libre. Renseignez-la dans Réglages → HubSpot.";
38
- }
39
- return hint;
40
- };
41
- const options = React.useMemo(() => {
42
- const known = properties.map((p) => ({
43
- value: p.name,
44
- label: `${OBJECT_LABEL[p.object]} · ${p.label} (${p.name})`
45
- }));
46
- if (value && !properties.some((p) => p.name === value)) {
47
- known.unshift({ value, label: `${value} — inconnue du portail` });
48
- }
49
- return known;
50
- }, [properties, value]);
51
- return /* @__PURE__ */ jsxs(Field.Root, { name, error, hint: describe(), required, children: [
52
- /* @__PURE__ */ jsx(Field.Label, { action: labelAction, children: label }),
53
- configured === null ? /* @__PURE__ */ jsx(Flex, { paddingTop: 2, paddingBottom: 2, children: /* @__PURE__ */ jsx(Loader, { small: true, children: "Chargement des propriétés HubSpot…" }) }) : configured ? /* @__PURE__ */ jsx(
54
- Combobox,
55
- {
56
- ref,
57
- value: value || "",
58
- onChange: emit,
59
- onClear: () => emit(""),
60
- disabled,
61
- placeholder: "Rechercher une propriété…",
62
- creatable: true,
63
- onCreateOption: emit,
64
- children: options.map((o) => /* @__PURE__ */ jsx(ComboboxOption, { value: o.value, children: o.label }, o.value))
65
- }
66
- ) : /* @__PURE__ */ jsx(
67
- Field.Input,
68
- {
69
- ref,
70
- value: value || "",
71
- onChange: (e) => emit(e.target.value),
72
- disabled,
73
- placeholder: "ex. hs_role, numberofemployees"
74
- }
75
- ),
76
- /* @__PURE__ */ jsx(Field.Hint, {}),
77
- /* @__PURE__ */ jsx(Field.Error, {})
78
- ] });
79
- }
80
- );
81
- HubspotPropertyInput.displayName = "HubspotPropertyInput";
82
- export {
83
- HubspotPropertyInput as default
84
- };