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
← All changes | app/Modules/MCP/Tools/TaskTools.php +195 -9 1.95.2 → 2.2.0 View file →
@@ -1,12 +1,15 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Modules\MCP\Tools;
4 4
5 +use FluentBoards\App\Models\Relation;
5 6 use FluentBoards\App\Models\Stage;
6 7 use FluentBoards\App\Models\Task;
7 8 use FluentBoards\App\Modules\MCP\Helpers\MCPHelper;
8 9 use FluentBoards\App\Services\Constant;
10 +use FluentBoards\App\Services\NotificationService;
11 +use FluentBoards\App\Services\PermissionManager;
9 12 use FluentBoards\App\Services\TaskService;
10 13
11 14 /**
12 15 * Task read/write MCP tools.
@@ -12,8 +15,10 @@
12 15 * Task read/write MCP tools.
13 16 */
14 17 class TaskTools
15 18 {
19 + const MAX_ASSIGNEES_PER_CALL = 50;
20 +
16 21 public static function canMoveTask($params = [])
17 22 {
18 23 $sourceBoardId = isset($params['board_id']) ? absint($params['board_id']) : 0;
19 24 $targetBoardId = !empty($params['target_board_id']) ? absint($params['target_board_id']) : $sourceBoardId;
@@ -52,8 +57,10 @@
52 57 'due_date' => isset($params['due_date']) ? (array) $params['due_date'] : [],
53 58 ];
54 59
55 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');
56 63
57 64 return [
58 65 'items' => MCPHelper::formatTaskList($paginated->items()),
59 66 'pagination' => [
@@ -108,11 +115,11 @@
108 115 'stage_id' => (int) $stage->id,
109 116 ];
110 117
111 118 if (!empty($params['priority'])) {
112 - if (!in_array($params['priority'], ['low', 'medium', 'high'], true)) {
119 + if (!in_array($params['priority'], self::getAllowedTaskPriorities(), true)) {
113 120 return MCPHelper::error('invalid_param', __('Invalid task priority', 'fluent-boards'), [
114 - 'allowed' => ['low', 'medium', 'high'],
121 + 'allowed' => self::getAllowedTaskPriorities(),
115 122 ]);
116 123 }
117 124 $taskData['priority'] = sanitize_text_field($params['priority']);
118 125 }
@@ -123,9 +130,9 @@
123 130 }
124 131 }
125 132
126 133 if (!empty($params['description'])) {
127 - $taskData['description'] = wp_kses_post($params['description']);
134 + $taskData['description'] = MCPHelper::sanitizeMarkdown($params['description']);
128 135 }
129 136
130 137 if (!empty($params['crm_contact_id'])) {
131 138 $taskData['crm_contact_id'] = absint($params['crm_contact_id']);
@@ -131,11 +138,12 @@
131 138 $taskData['crm_contact_id'] = absint($params['crm_contact_id']);
132 139 }
133 140
134 141 $task = (new TaskService())->createTask($taskData, $board->id);
142 + MCPHelper::loadTaskDetails($task);
135 143
136 144 return [
137 - 'task' => MCPHelper::formatTaskSummary($task),
145 + 'task' => MCPHelper::formatTask($task),
138 146 'message' => __('Task has been successfully created', 'fluent-boards'),
139 147 ];
140 148 }
141 149
@@ -149,12 +157,32 @@
149 157 if (!MCPHelper::canWriteBoard($task->board_id)) {
150 158 return MCPHelper::error('forbidden', __('You do not have permission to update this task', 'fluent-boards'));
151 159 }
152 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 +
153 181 $service = new TaskService();
154 182 $updatable = [
155 183 'title' => 'text',
156 - 'description' => 'html',
184 + 'description' => 'markdown',
157 185 'status' => 'text',
158 186 'priority' => 'nullable_text',
159 187 'due_at' => 'nullable_text',
160 188 'started_at' => 'nullable_text',
@@ -175,10 +203,10 @@
175 203 $value = self::sanitizeUpdateValue($params[$field], $type);
176 204 $task = $service->updateTaskProperty($field, $value, $task);
177 205 }
178 206
179 - if (array_key_exists('assignees', $params)) {
180 - $task = self::syncAssignees($task, MCPHelper::sanitizeIdArray($params['assignees']));
207 + if ($assigneeIds !== null) {
208 + $task = self::syncAssignees($task, $assigneeIds);
181 209 }
182 210
183 211 MCPHelper::loadTaskDetails($task);
184 212
@@ -242,8 +270,11 @@
242 270 $task = $task->reopen();
243 271 }
244 272
245 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);
246 277 }
247 278
248 279 do_action('fluent_boards/task_updated', $task, 'position');
249 280 MCPHelper::loadTaskDetails($task);
@@ -253,8 +284,100 @@
253 284 'message' => __('Task has been moved', 'fluent-boards'),
254 285 ];
255 286 }
256 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 +
257 380 public static function archiveTask($params = [])
258 381 {
259 382 $task = MCPHelper::resolveTask($params);
260 383 if (is_wp_error($task)) {
@@ -276,8 +399,51 @@
276 399 'message' => $archived ? __('Task has been archived', 'fluent-boards') : __('Task has been restored', 'fluent-boards'),
277 400 ];
278 401 }
279 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 +
280 446 private static function sanitizeUpdateValue($value, $type)
281 447 {
282 448 if ($type === 'html') {
283 449 return wp_kses_post((string) $value);
@@ -282,8 +448,12 @@
282 448 if ($type === 'html') {
283 449 return wp_kses_post((string) $value);
284 450 }
285 451
452 + if ($type === 'markdown') {
453 + return MCPHelper::sanitizeMarkdown($value);
454 + }
455 +
286 456 if ($type === 'array') {
287 457 return is_array($value) ? self::sanitizeArray($value) : [];
288 458 }
289 459
@@ -305,15 +475,31 @@
305 475 'allowed' => ['open', 'closed'],
306 476 ]);
307 477 }
308 478
309 - if ($field === 'priority' && $value !== '' && $value !== null && !in_array($value, ['low', 'medium', 'high'], true)) {
479 + if ($field === 'priority' && $value !== '' && $value !== null && !in_array($value, self::getAllowedTaskPriorities(), true)) {
310 480 return MCPHelper::error('invalid_param', __('Invalid task priority', 'fluent-boards'), [
311 - 'allowed' => ['low', 'medium', 'high'],
481 + 'allowed' => self::getAllowedTaskPriorities(),
312 482 ]);
313 483 }
314 484
315 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 + ])));
316 502 }
317 503
318 504 private static function sanitizeArray($value)
319 505 {