PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.0.12
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.0.12
2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 1.45 All 41 releases
fluent-boards / app / Modules / MCP / Tools / TaskTools.php

TaskTools.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.0.12, at app/Modules/MCP/Tools/TaskTools.php

485 lines 17.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Modules\MCP\Tools;
4
5 use FluentBoards\App\Models\Stage;
6 use FluentBoards\App\Models\Task;
7 use FluentBoards\App\Modules\MCP\Helpers\MCPHelper;
8 use FluentBoards\App\Services\Constant;
9 use FluentBoards\App\Services\TaskService;
10
11 /**
12 * Task read/write MCP tools.
13 */
14 class TaskTools
15 {
16 const MAX_ASSIGNEES_PER_CALL = 50;
17
18 public static function canMoveTask($params = [])
19 {
20 $sourceBoardId = isset($params['board_id']) ? absint($params['board_id']) : 0;
21 $targetBoardId = !empty($params['target_board_id']) ? absint($params['target_board_id']) : $sourceBoardId;
22
23 if (!$sourceBoardId || !$targetBoardId) {
24 return false;
25 }
26
27 return MCPHelper::canWriteBoard($sourceBoardId) && MCPHelper::canWriteBoard($targetBoardId);
28 }
29
30 public static function listTasks($params = [])
31 {
32 $board = MCPHelper::resolveBoard($params);
33 if (is_wp_error($board)) {
34 return $board;
35 }
36
37 if (!MCPHelper::canReadBoard($board->id)) {
38 return MCPHelper::error('forbidden', __('You do not have access to this board', 'fluent-boards'));
39 }
40
41 $pagination = MCPHelper::normalizePagination($params);
42 $args = [
43 'page' => $pagination['page'],
44 'per_page' => $pagination['per_page'],
45 'sort_by' => !empty($params['sort_by']) ? sanitize_text_field($params['sort_by']) : 'position',
46 'sort_direction' => !empty($params['sort_direction']) ? sanitize_text_field($params['sort_direction']) : 'asc',
47 'search' => !empty($params['search']) ? sanitize_text_field($params['search']) : '',
48 'include_archived' => !empty($params['include_archived']),
49 'stage' => isset($params['stage']) ? (array) $params['stage'] : [],
50 'task_status' => isset($params['task_status']) ? (array) $params['task_status'] : [],
51 'priority' => isset($params['priority']) ? (array) $params['priority'] : [],
52 'assignee' => isset($params['assignee']) ? (array) $params['assignee'] : [],
53 'labels' => isset($params['labels']) ? (array) $params['labels'] : [],
54 'due_date' => isset($params['due_date']) ? (array) $params['due_date'] : [],
55 ];
56
57 $paginated = (new TaskService())->getTableTasks($board->id, $args);
58
59 return [
60 'items' => MCPHelper::formatTaskList($paginated->items()),
61 'pagination' => [
62 'total' => (int) $paginated->total(),
63 'current_page' => (int) $paginated->currentPage(),
64 'per_page' => (int) $paginated->perPage(),
65 'last_page' => (int) $paginated->lastPage(),
66 ],
67 ];
68 }
69
70 public static function getTask($params = [])
71 {
72 $task = MCPHelper::resolveTask($params);
73 if (is_wp_error($task)) {
74 return $task;
75 }
76
77 if (!MCPHelper::canReadBoard($task->board_id)) {
78 return MCPHelper::error('forbidden', __('You do not have access to this task', 'fluent-boards'));
79 }
80
81 return [
82 'task' => MCPHelper::formatTask($task),
83 ];
84 }
85
86 public static function createTask($params = [])
87 {
88 $board = MCPHelper::resolveBoard($params);
89 if (is_wp_error($board)) {
90 return $board;
91 }
92
93 if (!MCPHelper::canWriteBoard($board->id)) {
94 return MCPHelper::error('forbidden', __('You do not have permission to create tasks on this board', 'fluent-boards'));
95 }
96
97 $title = isset($params['title']) ? sanitize_text_field($params['title']) : '';
98 if ($title === '') {
99 return MCPHelper::error('invalid_param', __('Task title is required', 'fluent-boards'));
100 }
101
102 $stage = MCPHelper::assertStageBelongsToBoard($params['stage_id'] ?? 0, $board->id);
103 if (is_wp_error($stage)) {
104 return $stage;
105 }
106
107 $taskData = [
108 'title' => $title,
109 'board_id' => (int) $board->id,
110 'stage_id' => (int) $stage->id,
111 ];
112
113 if (!empty($params['priority'])) {
114 if (!in_array($params['priority'], self::getAllowedTaskPriorities(), true)) {
115 return MCPHelper::error('invalid_param', __('Invalid task priority', 'fluent-boards'), [
116 'allowed' => self::getAllowedTaskPriorities(),
117 ]);
118 }
119 $taskData['priority'] = sanitize_text_field($params['priority']);
120 }
121
122 foreach (['due_at', 'started_at'] as $field) {
123 if (!empty($params[$field])) {
124 $taskData[$field] = sanitize_text_field($params[$field]);
125 }
126 }
127
128 if (!empty($params['description'])) {
129 $taskData['description'] = MCPHelper::sanitizeMarkdown($params['description']);
130 }
131
132 if (!empty($params['crm_contact_id'])) {
133 $taskData['crm_contact_id'] = absint($params['crm_contact_id']);
134 }
135
136 $task = (new TaskService())->createTask($taskData, $board->id);
137 MCPHelper::loadTaskDetails($task);
138
139 return [
140 'task' => MCPHelper::formatTask($task),
141 'message' => __('Task has been successfully created', 'fluent-boards'),
142 ];
143 }
144
145 public static function updateTask($params = [])
146 {
147 $task = MCPHelper::resolveTask($params);
148 if (is_wp_error($task)) {
149 return $task;
150 }
151
152 if (!MCPHelper::canWriteBoard($task->board_id)) {
153 return MCPHelper::error('forbidden', __('You do not have permission to update this task', 'fluent-boards'));
154 }
155
156 $service = new TaskService();
157 $updatable = [
158 'title' => 'text',
159 'description' => 'markdown',
160 'status' => 'text',
161 'priority' => 'nullable_text',
162 'due_at' => 'nullable_text',
163 'started_at' => 'nullable_text',
164 'crm_contact_id' => 'nullable_int',
165 'settings' => 'array',
166 ];
167
168 foreach ($updatable as $field => $type) {
169 if (!array_key_exists($field, $params)) {
170 continue;
171 }
172
173 $validationError = self::validateUpdateField($field, $params[$field]);
174 if (is_wp_error($validationError)) {
175 return $validationError;
176 }
177
178 $value = self::sanitizeUpdateValue($params[$field], $type);
179 $task = $service->updateTaskProperty($field, $value, $task);
180 }
181
182 if (array_key_exists('assignees', $params)) {
183 $task = self::syncAssignees($task, MCPHelper::sanitizeIdArray($params['assignees']));
184 }
185
186 MCPHelper::loadTaskDetails($task);
187
188 return [
189 'task' => MCPHelper::formatTask($task),
190 'message' => __('Task has been updated', 'fluent-boards'),
191 ];
192 }
193
194 public static function moveTask($params = [])
195 {
196 $task = MCPHelper::resolveTask($params);
197 if (is_wp_error($task)) {
198 return $task;
199 }
200
201 $sourceBoardId = (int) $task->board_id;
202 $targetBoardId = !empty($params['target_board_id']) ? absint($params['target_board_id']) : $sourceBoardId;
203 $targetStageId = !empty($params['target_stage_id']) ? absint($params['target_stage_id']) : 0;
204
205 if (!MCPHelper::canWriteBoard($sourceBoardId) || !MCPHelper::canWriteBoard($targetBoardId)) {
206 return MCPHelper::error('forbidden', __('You do not have permission to move this task', 'fluent-boards'));
207 }
208
209 $stage = MCPHelper::assertStageBelongsToBoard($targetStageId, $targetBoardId);
210 if (is_wp_error($stage)) {
211 return $stage;
212 }
213
214 $service = new TaskService();
215 $oldStageId = (int) $task->stage_id;
216
217 if ($targetBoardId !== $sourceBoardId) {
218 $task = $service->changeBoardByTask($task, $targetBoardId);
219 }
220
221 if ($oldStageId !== $targetStageId) {
222 \FluentBoards\App\Models\TaskMeta::where('task_id', $task->id)
223 ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
224 ->delete();
225 }
226
227 $task->stage_id = $targetStageId;
228 $task->save();
229
230 $previousTaskId = !empty($params['previous_task_id']) ? absint($params['previous_task_id']) : 0;
231 $nextTaskId = !empty($params['next_task_id']) ? absint($params['next_task_id']) : 0;
232 if ($previousTaskId || $nextTaskId) {
233 $task = $task->moveBetweenTasks($previousTaskId, $nextTaskId);
234 } else {
235 $position = !empty($params['position']) ? absint($params['position']) : 1;
236 $task = $task->moveToNewPosition($position);
237 }
238
239 if ($oldStageId !== $targetStageId) {
240 $service->manageDefaultAssignees($task, $targetStageId);
241
242 if ($stage->defaultTaskStatus() === 'closed' && $task->status !== 'closed') {
243 $task = $task->close();
244 } elseif ($stage->defaultTaskStatus() === 'open' && $task->status === 'closed') {
245 $task = $task->reopen();
246 }
247
248 do_action('fluent_boards/task_stage_updated', $task, $oldStageId);
249 }
250
251 do_action('fluent_boards/task_updated', $task, 'position');
252 MCPHelper::loadTaskDetails($task);
253
254 return [
255 'task' => MCPHelper::formatTask($task),
256 'message' => __('Task has been moved', 'fluent-boards'),
257 ];
258 }
259
260 /**
261 * Core assignee management. Pro also ships add-task-assignee/remove-task-assignee; this works
262 * without Pro and adds a sync mode.
263 */
264 public static function assignTask($params = [])
265 {
266 $task = MCPHelper::resolveTask($params);
267 if (is_wp_error($task)) {
268 return $task;
269 }
270
271 if (!MCPHelper::canWriteBoard($task->board_id)) {
272 return MCPHelper::error('forbidden', __('You do not have permission to update this task', 'fluent-boards'));
273 }
274
275 $mode = !empty($params['mode']) ? sanitize_text_field($params['mode']) : 'add';
276 if (!in_array($mode, ['add', 'remove', 'sync'], true)) {
277 return MCPHelper::error('invalid_param', __('Invalid assignment mode', 'fluent-boards'), [
278 'allowed' => ['add', 'remove', 'sync'],
279 ]);
280 }
281
282 $hasUserIds = array_key_exists('user_ids', $params);
283 $userIds = MCPHelper::sanitizeIdArray($params['user_ids'] ?? []);
284 if (!$hasUserIds && !empty($params['user_id'])) {
285 $userIds = [absint($params['user_id'])];
286 }
287
288 if (!$userIds && !($mode === 'sync' && $hasUserIds && is_array($params['user_ids']))) {
289 return MCPHelper::error('invalid_param', __('Provide user_id or user_ids', 'fluent-boards'));
290 }
291
292 if (count($userIds) > self::MAX_ASSIGNEES_PER_CALL) {
293 return MCPHelper::error('invalid_param', __('Too many users in one call', 'fluent-boards'), [
294 'max' => self::MAX_ASSIGNEES_PER_CALL,
295 ]);
296 }
297
298 // fbs_relations has no foreign key, so an unknown id would persist as an assignee
299 // and a watcher that no user can ever be loaded for.
300 $unknownIds = self::findUnknownUserIds($userIds);
301 if ($unknownIds) {
302 return MCPHelper::error('not_found', __('Some users do not exist', 'fluent-boards'), [
303 'unknown_user_ids' => $unknownIds,
304 ]);
305 }
306
307 $task->load('assignees');
308 $currentIds = [];
309 foreach ($task->assignees as $assignee) {
310 $currentIds[] = (int) $assignee->ID;
311 }
312
313 // updateAssignee() toggles, so only feed it genuine changes.
314 if ($mode === 'add') {
315 $toAdd = array_diff($userIds, $currentIds);
316 $toRemove = [];
317 } elseif ($mode === 'remove') {
318 $toAdd = [];
319 $toRemove = array_intersect($userIds, $currentIds);
320 } else {
321 $toAdd = array_diff($userIds, $currentIds);
322 $toRemove = array_diff($currentIds, $userIds);
323 }
324
325 $service = new TaskService();
326
327 foreach (array_merge($toRemove, $toAdd) as $userId) {
328 $service->updateAssignee($userId, $task);
329 $task->load('assignees');
330 }
331
332 MCPHelper::loadTaskDetails($task);
333
334 return [
335 'task_id' => (int) $task->id,
336 'board_id' => (int) $task->board_id,
337 'mode' => $mode,
338 'added' => array_values($toAdd),
339 'removed' => array_values($toRemove),
340 'assignees' => MCPHelper::formatUserList($task->assignees),
341 'message' => __('Task assignees have been updated', 'fluent-boards'),
342 ];
343 }
344
345 public static function archiveTask($params = [])
346 {
347 $task = MCPHelper::resolveTask($params);
348 if (is_wp_error($task)) {
349 return $task;
350 }
351
352 if (!MCPHelper::canWriteBoard($task->board_id)) {
353 return MCPHelper::error('forbidden', __('You do not have permission to archive this task', 'fluent-boards'));
354 }
355
356 $archived = array_key_exists('archived', $params) ? (bool) $params['archived'] : true;
357 $value = $archived ? current_time('mysql') : null;
358
359 $task = (new TaskService())->updateTaskProperty('archived_at', $value, $task);
360 MCPHelper::loadTaskDetails($task);
361
362 return [
363 'task' => MCPHelper::formatTask($task),
364 'message' => $archived ? __('Task has been archived', 'fluent-boards') : __('Task has been restored', 'fluent-boards'),
365 ];
366 }
367
368 /**
369 * @return array Ids with no matching WordPress user, in the order supplied.
370 */
371 private static function findUnknownUserIds($userIds)
372 {
373 $found = get_users([
374 'include' => $userIds,
375 'fields' => 'ID',
376 'number' => count($userIds),
377 ]);
378
379 $found = array_map('intval', (array) $found);
380
381 return array_values(array_diff($userIds, $found));
382 }
383
384 private static function sanitizeUpdateValue($value, $type)
385 {
386 if ($type === 'html') {
387 return wp_kses_post((string) $value);
388 }
389
390 if ($type === 'markdown') {
391 return MCPHelper::sanitizeMarkdown($value);
392 }
393
394 if ($type === 'array') {
395 return is_array($value) ? self::sanitizeArray($value) : [];
396 }
397
398 if ($type === 'nullable_int') {
399 return $value === null || $value === '' ? null : absint($value);
400 }
401
402 if ($type === 'nullable_text') {
403 return $value === null ? null : sanitize_text_field((string) $value);
404 }
405
406 return sanitize_text_field((string) $value);
407 }
408
409 private static function validateUpdateField($field, $value)
410 {
411 if ($field === 'status' && !in_array($value, ['open', 'closed'], true)) {
412 return MCPHelper::error('invalid_param', __('Invalid task status', 'fluent-boards'), [
413 'allowed' => ['open', 'closed'],
414 ]);
415 }
416
417 if ($field === 'priority' && $value !== '' && $value !== null && !in_array($value, self::getAllowedTaskPriorities(), true)) {
418 return MCPHelper::error('invalid_param', __('Invalid task priority', 'fluent-boards'), [
419 'allowed' => self::getAllowedTaskPriorities(),
420 ]);
421 }
422
423 return true;
424 }
425
426 /**
427 * Get priority keys allowed by the task priority filter.
428 *
429 * @return array
430 */
431 private static function getAllowedTaskPriorities()
432 {
433 return array_map('strval', array_keys(apply_filters('fluent_boards/task_priorities', [
434 '' => __('No priority', 'fluent-boards'),
435 'urgent' => __('Urgent', 'fluent-boards'),
436 'high' => __('High', 'fluent-boards'),
437 'medium' => __('Medium', 'fluent-boards'),
438 'low' => __('Low', 'fluent-boards'),
439 ])));
440 }
441
442 private static function sanitizeArray($value)
443 {
444 $sanitized = [];
445 foreach ((array) $value as $key => $item) {
446 $safeKey = sanitize_key((string) $key);
447 if ($safeKey === '') {
448 continue;
449 }
450
451 if (is_array($item)) {
452 $sanitized[$safeKey] = self::sanitizeArray($item);
453 } elseif (is_bool($item) || is_int($item) || is_float($item) || $item === null) {
454 $sanitized[$safeKey] = $item;
455 } else {
456 $sanitized[$safeKey] = sanitize_text_field((string) $item);
457 }
458 }
459
460 return $sanitized;
461 }
462
463 private static function syncAssignees($task, $newAssigneeIds)
464 {
465 $task->load('assignees');
466 $currentIds = [];
467 foreach ($task->assignees as $assignee) {
468 $currentIds[] = (int) $assignee->ID;
469 }
470
471 $service = new TaskService();
472 foreach (array_diff($currentIds, $newAssigneeIds) as $removeId) {
473 $service->updateAssignee($removeId, $task);
474 $task->load('assignees');
475 }
476
477 foreach (array_diff($newAssigneeIds, $currentIds) as $addId) {
478 $service->updateAssignee($addId, $task);
479 $task->load('assignees');
480 }
481
482 return $task;
483 }
484 }
485