PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 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 All 43 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.2.0, at app/Modules/MCP/Tools/TaskTools.php

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