strapi-plugin-hubspot 0.3.0 → 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.
- package/README.md +121 -2
- package/dist/_chunks/HubspotObjectInput-DAq_W1rA.js +91 -0
- package/dist/_chunks/HubspotObjectInput-G-A_pL2j.mjs +73 -0
- package/dist/_chunks/HubspotPropertyInput-B-Jg0x68.js +186 -0
- package/dist/_chunks/HubspotPropertyInput-Cv77QiS6.mjs +168 -0
- package/dist/_chunks/Settings-58edRL5D.mjs +1293 -0
- package/dist/_chunks/Settings-CryiUgSe.js +1311 -0
- package/dist/_chunks/en-CnQbjHs-.js +81 -0
- package/dist/_chunks/en-DHQZuRsj.mjs +81 -0
- package/dist/_chunks/fr-Bv8zsNX5.js +81 -0
- package/dist/_chunks/fr-Dxgil7RP.mjs +81 -0
- package/dist/_chunks/index-BudHTwSw.mjs +137 -0
- package/dist/_chunks/index-Tlw1p93d.js +136 -0
- package/dist/_chunks/objectLabels-CJTKZ4vT.mjs +425 -0
- package/dist/_chunks/objectLabels-GRmYGCIn.js +442 -0
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/src/components/AuditSection.d.ts +5 -0
- package/dist/admin/src/components/FailuresSection.d.ts +11 -0
- package/dist/admin/src/components/HubspotObjectInput.d.ts +28 -0
- package/dist/admin/src/components/HubspotPropertyInput.d.ts +1 -0
- package/dist/admin/src/getTranslation.d.ts +3 -0
- package/dist/admin/src/index.d.ts +6 -0
- package/dist/admin/src/objectLabels.d.ts +2 -1
- package/dist/server/index.js +417 -69
- package/dist/server/index.mjs +417 -69
- package/dist/server/src/__tests__/audit.test.d.ts +1 -0
- package/dist/server/src/__tests__/checkMapping.test.d.ts +1 -0
- package/dist/server/src/__tests__/loadSchema.test.d.ts +1 -0
- package/dist/server/src/__tests__/submit.test.d.ts +1 -0
- package/dist/server/src/__tests__/validation.test.d.ts +1 -0
- package/dist/server/src/audit.d.ts +60 -0
- package/dist/server/src/content-types.d.ts +56 -0
- package/dist/server/src/index.d.ts +104 -28
- package/dist/server/src/properties.d.ts +7 -2
- package/dist/server/src/submit.d.ts +35 -0
- package/dist/server/src/validation.d.ts +37 -0
- package/package.json +5 -2
- package/dist/_chunks/HubspotPropertyInput-C3KbDHYP.mjs +0 -106
- package/dist/_chunks/HubspotPropertyInput-C9PpYYgj.js +0 -124
- package/dist/_chunks/Settings-DGJ_mmlw.mjs +0 -140
- package/dist/_chunks/Settings-Lj49S5fO.js +0 -158
- package/dist/_chunks/index-Bq1tS5h6.js +0 -71
- package/dist/_chunks/index-lx7um1Yx.mjs +0 -72
package/dist/server/index.mjs
CHANGED
|
@@ -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
|
|
29
|
-
apiKey,
|
|
30
|
-
|
|
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) {
|
|
@@ -110,7 +121,8 @@ function checkMapping(properties, mapping) {
|
|
|
110
121
|
}
|
|
111
122
|
if (trimmed !== property) return { code: "whitespace", property, object };
|
|
112
123
|
if (match.type === "enumeration" && match.options.length && mapping.values?.length) {
|
|
113
|
-
const
|
|
124
|
+
const allowed = match.options.map((o) => o.value);
|
|
125
|
+
const stray = mapping.values.filter((v) => v && !allowed.includes(v));
|
|
114
126
|
if (stray.length) {
|
|
115
127
|
return { code: "bad-option", property: trimmed, object, values: stray };
|
|
116
128
|
}
|
|
@@ -171,6 +183,337 @@ async function publicSettings(strapi) {
|
|
|
171
183
|
hint: apiKey ? `…${apiKey.slice(-4)}` : ""
|
|
172
184
|
};
|
|
173
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";
|
|
174
517
|
const config = {
|
|
175
518
|
default: {
|
|
176
519
|
apiKey: "",
|
|
@@ -207,6 +550,37 @@ const controllers = {
|
|
|
207
550
|
}
|
|
208
551
|
}
|
|
209
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.");
|
|
572
|
+
}
|
|
573
|
+
}
|
|
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
|
+
}),
|
|
210
584
|
settings: ({ strapi }) => ({
|
|
211
585
|
async get(ctx) {
|
|
212
586
|
ctx.body = await publicSettings(strapi);
|
|
@@ -222,91 +596,65 @@ const controllers = {
|
|
|
222
596
|
}
|
|
223
597
|
})
|
|
224
598
|
};
|
|
225
|
-
const adminRoute = (method, path, handler) => ({
|
|
599
|
+
const adminRoute = (method, path, handler, actions) => ({
|
|
226
600
|
method,
|
|
227
601
|
path,
|
|
228
602
|
handler,
|
|
229
|
-
config: {
|
|
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
|
+
}
|
|
230
611
|
});
|
|
231
612
|
const routes = {
|
|
232
613
|
admin: {
|
|
233
614
|
type: "admin",
|
|
234
615
|
routes: [
|
|
235
616
|
adminRoute("GET", "/properties", "properties.list"),
|
|
236
|
-
adminRoute("GET", "/
|
|
237
|
-
adminRoute("
|
|
238
|
-
adminRoute("
|
|
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])
|
|
239
623
|
]
|
|
240
624
|
}
|
|
241
625
|
};
|
|
242
|
-
function collectMappings(node, target, found = []) {
|
|
243
|
-
if (Array.isArray(node)) {
|
|
244
|
-
for (const item of node) collectMappings(item, target, found);
|
|
245
|
-
return found;
|
|
246
|
-
}
|
|
247
|
-
if (!node || typeof node !== "object") return found;
|
|
248
|
-
const obj = node;
|
|
249
|
-
const property = obj[target.propertyField];
|
|
250
|
-
if (typeof property === "string" && property.trim()) {
|
|
251
|
-
const object = obj[target.objectField];
|
|
252
|
-
const rawOptions = obj[target.optionsField || "options"];
|
|
253
|
-
const values = Array.isArray(rawOptions) ? rawOptions.map((o) => {
|
|
254
|
-
const opt = o ?? {};
|
|
255
|
-
const v = typeof opt.value === "string" && opt.value.trim() ? opt.value : opt.label;
|
|
256
|
-
return typeof v === "string" ? v.trim() : "";
|
|
257
|
-
}).filter(Boolean) : void 0;
|
|
258
|
-
found.push({
|
|
259
|
-
object: typeof object === "string" && object ? object : "contact",
|
|
260
|
-
property,
|
|
261
|
-
values
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
for (const value of Object.values(obj)) collectMappings(value, target, found);
|
|
265
|
-
return found;
|
|
266
|
-
}
|
|
267
626
|
const index = {
|
|
268
627
|
config,
|
|
628
|
+
contentTypes,
|
|
269
629
|
controllers,
|
|
270
630
|
routes,
|
|
631
|
+
services: {
|
|
632
|
+
submit: ({ strapi }) => createSubmitService(strapi)
|
|
633
|
+
},
|
|
271
634
|
register({ strapi }) {
|
|
272
635
|
strapi.customFields.register({
|
|
273
636
|
name: "property",
|
|
274
637
|
plugin: "hubspot",
|
|
275
638
|
type: "string"
|
|
276
639
|
});
|
|
640
|
+
strapi.customFields.register({
|
|
641
|
+
name: "object",
|
|
642
|
+
plugin: "hubspot",
|
|
643
|
+
type: "string"
|
|
644
|
+
});
|
|
277
645
|
},
|
|
278
|
-
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
|
+
]);
|
|
279
655
|
const targets = strapi.plugin("hubspot").config("validate", []);
|
|
280
|
-
if (!targets.length) return;
|
|
281
656
|
for (const target of targets) {
|
|
282
|
-
strapi.documents.use(
|
|
283
|
-
const isWrite = ["create", "update"].includes(context.action);
|
|
284
|
-
if (!isWrite || context.uid !== target.uid) return next();
|
|
285
|
-
const mappings = collectMappings(context.params?.data, target);
|
|
286
|
-
if (!mappings.length) return next();
|
|
287
|
-
const { apiKey } = await resolveApiKey(strapi);
|
|
288
|
-
if (!apiKey) return next();
|
|
289
|
-
let schema;
|
|
290
|
-
try {
|
|
291
|
-
schema = await loadSchema(
|
|
292
|
-
strapi,
|
|
293
|
-
apiKey,
|
|
294
|
-
resolveObjects(strapi.plugin("hubspot").config("objects", []))
|
|
295
|
-
);
|
|
296
|
-
} catch {
|
|
297
|
-
strapi.log.warn("[hubspot] schema unavailable — validation skipped");
|
|
298
|
-
return next();
|
|
299
|
-
}
|
|
300
|
-
const problems = mappings.map((m) => checkMapping(schema.properties, m)).filter((p) => Boolean(p));
|
|
301
|
-
if (problems.length) {
|
|
302
|
-
const sentences = [...new Set(problems.map(describeProblem))];
|
|
303
|
-
throw new errors.ValidationError(
|
|
304
|
-
`Invalid HubSpot mapping — ${sentences.join("; ")}`,
|
|
305
|
-
{ problems }
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
return next();
|
|
309
|
-
});
|
|
657
|
+
strapi.documents.use(makeValidationMiddleware(strapi, target));
|
|
310
658
|
}
|
|
311
659
|
}
|
|
312
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 {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dead-letter queue for submissions HubSpot couldn't take: transient
|
|
3
|
+
* failures land here after the in-process retries are exhausted, and
|
|
4
|
+
* `retryFailures()` replays them.
|
|
5
|
+
*
|
|
6
|
+
* Visible in the Content Manager on purpose — an admin can inspect a stuck
|
|
7
|
+
* payload and delete it — but hidden from the Content-Type Builder: its shape
|
|
8
|
+
* belongs to the plugin.
|
|
9
|
+
*/
|
|
10
|
+
declare const _default: {
|
|
11
|
+
failure: {
|
|
12
|
+
schema: {
|
|
13
|
+
kind: string;
|
|
14
|
+
collectionName: string;
|
|
15
|
+
info: {
|
|
16
|
+
singularName: string;
|
|
17
|
+
pluralName: string;
|
|
18
|
+
displayName: string;
|
|
19
|
+
description: string;
|
|
20
|
+
};
|
|
21
|
+
options: {
|
|
22
|
+
draftAndPublish: boolean;
|
|
23
|
+
};
|
|
24
|
+
pluginOptions: {
|
|
25
|
+
"content-manager": {
|
|
26
|
+
visible: boolean;
|
|
27
|
+
};
|
|
28
|
+
"content-type-builder": {
|
|
29
|
+
visible: boolean;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
attributes: {
|
|
33
|
+
object: {
|
|
34
|
+
type: string;
|
|
35
|
+
required: boolean;
|
|
36
|
+
};
|
|
37
|
+
idProperty: {
|
|
38
|
+
type: string;
|
|
39
|
+
required: boolean;
|
|
40
|
+
};
|
|
41
|
+
properties: {
|
|
42
|
+
type: string;
|
|
43
|
+
required: boolean;
|
|
44
|
+
};
|
|
45
|
+
error: {
|
|
46
|
+
type: string;
|
|
47
|
+
};
|
|
48
|
+
attempts: {
|
|
49
|
+
type: string;
|
|
50
|
+
default: number;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
export default _default;
|