PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / trunk
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration vtrunk
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 trunk, at app/Modules/MCP/Tools/TaskTools.php

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