@wesell/n8n-nodes-confirmx 0.1.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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/credentials/ConfirmXApi.credentials.d.ts +18 -0
  4. package/credentials/ConfirmXApi.credentials.js +31 -0
  5. package/credentials/ConfirmXApi.credentials.js.map +1 -0
  6. package/credentials/ConfirmXApi.credentials.ts +40 -0
  7. package/index.d.ts +14 -0
  8. package/index.js +26 -0
  9. package/nodes/ConfirmX/ConfirmXAccount.node.d.ts +5 -0
  10. package/nodes/ConfirmX/ConfirmXAccount.node.js +81 -0
  11. package/nodes/ConfirmX/ConfirmXAccount.node.js.map +1 -0
  12. package/nodes/ConfirmX/ConfirmXAccount.node.ts +81 -0
  13. package/nodes/ConfirmX/ConfirmXConversation.node.d.ts +13 -0
  14. package/nodes/ConfirmX/ConfirmXConversation.node.js +266 -0
  15. package/nodes/ConfirmX/ConfirmXConversation.node.js.map +1 -0
  16. package/nodes/ConfirmX/ConfirmXConversation.node.ts +263 -0
  17. package/nodes/ConfirmX/ConfirmXMessage.node.d.ts +13 -0
  18. package/nodes/ConfirmX/ConfirmXMessage.node.js +364 -0
  19. package/nodes/ConfirmX/ConfirmXMessage.node.js.map +1 -0
  20. package/nodes/ConfirmX/ConfirmXMessage.node.ts +361 -0
  21. package/nodes/ConfirmX/ConfirmXShippingZone.node.d.ts +5 -0
  22. package/nodes/ConfirmX/ConfirmXShippingZone.node.js +100 -0
  23. package/nodes/ConfirmX/ConfirmXShippingZone.node.js.map +1 -0
  24. package/nodes/ConfirmX/ConfirmXShippingZone.node.ts +103 -0
  25. package/nodes/ConfirmX/ConfirmXTemplate.node.d.ts +13 -0
  26. package/nodes/ConfirmX/ConfirmXTemplate.node.js +310 -0
  27. package/nodes/ConfirmX/ConfirmXTemplate.node.js.map +1 -0
  28. package/nodes/ConfirmX/ConfirmXTemplate.node.ts +310 -0
  29. package/nodes/ConfirmX/ConfirmXTrigger.node.d.ts +29 -0
  30. package/nodes/ConfirmX/ConfirmXTrigger.node.js +190 -0
  31. package/nodes/ConfirmX/ConfirmXTrigger.node.js.map +1 -0
  32. package/nodes/ConfirmX/ConfirmXTrigger.node.ts +245 -0
  33. package/nodes/ConfirmX/ConfirmXWebhook.node.d.ts +5 -0
  34. package/nodes/ConfirmX/ConfirmXWebhook.node.js +169 -0
  35. package/nodes/ConfirmX/ConfirmXWebhook.node.js.map +1 -0
  36. package/nodes/ConfirmX/ConfirmXWebhook.node.ts +163 -0
  37. package/nodes/ConfirmX/confirmx.svg +4 -0
  38. package/package.json +69 -0
  39. package/transports/http.d.ts +43 -0
  40. package/transports/http.js +117 -0
  41. package/transports/http.js.map +1 -0
  42. package/transports/http.ts +170 -0
  43. package/transports/signature.d.ts +21 -0
  44. package/transports/signature.js +50 -0
  45. package/transports/signature.js.map +1 -0
  46. package/transports/signature.ts +55 -0
  47. package/types/api.d.ts +199 -0
  48. package/types/api.js +21 -0
  49. package/types/api.js.map +1 -0
  50. package/types/api.ts +238 -0
@@ -0,0 +1,361 @@
1
+ import type { INodeType, INodeTypeDescription } from 'n8n-workflow'
2
+ import { confirmxApiRequest, loadAccountOptions } from '../../transports/http'
3
+
4
+ export class ConfirmXMessage implements INodeType {
5
+ description: INodeTypeDescription = {
6
+ displayName: 'ConfirmX Message',
7
+ name: 'confirmXMessage',
8
+ icon: 'file:confirmx.svg',
9
+ group: ['transform'],
10
+ version: 1,
11
+ subtitle: '={{$parameter["operation"]}}',
12
+ description:
13
+ 'Send WhatsApp messages, list messages in a conversation, start a new conversation with a template, or retry a failed message.',
14
+ defaults: { name: 'ConfirmX Message' },
15
+ inputs: ['main'],
16
+ outputs: ['main'],
17
+ credentials: [{ name: 'confirmXApi', required: true }],
18
+ properties: [
19
+ {
20
+ displayName: 'Operation',
21
+ name: 'operation',
22
+ type: 'options',
23
+ noDataExpression: true,
24
+ options: [
25
+ {
26
+ name: 'Send Text',
27
+ value: 'sendText',
28
+ action: 'Send text message',
29
+ description:
30
+ 'Send a free-form text message in an existing conversation. Use ONLY when the conversation is open (within 24h of the customer\'s last inbound message); otherwise use `startConversation` with a template. Requires `conversationId` and `body`. Optional `replyToMessageId` to quote a previous message.',
31
+ },
32
+ {
33
+ name: 'Send Media',
34
+ value: 'sendMedia',
35
+ action: 'Send media message',
36
+ description:
37
+ 'Send a media message (image, video, document, audio) inside an existing conversation. Requires `conversationId` and `mediaUrl` (publicly fetchable URL); for documents/audio/voice also supply `mediaFilename`. Use ONLY within the 24h messaging window.',
38
+ },
39
+ {
40
+ name: 'Start Conversation',
41
+ value: 'startConversation',
42
+ action: 'Start conversation with template',
43
+ description:
44
+ "Open a new WhatsApp conversation with a customer by sending an approved template. This bypasses the 24h messaging window — it's how you reach a customer proactively. Requires `accountId`, `phoneNumber` (E.164), and `templateName`. Returns the new conversation id you can use for follow-up messages.",
45
+ },
46
+ {
47
+ name: 'List Messages',
48
+ value: 'listMessages',
49
+ action: 'List messages in conversation',
50
+ description:
51
+ 'Page through the messages in a conversation. Returns up to `limit` messages per page with an opaque `nextCursor` for the next page. Requires `conversationId`.',
52
+ },
53
+ {
54
+ name: 'Retry',
55
+ value: 'retry',
56
+ action: 'Retry a failed message',
57
+ description:
58
+ 'Retry a previously failed outbound message. Requires `messageId`. Only failed messages can be retried — calling on a delivered/sent message returns 400.',
59
+ },
60
+ ],
61
+ default: 'sendText',
62
+ },
63
+ // --- Account (only for startConversation) ---
64
+ {
65
+ displayName: 'Account',
66
+ name: 'accountId',
67
+ type: 'resourceLocator',
68
+ default: { mode: 'list', value: '' },
69
+ required: true,
70
+ displayOptions: { show: { operation: ['startConversation'] } },
71
+ modes: [
72
+ { displayName: 'From List', name: 'list', type: 'list', typeOptions: { searchable: true } },
73
+ { displayName: 'By ID', name: 'id', type: 'string' },
74
+ ],
75
+ typeOptions: { loadOptionsMethod: 'getAccounts' },
76
+ description: 'WhatsApp account to send from',
77
+ },
78
+ // --- Send Text ---
79
+ {
80
+ displayName: 'Conversation ID',
81
+ name: 'sendConversationId',
82
+ type: 'string',
83
+ default: '',
84
+ required: true,
85
+ displayOptions: { show: { operation: ['sendText', 'sendMedia'] } },
86
+ },
87
+ {
88
+ displayName: 'Body',
89
+ name: 'textBody',
90
+ type: 'string',
91
+ typeOptions: { rows: 4 },
92
+ default: '',
93
+ required: true,
94
+ displayOptions: { show: { operation: ['sendText'] } },
95
+ },
96
+ {
97
+ displayName: 'Reply To Message ID',
98
+ name: 'replyToMessageId',
99
+ type: 'string',
100
+ default: '',
101
+ displayOptions: { show: { operation: ['sendText', 'sendMedia'] } },
102
+ description: 'Optional — quote an existing message',
103
+ },
104
+ // --- Send Media ---
105
+ {
106
+ displayName: 'Media Type',
107
+ name: 'mediaType',
108
+ type: 'options',
109
+ default: 'image',
110
+ required: true,
111
+ displayOptions: { show: { operation: ['sendMedia'] } },
112
+ options: [
113
+ { name: 'Image', value: 'image' },
114
+ { name: 'Video', value: 'video' },
115
+ { name: 'Document', value: 'document' },
116
+ { name: 'Audio', value: 'audio' },
117
+ { name: 'Voice', value: 'voice' },
118
+ ],
119
+ },
120
+ {
121
+ displayName: 'Media URL',
122
+ name: 'mediaUrl',
123
+ type: 'string',
124
+ default: '',
125
+ required: true,
126
+ displayOptions: { show: { operation: ['sendMedia'] } },
127
+ description: 'Public URL of the media file',
128
+ },
129
+ {
130
+ displayName: 'Media Caption',
131
+ name: 'mediaCaption',
132
+ type: 'string',
133
+ default: '',
134
+ displayOptions: { show: { operation: ['sendMedia'] } },
135
+ },
136
+ {
137
+ displayName: 'Media Filename',
138
+ name: 'mediaFilename',
139
+ type: 'string',
140
+ default: '',
141
+ displayOptions: {
142
+ show: { operation: ['sendMedia'], mediaType: ['document', 'audio', 'voice'] },
143
+ },
144
+ description: 'Required for document/audio/voice; controls how the file is named on receipt.',
145
+ },
146
+ // --- Start Conversation ---
147
+ {
148
+ displayName: 'Phone Number (E.164)',
149
+ name: 'phoneNumber',
150
+ type: 'string',
151
+ default: '',
152
+ required: true,
153
+ displayOptions: { show: { operation: ['startConversation'] } },
154
+ placeholder: '+201234567890',
155
+ description: 'Customer phone in E.164 format',
156
+ },
157
+ {
158
+ displayName: 'Template Name',
159
+ name: 'templateName',
160
+ type: 'string',
161
+ default: '',
162
+ required: true,
163
+ displayOptions: { show: { operation: ['startConversation'] } },
164
+ },
165
+ {
166
+ displayName: 'Template Language',
167
+ name: 'templateLanguage',
168
+ type: 'string',
169
+ default: 'en',
170
+ displayOptions: { show: { operation: ['startConversation'] } },
171
+ },
172
+ {
173
+ displayName: 'Template Components (JSON)',
174
+ name: 'templateComponents',
175
+ type: 'json',
176
+ default: '[]',
177
+ displayOptions: { show: { operation: ['startConversation'] } },
178
+ description:
179
+ 'Optional JSON array matching Meta template components. Example: `[{"type":"body","parameters":[{"type":"text","text":"Order #12345"}]}]`.',
180
+ },
181
+ // --- List Messages ---
182
+ {
183
+ displayName: 'Conversation ID',
184
+ name: 'listConversationId',
185
+ type: 'string',
186
+ default: '',
187
+ required: true,
188
+ displayOptions: { show: { operation: ['listMessages'] } },
189
+ },
190
+ {
191
+ displayName: 'Cursor',
192
+ name: 'cursor',
193
+ type: 'string',
194
+ default: '',
195
+ displayOptions: { show: { operation: ['listMessages'] } },
196
+ description: 'Opaque cursor from previous page response',
197
+ },
198
+ {
199
+ displayName: 'Limit',
200
+ name: 'messagesLimit',
201
+ type: 'number',
202
+ default: 50,
203
+ typeOptions: { minValue: 1, maxValue: 100 },
204
+ displayOptions: { show: { operation: ['listMessages'] } },
205
+ },
206
+ // --- Retry ---
207
+ {
208
+ displayName: 'Message ID',
209
+ name: 'messageId',
210
+ type: 'string',
211
+ default: '',
212
+ required: true,
213
+ displayOptions: { show: { operation: ['retry'] } },
214
+ },
215
+ // --- Common ---
216
+ {
217
+ displayName: 'Integration Label',
218
+ name: 'integrationLabel',
219
+ type: 'string',
220
+ default: '',
221
+ displayOptions: { show: { operation: ['sendText', 'sendMedia', 'startConversation'] } },
222
+ description:
223
+ 'Optional human-readable label shown in inbox (max 64 chars). Example: "n8n: Order Confirmation".',
224
+ },
225
+ {
226
+ displayName: 'Idempotency Key (X-Request-Id)',
227
+ name: 'requestId',
228
+ type: 'string',
229
+ default: '',
230
+ displayOptions: { show: { operation: ['sendText', 'sendMedia', 'startConversation', 'retry'] } },
231
+ description:
232
+ 'Optional UUID — replay-safe debits. Replays with the same id return the cached debit without re-charging.',
233
+ },
234
+ ],
235
+ usableAsTool: true,
236
+ }
237
+
238
+ methods = {
239
+ loadOptions: {
240
+ async getAccounts(this: any) {
241
+ return await loadAccountOptions(this)
242
+ },
243
+ },
244
+ }
245
+
246
+ async execute(this: any) {
247
+ const items = this.getInputData()
248
+ const returnData: any[] = []
249
+ const resolveAccountId = (i: number) => {
250
+ const v = this.getNodeParameter('accountId', i, '') as any
251
+ if (!v) return undefined
252
+ return typeof v === 'string' ? v : v?.value
253
+ }
254
+
255
+ for (let i = 0; i < items.length; i++) {
256
+ const operation = this.getNodeParameter('operation', i) as string
257
+ const extraHeaders: Record<string, string> = {}
258
+ const requestId = (this.getNodeParameter('requestId', i, '') as string).trim()
259
+ if (requestId) extraHeaders['X-Request-Id'] = requestId
260
+
261
+ if (operation === 'sendText') {
262
+ const conversationId = this.getNodeParameter('sendConversationId', i) as string
263
+ const body = this.getNodeParameter('textBody', i) as string
264
+ const replyToMessageId = (this.getNodeParameter('replyToMessageId', i, '') as string) || undefined
265
+ const integrationLabel = (this.getNodeParameter('integrationLabel', i, '') as string) || undefined
266
+ const payload: Record<string, any> = { conversationId, type: 'text', body }
267
+ if (replyToMessageId) payload.replyToMessageId = replyToMessageId
268
+ if (integrationLabel) payload.integrationLabel = integrationLabel
269
+
270
+ const res = await confirmxApiRequest<{ message: any }>(this, {
271
+ method: 'POST',
272
+ endpoint: '/messages/send',
273
+ body: payload,
274
+ headers: extraHeaders,
275
+ })
276
+ returnData.push({ json: res.message })
277
+ } else if (operation === 'sendMedia') {
278
+ const conversationId = this.getNodeParameter('sendConversationId', i) as string
279
+ const mediaType = this.getNodeParameter('mediaType', i) as string
280
+ const mediaUrl = this.getNodeParameter('mediaUrl', i) as string
281
+ const mediaCaption = (this.getNodeParameter('mediaCaption', i, '') as string) || undefined
282
+ const mediaFilename = (this.getNodeParameter('mediaFilename', i, '') as string) || undefined
283
+ const replyToMessageId = (this.getNodeParameter('replyToMessageId', i, '') as string) || undefined
284
+ const integrationLabel = (this.getNodeParameter('integrationLabel', i, '') as string) || undefined
285
+ const payload: Record<string, any> = {
286
+ conversationId,
287
+ type: mediaType,
288
+ mediaUrl,
289
+ }
290
+ if (mediaCaption) payload.mediaCaption = mediaCaption
291
+ if (mediaFilename) payload.mediaFilename = mediaFilename
292
+ if (replyToMessageId) payload.replyToMessageId = replyToMessageId
293
+ if (integrationLabel) payload.integrationLabel = integrationLabel
294
+
295
+ const res = await confirmxApiRequest<{ message: any }>(this, {
296
+ method: 'POST',
297
+ endpoint: '/messages/send',
298
+ body: payload,
299
+ headers: extraHeaders,
300
+ })
301
+ returnData.push({ json: res.message })
302
+ } else if (operation === 'startConversation') {
303
+ const accountId = resolveAccountId(i) as string
304
+ const phoneNumber = this.getNodeParameter('phoneNumber', i) as string
305
+ const templateName = this.getNodeParameter('templateName', i) as string
306
+ const templateLanguage = (this.getNodeParameter('templateLanguage', i) as string) || 'en'
307
+ const templateComponentsRaw = (this.getNodeParameter('templateComponents', i, '[]') as string) || '[]'
308
+ const integrationLabel = (this.getNodeParameter('integrationLabel', i, '') as string) || undefined
309
+
310
+ let templateComponents: any[] = []
311
+ try {
312
+ const parsed = JSON.parse(templateComponentsRaw)
313
+ if (Array.isArray(parsed) && parsed.length) templateComponents = parsed
314
+ } catch {
315
+ throw new Error('Template Components must be valid JSON array')
316
+ }
317
+
318
+ const payload: Record<string, any> = {
319
+ accountId,
320
+ phoneNumber,
321
+ templateName,
322
+ templateLanguage,
323
+ }
324
+ if (templateComponents.length) payload.templateComponents = templateComponents
325
+ if (integrationLabel) payload.integrationLabel = integrationLabel
326
+
327
+ const res = await confirmxApiRequest<{ conversation: any; message: any }>(this, {
328
+ method: 'POST',
329
+ endpoint: '/messages/start-conversation',
330
+ body: payload,
331
+ headers: extraHeaders,
332
+ })
333
+ returnData.push({ json: { conversation: res.conversation, message: res.message } })
334
+ } else if (operation === 'listMessages') {
335
+ const conversationId = this.getNodeParameter('listConversationId', i) as string
336
+ const cursor = (this.getNodeParameter('cursor', i, '') as string) || undefined
337
+ const limit = (this.getNodeParameter('messagesLimit', i) as number) || 50
338
+ const qs: Record<string, any> = { limit }
339
+ if (cursor) qs.cursor = cursor
340
+ const res = await confirmxApiRequest<{ messages: any[]; nextCursor: string | null }>(this, {
341
+ method: 'GET',
342
+ endpoint: `/conversations/${conversationId}/messages`,
343
+ qs,
344
+ })
345
+ for (const m of res.messages || []) {
346
+ returnData.push({ json: m })
347
+ }
348
+ } else if (operation === 'retry') {
349
+ const messageId = this.getNodeParameter('messageId', i) as string
350
+ const res = await confirmxApiRequest<{ ok?: boolean; message?: any }>(this, {
351
+ method: 'POST',
352
+ endpoint: `/messages/${messageId}/retry`,
353
+ headers: extraHeaders,
354
+ })
355
+ returnData.push({ json: { messageId, ...(res as any) } })
356
+ }
357
+ }
358
+
359
+ return [returnData]
360
+ }
361
+ }
@@ -0,0 +1,5 @@
1
+ import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
2
+ export declare class ConfirmXShippingZone implements INodeType {
3
+ description: INodeTypeDescription;
4
+ execute(this: any): Promise<any[][]>;
5
+ }
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConfirmXShippingZone = void 0;
4
+ const http_1 = require("../../transports/http");
5
+ class ConfirmXShippingZone {
6
+ constructor() {
7
+ this.description = {
8
+ displayName: 'ConfirmX Shipping Zone',
9
+ name: 'confirmXShippingZone',
10
+ icon: 'file:confirmx.svg',
11
+ group: ['transform'],
12
+ version: 1,
13
+ subtitle: '={{$parameter["operation"]}}',
14
+ description: 'Fuzzy-match an Egyptian governorate + optional area against the ConfirmX shipping-zone taxonomy (10k+ rows, Arabic + English, aliases). Useful for extracting shipping zones from free-text customer input.',
15
+ defaults: { name: 'ConfirmX Shipping Zone' },
16
+ inputs: ['main'],
17
+ outputs: ['main'],
18
+ credentials: [{ name: 'confirmXApi', required: true }],
19
+ properties: [
20
+ {
21
+ displayName: 'Operation',
22
+ name: 'operation',
23
+ type: 'options',
24
+ noDataExpression: true,
25
+ options: [
26
+ {
27
+ name: 'Match',
28
+ value: 'match',
29
+ action: 'Fuzzy-match a shipping zone',
30
+ description: "Fuzzy-match an Egyptian governorate and (optionally) area from free-text customer input (Arabic or English, with typos / aliases tolerated). Returns the matched governorate + area with confidence scores and `externalId`s suitable for writing back to a shipping platform. If `area` is omitted, only the governorate is matched. On success returns `{governorate, area}`; on governorate-not-found returns `suggestions`; on ambiguous area returns `options[]` — present those options to the user and call again with the chosen governorate. Best for Egyptian e-commerce flows.",
31
+ },
32
+ {
33
+ name: 'List Governorates',
34
+ value: 'list',
35
+ action: 'List governorates and areas',
36
+ description: 'Return the full active governorate→areas tree. Reference data for orgs shipping to Egypt.',
37
+ },
38
+ ],
39
+ default: 'match',
40
+ },
41
+ // --- Match parameters ---
42
+ {
43
+ displayName: 'Governorate',
44
+ name: 'governorate',
45
+ type: 'string',
46
+ default: '',
47
+ required: true,
48
+ displayOptions: { show: { operation: ['match'] } },
49
+ description: 'Free-text governorate name. Examples: "Cairo", "القاهرة", "Giza". Typos and Arabic/English mixing are tolerated.',
50
+ },
51
+ {
52
+ displayName: 'Area',
53
+ name: 'area',
54
+ type: 'string',
55
+ default: '',
56
+ displayOptions: { show: { operation: ['match'] } },
57
+ description: 'Optional free-text area name (e.g. "Maadi", "المعادي"). If omitted, only the governorate is matched.',
58
+ },
59
+ ],
60
+ usableAsTool: true,
61
+ };
62
+ }
63
+ async execute() {
64
+ const items = this.getInputData();
65
+ const returnData = [];
66
+ for (let i = 0; i < items.length; i++) {
67
+ const operation = this.getNodeParameter('operation', i);
68
+ if (operation === 'match') {
69
+ const governorate = this.getNodeParameter('governorate', i);
70
+ const areaRaw = this.getNodeParameter('area', i) || '';
71
+ const body = { governorate };
72
+ if (areaRaw.trim())
73
+ body.area = areaRaw.trim();
74
+ const res = await (0, http_1.confirmxApiRequest)(this, {
75
+ method: 'POST',
76
+ endpoint: '/shipping-zones/match',
77
+ body,
78
+ });
79
+ returnData.push({ json: res });
80
+ }
81
+ else if (operation === 'list') {
82
+ const res = await (0, http_1.confirmxApiRequest)(this, {
83
+ method: 'GET',
84
+ endpoint: '/shipping-zones',
85
+ });
86
+ const governorates = res.governorates || [];
87
+ // Emit one item per governorate so downstream nodes can iterate easily.
88
+ for (const g of governorates) {
89
+ returnData.push({ json: g });
90
+ }
91
+ if (governorates.length === 0) {
92
+ returnData.push({ json: { governorates: [] } });
93
+ }
94
+ }
95
+ }
96
+ return [returnData];
97
+ }
98
+ }
99
+ exports.ConfirmXShippingZone = ConfirmXShippingZone;
100
+ //# sourceMappingURL=ConfirmXShippingZone.node.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ConfirmXShippingZone.node.js","sourceRoot":"","sources":["ConfirmXShippingZone.node.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,103 @@
1
+ import type { INodeType, INodeTypeDescription } from 'n8n-workflow'
2
+ import { confirmxApiRequest } from '../../transports/http'
3
+
4
+ export class ConfirmXShippingZone implements INodeType {
5
+ description: INodeTypeDescription = {
6
+ displayName: 'ConfirmX Shipping Zone',
7
+ name: 'confirmXShippingZone',
8
+ icon: 'file:confirmx.svg',
9
+ group: ['transform'],
10
+ version: 1,
11
+ subtitle: '={{$parameter["operation"]}}',
12
+ description:
13
+ 'Fuzzy-match an Egyptian governorate + optional area against the ConfirmX shipping-zone taxonomy (10k+ rows, Arabic + English, aliases). Useful for extracting shipping zones from free-text customer input.',
14
+ defaults: { name: 'ConfirmX Shipping Zone' },
15
+ inputs: ['main'],
16
+ outputs: ['main'],
17
+ credentials: [{ name: 'confirmXApi', required: true }],
18
+ properties: [
19
+ {
20
+ displayName: 'Operation',
21
+ name: 'operation',
22
+ type: 'options',
23
+ noDataExpression: true,
24
+ options: [
25
+ {
26
+ name: 'Match',
27
+ value: 'match',
28
+ action: 'Fuzzy-match a shipping zone',
29
+ description:
30
+ "Fuzzy-match an Egyptian governorate and (optionally) area from free-text customer input (Arabic or English, with typos / aliases tolerated). Returns the matched governorate + area with confidence scores and `externalId`s suitable for writing back to a shipping platform. If `area` is omitted, only the governorate is matched. On success returns `{governorate, area}`; on governorate-not-found returns `suggestions`; on ambiguous area returns `options[]` — present those options to the user and call again with the chosen governorate. Best for Egyptian e-commerce flows.",
31
+ },
32
+ {
33
+ name: 'List Governorates',
34
+ value: 'list',
35
+ action: 'List governorates and areas',
36
+ description:
37
+ 'Return the full active governorate→areas tree. Reference data for orgs shipping to Egypt.',
38
+ },
39
+ ],
40
+ default: 'match',
41
+ },
42
+ // --- Match parameters ---
43
+ {
44
+ displayName: 'Governorate',
45
+ name: 'governorate',
46
+ type: 'string',
47
+ default: '',
48
+ required: true,
49
+ displayOptions: { show: { operation: ['match'] } },
50
+ description:
51
+ 'Free-text governorate name. Examples: "Cairo", "القاهرة", "Giza". Typos and Arabic/English mixing are tolerated.',
52
+ },
53
+ {
54
+ displayName: 'Area',
55
+ name: 'area',
56
+ type: 'string',
57
+ default: '',
58
+ displayOptions: { show: { operation: ['match'] } },
59
+ description:
60
+ 'Optional free-text area name (e.g. "Maadi", "المعادي"). If omitted, only the governorate is matched.',
61
+ },
62
+ ],
63
+ usableAsTool: true,
64
+ }
65
+
66
+ async execute(this: any) {
67
+ const items = this.getInputData()
68
+ const returnData: any[] = []
69
+
70
+ for (let i = 0; i < items.length; i++) {
71
+ const operation = this.getNodeParameter('operation', i) as string
72
+
73
+ if (operation === 'match') {
74
+ const governorate = this.getNodeParameter('governorate', i) as string
75
+ const areaRaw = (this.getNodeParameter('area', i) as string) || ''
76
+ const body: Record<string, any> = { governorate }
77
+ if (areaRaw.trim()) body.area = areaRaw.trim()
78
+
79
+ const res = await confirmxApiRequest<any>(this, {
80
+ method: 'POST',
81
+ endpoint: '/shipping-zones/match',
82
+ body,
83
+ })
84
+ returnData.push({ json: res })
85
+ } else if (operation === 'list') {
86
+ const res = await confirmxApiRequest<any>(this, {
87
+ method: 'GET',
88
+ endpoint: '/shipping-zones',
89
+ })
90
+ const governorates = res.governorates || []
91
+ // Emit one item per governorate so downstream nodes can iterate easily.
92
+ for (const g of governorates) {
93
+ returnData.push({ json: g })
94
+ }
95
+ if (governorates.length === 0) {
96
+ returnData.push({ json: { governorates: [] } })
97
+ }
98
+ }
99
+ }
100
+
101
+ return [returnData]
102
+ }
103
+ }
@@ -0,0 +1,13 @@
1
+ import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
2
+ export declare class ConfirmXTemplate implements INodeType {
3
+ description: INodeTypeDescription;
4
+ methods: {
5
+ loadOptions: {
6
+ getAccounts(this: any): Promise<{
7
+ name: string;
8
+ value: string;
9
+ }[]>;
10
+ };
11
+ };
12
+ execute(this: any): Promise<any[][]>;
13
+ }