n8n-nodes-mautic-advanced 1.3.2 → 1.3.9
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 +5 -0
- package/dist/nodes/MauticAdvanced/ContactDescription.js +1 -1
- package/dist/nodes/MauticAdvanced/GenericFunctions.js +29 -9
- package/dist/nodes/MauticAdvanced/ai-tools/runtime.js +242 -17
- package/dist/nodes/MauticAdvanced/operations/CompanyOperations.js +89 -30
- package/dist/nodes/MauticAdvanced/operations/ContactOperations.js +44 -38
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -193,6 +193,11 @@ npm install n8n-nodes-mautic-advanced
|
|
|
193
193
|
3. Enter your Mautic URL
|
|
194
194
|
4. Follow the OAuth2 authorization flow
|
|
195
195
|
|
|
196
|
+
> **Auth method and Company Owner (v7 enrichment):** The Mautic version (v6 vs v7) is auto-detected by probing the v2 API (API Platform). Company **owner** is a v7-only field, populated by enriching Company *Get* / *Get Many* via the v2 API. The v2 API requires an auth method the server accepts:
|
|
197
|
+
>
|
|
198
|
+
> - **Basic auth** — confirmed working with the v2 API; owner enrichment populates `owner: { id }`.
|
|
199
|
+
> - **OAuth2** — depends on the Mautic server allowing v2 access for the bearer token. Many Mautic installs reject v1-style OAuth2 tokens at the v2 API Platform firewall (HTTP 401), in which case the node falls back to the v1 API for all company operations and **owner cannot be enriched** (`owner: null`). When this happens, a warning is logged explaining the cause. Custom fields and all other company data are unaffected. To populate owner, switch the credential to Basic auth, or enable v2 API access for OAuth2 on the Mautic server.
|
|
200
|
+
|
|
196
201
|
## Advanced Features
|
|
197
202
|
|
|
198
203
|
### Where Filters
|
|
@@ -1561,7 +1561,7 @@ exports.contactFields = [
|
|
|
1561
1561
|
},
|
|
1562
1562
|
},
|
|
1563
1563
|
default: [],
|
|
1564
|
-
description: 'Filter contacts by owner (assigned user).
|
|
1564
|
+
description: 'Filter contacts by owner (assigned user). Matched on the returned owner ID. Note: Mautic has no server-side owner-by-ID search, so this is applied client-side over the result set — combine with other filters (e.g. segment/tag/DNC) to narrow the set first.',
|
|
1565
1565
|
},
|
|
1566
1566
|
{
|
|
1567
1567
|
displayName: 'Published Only',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.validateJSON = exports.serialiseMauticWhere = exports.mauticApiRequestAllItems = exports.DEFAULT_MAUTIC_PAGE_SIZE = exports.mauticApiRequest = exports.getMauticVersion = void 0;
|
|
3
|
+
exports.validateJSON = exports.serialiseMauticWhere = exports.mauticApiRequestAllItems = exports.DEFAULT_MAUTIC_PAGE_SIZE = exports.mauticApiRequest = exports.getMauticV2Status = exports.getMauticVersion = void 0;
|
|
4
4
|
const n8n_workflow_1 = require("n8n-workflow");
|
|
5
5
|
const authenticatedRequest_1 = require("./utils/authenticatedRequest");
|
|
6
6
|
const versionCache = new Map();
|
|
@@ -22,7 +22,7 @@ function normalizeInstanceUrl(raw) {
|
|
|
22
22
|
return trimmed.replace(/\/+$/, '');
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
-
async function
|
|
25
|
+
async function detectMauticV2(context) {
|
|
26
26
|
const authMethod = context.getNodeParameter('authentication', 0, 'credentials');
|
|
27
27
|
const credentialType = authMethod === 'credentials' ? 'mauticAdvancedApi' : 'mauticAdvancedOAuth2Api';
|
|
28
28
|
const credentials = await context.getCredentials(credentialType);
|
|
@@ -30,23 +30,43 @@ async function getMauticVersion(context) {
|
|
|
30
30
|
const cacheKey = `${credentialType}:${baseUrl}`;
|
|
31
31
|
const cached = versionCache.get(cacheKey);
|
|
32
32
|
if (cached && Date.now() < cached.expiresAt) {
|
|
33
|
-
return cached.version;
|
|
33
|
+
return { version: cached.version, v2Status: cached.v2Status };
|
|
34
34
|
}
|
|
35
|
-
let
|
|
35
|
+
let v2Status;
|
|
36
36
|
try {
|
|
37
37
|
await mauticApiRequest.call(context, 'GET', '/v2/companies', {}, { page: 1 }, undefined, {
|
|
38
38
|
Accept: 'application/json',
|
|
39
39
|
});
|
|
40
|
-
|
|
40
|
+
v2Status = 'usable';
|
|
41
41
|
}
|
|
42
42
|
catch (error) {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const httpCode = String(error?.httpCode ?? '');
|
|
44
|
+
// 401/403 = v2 route exists but the credential is rejected/forbidden there. Mautic's v2 API
|
|
45
|
+
// Platform firewall commonly rejects v1-style OAuth2 bearer tokens (Basic auth works). The
|
|
46
|
+
// route exists, but this credential cannot use it — fall back to v1 for routing.
|
|
47
|
+
// 404 / parse errors / network = v2 route absent → genuine v6 instance.
|
|
48
|
+
v2Status = httpCode === '401' || httpCode === '403' ? 'unauthorized' : 'absent';
|
|
45
49
|
}
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
// Routing version: only treat as v7 when v2 is actually usable, so that operations route to the
|
|
51
|
+
// v1 endpoints (which work under this credential) whenever v2 is unreachable. Owner enrichment,
|
|
52
|
+
// a v7-only feature, is gated separately on `v2Status === 'usable'`.
|
|
53
|
+
const version = v2Status === 'usable' ? 'v7' : 'v6';
|
|
54
|
+
versionCache.set(cacheKey, { version, v2Status, expiresAt: Date.now() + VERSION_CACHE_TTL_MS });
|
|
55
|
+
return { version, v2Status };
|
|
56
|
+
}
|
|
57
|
+
async function getMauticVersion(context) {
|
|
58
|
+
return (await detectMauticV2(context)).version;
|
|
48
59
|
}
|
|
49
60
|
exports.getMauticVersion = getMauticVersion;
|
|
61
|
+
/**
|
|
62
|
+
* Returns whether the Mautic v2 (API Platform) endpoints are usable with the active credential.
|
|
63
|
+
* Callers use this to decide whether v7-only enrichment is possible and to warn actionably when a
|
|
64
|
+
* v2 route exists but the credential is rejected there (`unauthorized`).
|
|
65
|
+
*/
|
|
66
|
+
async function getMauticV2Status(context) {
|
|
67
|
+
return (await detectMauticV2(context)).v2Status;
|
|
68
|
+
}
|
|
69
|
+
exports.getMauticV2Status = getMauticV2Status;
|
|
50
70
|
async function mauticApiRequest(method, endpoint, body = {}, query, uri, headers) {
|
|
51
71
|
const authenticationMethod = this.getNodeParameter('authentication', 0, 'credentials');
|
|
52
72
|
const options = {
|
|
@@ -3,6 +3,32 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.runtimeZod = exports.RuntimeDynamicStructuredTool = void 0;
|
|
4
4
|
// nodes/MauticAdvanced/ai-tools/runtime.ts
|
|
5
5
|
const module_1 = require("module");
|
|
6
|
+
/**
|
|
7
|
+
* Anchor candidates — packages in n8n's dependency tree that can serve as a
|
|
8
|
+
* createRequire() anchor to resolve @langchain/core/tools and zod from n8n's
|
|
9
|
+
* own module tree (not this package's bundled devDependency copies).
|
|
10
|
+
*
|
|
11
|
+
* IMPORTANT: resolution must NEVER run at module-import time. n8n's
|
|
12
|
+
* node-directory-loader requires every node file in this package (including
|
|
13
|
+
* this one) purely to register node metadata, long before any workflow
|
|
14
|
+
* executes — and it aborts loading the ENTIRE package if any one file throws
|
|
15
|
+
* while being required. That used to be exactly what happened here: under
|
|
16
|
+
* pnpm-strict-isolated n8n installs (v2.29.x+) this package is installed
|
|
17
|
+
* outside n8n's own node_modules tree (e.g. ~/.n8n/nodes/), so neither
|
|
18
|
+
* anchor below resolves at all — @langchain/classic/@langchain/core are only
|
|
19
|
+
* reachable from inside @n8n/n8n-nodes-langchain's own isolated pnpm
|
|
20
|
+
* subtree, which no filesystem-based require.resolve() from here can walk
|
|
21
|
+
* into. The resulting throw took down the plain, non-AI `mauticAdvanced`
|
|
22
|
+
* node and its trigger too, not just this AI-tools node.
|
|
23
|
+
*
|
|
24
|
+
* Resolution is therefore deferred to first actual use, inside the Proxy
|
|
25
|
+
* traps below (which only fire when a connected AI tool is invoked at
|
|
26
|
+
* workflow-execution time via supplyData()). By then, n8n's own Agent/MCP
|
|
27
|
+
* Trigger machinery has already loaded @langchain/core/tools itself (to run
|
|
28
|
+
* its own tool-calling logic), so if the anchors still don't resolve, a
|
|
29
|
+
* require.cache scan finds the exact module instance n8n itself loaded —
|
|
30
|
+
* which works regardless of install layout.
|
|
31
|
+
*/
|
|
6
32
|
const ANCHOR_CANDIDATES = [
|
|
7
33
|
// primary: @langchain/classic is a direct dep of @n8n/nodes-langchain, stable since n8n 2.4.x.
|
|
8
34
|
// Its @langchain/core peerDep resolves to n8n's hoisted @langchain/core.
|
|
@@ -10,25 +36,224 @@ const ANCHOR_CANDIDATES = [
|
|
|
10
36
|
// secondary: langchain package is in the n8n catalog and also has @langchain/core as peerDep.
|
|
11
37
|
'langchain/agents',
|
|
12
38
|
];
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
39
|
+
// Memoized ONLY on success — a failed attempt must not permanently disable
|
|
40
|
+
// retries (e.g. the require.cache scan fallback below may only succeed on a
|
|
41
|
+
// later call, once n8n has finished loading its own langchain-dependent
|
|
42
|
+
// nodes).
|
|
43
|
+
let _runtimeReq;
|
|
44
|
+
let _anchorDiagnostic = null;
|
|
45
|
+
function getRuntimeRequire() {
|
|
46
|
+
if (_runtimeReq)
|
|
47
|
+
return _runtimeReq;
|
|
48
|
+
const tried = [];
|
|
49
|
+
for (const candidate of ANCHOR_CANDIDATES) {
|
|
50
|
+
try {
|
|
51
|
+
const resolved = require.resolve(candidate);
|
|
52
|
+
_runtimeReq = (0, module_1.createRequire)(resolved);
|
|
53
|
+
_anchorDiagnostic = `resolved via anchor: ${candidate}`;
|
|
54
|
+
return _runtimeReq;
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
tried.push(`${candidate}: ${e.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
_anchorDiagnostic = `Could not resolve LangChain anchor. Tried:\n${tried.join('\n')}`;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Scans Node's process-global module cache (shared across every node_modules
|
|
65
|
+
* tree in this process, regardless of who loaded what) for an already-loaded
|
|
66
|
+
* module whose resolved path matches `pathPattern`, returning the first
|
|
67
|
+
* match whose exports satisfy `validate`. Must be called lazily (not at
|
|
68
|
+
* module load) — n8n requires node files for registration before any
|
|
69
|
+
* workflow runs, i.e. before langchain/zod are necessarily loaded into cache.
|
|
70
|
+
*/
|
|
71
|
+
function findCachedExports(pathPattern, validate, excludeKey) {
|
|
19
72
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
73
|
+
// require.cache is the public, documented CommonJS alias that points at the exact
|
|
74
|
+
// same underlying object as the internal Module._cache. It is available directly in
|
|
75
|
+
// CJS module scope (this file compiles to CJS via tsc), so no require('module') needed.
|
|
76
|
+
const cache = require.cache;
|
|
77
|
+
if (!cache)
|
|
78
|
+
return undefined;
|
|
79
|
+
for (const key of Object.keys(cache)) {
|
|
80
|
+
if (!pathPattern.test(key))
|
|
81
|
+
continue;
|
|
82
|
+
if (excludeKey && excludeKey(key))
|
|
83
|
+
continue;
|
|
84
|
+
const entry = cache[key];
|
|
85
|
+
if (!entry)
|
|
86
|
+
continue;
|
|
87
|
+
const result = validate(entry.exports);
|
|
88
|
+
if (result !== undefined)
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (_e) {
|
|
93
|
+
// best-effort — require.cache introspection is not guaranteed across Node versions
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
let _RuntimeDynamicStructuredTool;
|
|
98
|
+
let _runtimeZod;
|
|
99
|
+
let _langchainLoadError = null;
|
|
100
|
+
let _zodLoadError = null;
|
|
101
|
+
let _zodDiagnostic = null;
|
|
102
|
+
function resolveDynamicStructuredTool() {
|
|
103
|
+
if (_RuntimeDynamicStructuredTool)
|
|
104
|
+
return _RuntimeDynamicStructuredTool;
|
|
105
|
+
const runtimeReq = getRuntimeRequire();
|
|
106
|
+
if (runtimeReq) {
|
|
107
|
+
try {
|
|
108
|
+
const coreTools = runtimeReq('@langchain/core/tools');
|
|
109
|
+
if (typeof coreTools?.['DynamicStructuredTool'] === 'function') {
|
|
110
|
+
_RuntimeDynamicStructuredTool = coreTools['DynamicStructuredTool'];
|
|
111
|
+
return _RuntimeDynamicStructuredTool;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
_langchainLoadError = e.message;
|
|
116
|
+
}
|
|
23
117
|
}
|
|
24
|
-
|
|
25
|
-
|
|
118
|
+
// Fallback for pnpm-isolated installs where no filesystem anchor reaches
|
|
119
|
+
// @langchain/core at all: it is loaded by n8n's own Agent/MCP Trigger
|
|
120
|
+
// machinery before our supplyData() ever runs, so grab it straight out of
|
|
121
|
+
// require.cache once it is resident.
|
|
122
|
+
const cached = findCachedExports(/[\\/]@langchain[\\/]core[\\/]/, (exports) => typeof exports['DynamicStructuredTool'] === 'function'
|
|
123
|
+
? exports['DynamicStructuredTool']
|
|
124
|
+
: undefined);
|
|
125
|
+
if (cached) {
|
|
126
|
+
_RuntimeDynamicStructuredTool = cached;
|
|
127
|
+
_langchainLoadError = null;
|
|
26
128
|
}
|
|
129
|
+
return _RuntimeDynamicStructuredTool;
|
|
27
130
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
131
|
+
function resolveZod() {
|
|
132
|
+
if (_runtimeZod)
|
|
133
|
+
return _runtimeZod;
|
|
134
|
+
// Three-step resolution, in priority order: require.main → anchor → require.cache scan.
|
|
135
|
+
//
|
|
136
|
+
// Primary path: resolve zod starting from n8n's own main entry point's module tree
|
|
137
|
+
// (require.main). For a correctly-installed (non-isolated) setup this lands on n8n's
|
|
138
|
+
// top-level zod — the exact copy n8n's own normalizeToolSchema does `instanceof ZodType`
|
|
139
|
+
// against. Even under pnpm-strict isolation this is a DIFFERENT resolution path than this
|
|
140
|
+
// package's own local require('zod'), so it is far more likely to reach n8n's real copy
|
|
141
|
+
// (or fail cleanly) than to silently return this package's OWN bundled zod. Resolved
|
|
142
|
+
// lazily here (NOT at module top level) so node registration never triggers it — that
|
|
143
|
+
// laziness is the entire point of this fix (#2).
|
|
144
|
+
//
|
|
145
|
+
// Only anchor at require.main when it is actually defined. When it is undefined (ESM-
|
|
146
|
+
// launched n8n, worker_threads / queue-mode workers — exactly the deployment mode #2 was
|
|
147
|
+
// filed against), there is NO useful main to anchor at: falling back to `__filename` would
|
|
148
|
+
// anchor at THIS file (mautic's own runtime.js), whose `require.resolve('zod')` lands on
|
|
149
|
+
// this package's OWN bundled zod — the copy that fails n8n's `instanceof ZodType` check.
|
|
150
|
+
// So resolve-then-check the path and skip it if it points into this package's own tree,
|
|
151
|
+
// reusing the same exclusion substring as the cache-scan path below.
|
|
152
|
+
if (require.main?.filename) {
|
|
153
|
+
try {
|
|
154
|
+
const mainReq = (0, module_1.createRequire)(require.main.filename);
|
|
155
|
+
const resolvedPath = mainReq.resolve('zod');
|
|
156
|
+
if (!resolvedPath.includes('n8n-nodes-mautic-advanced')) {
|
|
157
|
+
_runtimeZod = mainReq('zod');
|
|
158
|
+
if (_runtimeZod) {
|
|
159
|
+
_zodDiagnostic = 'resolved zod via require.main';
|
|
160
|
+
_zodLoadError = null;
|
|
161
|
+
return _runtimeZod;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
_zodLoadError = e.message;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Secondary path: the existing @langchain/classic / langchain anchor. Same resolve-then-
|
|
170
|
+
// check exclusion for defense-in-depth consistency — very unlikely to ever resolve into
|
|
171
|
+
// this package's own tree, but structurally guarded identically to the other two paths.
|
|
172
|
+
const runtimeReq = getRuntimeRequire();
|
|
173
|
+
if (runtimeReq) {
|
|
174
|
+
try {
|
|
175
|
+
const resolvedPath = runtimeReq.resolve('zod');
|
|
176
|
+
if (!resolvedPath.includes('n8n-nodes-mautic-advanced')) {
|
|
177
|
+
_runtimeZod = runtimeReq('zod');
|
|
178
|
+
if (_runtimeZod) {
|
|
179
|
+
_zodDiagnostic = 'resolved zod via anchor';
|
|
180
|
+
_zodLoadError = null;
|
|
181
|
+
return _runtimeZod;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
_zodLoadError = e.message;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Tertiary path: scan require.cache for an already-resident zod once neither
|
|
190
|
+
// filesystem resolution reaches it (pnpm-strict-isolated installs). The path regex is
|
|
191
|
+
// narrowed to zod's own entry-point directories (lib/dist/index/v3/v4) so it never
|
|
192
|
+
// matches zod-adjacent package names (e.g. zod-to-json-schema). Validate the exports
|
|
193
|
+
// look like zod via `ZodType` (the class n8n's normalizeToolSchema does `instanceof`
|
|
194
|
+
// against — the meaningful correctness signal) plus the `object` factory our
|
|
195
|
+
// schema-generator calls.
|
|
196
|
+
//
|
|
197
|
+
// Exclusion guard: this package declares zod as a REAL dependency and imports it at
|
|
198
|
+
// node-registration time (schema-generator.ts), so this package's OWN bundled zod is
|
|
199
|
+
// ALWAYS resident in require.cache — potentially earlier in iteration order than n8n's.
|
|
200
|
+
// We specifically want a DIFFERENT copy (n8n's), because a self-bundled copy would fail
|
|
201
|
+
// n8n's `instanceof ZodType` class-identity check. So skip any cache key belonging to
|
|
202
|
+
// this package's own bundled zod. (The pathPattern has already matched the zod segment,
|
|
203
|
+
// so a package-name match on the key is sufficient and robust to both nested
|
|
204
|
+
// `.../n8n-nodes-mautic-advanced/node_modules/zod/...` and flatter install layouts.)
|
|
205
|
+
const cached = findCachedExports(/[\\/]zod[\\/](lib|dist|index|v3|v4)/, (exports) => typeof exports['ZodType'] === 'function' && typeof exports['object'] === 'function'
|
|
206
|
+
? exports
|
|
207
|
+
: undefined, (key) => key.includes('n8n-nodes-mautic-advanced'));
|
|
208
|
+
if (cached) {
|
|
209
|
+
_runtimeZod = cached;
|
|
210
|
+
_zodLoadError = null;
|
|
211
|
+
_zodDiagnostic = 'resolved zod via require.cache scan (pnpm-isolated install)';
|
|
212
|
+
}
|
|
213
|
+
return _runtimeZod;
|
|
31
214
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
215
|
+
// IMPORTANT: Proxy target MUST be `function () {}`, not `{}`.
|
|
216
|
+
// ECMAScript spec §10.5.13: a Proxy only has [[Construct]] if its target does.
|
|
217
|
+
// Plain objects lack [[Construct]], so `new Proxy({}, ...)` throws
|
|
218
|
+
// "is not a constructor" before the construct trap ever fires.
|
|
219
|
+
exports.RuntimeDynamicStructuredTool = new Proxy(function () { }, {
|
|
220
|
+
construct(_target, args) {
|
|
221
|
+
const ctor = resolveDynamicStructuredTool();
|
|
222
|
+
if (!ctor) {
|
|
223
|
+
throw new Error(`[MauticAdvancedAiTools] Could not resolve LangChain's DynamicStructuredTool. ` +
|
|
224
|
+
`Ensure @n8n/nodes-langchain is installed in n8n's node_modules.` +
|
|
225
|
+
(_anchorDiagnostic ? ` Diagnostic: ${_anchorDiagnostic}` : '') +
|
|
226
|
+
(_langchainLoadError ? ` Load error: ${_langchainLoadError}` : ''));
|
|
227
|
+
}
|
|
228
|
+
return new ctor(...args);
|
|
229
|
+
},
|
|
230
|
+
get(_target, prop) {
|
|
231
|
+
const ctor = resolveDynamicStructuredTool();
|
|
232
|
+
if (ctor) {
|
|
233
|
+
return ctor[prop];
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
exports.runtimeZod = new Proxy({}, {
|
|
239
|
+
get(_target, prop) {
|
|
240
|
+
// Guard: frameworks probe Symbol.toPrimitive, Symbol.toStringTag, .then
|
|
241
|
+
// (Promise thenable), and .constructor. Throwing on these causes
|
|
242
|
+
// misleading errors during structural inspection.
|
|
243
|
+
if (typeof prop === 'symbol' || prop === 'then' || prop === 'constructor')
|
|
244
|
+
return undefined;
|
|
245
|
+
const z = resolveZod();
|
|
246
|
+
if (!z) {
|
|
247
|
+
throw new Error(`[MauticAdvancedAiTools] Could not resolve zod (accessing .${String(prop)}) ` +
|
|
248
|
+
`via require.main, LangChain anchor, or require.cache scan. ` +
|
|
249
|
+
`Ensure @n8n/nodes-langchain is installed in n8n's node_modules.` +
|
|
250
|
+
(_zodDiagnostic
|
|
251
|
+
? ` Diagnostic: ${_zodDiagnostic}`
|
|
252
|
+
: _anchorDiagnostic
|
|
253
|
+
? ` Diagnostic: ${_anchorDiagnostic}`
|
|
254
|
+
: '') +
|
|
255
|
+
(_zodLoadError ? ` Load error: ${_zodLoadError}` : ''));
|
|
256
|
+
}
|
|
257
|
+
return z[prop];
|
|
258
|
+
},
|
|
259
|
+
});
|
|
@@ -223,40 +223,86 @@ async function updateCompany(context, itemIndex) {
|
|
|
223
223
|
}
|
|
224
224
|
return result;
|
|
225
225
|
}
|
|
226
|
-
//
|
|
227
|
-
//
|
|
226
|
+
// Logged once per Get/Get Many call when the v2 API exists but rejected the credential, so the
|
|
227
|
+
// reason owner is null is visible in the n8n logs instead of failing silently.
|
|
228
|
+
const V2_UNAUTHORIZED_OWNER_WARNING = 'Mautic company owner enrichment skipped: the v2 API (API Platform) rejected this credential (401/403). ' +
|
|
229
|
+
'Owner is a v7-only field that requires an auth method the v2 API accepts — Basic auth is confirmed working; ' +
|
|
230
|
+
'OAuth2 depends on the Mautic server allowing v2 access for the token. Returning owner: null. ' +
|
|
231
|
+
'Switch the credential to Basic auth (or enable v2 access for OAuth2 on the Mautic server) to populate owner.';
|
|
232
|
+
// Force API Platform to return JSON-LD/Hydra so the owner is serialised with its `@id` IRI
|
|
233
|
+
// (/api/v2/users/{id}). The default `application/json` representation omits the IRI, leaving only
|
|
234
|
+
// FormEntity fields (isPublished/dateAdded/dateModified) with no way to resolve the owner's user ID.
|
|
235
|
+
const LD_JSON_HEADERS = { Accept: 'application/ld+json' };
|
|
236
|
+
// Extract the owner's user ID from a v7 (JSON-LD) company object.
|
|
237
|
+
// JSON-LD embeds the owner with `@id: "/api/v2/users/{id}"`; some shapes also expose a plain `id`.
|
|
228
238
|
function extractOwnerFromV7(v7Item) {
|
|
229
|
-
|
|
239
|
+
const owner = v7Item?.owner;
|
|
240
|
+
if (owner === null || owner === undefined)
|
|
230
241
|
return null;
|
|
231
|
-
|
|
242
|
+
// Owner may be embedded as an object (with @id), or serialised as a bare IRI string.
|
|
243
|
+
const iri = typeof owner === 'string' ? owner : owner['@id'];
|
|
232
244
|
if (iri) {
|
|
233
245
|
const match = /\/(\d+)$/.exec(iri);
|
|
234
246
|
if (match)
|
|
235
247
|
return { id: Number(match[1]) };
|
|
236
248
|
}
|
|
237
|
-
|
|
238
|
-
|
|
249
|
+
if (typeof owner === 'object' && owner.id !== undefined && owner.id !== null) {
|
|
250
|
+
return { id: Number(owner.id) };
|
|
251
|
+
}
|
|
252
|
+
// No IRI and no id (plain-JSON owner) — return the partial FormEntity data as-is
|
|
253
|
+
return owner;
|
|
254
|
+
}
|
|
255
|
+
// Requested page size. NOTE: stock Mautic disables client control of page size
|
|
256
|
+
// (pagination_client_items_per_page = false) and hard-caps at 30, so this is currently a no-op on
|
|
257
|
+
// default installs — the server returns 30/page regardless. Kept because it is harmless and engages
|
|
258
|
+
// automatically if an instance enables client page size; termination does not rely on it (we count
|
|
259
|
+
// actual items returned, see the loop below).
|
|
260
|
+
const V7_OWNER_PAGE_SIZE = 100;
|
|
261
|
+
// Backstop only — the loop normally exits when every needed owner is found or the collection ends.
|
|
262
|
+
const V7_OWNER_MAX_PAGES = 1000;
|
|
263
|
+
// Read the collection members from a v2 list response, tolerating both the legacy Hydra key
|
|
264
|
+
// (`hydra:member`) and the newer API Platform 4.x form (`member`), plus a bare JSON array.
|
|
265
|
+
function getV2CollectionItems(response) {
|
|
266
|
+
if (Array.isArray(response))
|
|
267
|
+
return response;
|
|
268
|
+
return response?.['hydra:member'] ?? response?.member ?? [];
|
|
269
|
+
}
|
|
270
|
+
function getV2TotalItems(response) {
|
|
271
|
+
const total = response?.['hydra:totalItems'] ?? response?.totalItems;
|
|
272
|
+
return typeof total === 'number' ? total : undefined;
|
|
239
273
|
}
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
274
|
+
// Build a map of companyId → owner for ONLY the given company IDs, by paging the v2 collection
|
|
275
|
+
// (JSON-LD, so the owner `@id` IRI is present) and stopping as soon as every needed owner is
|
|
276
|
+
// resolved. Mautic's v2 collection defaults to ORDER BY id ASC, so a limited Get Many (whose v1
|
|
277
|
+
// result is the lowest ids, also id ASC) resolves its owners in the first page(s) rather than
|
|
278
|
+
// scanning the whole instance. Returns an empty map when no IDs are needed.
|
|
279
|
+
async function buildV7OwnerMap(context, neededIds) {
|
|
243
280
|
const ownerMap = new Map();
|
|
281
|
+
if (neededIds.size === 0)
|
|
282
|
+
return ownerMap;
|
|
244
283
|
let page = 1;
|
|
245
|
-
|
|
246
|
-
while (page <=
|
|
247
|
-
const response = await (0, ApiHelpers_1.makeApiRequest)(context, 'GET', '/v2/companies', {},
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
284
|
+
let itemsSeen = 0;
|
|
285
|
+
while (page <= V7_OWNER_MAX_PAGES) {
|
|
286
|
+
const response = await (0, ApiHelpers_1.makeApiRequest)(context, 'GET', '/v2/companies', {},
|
|
287
|
+
// order[id]=asc is a no-op on stock Mautic (no OrderFilter on Company), but the v2 collection
|
|
288
|
+
// already defaults to ORDER BY id ASC, so this just makes the relied-on ordering explicit.
|
|
289
|
+
{ page, itemsPerPage: V7_OWNER_PAGE_SIZE, 'order[id]': 'asc' }, undefined, LD_JSON_HEADERS);
|
|
290
|
+
const items = getV2CollectionItems(response);
|
|
251
291
|
if (!items.length)
|
|
252
292
|
break;
|
|
293
|
+
itemsSeen += items.length;
|
|
253
294
|
for (const item of items) {
|
|
254
295
|
const id = Number(item.id);
|
|
255
|
-
if (id)
|
|
296
|
+
if (id && neededIds.has(id))
|
|
256
297
|
ownerMap.set(id, extractOwnerFromV7(item));
|
|
257
298
|
}
|
|
258
|
-
|
|
259
|
-
if (
|
|
299
|
+
// Stop once every needed owner is resolved.
|
|
300
|
+
if (ownerMap.size >= neededIds.size)
|
|
301
|
+
break;
|
|
302
|
+
// Stop at the end of the collection (covers needed IDs that no longer exist). Compare against
|
|
303
|
+
// the actual number of items seen, not an assumed page size — the server may cap itemsPerPage.
|
|
304
|
+
const total = getV2TotalItems(response);
|
|
305
|
+
if (total !== undefined && itemsSeen >= total)
|
|
260
306
|
break;
|
|
261
307
|
page++;
|
|
262
308
|
}
|
|
@@ -265,20 +311,26 @@ async function buildV7OwnerMap(context) {
|
|
|
265
311
|
async function getCompany(context, itemIndex) {
|
|
266
312
|
const companyId = (0, ApiHelpers_1.getRequiredParam)(context, 'companyId', itemIndex);
|
|
267
313
|
const simple = (0, ApiHelpers_1.getOptionalParam)(context, 'simple', itemIndex, false);
|
|
268
|
-
const
|
|
314
|
+
const v2Status = await (0, GenericFunctions_1.getMauticV2Status)(context);
|
|
269
315
|
// v1: custom fields via fields.all
|
|
270
316
|
const v1Response = await (0, ApiHelpers_1.makeApiRequest)(context, 'GET', `/companies/${companyId}`);
|
|
271
317
|
let result = v1Response.company;
|
|
272
|
-
if (
|
|
318
|
+
if (v2Status === 'usable') {
|
|
273
319
|
try {
|
|
274
|
-
//
|
|
275
|
-
const v7Company = await (0, ApiHelpers_1.makeApiRequest)(context, 'GET', `/v2/companies/${companyId}
|
|
320
|
+
// JSON-LD response includes the owner @id IRI for user-ID extraction
|
|
321
|
+
const v7Company = await (0, ApiHelpers_1.makeApiRequest)(context, 'GET', `/v2/companies/${companyId}`, {}, {}, undefined, LD_JSON_HEADERS);
|
|
276
322
|
result = { ...result, owner: extractOwnerFromV7(v7Company) };
|
|
277
323
|
}
|
|
278
|
-
catch {
|
|
279
|
-
//
|
|
324
|
+
catch (error) {
|
|
325
|
+
// Enrichment failed unexpectedly (v2 was usable at probe time). Surface it instead of
|
|
326
|
+
// silently degrading to owner: null, so the cause is visible in the logs.
|
|
327
|
+
context.logger.warn(`Mautic company owner enrichment failed for company ${companyId}: ${error?.message ?? error}. Returning owner: null.`);
|
|
280
328
|
}
|
|
281
329
|
}
|
|
330
|
+
else if (v2Status === 'unauthorized') {
|
|
331
|
+
// v2 route exists but the credential is rejected there — owner cannot be enriched. Warn once.
|
|
332
|
+
context.logger.warn(V2_UNAUTHORIZED_OWNER_WARNING);
|
|
333
|
+
}
|
|
282
334
|
if (simple)
|
|
283
335
|
result = toSimpleCompany(result);
|
|
284
336
|
return (0, DataHelpers_1.convertNumericStrings)(result);
|
|
@@ -286,7 +338,7 @@ async function getCompany(context, itemIndex) {
|
|
|
286
338
|
async function getAllCompanies(context, itemIndex) {
|
|
287
339
|
const returnAll = (0, ApiHelpers_1.getOptionalParam)(context, 'returnAll', itemIndex, false);
|
|
288
340
|
const simple = (0, ApiHelpers_1.getOptionalParam)(context, 'simple', itemIndex, false);
|
|
289
|
-
const
|
|
341
|
+
const v2Status = await (0, GenericFunctions_1.getMauticV2Status)(context);
|
|
290
342
|
const additionalFields = (0, ApiHelpers_1.getOptionalParam)(context, 'additionalFields', itemIndex, {});
|
|
291
343
|
const qs = (0, DataHelpers_1.buildQueryFromOptions)(additionalFields);
|
|
292
344
|
if (!qs.orderBy)
|
|
@@ -305,19 +357,26 @@ async function getAllCompanies(context, itemIndex) {
|
|
|
305
357
|
const response = await (0, ApiHelpers_1.makeApiRequest)(context, 'GET', '/companies', {}, qs);
|
|
306
358
|
responseData = (response.companies ? Object.values(response.companies) : []);
|
|
307
359
|
}
|
|
308
|
-
if (
|
|
360
|
+
if (v2Status === 'usable' && responseData.length > 0) {
|
|
309
361
|
try {
|
|
310
|
-
// v7 enrichment:
|
|
311
|
-
const
|
|
362
|
+
// v7 enrichment: resolve owners for ONLY the companies v1 returned, then overlay them.
|
|
363
|
+
const neededIds = new Set(responseData.map((company) => Number(company.id)).filter((id) => Number.isFinite(id)));
|
|
364
|
+
const ownerMap = await buildV7OwnerMap(context, neededIds);
|
|
312
365
|
responseData = responseData.map((company) => ({
|
|
313
366
|
...company,
|
|
314
367
|
owner: ownerMap.get(Number(company.id)) ?? null,
|
|
315
368
|
}));
|
|
316
369
|
}
|
|
317
|
-
catch {
|
|
318
|
-
//
|
|
370
|
+
catch (error) {
|
|
371
|
+
// Enrichment failed unexpectedly (v2 was usable at probe time). Surface it instead of
|
|
372
|
+
// silently degrading to owner: null, so the cause is visible in the logs.
|
|
373
|
+
context.logger.warn(`Mautic company owner enrichment failed for Get Many: ${error?.message ?? error}. Returning owner: null for all rows.`);
|
|
319
374
|
}
|
|
320
375
|
}
|
|
376
|
+
else if (v2Status === 'unauthorized') {
|
|
377
|
+
// v2 route exists but the credential is rejected there — owner cannot be enriched. Warn once.
|
|
378
|
+
context.logger.warn(V2_UNAUTHORIZED_OWNER_WARNING);
|
|
379
|
+
}
|
|
321
380
|
if (simple) {
|
|
322
381
|
responseData = responseData.map((item) => toSimpleCompany(item));
|
|
323
382
|
}
|
|
@@ -191,12 +191,34 @@ async function getAllContacts(context, itemIndex) {
|
|
|
191
191
|
if (filterExpr)
|
|
192
192
|
searchParts.push(filterExpr);
|
|
193
193
|
}
|
|
194
|
-
// Owner filter
|
|
194
|
+
// Owner filter — applied CLIENT-SIDE on the contact's owner.id.
|
|
195
|
+
// Mautic's `owner:` search command matches the owner's first/last NAME (LIKE), not the user id,
|
|
196
|
+
// so sending `owner:<id>` (what the Owner(s) dropdown provides) silently returns nothing. The v1
|
|
197
|
+
// contact body already includes `owner.id`, so we filter on that instead.
|
|
195
198
|
const owners = options.owners;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
199
|
+
const ownerIdFilter = Array.isArray(owners) && owners.length > 0
|
|
200
|
+
? new Set(owners.map((o) => Number(o)).filter((n) => Number.isFinite(n)))
|
|
201
|
+
: undefined;
|
|
202
|
+
// Do-Not-Contact filter — applied SERVER-SIDE via Mautic's `dnc:` search command, so only
|
|
203
|
+
// matching contacts are fetched (replaces the previous client-side page-and-discard scan).
|
|
204
|
+
// `dnc:<channel>` = IS on DNC for that channel; `dnc:any` = on any channel.
|
|
205
|
+
const emailDncOnly = options.emailDncOnly === true;
|
|
206
|
+
const smsDncOnly = options.smsDncOnly === true;
|
|
207
|
+
const anyDncOnly = options.anyDncOnly === true;
|
|
208
|
+
if (anyDncOnly) {
|
|
209
|
+
searchParts.push('dnc:any');
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
const dncTokens = [];
|
|
213
|
+
if (emailDncOnly)
|
|
214
|
+
dncTokens.push('dnc:email');
|
|
215
|
+
if (smsDncOnly)
|
|
216
|
+
dncTokens.push('dnc:sms');
|
|
217
|
+
// Multiple specific channels = union (on email OR sms DNC); one token if only one is set.
|
|
218
|
+
if (dncTokens.length === 1)
|
|
219
|
+
searchParts.push(dncTokens[0]);
|
|
220
|
+
else if (dncTokens.length > 1)
|
|
221
|
+
searchParts.push(dncTokens.join(' OR '));
|
|
200
222
|
}
|
|
201
223
|
// Stage filter (always OR for multiple stages - a contact can only be in one stage)
|
|
202
224
|
const stages = options.stages;
|
|
@@ -241,23 +263,19 @@ async function getAllContacts(context, itemIndex) {
|
|
|
241
263
|
}
|
|
242
264
|
}
|
|
243
265
|
let responseData;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const useDncPostFilter = emailDncOnly || smsDncOnly || anyDncOnly;
|
|
248
|
-
if (useDncPostFilter) {
|
|
266
|
+
if (ownerIdFilter) {
|
|
267
|
+
// Owner filter has no server-side equivalent, so page through the (already server-filtered)
|
|
268
|
+
// results and keep only those whose owner.id matches, until the limit is met or results run out.
|
|
249
269
|
const limit = returnAll ? undefined : (0, ApiHelpers_1.getOptionalParam)(context, 'limit', itemIndex, 30);
|
|
250
|
-
responseData = await
|
|
270
|
+
responseData = await getContactsWithClientFilter(context, qs, (contact) => ownerIdFilter.has(Number(contact?.owner?.id)), limit);
|
|
271
|
+
}
|
|
272
|
+
else if (returnAll) {
|
|
273
|
+
responseData = await (0, ApiHelpers_1.makePaginatedRequest)(context, 'contacts', 'GET', '/contacts', {}, qs);
|
|
251
274
|
}
|
|
252
275
|
else {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
else {
|
|
257
|
-
qs.limit = (0, ApiHelpers_1.getOptionalParam)(context, 'limit', itemIndex, 30);
|
|
258
|
-
const response = await (0, ApiHelpers_1.makeApiRequest)(context, 'GET', '/contacts', {}, qs);
|
|
259
|
-
responseData = response.contacts ? Object.values(response.contacts) : [];
|
|
260
|
-
}
|
|
276
|
+
qs.limit = (0, ApiHelpers_1.getOptionalParam)(context, 'limit', itemIndex, 30);
|
|
277
|
+
const response = await (0, ApiHelpers_1.makeApiRequest)(context, 'GET', '/contacts', {}, qs);
|
|
278
|
+
responseData = response.contacts ? Object.values(response.contacts) : [];
|
|
261
279
|
}
|
|
262
280
|
const processedData = (0, DataHelpers_1.processContactFields)(responseData, options, options.fieldsToReturn);
|
|
263
281
|
return (0, DataHelpers_1.convertNumericStrings)(processedData);
|
|
@@ -620,34 +638,22 @@ function addContactFields(body, fields) {
|
|
|
620
638
|
Object.assign(body, data);
|
|
621
639
|
}
|
|
622
640
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
641
|
+
// Page through /contacts (already narrowed by any server-side search in `qs`) and keep only the
|
|
642
|
+
// contacts that satisfy `predicate`, stopping once `limit` matches are collected or results run out.
|
|
643
|
+
// Used for filters that have no server-side equivalent (e.g. owner-by-id).
|
|
644
|
+
async function getContactsWithClientFilter(context, qs, predicate, limit) {
|
|
627
645
|
const requestedStart = Number(qs.start ?? 0);
|
|
628
646
|
const maxResults = typeof limit === 'number' && limit > 0 ? Math.floor(limit) : undefined;
|
|
629
647
|
const contacts = [];
|
|
630
648
|
let remaining = maxResults;
|
|
631
649
|
let currentStart = Number.isFinite(requestedStart) && requestedStart > 0 ? requestedStart : 0;
|
|
632
650
|
while (remaining === undefined || remaining > 0) {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
: Math.min(remaining, GenericFunctions_1.DEFAULT_MAUTIC_PAGE_SIZE);
|
|
651
|
+
// Always fetch full pages so the scan terminates promptly; `remaining` only caps what we keep.
|
|
652
|
+
const pageLimit = GenericFunctions_1.DEFAULT_MAUTIC_PAGE_SIZE;
|
|
636
653
|
const pageQs = { ...qs, start: currentStart, limit: pageLimit };
|
|
637
654
|
const pageResponse = await (0, ApiHelpers_1.makeApiRequest)(context, 'GET', '/contacts', {}, pageQs);
|
|
638
655
|
const pageContacts = pageResponse.contacts ? Object.values(pageResponse.contacts) : [];
|
|
639
|
-
const filtered = pageContacts.filter(
|
|
640
|
-
const dnc = contact.doNotContact || [];
|
|
641
|
-
const hasEmailDnc = dnc.some((d) => d.channel === 'email');
|
|
642
|
-
const hasSmsDnc = dnc.some((d) => d.channel === 'sms');
|
|
643
|
-
if (emailDncOnly)
|
|
644
|
-
return hasEmailDnc;
|
|
645
|
-
if (smsDncOnly)
|
|
646
|
-
return hasSmsDnc;
|
|
647
|
-
if (anyDncOnly)
|
|
648
|
-
return hasEmailDnc || hasSmsDnc;
|
|
649
|
-
return true;
|
|
650
|
-
});
|
|
656
|
+
const filtered = pageContacts.filter(predicate);
|
|
651
657
|
const toAdd = remaining === undefined ? filtered : filtered.slice(0, remaining);
|
|
652
658
|
contacts.push(...toAdd);
|
|
653
659
|
if (remaining !== undefined) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n8n-nodes-mautic-advanced",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.9",
|
|
4
4
|
"description": "Enhanced n8n node for Mautic with comprehensive API coverage including tags, campaigns, categories, and advanced contact management",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"n8n",
|
|
@@ -72,5 +72,13 @@
|
|
|
72
72
|
"@types/change-case": "^0.0.30",
|
|
73
73
|
"change-case": "^5.4.4",
|
|
74
74
|
"zod": "^3.22.0"
|
|
75
|
+
},
|
|
76
|
+
"peerDependencies": {
|
|
77
|
+
"@langchain/core": "*"
|
|
78
|
+
},
|
|
79
|
+
"peerDependenciesMeta": {
|
|
80
|
+
"@langchain/core": {
|
|
81
|
+
"optional": true
|
|
82
|
+
}
|
|
75
83
|
}
|
|
76
84
|
}
|