@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.
- package/Dockerfile +38 -0
- package/LICENSE +21 -0
- package/README.md +470 -0
- package/build/config.js +237 -0
- package/build/index.js +87 -0
- package/build/logger.js +163 -0
- package/build/middleware/security.js +231 -0
- package/build/server.js +288 -0
- package/build/services/clickup/base.js +432 -0
- package/build/services/clickup/bulk.js +180 -0
- package/build/services/clickup/document.js +159 -0
- package/build/services/clickup/folder.js +136 -0
- package/build/services/clickup/index.js +76 -0
- package/build/services/clickup/list.js +191 -0
- package/build/services/clickup/tag.js +239 -0
- package/build/services/clickup/task/index.js +32 -0
- package/build/services/clickup/task/task-attachments.js +105 -0
- package/build/services/clickup/task/task-comments.js +114 -0
- package/build/services/clickup/task/task-core.js +604 -0
- package/build/services/clickup/task/task-custom-fields.js +107 -0
- package/build/services/clickup/task/task-search.js +986 -0
- package/build/services/clickup/task/task-service.js +104 -0
- package/build/services/clickup/task/task-tags.js +113 -0
- package/build/services/clickup/time.js +244 -0
- package/build/services/clickup/types.js +33 -0
- package/build/services/clickup/workspace.js +397 -0
- package/build/services/shared.js +61 -0
- package/build/sse_server.js +277 -0
- package/build/tools/documents.js +489 -0
- package/build/tools/folder.js +331 -0
- package/build/tools/index.js +16 -0
- package/build/tools/list.js +428 -0
- package/build/tools/member.js +106 -0
- package/build/tools/tag.js +833 -0
- package/build/tools/task/attachments.js +357 -0
- package/build/tools/task/attachments.types.js +9 -0
- package/build/tools/task/bulk-operations.js +338 -0
- package/build/tools/task/handlers.js +919 -0
- package/build/tools/task/index.js +30 -0
- package/build/tools/task/main.js +233 -0
- package/build/tools/task/single-operations.js +469 -0
- package/build/tools/task/time-tracking.js +575 -0
- package/build/tools/task/utilities.js +310 -0
- package/build/tools/task/workspace-operations.js +258 -0
- package/build/tools/tool-enhancer.js +37 -0
- package/build/tools/utils.js +12 -0
- package/build/tools/workspace-helper.js +44 -0
- package/build/tools/workspace.js +73 -0
- package/build/utils/color-processor.js +183 -0
- package/build/utils/concurrency-utils.js +248 -0
- package/build/utils/date-utils.js +542 -0
- package/build/utils/resolver-utils.js +135 -0
- package/build/utils/sponsor-service.js +93 -0
- package/build/utils/token-utils.js +49 -0
- package/package.json +77 -0
- 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
|
+
}
|