@alanse/clickup-multi-mcp-server 1.0.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 (56) hide show
  1. package/Dockerfile +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +470 -0
  4. package/build/config.js +237 -0
  5. package/build/index.js +87 -0
  6. package/build/logger.js +163 -0
  7. package/build/middleware/security.js +231 -0
  8. package/build/server.js +288 -0
  9. package/build/services/clickup/base.js +432 -0
  10. package/build/services/clickup/bulk.js +180 -0
  11. package/build/services/clickup/document.js +159 -0
  12. package/build/services/clickup/folder.js +136 -0
  13. package/build/services/clickup/index.js +76 -0
  14. package/build/services/clickup/list.js +191 -0
  15. package/build/services/clickup/tag.js +239 -0
  16. package/build/services/clickup/task/index.js +32 -0
  17. package/build/services/clickup/task/task-attachments.js +105 -0
  18. package/build/services/clickup/task/task-comments.js +114 -0
  19. package/build/services/clickup/task/task-core.js +604 -0
  20. package/build/services/clickup/task/task-custom-fields.js +107 -0
  21. package/build/services/clickup/task/task-search.js +986 -0
  22. package/build/services/clickup/task/task-service.js +104 -0
  23. package/build/services/clickup/task/task-tags.js +113 -0
  24. package/build/services/clickup/time.js +244 -0
  25. package/build/services/clickup/types.js +33 -0
  26. package/build/services/clickup/workspace.js +397 -0
  27. package/build/services/shared.js +61 -0
  28. package/build/sse_server.js +277 -0
  29. package/build/tools/documents.js +489 -0
  30. package/build/tools/folder.js +331 -0
  31. package/build/tools/index.js +16 -0
  32. package/build/tools/list.js +428 -0
  33. package/build/tools/member.js +106 -0
  34. package/build/tools/tag.js +833 -0
  35. package/build/tools/task/attachments.js +357 -0
  36. package/build/tools/task/attachments.types.js +9 -0
  37. package/build/tools/task/bulk-operations.js +338 -0
  38. package/build/tools/task/handlers.js +919 -0
  39. package/build/tools/task/index.js +30 -0
  40. package/build/tools/task/main.js +233 -0
  41. package/build/tools/task/single-operations.js +469 -0
  42. package/build/tools/task/time-tracking.js +575 -0
  43. package/build/tools/task/utilities.js +310 -0
  44. package/build/tools/task/workspace-operations.js +258 -0
  45. package/build/tools/tool-enhancer.js +37 -0
  46. package/build/tools/utils.js +12 -0
  47. package/build/tools/workspace-helper.js +44 -0
  48. package/build/tools/workspace.js +73 -0
  49. package/build/utils/color-processor.js +183 -0
  50. package/build/utils/concurrency-utils.js +248 -0
  51. package/build/utils/date-utils.js +542 -0
  52. package/build/utils/resolver-utils.js +135 -0
  53. package/build/utils/sponsor-service.js +93 -0
  54. package/build/utils/token-utils.js +49 -0
  55. package/package.json +77 -0
  56. package/smithery.yaml +23 -0
@@ -0,0 +1,604 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * ClickUp Task Service - Core Module
6
+ *
7
+ * Handles core operations related to tasks in ClickUp, including:
8
+ * - Base service initialization
9
+ * - Core utility methods
10
+ * - Basic CRUD operations
11
+ */
12
+ import { BaseClickUpService, ErrorCode, ClickUpServiceError } from '../base.js';
13
+ import { ListService } from '../list.js';
14
+ /**
15
+ * Core TaskService class providing basic task operations
16
+ */
17
+ export class TaskServiceCore extends BaseClickUpService {
18
+ constructor(apiKey, teamId, baseUrl, workspaceService) {
19
+ super(apiKey, teamId, baseUrl);
20
+ this.workspaceService = null;
21
+ // Cache for validated tasks and lists
22
+ this.validationCache = {
23
+ tasks: new Map(),
24
+ lists: new Map()
25
+ };
26
+ // Cache for task name to ID mapping
27
+ this.nameToIdCache = new Map();
28
+ // Cache TTL in milliseconds (5 minutes)
29
+ this.CACHE_TTL = 5 * 60 * 1000;
30
+ if (workspaceService) {
31
+ this.workspaceService = workspaceService;
32
+ this.logOperation('constructor', { usingSharedWorkspaceService: true });
33
+ }
34
+ // Initialize list service for list lookups
35
+ this.listService = new ListService(apiKey, teamId, baseUrl, this.workspaceService);
36
+ this.logOperation('constructor', { initialized: true });
37
+ }
38
+ /**
39
+ * Helper method to handle errors consistently
40
+ * @param error The error that occurred
41
+ * @param message Optional custom error message
42
+ * @returns A ClickUpServiceError
43
+ */
44
+ handleError(error, message) {
45
+ if (error instanceof ClickUpServiceError) {
46
+ return error;
47
+ }
48
+ return new ClickUpServiceError(message || `Task service error: ${error.message}`, ErrorCode.UNKNOWN, error);
49
+ }
50
+ /**
51
+ * Build URL parameters from task filters
52
+ * @param filters Task filters to convert to URL parameters
53
+ * @returns URLSearchParams object
54
+ */
55
+ buildTaskFilterParams(filters) {
56
+ const params = new URLSearchParams();
57
+ // Add all filters to the query parameters
58
+ if (filters.include_closed)
59
+ params.append('include_closed', String(filters.include_closed));
60
+ if (filters.subtasks)
61
+ params.append('subtasks', String(filters.subtasks));
62
+ if (filters.include_subtasks)
63
+ params.append('include_subtasks', String(filters.include_subtasks));
64
+ if (filters.page)
65
+ params.append('page', String(filters.page));
66
+ if (filters.order_by)
67
+ params.append('order_by', filters.order_by);
68
+ if (filters.reverse)
69
+ params.append('reverse', String(filters.reverse));
70
+ // Array parameters
71
+ if (filters.statuses && filters.statuses.length > 0) {
72
+ filters.statuses.forEach(status => params.append('statuses[]', status));
73
+ }
74
+ if (filters.assignees && filters.assignees.length > 0) {
75
+ filters.assignees.forEach(assignee => params.append('assignees[]', assignee));
76
+ }
77
+ // Team tasks endpoint specific parameters
78
+ if (filters.tags && filters.tags.length > 0) {
79
+ filters.tags.forEach(tag => params.append('tags[]', tag));
80
+ }
81
+ if (filters.list_ids && filters.list_ids.length > 0) {
82
+ filters.list_ids.forEach(id => params.append('list_ids[]', id));
83
+ }
84
+ if (filters.folder_ids && filters.folder_ids.length > 0) {
85
+ filters.folder_ids.forEach(id => params.append('folder_ids[]', id));
86
+ }
87
+ if (filters.space_ids && filters.space_ids.length > 0) {
88
+ filters.space_ids.forEach(id => params.append('space_ids[]', id));
89
+ }
90
+ if (filters.archived !== undefined)
91
+ params.append('archived', String(filters.archived));
92
+ if (filters.include_closed_lists !== undefined)
93
+ params.append('include_closed_lists', String(filters.include_closed_lists));
94
+ if (filters.include_archived_lists !== undefined)
95
+ params.append('include_archived_lists', String(filters.include_archived_lists));
96
+ if (filters.include_compact_time_entries !== undefined)
97
+ params.append('include_compact_time_entries', String(filters.include_compact_time_entries));
98
+ // Date filters
99
+ if (filters.due_date_gt)
100
+ params.append('due_date_gt', String(filters.due_date_gt));
101
+ if (filters.due_date_lt)
102
+ params.append('due_date_lt', String(filters.due_date_lt));
103
+ if (filters.date_created_gt)
104
+ params.append('date_created_gt', String(filters.date_created_gt));
105
+ if (filters.date_created_lt)
106
+ params.append('date_created_lt', String(filters.date_created_lt));
107
+ if (filters.date_updated_gt)
108
+ params.append('date_updated_gt', String(filters.date_updated_gt));
109
+ if (filters.date_updated_lt)
110
+ params.append('date_updated_lt', String(filters.date_updated_lt));
111
+ // Handle custom fields if present
112
+ if (filters.custom_fields) {
113
+ Object.entries(filters.custom_fields).forEach(([key, value]) => {
114
+ params.append(`custom_fields[${key}]`, String(value));
115
+ });
116
+ }
117
+ return params;
118
+ }
119
+ /**
120
+ * Extract priority value from a task
121
+ * @param task The task to extract priority from
122
+ * @returns TaskPriority or null
123
+ */
124
+ extractPriorityValue(task) {
125
+ if (!task.priority || !task.priority.id) {
126
+ return null;
127
+ }
128
+ const priorityValue = parseInt(task.priority.id);
129
+ // Ensure it's in the valid range 1-4
130
+ if (isNaN(priorityValue) || priorityValue < 1 || priorityValue > 4) {
131
+ return null;
132
+ }
133
+ return priorityValue;
134
+ }
135
+ /**
136
+ * Extract task data for creation/duplication
137
+ * @param task The source task
138
+ * @param nameOverride Optional override for the task name
139
+ * @returns CreateTaskData object
140
+ */
141
+ extractTaskData(task, nameOverride) {
142
+ return {
143
+ name: nameOverride || task.name,
144
+ description: task.description || '',
145
+ status: task.status?.status,
146
+ priority: this.extractPriorityValue(task),
147
+ due_date: task.due_date ? Number(task.due_date) : undefined,
148
+ assignees: task.assignees?.map(a => a.id) || []
149
+ };
150
+ }
151
+ /**
152
+ * Create a new task in the specified list
153
+ * @param listId The ID of the list to create the task in
154
+ * @param taskData The data for the new task
155
+ * @returns The created task
156
+ */
157
+ async createTask(listId, taskData) {
158
+ this.logOperation('createTask', { listId, ...taskData });
159
+ try {
160
+ return await this.makeRequest(async () => {
161
+ const response = await this.client.post(`/list/${listId}/task`, taskData);
162
+ // Handle both JSON and text responses
163
+ const data = response.data;
164
+ if (typeof data === 'string') {
165
+ // If we got a text response, try to extract task ID from common patterns
166
+ const idMatch = data.match(/task.*?(\w{9})/i);
167
+ if (idMatch) {
168
+ // If we found an ID, fetch the full task details
169
+ return await this.getTask(idMatch[1]);
170
+ }
171
+ throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
172
+ }
173
+ return data;
174
+ });
175
+ }
176
+ catch (error) {
177
+ throw this.handleError(error, 'Failed to create task');
178
+ }
179
+ }
180
+ /**
181
+ * Get a task by its ID
182
+ * Automatically detects custom task IDs and routes them appropriately
183
+ * @param taskId The ID of the task to retrieve (regular or custom)
184
+ * @returns The task
185
+ */
186
+ async getTask(taskId) {
187
+ this.logOperation('getTask', { taskId });
188
+ // Import the detection function here to avoid circular dependencies
189
+ const { isCustomTaskId } = await import('../../../tools/task/utilities.js');
190
+ // Test the detection function
191
+ const isCustom = isCustomTaskId(taskId);
192
+ this.logger.debug('Custom task ID detection result', {
193
+ taskId,
194
+ isCustom,
195
+ taskIdLength: taskId.length,
196
+ containsHyphen: taskId.includes('-'),
197
+ containsUnderscore: taskId.includes('_')
198
+ });
199
+ // Automatically detect custom task IDs and route to appropriate method
200
+ if (isCustom) {
201
+ this.logger.debug('Detected custom task ID, routing to getTaskByCustomId', { taskId });
202
+ return this.getTaskByCustomId(taskId);
203
+ }
204
+ this.logger.debug('Detected regular task ID, using standard getTask flow', { taskId });
205
+ try {
206
+ return await this.makeRequest(async () => {
207
+ const response = await this.client.get(`/task/${taskId}`);
208
+ // Handle both JSON and text responses
209
+ const data = response.data;
210
+ if (typeof data === 'string') {
211
+ throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
212
+ }
213
+ return data;
214
+ });
215
+ }
216
+ catch (error) {
217
+ // If this was detected as a regular task ID but failed, provide helpful error message
218
+ // suggesting it might be a custom ID that wasn't properly detected
219
+ if (error instanceof ClickUpServiceError && error.code === ErrorCode.NOT_FOUND) {
220
+ const { isCustomTaskId } = await import('../../../tools/task/utilities.js');
221
+ if (!isCustomTaskId(taskId) && (taskId.includes('-') || taskId.includes('_'))) {
222
+ throw new ClickUpServiceError(`Task ${taskId} not found. If this is a custom task ID, ensure your workspace has custom task IDs enabled and you have access to the task.`, ErrorCode.NOT_FOUND, error.data);
223
+ }
224
+ }
225
+ throw this.handleError(error, `Failed to get task ${taskId}`);
226
+ }
227
+ }
228
+ /**
229
+ * Get all tasks in a list
230
+ * @param listId The ID of the list to get tasks from
231
+ * @param filters Optional filters to apply
232
+ * @returns Array of tasks
233
+ */
234
+ async getTasks(listId, filters = {}) {
235
+ this.logOperation('getTasks', { listId, filters });
236
+ try {
237
+ return await this.makeRequest(async () => {
238
+ const params = this.buildTaskFilterParams(filters);
239
+ const response = await this.client.get(`/list/${listId}/task`, { params });
240
+ // Handle both JSON and text responses
241
+ const data = response.data;
242
+ if (typeof data === 'string') {
243
+ throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
244
+ }
245
+ return Array.isArray(data) ? data : data.tasks || [];
246
+ });
247
+ }
248
+ catch (error) {
249
+ throw this.handleError(error, `Failed to get tasks for list ${listId}`);
250
+ }
251
+ }
252
+ /**
253
+ * Get subtasks of a specific task
254
+ * @param taskId The ID of the parent task
255
+ * @returns Array of subtask details
256
+ */
257
+ async getSubtasks(taskId) {
258
+ this.logOperation('getSubtasks', { taskId });
259
+ try {
260
+ return await this.makeRequest(async () => {
261
+ const response = await this.client.get(`/task/${taskId}?subtasks=true&include_subtasks=true`);
262
+ // Return subtasks if present, otherwise empty array
263
+ return response.data.subtasks || [];
264
+ });
265
+ }
266
+ catch (error) {
267
+ throw this.handleError(error, `Failed to get subtasks for task ${taskId}`);
268
+ }
269
+ }
270
+ /**
271
+ * Get a task by its custom ID
272
+ * @param customTaskId The custom ID of the task (e.g., "ABC-123")
273
+ * @param listId Optional list ID to limit the search (Note: ClickUp API might not filter by list_id when using custom_task_id)
274
+ * @returns The task details
275
+ */
276
+ async getTaskByCustomId(customTaskId, listId) {
277
+ // Log the operation, including listId even if the API might ignore it for this specific lookup type
278
+ this.logOperation('getTaskByCustomId', { customTaskId, listId });
279
+ try {
280
+ return await this.makeRequest(async () => {
281
+ // Use the standard task endpoint with the custom task ID
282
+ const url = `/task/${encodeURIComponent(customTaskId)}`;
283
+ // Add required query parameters for custom ID lookup
284
+ const params = new URLSearchParams({
285
+ custom_task_ids: 'true',
286
+ team_id: this.teamId // team_id is required when custom_task_ids is true
287
+ });
288
+ // Debug logging for troubleshooting
289
+ this.logger.debug('Making custom task ID API request', {
290
+ customTaskId,
291
+ url,
292
+ teamId: this.teamId,
293
+ params: params.toString(),
294
+ fullUrl: `${url}?${params.toString()}`
295
+ });
296
+ // Note: The ClickUp API documentation for GET /task/{task_id} doesn't explicitly mention
297
+ // filtering by list_id when custom_task_ids=true. This parameter might be ignored.
298
+ if (listId) {
299
+ this.logger.warn('listId provided to getTaskByCustomId, but the ClickUp API endpoint might not support it directly for custom ID lookups.', { customTaskId, listId });
300
+ // If ClickUp API were to support it, you would add it like this:
301
+ // params.append('list_id', listId);
302
+ }
303
+ const response = await this.client.get(url, { params });
304
+ // Handle potential non-JSON responses (though less likely with GET)
305
+ const data = response.data;
306
+ if (typeof data === 'string') {
307
+ throw new ClickUpServiceError('Received unexpected text response from API when fetching by custom ID', ErrorCode.UNKNOWN, data);
308
+ }
309
+ return data;
310
+ });
311
+ }
312
+ catch (error) {
313
+ // Enhanced error logging for debugging
314
+ this.logger.error('Custom task ID request failed', {
315
+ customTaskId,
316
+ teamId: this.teamId,
317
+ error: error instanceof Error ? error.message : String(error),
318
+ errorDetails: error
319
+ });
320
+ // Provide more specific error context if possible
321
+ if (error instanceof ClickUpServiceError && error.code === ErrorCode.NOT_FOUND) {
322
+ throw new ClickUpServiceError(`Task with custom ID ${customTaskId} not found or not accessible for team ${this.teamId}.`, ErrorCode.NOT_FOUND, error.data);
323
+ }
324
+ throw this.handleError(error, `Failed to get task with custom ID ${customTaskId}`);
325
+ }
326
+ }
327
+ /**
328
+ * Update an existing task
329
+ * @param taskId The ID of the task to update
330
+ * @param updateData The data to update
331
+ * @returns The updated task
332
+ */
333
+ async updateTask(taskId, updateData) {
334
+ this.logOperation('updateTask', { taskId, ...updateData });
335
+ try {
336
+ // Extract custom fields and assignees from updateData
337
+ const { custom_fields, assignees, ...standardFields } = updateData;
338
+ // Prepare the fields to send to API
339
+ let fieldsToSend = { ...standardFields };
340
+ // Handle assignees separately if provided
341
+ if (assignees !== undefined) {
342
+ // Get current task to compare assignees
343
+ const currentTask = await this.getTask(taskId);
344
+ const currentAssigneeIds = currentTask.assignees.map(a => a.id);
345
+ let assigneesToProcess;
346
+ if (Array.isArray(assignees)) {
347
+ // If assignees is an array, calculate add/rem based on current vs new
348
+ const newAssigneeIds = assignees;
349
+ assigneesToProcess = {
350
+ add: newAssigneeIds.filter(id => !currentAssigneeIds.includes(id)),
351
+ rem: currentAssigneeIds.filter(id => !newAssigneeIds.includes(id))
352
+ };
353
+ }
354
+ else {
355
+ // If assignees is already in add/rem format, use it directly
356
+ assigneesToProcess = assignees;
357
+ }
358
+ // Add assignees to the fields in the correct format
359
+ fieldsToSend.assignees = assigneesToProcess;
360
+ }
361
+ // First update the standard fields
362
+ const updatedTask = await this.makeRequest(async () => {
363
+ const response = await this.client.put(`/task/${taskId}`, fieldsToSend);
364
+ // Handle both JSON and text responses
365
+ const data = response.data;
366
+ if (typeof data === 'string') {
367
+ // If we got a text response, try to extract task ID from common patterns
368
+ const idMatch = data.match(/task.*?(\w{9})/i);
369
+ if (idMatch) {
370
+ // If we found an ID, fetch the full task details
371
+ return await this.getTask(idMatch[1]);
372
+ }
373
+ throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
374
+ }
375
+ return data;
376
+ });
377
+ // Then update custom fields if provided
378
+ if (custom_fields && Array.isArray(custom_fields) && custom_fields.length > 0) {
379
+ // Use the setCustomFieldValues method from the inherited class
380
+ // This will be available in TaskServiceCustomFields which extends this class
381
+ await this.setCustomFieldValues(taskId, custom_fields);
382
+ // Fetch the task again to get the updated version with custom fields
383
+ return await this.getTask(taskId);
384
+ }
385
+ return updatedTask;
386
+ }
387
+ catch (error) {
388
+ throw this.handleError(error, `Failed to update task ${taskId}`);
389
+ }
390
+ }
391
+ /**
392
+ * Delete a task
393
+ * @param taskId The ID of the task to delete
394
+ * @returns A ServiceResponse indicating success
395
+ */
396
+ async deleteTask(taskId) {
397
+ this.logOperation('deleteTask', { taskId });
398
+ try {
399
+ await this.makeRequest(async () => {
400
+ await this.client.delete(`/task/${taskId}`);
401
+ });
402
+ return {
403
+ success: true,
404
+ data: undefined,
405
+ error: undefined
406
+ };
407
+ }
408
+ catch (error) {
409
+ throw this.handleError(error, `Failed to delete task ${taskId}`);
410
+ }
411
+ }
412
+ /**
413
+ * Move a task to another list
414
+ * @param taskId The ID of the task to move
415
+ * @param destinationListId The ID of the list to move the task to
416
+ * @returns The updated task
417
+ */
418
+ async moveTask(taskId, destinationListId) {
419
+ const startTime = Date.now();
420
+ this.logOperation('moveTask', { taskId, destinationListId, operation: 'start' });
421
+ try {
422
+ // First, get task and validate destination list
423
+ const [sourceTask, _] = await Promise.all([
424
+ this.validateTaskExists(taskId),
425
+ this.validateListExists(destinationListId)
426
+ ]);
427
+ // Extract task data for creating the new task
428
+ const taskData = this.extractTaskData(sourceTask);
429
+ // Create the task in the new list
430
+ const newTask = await this.createTask(destinationListId, taskData);
431
+ // Delete the original task
432
+ await this.deleteTask(taskId);
433
+ // Update the cache
434
+ this.validationCache.tasks.delete(taskId);
435
+ this.validationCache.tasks.set(newTask.id, {
436
+ validatedAt: Date.now(),
437
+ task: newTask
438
+ });
439
+ const totalTime = Date.now() - startTime;
440
+ this.logOperation('moveTask', {
441
+ taskId,
442
+ destinationListId,
443
+ operation: 'complete',
444
+ timing: { totalTime },
445
+ newTaskId: newTask.id
446
+ });
447
+ return newTask;
448
+ }
449
+ catch (error) {
450
+ // Log failure
451
+ this.logOperation('moveTask', {
452
+ taskId,
453
+ destinationListId,
454
+ operation: 'failed',
455
+ error: error instanceof Error ? error.message : String(error),
456
+ timing: { totalTime: Date.now() - startTime }
457
+ });
458
+ throw this.handleError(error, 'Failed to move task');
459
+ }
460
+ }
461
+ /**
462
+ * Duplicate a task, optionally to a different list
463
+ * @param taskId The ID of the task to duplicate
464
+ * @param listId Optional ID of list to create duplicate in (defaults to same list)
465
+ * @returns The duplicated task
466
+ */
467
+ async duplicateTask(taskId, listId) {
468
+ this.logOperation('duplicateTask', { taskId, listId });
469
+ try {
470
+ // Get source task and validate destination list if provided
471
+ const [sourceTask, _] = await Promise.all([
472
+ this.validateTaskExists(taskId),
473
+ listId ? this.validateListExists(listId) : Promise.resolve()
474
+ ]);
475
+ // Create duplicate in specified list or original list
476
+ const targetListId = listId || sourceTask.list.id;
477
+ const taskData = this.extractTaskData(sourceTask);
478
+ return await this.createTask(targetListId, taskData);
479
+ }
480
+ catch (error) {
481
+ throw this.handleError(error, `Failed to duplicate task ${taskId}`);
482
+ }
483
+ }
484
+ /**
485
+ * Validate a task exists and cache the result
486
+ * @param taskId The ID of the task to validate
487
+ * @returns The validated task
488
+ */
489
+ async validateTaskExists(taskId) {
490
+ // Check cache first
491
+ const cached = this.validationCache.tasks.get(taskId);
492
+ if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) {
493
+ this.logger.debug('Using cached task validation', { taskId });
494
+ return cached.task;
495
+ }
496
+ // Not in cache or expired, fetch task
497
+ const task = await this.getTask(taskId);
498
+ // Cache the validation result
499
+ this.validationCache.tasks.set(taskId, {
500
+ validatedAt: Date.now(),
501
+ task
502
+ });
503
+ return task;
504
+ }
505
+ /**
506
+ * Validate that multiple tasks exist
507
+ * @param taskIds Array of task IDs to validate
508
+ * @returns Map of task IDs to task objects
509
+ */
510
+ async validateTasksExist(taskIds) {
511
+ const results = new Map();
512
+ const toFetch = [];
513
+ // Check cache first
514
+ for (const taskId of taskIds) {
515
+ const cached = this.validationCache.tasks.get(taskId);
516
+ if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) {
517
+ results.set(taskId, cached.task);
518
+ }
519
+ else {
520
+ toFetch.push(taskId);
521
+ }
522
+ }
523
+ if (toFetch.length > 0) {
524
+ // Fetch uncached tasks in parallel batches
525
+ const batchSize = 5;
526
+ for (let i = 0; i < toFetch.length; i += batchSize) {
527
+ const batch = toFetch.slice(i, i + batchSize);
528
+ const tasks = await Promise.all(batch.map(id => this.getTask(id)));
529
+ // Cache and store results
530
+ tasks.forEach((task, index) => {
531
+ const taskId = batch[index];
532
+ this.validationCache.tasks.set(taskId, {
533
+ validatedAt: Date.now(),
534
+ task
535
+ });
536
+ results.set(taskId, task);
537
+ });
538
+ }
539
+ }
540
+ return results;
541
+ }
542
+ /**
543
+ * Validate a list exists and cache the result
544
+ * @param listId The ID of the list to validate
545
+ */
546
+ async validateListExists(listId) {
547
+ // Check cache first
548
+ const cached = this.validationCache.lists.get(listId);
549
+ if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) {
550
+ this.logger.debug('Using cached list validation', { listId });
551
+ if (!cached.valid) {
552
+ throw new ClickUpServiceError(`List ${listId} does not exist`, ErrorCode.NOT_FOUND);
553
+ }
554
+ return;
555
+ }
556
+ try {
557
+ await this.listService.getList(listId);
558
+ // Cache the successful validation
559
+ this.validationCache.lists.set(listId, {
560
+ validatedAt: Date.now(),
561
+ valid: true
562
+ });
563
+ }
564
+ catch (error) {
565
+ // Cache the failed validation
566
+ this.validationCache.lists.set(listId, {
567
+ validatedAt: Date.now(),
568
+ valid: false
569
+ });
570
+ throw error;
571
+ }
572
+ }
573
+ /**
574
+ * Try to get a task ID from the name cache
575
+ * @param taskName The name of the task
576
+ * @param listId Optional list ID for context
577
+ * @returns The cached task ID if found and not expired, otherwise null
578
+ */
579
+ getCachedTaskId(taskName, listId) {
580
+ const cached = this.nameToIdCache.get(taskName);
581
+ if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) {
582
+ // If listId is provided, ensure it matches the cached context
583
+ if (!listId || cached.listId === listId) {
584
+ this.logger.debug('Using cached task ID for name', { taskName, cachedId: cached.taskId });
585
+ return cached.taskId;
586
+ }
587
+ }
588
+ return null;
589
+ }
590
+ /**
591
+ * Cache a task name to ID mapping
592
+ * @param taskName The name of the task
593
+ * @param taskId The ID of the task
594
+ * @param listId Optional list ID for context
595
+ */
596
+ cacheTaskNameToId(taskName, taskId, listId) {
597
+ this.nameToIdCache.set(taskName, {
598
+ taskId,
599
+ validatedAt: Date.now(),
600
+ listId
601
+ });
602
+ this.logger.debug('Cached task name to ID mapping', { taskName, taskId, listId });
603
+ }
604
+ }