strapi-plugin-hubspot 0.2.1 → 0.4.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 (44) hide show
  1. package/README.md +145 -5
  2. package/dist/_chunks/HubspotObjectInput-DAq_W1rA.js +91 -0
  3. package/dist/_chunks/HubspotObjectInput-G-A_pL2j.mjs +73 -0
  4. package/dist/_chunks/HubspotPropertyInput-B-Jg0x68.js +186 -0
  5. package/dist/_chunks/HubspotPropertyInput-Cv77QiS6.mjs +168 -0
  6. package/dist/_chunks/Settings-58edRL5D.mjs +1293 -0
  7. package/dist/_chunks/Settings-CryiUgSe.js +1311 -0
  8. package/dist/_chunks/en-CnQbjHs-.js +81 -0
  9. package/dist/_chunks/en-DHQZuRsj.mjs +81 -0
  10. package/dist/_chunks/fr-Bv8zsNX5.js +81 -0
  11. package/dist/_chunks/fr-Dxgil7RP.mjs +81 -0
  12. package/dist/_chunks/index-BudHTwSw.mjs +137 -0
  13. package/dist/_chunks/index-Tlw1p93d.js +136 -0
  14. package/dist/_chunks/objectLabels-CJTKZ4vT.mjs +425 -0
  15. package/dist/_chunks/objectLabels-GRmYGCIn.js +442 -0
  16. package/dist/admin/index.js +1 -1
  17. package/dist/admin/index.mjs +1 -1
  18. package/dist/admin/src/components/AuditSection.d.ts +5 -0
  19. package/dist/admin/src/components/FailuresSection.d.ts +11 -0
  20. package/dist/admin/src/components/HubspotObjectInput.d.ts +28 -0
  21. package/dist/admin/src/components/HubspotPropertyInput.d.ts +1 -0
  22. package/dist/admin/src/getTranslation.d.ts +3 -0
  23. package/dist/admin/src/index.d.ts +6 -0
  24. package/dist/admin/src/objectLabels.d.ts +2 -1
  25. package/dist/server/index.js +445 -69
  26. package/dist/server/index.mjs +445 -69
  27. package/dist/server/src/__tests__/audit.test.d.ts +1 -0
  28. package/dist/server/src/__tests__/checkMapping.test.d.ts +1 -0
  29. package/dist/server/src/__tests__/loadSchema.test.d.ts +1 -0
  30. package/dist/server/src/__tests__/submit.test.d.ts +1 -0
  31. package/dist/server/src/__tests__/validation.test.d.ts +1 -0
  32. package/dist/server/src/audit.d.ts +60 -0
  33. package/dist/server/src/content-types.d.ts +56 -0
  34. package/dist/server/src/index.d.ts +104 -22
  35. package/dist/server/src/properties.d.ts +41 -5
  36. package/dist/server/src/submit.d.ts +35 -0
  37. package/dist/server/src/validation.d.ts +37 -0
  38. package/package.json +5 -2
  39. package/dist/_chunks/HubspotPropertyInput-BVFRtt49.js +0 -124
  40. package/dist/_chunks/HubspotPropertyInput-C7Wcw8ee.mjs +0 -106
  41. package/dist/_chunks/Settings-B5w7LAsH.mjs +0 -140
  42. package/dist/_chunks/Settings-TGB0MOHr.js +0 -158
  43. package/dist/_chunks/index-BpZtcsPM.mjs +0 -42
  44. package/dist/_chunks/index-DgsAHPui.js +0 -41
@@ -1,5 +1,5 @@
1
1
  import { errors } from "@strapi/utils";
2
- const HS_BASE = "https://api.hubapi.com";
2
+ const HS_BASE$1 = "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}${path}`, {
16
+ const res = await fetch(`${HS_BASE$1}${path}`, {
17
17
  headers: { Authorization: `Bearer ${apiKey}` }
18
18
  });
19
19
  if (!res.ok) {
@@ -24,18 +24,29 @@ async function hsGet(apiKey, path) {
24
24
  }
25
25
  return await res.json();
26
26
  }
27
+ async function fetchGroups(apiKey, path) {
28
+ try {
29
+ const res = await hsGet(
30
+ apiKey,
31
+ `/crm/v3/properties/${path}/groups`
32
+ );
33
+ return Object.fromEntries((res.results ?? []).map((g) => [g.name, g.label || g.name]));
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
27
38
  async function fetchObject(apiKey, object) {
28
- const res = await hsGet(
29
- apiKey,
30
- `/crm/v3/properties/${object.path}`
31
- );
39
+ const [res, groups] = await Promise.all([
40
+ hsGet(apiKey, `/crm/v3/properties/${object.path}`),
41
+ fetchGroups(apiKey, object.path)
42
+ ]);
32
43
  return (res.results ?? []).filter((p) => !p.modificationMetadata?.readOnlyValue).map((p) => ({
33
44
  name: p.name,
34
45
  label: p.label || p.name,
35
46
  object: object.name,
36
47
  type: p.type,
37
- options: (p.options ?? []).map((o) => o.value),
38
- group: p.groupName
48
+ options: (p.options ?? []).map((o) => ({ value: o.value, label: o.label })),
49
+ group: p.groupName ? groups[p.groupName] ?? p.groupName : void 0
39
50
  }));
40
51
  }
41
52
  async function fetchAccount(apiKey) {
@@ -68,14 +79,14 @@ async function loadSchema(strapi, apiKey, objects, { force = false } = {}) {
68
79
  for (const entry of settled) {
69
80
  if ("error" in entry && entry.error) {
70
81
  unavailable.push({ object: entry.object.name, reason: entry.error });
71
- strapi.log.warn(`[hubspot] ${entry.object.name} illisible — ${entry.error}`);
82
+ strapi.log.warn(`[hubspot] ${entry.object.name} unreadable — ${entry.error}`);
72
83
  continue;
73
84
  }
74
85
  available.push(entry.object.name);
75
86
  properties.push(...entry.properties ?? []);
76
87
  }
77
88
  if (!available.length) {
78
- throw new Error(unavailable[0]?.reason ?? "Aucun objet lisible");
89
+ throw new Error(unavailable[0]?.reason ?? "No readable object");
79
90
  }
80
91
  properties.sort(
81
92
  (a, b) => a.object.localeCompare(b.object) || a.label.localeCompare(b.label)
@@ -99,16 +110,36 @@ async function loadSchema(strapi, apiKey, objects, { force = false } = {}) {
99
110
  function clearCache() {
100
111
  cache = null;
101
112
  }
102
- function checkProperty(properties, object, name) {
103
- const trimmed = name.trim();
113
+ function checkMapping(properties, mapping) {
114
+ const { object, property } = mapping;
115
+ const trimmed = property.trim();
104
116
  if (!trimmed) return null;
105
117
  const match = properties.find((p) => p.object === object && p.name === trimmed);
106
- if (match) return trimmed === name ? null : `« ${name} » contient un espace superflu`;
107
- const onOther = properties.find((p) => p.name === trimmed);
108
- if (onOther) {
109
- return `« ${trimmed} » existe sur l'objet ${onOther.object}, pas sur ${object}`;
118
+ if (!match) {
119
+ const elsewhere = properties.find((p) => p.name === trimmed);
120
+ return elsewhere ? { code: "wrong-object", property: trimmed, object, actualObject: elsewhere.object } : { code: "unknown", property: trimmed, object };
121
+ }
122
+ if (trimmed !== property) return { code: "whitespace", property, object };
123
+ if (match.type === "enumeration" && match.options.length && mapping.values?.length) {
124
+ const allowed = match.options.map((o) => o.value);
125
+ const stray = mapping.values.filter((v) => v && !allowed.includes(v));
126
+ if (stray.length) {
127
+ return { code: "bad-option", property: trimmed, object, values: stray };
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+ function describeProblem(problem) {
133
+ switch (problem.code) {
134
+ case "unknown":
135
+ return `"${problem.property}" does not exist on ${problem.object} in this HubSpot portal`;
136
+ case "wrong-object":
137
+ return `"${problem.property}" exists on ${problem.actualObject}, not on ${problem.object}`;
138
+ case "whitespace":
139
+ return `"${problem.property}" has surrounding whitespace`;
140
+ case "bad-option":
141
+ return `"${problem.property}" does not accept ${problem.values.map((v) => `"${v}"`).join(", ")}`;
110
142
  }
111
- return `« ${trimmed} » n'existe pas dans ce portail HubSpot`;
112
143
  }
113
144
  function resolveObjects(configured) {
114
145
  if (!Array.isArray(configured) || !configured.length) {
@@ -152,6 +183,337 @@ async function publicSettings(strapi) {
152
183
  hint: apiKey ? `…${apiKey.slice(-4)}` : ""
153
184
  };
154
185
  }
186
+ function collectMappings(node, target, found = []) {
187
+ if (Array.isArray(node)) {
188
+ for (const item of node) collectMappings(item, target, found);
189
+ return found;
190
+ }
191
+ if (!node || typeof node !== "object") return found;
192
+ const obj = node;
193
+ const property = obj[target.propertyField];
194
+ if (typeof property === "string" && property.trim()) {
195
+ const object = obj[target.objectField];
196
+ const rawOptions = obj[target.optionsField || "options"];
197
+ const values = Array.isArray(rawOptions) ? rawOptions.map((o) => {
198
+ const opt = o ?? {};
199
+ const v = typeof opt.value === "string" && opt.value.trim() ? opt.value : opt.label;
200
+ return typeof v === "string" ? v.trim() : "";
201
+ }).filter(Boolean) : void 0;
202
+ found.push({
203
+ object: typeof object === "string" && object ? object : "contact",
204
+ property,
205
+ values
206
+ });
207
+ }
208
+ for (const value of Object.values(obj)) collectMappings(value, target, found);
209
+ return found;
210
+ }
211
+ function makeValidationMiddleware(strapi, target) {
212
+ return async (context, next) => {
213
+ const isWrite = ["create", "update"].includes(context.action);
214
+ if (!isWrite || context.uid !== target.uid) return next();
215
+ const mappings = collectMappings(
216
+ context.params?.data,
217
+ target
218
+ );
219
+ if (!mappings.length) return next();
220
+ const { apiKey } = await resolveApiKey(strapi);
221
+ if (!apiKey) return next();
222
+ let schema;
223
+ try {
224
+ schema = await loadSchema(
225
+ strapi,
226
+ apiKey,
227
+ resolveObjects(strapi.plugin("hubspot").config("objects", []))
228
+ );
229
+ } catch {
230
+ strapi.log.warn("[hubspot] schema unavailable — validation skipped");
231
+ return next();
232
+ }
233
+ const problems = mappings.map((m) => checkMapping(schema.properties, m)).filter((p) => Boolean(p));
234
+ const blocking = target.strict === false ? problems.filter((p) => p.code !== "unknown") : problems;
235
+ const soft = problems.filter((p) => !blocking.includes(p));
236
+ if (soft.length) {
237
+ const sentences = [...new Set(soft.map(describeProblem))];
238
+ strapi.log.warn(
239
+ `[hubspot] ${target.uid}: ${sentences.join("; ")} — allowed (strict: false)`
240
+ );
241
+ }
242
+ if (blocking.length) {
243
+ const sentences = [...new Set(blocking.map(describeProblem))];
244
+ throw new errors.ValidationError(
245
+ `Invalid HubSpot mapping — ${sentences.join("; ")}`,
246
+ { problems: blocking }
247
+ );
248
+ }
249
+ return next();
250
+ };
251
+ }
252
+ function buildPopulate(components, schema, depth = 0) {
253
+ if (depth > 8) return {};
254
+ const populate = {};
255
+ for (const [key, attr] of Object.entries(schema.attributes ?? {})) {
256
+ if (attr.type === "component" && attr.component) {
257
+ const child = components[attr.component];
258
+ const nested = child ? buildPopulate(components, child, depth + 1) : {};
259
+ populate[key] = { populate: Object.keys(nested).length ? nested : "*" };
260
+ } else if (attr.type === "dynamiczone" && attr.components?.length) {
261
+ const on = {};
262
+ for (const uid of attr.components) {
263
+ const child = components[uid];
264
+ const nested = child ? buildPopulate(components, child, depth + 1) : {};
265
+ on[uid] = { populate: Object.keys(nested).length ? nested : "*" };
266
+ }
267
+ populate[key] = { on };
268
+ }
269
+ }
270
+ return populate;
271
+ }
272
+ function entryLabel(entry) {
273
+ for (const key of ["title", "name", "label", "heading", "slug"]) {
274
+ const value = entry[key];
275
+ if (typeof value === "string" && value.trim()) return value.trim();
276
+ }
277
+ return String(entry.documentId ?? entry.id ?? "?");
278
+ }
279
+ async function fetchEntries(strapi, uid, schema, populate) {
280
+ const params = {};
281
+ if (Object.keys(populate).length) params.populate = populate;
282
+ if (schema.options?.draftAndPublish) params.status = "draft";
283
+ let locales = [void 0];
284
+ if (schema.pluginOptions?.i18n?.localized) {
285
+ try {
286
+ const known = await strapi.plugin("i18n").service("locales").find();
287
+ if (known.length) locales = known.map((l) => l.code);
288
+ } catch {
289
+ }
290
+ }
291
+ const out = [];
292
+ for (const locale of locales) {
293
+ const docs = await strapi.documents(uid).findMany({
294
+ ...params,
295
+ ...locale ? { locale } : {}
296
+ });
297
+ for (const doc of docs ?? []) out.push(locale ? { ...doc, __auditLocale: locale } : doc);
298
+ }
299
+ return out;
300
+ }
301
+ async function runAudit(strapi, targets, schema) {
302
+ const reports = [];
303
+ for (const target of targets) {
304
+ const contentType = strapi.contentType(target.uid);
305
+ if (!contentType) {
306
+ reports.push({
307
+ uid: target.uid,
308
+ entries: 0,
309
+ mappings: 0,
310
+ invalid: [],
311
+ error: `unknown content type "${target.uid}"`
312
+ });
313
+ continue;
314
+ }
315
+ const populate = buildPopulate(
316
+ strapi.components,
317
+ contentType
318
+ );
319
+ let entries;
320
+ try {
321
+ entries = await fetchEntries(strapi, target.uid, contentType, populate);
322
+ } catch (err) {
323
+ reports.push({
324
+ uid: target.uid,
325
+ entries: 0,
326
+ mappings: 0,
327
+ invalid: [],
328
+ error: err.message
329
+ });
330
+ continue;
331
+ }
332
+ const report = {
333
+ uid: target.uid,
334
+ entries: entries.length,
335
+ mappings: 0,
336
+ invalid: []
337
+ };
338
+ for (const entry of entries) {
339
+ const mappings = collectMappings(entry, target);
340
+ report.mappings += mappings.length;
341
+ const problems = mappings.map((m) => checkMapping(schema.properties, m)).filter((p) => Boolean(p));
342
+ if (problems.length) {
343
+ report.invalid.push({
344
+ documentId: String(entry.documentId ?? entry.id ?? "?"),
345
+ locale: entry.__auditLocale ?? void 0,
346
+ label: entryLabel(entry),
347
+ problems
348
+ });
349
+ }
350
+ }
351
+ reports.push(report);
352
+ }
353
+ return reports;
354
+ }
355
+ const contentTypes = {
356
+ failure: {
357
+ schema: {
358
+ kind: "collectionType",
359
+ collectionName: "hubspot_failures",
360
+ info: {
361
+ singularName: "failure",
362
+ pluralName: "failures",
363
+ displayName: "HubSpot failed submissions",
364
+ description: "Submissions HubSpot couldn't take, waiting to be replayed"
365
+ },
366
+ options: { draftAndPublish: false },
367
+ pluginOptions: {
368
+ "content-manager": { visible: true },
369
+ "content-type-builder": { visible: false }
370
+ },
371
+ attributes: {
372
+ object: { type: "string", required: true },
373
+ idProperty: { type: "string", required: true },
374
+ properties: { type: "json", required: true },
375
+ error: { type: "text" },
376
+ attempts: { type: "integer", default: 1 }
377
+ }
378
+ }
379
+ }
380
+ };
381
+ const HS_BASE = "https://api.hubapi.com";
382
+ const FAILURE_UID = "plugin::hubspot.failure";
383
+ const RETRY_DELAYS_MS = [500, 2e3];
384
+ const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
385
+ function coerceProperties(properties) {
386
+ const out = {};
387
+ for (const [key, value] of Object.entries(properties)) {
388
+ if (value === null || value === void 0 || value === "") continue;
389
+ out[key] = String(value);
390
+ }
391
+ return out;
392
+ }
393
+ class HsError extends Error {
394
+ constructor(message, status) {
395
+ super(message);
396
+ this.status = status;
397
+ }
398
+ }
399
+ const isTransient = (err) => !(err instanceof HsError) || err.status === 429 || err.status >= 500;
400
+ function createSubmitService(strapi, { sleep = sleepFor } = {}) {
401
+ const objectPath = (object) => resolveObjects(strapi.plugin("hubspot").config("objects", [])).find(
402
+ (o) => o.name === object
403
+ )?.path ?? object;
404
+ async function sendOnce(apiKey, input, properties) {
405
+ const res = await fetch(`${HS_BASE}/crm/v3/objects/${objectPath(input.object)}/batch/upsert`, {
406
+ method: "POST",
407
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
408
+ body: JSON.stringify({
409
+ inputs: [{ idProperty: input.idProperty, id: properties[input.idProperty], properties }]
410
+ })
411
+ });
412
+ const body = await res.json().catch(() => ({}));
413
+ if (!res.ok) throw new HsError(body.message || `HubSpot ${res.status}`, res.status);
414
+ return body.results?.[0]?.id ?? "";
415
+ }
416
+ async function sendWithRetries(apiKey, input, properties) {
417
+ let lastError;
418
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
419
+ if (attempt > 0) await sleep(RETRY_DELAYS_MS[attempt - 1]);
420
+ try {
421
+ return await sendOnce(apiKey, input, properties);
422
+ } catch (err) {
423
+ lastError = err;
424
+ if (!isTransient(err)) throw err;
425
+ }
426
+ }
427
+ throw lastError;
428
+ }
429
+ async function validateAgainstSchema(apiKey, input, properties) {
430
+ let schema;
431
+ try {
432
+ schema = await loadSchema(
433
+ strapi,
434
+ apiKey,
435
+ resolveObjects(strapi.plugin("hubspot").config("objects", []))
436
+ );
437
+ } catch {
438
+ return null;
439
+ }
440
+ const problems = [];
441
+ for (const [name, value] of Object.entries(properties)) {
442
+ const problem = checkMapping(schema.properties, {
443
+ object: input.object,
444
+ property: name,
445
+ values: value.split(";").map((v) => v.trim()).filter(Boolean)
446
+ });
447
+ if (problem) problems.push(problem);
448
+ }
449
+ return problems;
450
+ }
451
+ async function upsert(input, { queueOnFailure = true } = {}) {
452
+ const { apiKey } = await resolveApiKey(strapi);
453
+ if (!apiKey) return { ok: false, error: "No HubSpot API key configured" };
454
+ const properties = coerceProperties(input.properties);
455
+ if (!properties[input.idProperty]) {
456
+ return { ok: false, error: `Missing value for idProperty "${input.idProperty}"` };
457
+ }
458
+ const problems = await validateAgainstSchema(apiKey, input, properties);
459
+ if (problems?.length) return { ok: false, problems };
460
+ try {
461
+ const id = await sendWithRetries(apiKey, input, properties);
462
+ return { ok: true, id };
463
+ } catch (err) {
464
+ const message = err.message;
465
+ if (!isTransient(err) || !queueOnFailure) {
466
+ strapi.log.error(`[hubspot] upsert refused — ${message}`);
467
+ return { ok: false, error: message };
468
+ }
469
+ strapi.log.warn(`[hubspot] upsert failed, queued for retry — ${message}`);
470
+ try {
471
+ await strapi.documents(FAILURE_UID).create({
472
+ data: {
473
+ object: input.object,
474
+ idProperty: input.idProperty,
475
+ properties: input.properties,
476
+ error: message,
477
+ attempts: 1
478
+ }
479
+ });
480
+ return { ok: false, queued: true, error: message };
481
+ } catch (storeErr) {
482
+ strapi.log.error(`[hubspot] could not queue the failure — ${storeErr.message}`);
483
+ return { ok: false, queued: false, error: message };
484
+ }
485
+ }
486
+ }
487
+ async function retryFailures({ limit = 50 } = {}) {
488
+ const rows = await strapi.documents(FAILURE_UID).findMany({
489
+ limit,
490
+ sort: "createdAt:asc"
491
+ });
492
+ let succeeded = 0;
493
+ for (const row of rows) {
494
+ const result = await upsert(
495
+ { object: row.object, idProperty: row.idProperty, properties: row.properties ?? {} },
496
+ { queueOnFailure: false }
497
+ // Already queued — don't duplicate the row.
498
+ );
499
+ if (result.ok) {
500
+ await strapi.documents(FAILURE_UID).delete({ documentId: row.documentId });
501
+ succeeded += 1;
502
+ } else {
503
+ await strapi.documents(FAILURE_UID).update({
504
+ documentId: row.documentId,
505
+ data: {
506
+ attempts: (row.attempts ?? 1) + 1,
507
+ error: result.error ?? (result.problems ? `invalid mapping: ${JSON.stringify(result.problems)}` : "unknown")
508
+ }
509
+ });
510
+ }
511
+ }
512
+ return { retried: rows.length, succeeded, failed: rows.length - succeeded };
513
+ }
514
+ return { upsert, retryFailures };
515
+ }
516
+ const SETTINGS_ACTION = "plugin::hubspot.settings";
155
517
  const config = {
156
518
  default: {
157
519
  apiKey: "",
@@ -184,10 +546,41 @@ const controllers = {
184
546
  ctx.body = { configured: true, ...schema };
185
547
  } catch (err) {
186
548
  strapi.log.error(`[hubspot] ${err.message}`);
187
- ctx.throw(502, "Impossible de joindre HubSpot — vérifiez la clé API.");
549
+ ctx.throw(502, "Cannot reach HubSpot — check the API key.");
550
+ }
551
+ }
552
+ }),
553
+ audit: ({ strapi }) => ({
554
+ async run(ctx) {
555
+ const { apiKey } = await resolveApiKey(strapi);
556
+ if (!apiKey) {
557
+ ctx.body = { configured: false, targets: [] };
558
+ return;
559
+ }
560
+ const targets = strapi.plugin("hubspot").config("validate", []);
561
+ try {
562
+ const schema = await loadSchema(
563
+ strapi,
564
+ apiKey,
565
+ resolveObjects(strapi.plugin("hubspot").config("objects", [])),
566
+ { force: true }
567
+ );
568
+ ctx.body = { configured: true, targets: await runAudit(strapi, targets, schema) };
569
+ } catch (err) {
570
+ strapi.log.error(`[hubspot] audit failed — ${err.message}`);
571
+ ctx.throw(502, "Cannot reach HubSpot — check the API key.");
188
572
  }
189
573
  }
190
574
  }),
575
+ failures: ({ strapi }) => ({
576
+ async list(ctx) {
577
+ const total = await strapi.documents(FAILURE_UID).count({});
578
+ ctx.body = { total };
579
+ },
580
+ async retry(ctx) {
581
+ ctx.body = await strapi.plugin("hubspot").service("submit").retryFailures();
582
+ }
583
+ }),
191
584
  settings: ({ strapi }) => ({
192
585
  async get(ctx) {
193
586
  ctx.body = await publicSettings(strapi);
@@ -203,82 +596,65 @@ const controllers = {
203
596
  }
204
597
  })
205
598
  };
206
- const adminRoute = (method, path, handler) => ({
599
+ const adminRoute = (method, path, handler, actions) => ({
207
600
  method,
208
601
  path,
209
602
  handler,
210
- config: { policies: ["admin::isAuthenticatedAdmin"] }
603
+ config: {
604
+ policies: [
605
+ "admin::isAuthenticatedAdmin",
606
+ // The token is a portal-wide secret; reading properties is not. Only the
607
+ // settings routes carry the extra permission.
608
+ ...actions ? [{ name: "admin::hasPermissions", config: { actions } }] : []
609
+ ]
610
+ }
211
611
  });
212
612
  const routes = {
213
613
  admin: {
214
614
  type: "admin",
215
615
  routes: [
216
616
  adminRoute("GET", "/properties", "properties.list"),
217
- adminRoute("GET", "/settings", "settings.get"),
218
- adminRoute("PUT", "/settings", "settings.update"),
219
- adminRoute("DELETE", "/settings", "settings.reset")
617
+ adminRoute("GET", "/audit", "audit.run", [SETTINGS_ACTION]),
618
+ adminRoute("GET", "/failures", "failures.list", [SETTINGS_ACTION]),
619
+ adminRoute("POST", "/failures/retry", "failures.retry", [SETTINGS_ACTION]),
620
+ adminRoute("GET", "/settings", "settings.get", [SETTINGS_ACTION]),
621
+ adminRoute("PUT", "/settings", "settings.update", [SETTINGS_ACTION]),
622
+ adminRoute("DELETE", "/settings", "settings.reset", [SETTINGS_ACTION])
220
623
  ]
221
624
  }
222
625
  };
223
- function collectMappings(node, target, found = []) {
224
- if (Array.isArray(node)) {
225
- for (const item of node) collectMappings(item, target, found);
226
- return found;
227
- }
228
- if (!node || typeof node !== "object") return found;
229
- const obj = node;
230
- const property = obj[target.propertyField];
231
- if (typeof property === "string" && property.trim()) {
232
- const object = obj[target.objectField];
233
- found.push({
234
- object: typeof object === "string" && object ? object : "contact",
235
- property
236
- });
237
- }
238
- for (const value of Object.values(obj)) collectMappings(value, target, found);
239
- return found;
240
- }
241
626
  const index = {
242
627
  config,
628
+ contentTypes,
243
629
  controllers,
244
630
  routes,
631
+ services: {
632
+ submit: ({ strapi }) => createSubmitService(strapi)
633
+ },
245
634
  register({ strapi }) {
246
635
  strapi.customFields.register({
247
636
  name: "property",
248
637
  plugin: "hubspot",
249
638
  type: "string"
250
639
  });
640
+ strapi.customFields.register({
641
+ name: "object",
642
+ plugin: "hubspot",
643
+ type: "string"
644
+ });
251
645
  },
252
- bootstrap({ strapi }) {
646
+ async bootstrap({ strapi }) {
647
+ await strapi.service("admin::permission").actionProvider.registerMany([
648
+ {
649
+ section: "plugins",
650
+ displayName: "Access the HubSpot settings",
651
+ uid: "settings",
652
+ pluginName: "hubspot"
653
+ }
654
+ ]);
253
655
  const targets = strapi.plugin("hubspot").config("validate", []);
254
- if (!targets.length) return;
255
656
  for (const target of targets) {
256
- strapi.documents.use(async (context, next) => {
257
- const isWrite = ["create", "update"].includes(context.action);
258
- if (!isWrite || context.uid !== target.uid) return next();
259
- const mappings = collectMappings(context.params?.data, target);
260
- if (!mappings.length) return next();
261
- const { apiKey } = await resolveApiKey(strapi);
262
- if (!apiKey) return next();
263
- let schema;
264
- try {
265
- schema = await loadSchema(
266
- strapi,
267
- apiKey,
268
- resolveObjects(strapi.plugin("hubspot").config("objects", []))
269
- );
270
- } catch {
271
- strapi.log.warn("[hubspot] schéma indisponible — validation ignorée");
272
- return next();
273
- }
274
- const problems = mappings.map((m) => checkProperty(schema.properties, m.object, m.property)).filter((reason) => Boolean(reason));
275
- if (problems.length) {
276
- throw new errors.ValidationError(
277
- `Mapping HubSpot invalide — ${[...new Set(problems)].join(" ; ")}`
278
- );
279
- }
280
- return next();
281
- });
657
+ strapi.documents.use(makeValidationMiddleware(strapi, target));
282
658
  }
283
659
  }
284
660
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import type { Core } from "@strapi/strapi";
2
+ import { type Problem, type Schema } from "./properties";
3
+ import { type ValidateTarget } from "./validation";
4
+ /**
5
+ * Full-content audit of HubSpot mappings.
6
+ *
7
+ * Save-time validation only protects entries as they are written: a property
8
+ * deleted in HubSpot afterwards leaves invalid mappings dormant in content
9
+ * that nobody re-saves. The audit walks every entry of every validated content
10
+ * type and reports the mappings the portal would reject today.
11
+ *
12
+ * Problems are returned as structured codes — the admin UI renders and
13
+ * translates them, symmetrical with save-time validation's `details`.
14
+ */
15
+ interface AttributeSchema {
16
+ type: string;
17
+ component?: string;
18
+ components?: string[];
19
+ }
20
+ interface EntitySchema {
21
+ attributes: Record<string, AttributeSchema>;
22
+ options?: {
23
+ draftAndPublish?: boolean;
24
+ };
25
+ pluginOptions?: {
26
+ i18n?: {
27
+ localized?: boolean;
28
+ };
29
+ };
30
+ }
31
+ /**
32
+ * A populate object reaching every component and dynamic zone of a schema, at
33
+ * any depth. Mappings live inside form components nested in dynamic zones —
34
+ * without deep populate they are simply absent from the entry and the audit
35
+ * would happily report a clean bill.
36
+ *
37
+ * Relations and media are deliberately not followed: mappings belong to the
38
+ * entry itself, and following relations would drag in unrelated documents.
39
+ */
40
+ export declare function buildPopulate(components: Record<string, EntitySchema | undefined>, schema: EntitySchema, depth?: number): Record<string, unknown>;
41
+ /** Something to show for an entry — better than a bare documentId when possible. */
42
+ export declare function entryLabel(entry: Record<string, unknown>): string;
43
+ export interface AuditEntry {
44
+ documentId: string;
45
+ locale?: string;
46
+ label: string;
47
+ problems: Problem[];
48
+ }
49
+ export interface AuditTargetReport {
50
+ uid: string;
51
+ /** Entries scanned, across locales. */
52
+ entries: number;
53
+ /** Mappings found in them. */
54
+ mappings: number;
55
+ invalid: AuditEntry[];
56
+ /** Set when the content type itself couldn't be scanned. */
57
+ error?: string;
58
+ }
59
+ export declare function runAudit(strapi: Core.Strapi, targets: ValidateTarget[], schema: Schema): Promise<AuditTargetReport[]>;
60
+ export {};