@trg-admin/n8n-nodes-zoho-desk 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,15 +33,19 @@ When you create the credential in n8n, the default scope is:
33
33
 
34
34
  ```
35
35
  ZohoCRM.modules.ALL Desk.tickets.ALL Desk.contacts.ALL Desk.search.READ
36
+ Desk.tasks.ALL Desk.basic.READ Desk.fields.READ Desk.settings.READ
37
+ Desk.webhooks.CREATE Desk.webhooks.READ Desk.webhooks.UPDATE Desk.webhooks.DELETE
36
38
  ```
37
39
 
38
40
  **Available Zoho Desk Scopes:**
39
41
  - `Desk.tickets.ALL` - Full access to tickets
40
42
  - `Desk.contacts.ALL` - Full access to contacts
41
43
  - `Desk.search.READ` - Search across modules
44
+ - `Desk.fields.READ` - Read field definitions (required for the Classification / Category / Sub-Category and Channel dropdowns)
42
45
  - `Desk.settings.ALL` - Access to settings and configurations
43
46
  - `Desk.tasks.ALL` - Task management
44
47
  - `Desk.basic.READ` - Read-only access to basic information
48
+ - `Desk.webhooks.*` - Webhook subscription management (required by the Zoho Desk Trigger node)
45
49
 
46
50
  You can customize the scope field in the credential to add or remove permissions as needed.
47
51
 
@@ -110,7 +110,7 @@ class ZohoCRMDeskOAuth2Api {
110
110
  displayName: 'Scope',
111
111
  name: 'scope',
112
112
  type: 'string',
113
- default: 'ZohoCRM.modules.ALL Desk.tickets.ALL Desk.contacts.ALL Desk.search.READ Desk.tasks.ALL Desk.basic.READ Desk.settings.READ Desk.webhooks.CREATE Desk.webhooks.READ Desk.webhooks.UPDATE Desk.webhooks.DELETE',
113
+ default: 'ZohoCRM.modules.ALL Desk.tickets.ALL Desk.contacts.ALL Desk.search.READ Desk.tasks.ALL Desk.basic.READ Desk.fields.READ Desk.settings.READ Desk.webhooks.CREATE Desk.webhooks.READ Desk.webhooks.UPDATE Desk.webhooks.DELETE',
114
114
  required: true,
115
115
  description: 'Space-separated OAuth scopes. Default includes both CRM and Desk permissions. Add or remove scopes as needed (e.g., Desk.settings.ALL, Desk.tasks.ALL).',
116
116
  },
@@ -1,4 +1,23 @@
1
1
  import type { IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription, ILoadOptionsFunctions } from 'n8n-workflow';
2
+ /**
3
+ * Load a ticket taxonomy picklist (classification / category / subCategory) for a dropdown.
4
+ *
5
+ * Zoho Desk has no /classifications, /categories or /subcategories endpoints — ticket
6
+ * taxonomy is exposed through the ticket module's organization field definitions.
7
+ *
8
+ * The ticket payload takes the taxonomy *name string* (there is no classificationId /
9
+ * categoryId / subCategoryId on a ticket), so the option value is the value itself.
10
+ *
11
+ * ponytail: the three levels are listed independently rather than cascaded. Whether a
12
+ * subCategory must be mapped under the chosen category is configured per-tenant (field
13
+ * dependencies) and is not documented as an API rule, so we list all values and let the
14
+ * API answer. Upgrade to GET /layouts/{layoutId}/fields/{fieldId}/dependentValues if users
15
+ * hit rejections in tenants that do enforce the mapping.
16
+ */
17
+ export declare function loadTaxonomyOptions(context: ILoadOptionsFunctions, apiName: string): Promise<Array<{
18
+ name: string;
19
+ value: string;
20
+ }>>;
2
21
  export declare class ZohoDesk implements INodeType {
3
22
  description: INodeTypeDescription;
4
23
  methods: {
@@ -11,6 +30,18 @@ export declare class ZohoDesk implements INodeType {
11
30
  name: string;
12
31
  value: string;
13
32
  }[]>;
33
+ getClassifications(this: ILoadOptionsFunctions): Promise<{
34
+ name: string;
35
+ value: string;
36
+ }[]>;
37
+ getCategories(this: ILoadOptionsFunctions): Promise<{
38
+ name: string;
39
+ value: string;
40
+ }[]>;
41
+ getSubCategories(this: ILoadOptionsFunctions): Promise<{
42
+ name: string;
43
+ value: string;
44
+ }[]>;
14
45
  };
15
46
  };
16
47
  execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
@@ -1,8 +1,69 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ZohoDesk = void 0;
4
+ exports.loadTaxonomyOptions = loadTaxonomyOptions;
4
5
  const n8n_workflow_1 = require("n8n-workflow");
5
6
  const GenericFunctions_1 = require("./GenericFunctions");
7
+ function cleanCommaSeparated(value) {
8
+ if (typeof value !== 'string') {
9
+ return '';
10
+ }
11
+ return value
12
+ .split(',')
13
+ .map((s) => s.trim())
14
+ .filter(Boolean)
15
+ .join(',');
16
+ }
17
+ /**
18
+ * Load a ticket taxonomy picklist (classification / category / subCategory) for a dropdown.
19
+ *
20
+ * Zoho Desk has no /classifications, /categories or /subcategories endpoints — ticket
21
+ * taxonomy is exposed through the ticket module's organization field definitions.
22
+ *
23
+ * The ticket payload takes the taxonomy *name string* (there is no classificationId /
24
+ * categoryId / subCategoryId on a ticket), so the option value is the value itself.
25
+ *
26
+ * ponytail: the three levels are listed independently rather than cascaded. Whether a
27
+ * subCategory must be mapped under the chosen category is configured per-tenant (field
28
+ * dependencies) and is not documented as an API rule, so we list all values and let the
29
+ * API answer. Upgrade to GET /layouts/{layoutId}/fields/{fieldId}/dependentValues if users
30
+ * hit rejections in tenants that do enforce the mapping.
31
+ */
32
+ async function loadTaxonomyOptions(context, apiName) {
33
+ // Taxonomy is department-scoped; only top-level ticket parameters carry a department.
34
+ const departmentId = context.getCurrentNodeParameter('departmentId');
35
+ const qs = { module: 'tickets', apiNames: apiName };
36
+ if (departmentId) {
37
+ qs.departmentId = String(departmentId);
38
+ }
39
+ try {
40
+ const responseData = await GenericFunctions_1.zohoDeskApiRequest.call(context, 'GET', '/organizationFields', {}, qs, 0);
41
+ const fields = Array.isArray(responseData?.data)
42
+ ? responseData.data
43
+ : Array.isArray(responseData)
44
+ ? responseData
45
+ : [];
46
+ const field = fields.find((f) => String(f.apiName || '').toLowerCase() === apiName.toLowerCase());
47
+ // Field definitions carry picklist values under either key depending on tenant.
48
+ const rawValues = (field?.pickListValues ?? field?.allowedValues ?? []);
49
+ const seen = new Set();
50
+ const options = [];
51
+ for (const entry of rawValues) {
52
+ const value = typeof entry === 'string'
53
+ ? entry
54
+ : (entry.value || entry.displayValue || '');
55
+ if (!value || seen.has(value)) {
56
+ continue;
57
+ }
58
+ seen.add(value);
59
+ options.push({ name: value, value });
60
+ }
61
+ return options.sort((a, b) => a.name.localeCompare(b.name));
62
+ }
63
+ catch (error) {
64
+ throw new Error(`Failed to load "${apiName}" values from the ticket layout. Error: ${error.message}`);
65
+ }
66
+ }
6
67
  class ZohoDesk {
7
68
  constructor() {
8
69
  this.description = {
@@ -43,6 +104,21 @@ class ZohoDesk {
43
104
  { name: 'Ticket', value: 'ticket' },
44
105
  { name: 'Contact', value: 'contact' },
45
106
  { name: 'Comment', value: 'comment' },
107
+ { name: 'Thread', value: 'thread' },
108
+ ],
109
+ },
110
+ // Thread Operations
111
+ {
112
+ displayName: 'Operation',
113
+ name: 'operation',
114
+ type: 'options',
115
+ noDataExpression: true,
116
+ displayOptions: { show: { resource: ['thread'] } },
117
+ default: 'getAll',
118
+ options: [
119
+ { name: 'Get', value: 'get', action: 'Get a thread from a ticket' },
120
+ { name: 'Get Many', value: 'getAll', action: 'Get all threads from a ticket' },
121
+ { name: 'Send Reply', value: 'sendReply', action: 'Send a reply to a ticket' },
46
122
  ],
47
123
  },
48
124
  // Ticket Operations
@@ -165,6 +241,39 @@ class ZohoDesk {
165
241
  default: 'Email',
166
242
  description: 'Channel through which the ticket was received or created. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
167
243
  },
244
+ {
245
+ displayName: 'Classification',
246
+ name: 'classification',
247
+ type: 'options',
248
+ typeOptions: {
249
+ loadOptionsMethod: 'getClassifications',
250
+ loadOptionsDependsOn: ['organizationId', 'departmentId'],
251
+ },
252
+ default: '',
253
+ description: 'Top-level ticket classification, as configured on the department\'s ticket layout. Choose from the list, or specify a value using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
254
+ },
255
+ {
256
+ displayName: 'Category',
257
+ name: 'category',
258
+ type: 'options',
259
+ typeOptions: {
260
+ loadOptionsMethod: 'getCategories',
261
+ loadOptionsDependsOn: ['organizationId', 'departmentId'],
262
+ },
263
+ default: '',
264
+ description: 'Ticket category, as configured on the department\'s ticket layout. Choose from the list, or specify a value using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
265
+ },
266
+ {
267
+ displayName: 'Sub-Category',
268
+ name: 'subCategory',
269
+ type: 'options',
270
+ typeOptions: {
271
+ loadOptionsMethod: 'getSubCategories',
272
+ loadOptionsDependsOn: ['organizationId', 'departmentId'],
273
+ },
274
+ default: '',
275
+ description: 'Ticket sub-category, as configured on the department\'s ticket layout. Choose from the list, or specify a value using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
276
+ },
168
277
  {
169
278
  displayName: 'Contact ID',
170
279
  name: 'contactId',
@@ -593,6 +702,155 @@ class ZohoDesk {
593
702
  displayOptions: { show: { resource: ['comment'], operation: ['getAll'] } },
594
703
  description: 'Max number of results to return',
595
704
  },
705
+ // ================== Thread Resource Parameters ==================
706
+ {
707
+ displayName: 'Ticket ID',
708
+ name: 'ticketId',
709
+ type: 'string',
710
+ default: '',
711
+ required: true,
712
+ displayOptions: { show: { resource: ['thread'], operation: ['get', 'getAll', 'sendReply'] } },
713
+ description: 'The unique ID of the ticket',
714
+ },
715
+ {
716
+ displayName: 'Thread ID',
717
+ name: 'threadId',
718
+ type: 'string',
719
+ default: '',
720
+ required: true,
721
+ displayOptions: { show: { resource: ['thread'], operation: ['get'] } },
722
+ description: 'The unique ID of the thread',
723
+ },
724
+ {
725
+ displayName: 'Content',
726
+ name: 'content',
727
+ type: 'string',
728
+ typeOptions: { alwaysOpenEditWindow: true },
729
+ default: '',
730
+ required: true,
731
+ displayOptions: { show: { resource: ['thread'], operation: ['sendReply'] } },
732
+ description: 'Content / body of the reply',
733
+ },
734
+ {
735
+ displayName: 'Content Type',
736
+ name: 'contentType',
737
+ type: 'options',
738
+ options: [
739
+ { name: 'HTML', value: 'html' },
740
+ { name: 'Plain Text', value: 'plainText' },
741
+ ],
742
+ default: 'html',
743
+ displayOptions: { show: { resource: ['thread'], operation: ['sendReply'] } },
744
+ description: 'Format of the reply content',
745
+ },
746
+ {
747
+ displayName: 'Additional Fields',
748
+ name: 'additionalFields',
749
+ type: 'collection',
750
+ placeholder: 'Add Field',
751
+ default: {},
752
+ displayOptions: { show: { resource: ['thread'], operation: ['sendReply'] } },
753
+ options: [
754
+ {
755
+ displayName: 'BCC',
756
+ name: 'bcc',
757
+ type: 'string',
758
+ default: '',
759
+ placeholder: '[EMAIL_REDACTED], [EMAIL_REDACTED]',
760
+ description: 'Blind carbon copy recipient email addresses. Multiple addresses can be separated with commas.',
761
+ },
762
+ {
763
+ displayName: 'CC',
764
+ name: 'cc',
765
+ type: 'string',
766
+ default: '',
767
+ placeholder: '[EMAIL_REDACTED], [EMAIL_REDACTED]',
768
+ description: 'Carbon copy recipient email addresses. Multiple addresses can be separated with commas.',
769
+ },
770
+ {
771
+ displayName: 'Channel',
772
+ name: 'channel',
773
+ type: 'options',
774
+ options: [
775
+ { name: 'Email', value: 'EMAIL' },
776
+ { name: 'Phone', value: 'PHONE' },
777
+ ],
778
+ default: 'EMAIL',
779
+ description: 'Channel through which the reply is sent',
780
+ },
781
+ {
782
+ displayName: 'From Email Address',
783
+ name: 'fromEmailAddress',
784
+ type: 'string',
785
+ default: '',
786
+ description: 'Sender email address. Must be a verified "From Address" configured in Zoho Desk Setup.',
787
+ },
788
+ {
789
+ displayName: 'Is Forward',
790
+ name: 'isForward',
791
+ type: 'boolean',
792
+ default: false,
793
+ description: 'Whether this reply is a forwarded message',
794
+ },
795
+ {
796
+ displayName: 'To',
797
+ name: 'to',
798
+ type: 'string',
799
+ default: '',
800
+ placeholder: '[EMAIL_REDACTED], [EMAIL_REDACTED]',
801
+ description: 'Recipient email address(es). Multiple addresses can be separated with commas.',
802
+ },
803
+ ],
804
+ },
805
+ {
806
+ displayName: 'Return All',
807
+ name: 'returnAll',
808
+ type: 'boolean',
809
+ default: false,
810
+ displayOptions: { show: { resource: ['thread'], operation: ['getAll'] } },
811
+ description: 'Whether to return all results or limit to specified number',
812
+ },
813
+ {
814
+ displayName: 'Limit',
815
+ name: 'limit',
816
+ type: 'number',
817
+ typeOptions: { minValue: 1, maxValue: 100 },
818
+ default: 50,
819
+ displayOptions: {
820
+ show: {
821
+ resource: ['thread'],
822
+ operation: ['getAll'],
823
+ returnAll: [false],
824
+ },
825
+ },
826
+ description: 'Max number of results to return (1-100)',
827
+ },
828
+ {
829
+ displayName: 'Options',
830
+ name: 'options',
831
+ type: 'collection',
832
+ placeholder: 'Add Option',
833
+ default: {},
834
+ displayOptions: { show: { resource: ['thread'], operation: ['get', 'getAll'] } },
835
+ options: [
836
+ {
837
+ displayName: 'From (Offset)',
838
+ name: 'from',
839
+ type: 'number',
840
+ typeOptions: { minValue: 0 },
841
+ default: 0,
842
+ description: 'Index number from which the threads should be fetched (getAll only)',
843
+ },
844
+ {
845
+ displayName: 'Include',
846
+ name: 'include',
847
+ type: 'string',
848
+ default: '',
849
+ placeholder: 'canReply, draft, portalHtmlUrl',
850
+ description: 'Additional information to include in response (e.g. canReply, draft, portalHtmlUrl). Multiple values can be separated with commas.',
851
+ },
852
+ ],
853
+ },
596
854
  ],
597
855
  };
598
856
  this.methods = {
@@ -701,6 +959,15 @@ class ZohoDesk {
701
959
  }
702
960
  return fallbackChannels;
703
961
  },
962
+ async getClassifications() {
963
+ return loadTaxonomyOptions(this, 'classification');
964
+ },
965
+ async getCategories() {
966
+ return loadTaxonomyOptions(this, 'category');
967
+ },
968
+ async getSubCategories() {
969
+ return loadTaxonomyOptions(this, 'subCategory');
970
+ },
704
971
  },
705
972
  };
706
973
  }
@@ -1049,6 +1316,85 @@ class ZohoDesk {
1049
1316
  }
1050
1317
  }
1051
1318
  }
1319
+ // ==================== THREAD OPERATIONS ====================
1320
+ if (resource === 'thread') {
1321
+ if (operation === 'get') {
1322
+ const ticketId = this.getNodeParameter('ticketId', i);
1323
+ const threadId = this.getNodeParameter('threadId', i);
1324
+ const options = this.getNodeParameter('options', i, {});
1325
+ const qs = {};
1326
+ if (options.include) {
1327
+ const clean = cleanCommaSeparated(options.include);
1328
+ if (clean)
1329
+ qs.include = clean;
1330
+ }
1331
+ const responseData = await GenericFunctions_1.zohoDeskApiRequest.call(this, 'GET', `/tickets/${ticketId}/threads/${threadId}`, {}, qs, i);
1332
+ returnData.push({ json: responseData });
1333
+ }
1334
+ if (operation === 'getAll') {
1335
+ const ticketId = this.getNodeParameter('ticketId', i);
1336
+ const returnAll = this.getNodeParameter('returnAll', i, false);
1337
+ const options = this.getNodeParameter('options', i, {});
1338
+ const qs = {};
1339
+ if (options.include) {
1340
+ const clean = cleanCommaSeparated(options.include);
1341
+ if (clean)
1342
+ qs.include = clean;
1343
+ }
1344
+ if (returnAll) {
1345
+ const responseData = await GenericFunctions_1.zohoDeskApiRequestAllItems.call(this, 'GET', `/tickets/${ticketId}/threads`, {}, qs, i);
1346
+ for (const row of responseData) {
1347
+ returnData.push({ json: row });
1348
+ }
1349
+ }
1350
+ else {
1351
+ const limit = this.getNodeParameter('limit', i, 50);
1352
+ qs.limit = limit;
1353
+ if (options.from !== undefined && options.from !== null && options.from !== '') {
1354
+ qs.from = options.from;
1355
+ }
1356
+ const responseData = await GenericFunctions_1.zohoDeskApiRequest.call(this, 'GET', `/tickets/${ticketId}/threads`, {}, qs, i);
1357
+ const data = Array.isArray(responseData.data)
1358
+ ? responseData.data
1359
+ : Array.isArray(responseData)
1360
+ ? responseData
1361
+ : [responseData];
1362
+ for (const row of data) {
1363
+ returnData.push({ json: row });
1364
+ }
1365
+ }
1366
+ }
1367
+ if (operation === 'sendReply') {
1368
+ const ticketId = this.getNodeParameter('ticketId', i);
1369
+ const content = this.getNodeParameter('content', i);
1370
+ const contentType = this.getNodeParameter('contentType', i, 'html');
1371
+ const additionalFields = this.getNodeParameter('additionalFields', i, {});
1372
+ const body = {
1373
+ content,
1374
+ contentType,
1375
+ };
1376
+ if (additionalFields.channel) {
1377
+ body.channel = additionalFields.channel;
1378
+ }
1379
+ if (additionalFields.fromEmailAddress) {
1380
+ body.fromEmailAddress = String(additionalFields.fromEmailAddress).trim();
1381
+ }
1382
+ if (additionalFields.to) {
1383
+ body.to = cleanCommaSeparated(additionalFields.to);
1384
+ }
1385
+ if (additionalFields.cc) {
1386
+ body.cc = cleanCommaSeparated(additionalFields.cc).split(',').filter(Boolean);
1387
+ }
1388
+ if (additionalFields.bcc) {
1389
+ body.bcc = cleanCommaSeparated(additionalFields.bcc).split(',').filter(Boolean);
1390
+ }
1391
+ if (additionalFields.isForward !== undefined) {
1392
+ body.isForward = additionalFields.isForward;
1393
+ }
1394
+ const responseData = await GenericFunctions_1.zohoDeskApiRequest.call(this, 'POST', `/tickets/${ticketId}/sendReply`, body, {}, i);
1395
+ returnData.push({ json: responseData });
1396
+ }
1397
+ }
1052
1398
  }
1053
1399
  catch (error) {
1054
1400
  if (this.continueOnFail()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trg-admin/n8n-nodes-zoho-desk",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Community n8n node starter for Zoho Desk using built-in Zoho OAuth2 auth behavior",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -10,7 +10,7 @@
10
10
  "copy-assets": "cp nodes/ZohoDesk/zohodesk.png dist/nodes/ZohoDesk/",
11
11
  "lint": "echo 'No linter configured'",
12
12
  "typecheck": "tsc --noEmit",
13
- "test": "node scripts.validate.js",
13
+ "test": "node scripts.validate.js && node test/Taxonomy.test.js",
14
14
  "prepublishOnly": "npm run typecheck && npm run build"
15
15
  },
16
16
  "files": [