PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.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 1.41 All 42 releases
← All changes | app/Modules/MCP/Tools/TaskTools.php +189 -9 1.952.1.0 View file →
@@ -1,12 +1,14 @@
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\PermissionManager;
9 11 use FluentBoards\App\Services\TaskService;
10 12
11 13 /**
12 14 * Task read/write MCP tools.
@@ -12,8 +14,10 @@
12 14 * Task read/write MCP tools.
13 15 */
14 16 class TaskTools
15 17 {
18 + const MAX_ASSIGNEES_PER_CALL = 50;
19 +
16 20 public static function canMoveTask($params = [])
17 21 {
18 22 $sourceBoardId = isset($params['board_id']) ? absint($params['board_id']) : 0;
19 23 $targetBoardId = !empty($params['target_board_id']) ? absint($params['target_board_id']) : $sourceBoardId;
@@ -108,11 +112,11 @@
108 112 'stage_id' => (int) $stage->id,
109 113 ];
110 114
111 115 if (!empty($params['priority'])) {
112 - if (!in_array($params['priority'], ['low', 'medium', 'high'], true)) {
116 + if (!in_array($params['priority'], self::getAllowedTaskPriorities(), true)) {
113 117 return MCPHelper::error('invalid_param', __('Invalid task priority', 'fluent-boards'), [
114 - 'allowed' => ['low', 'medium', 'high'],
118 + 'allowed' => self::getAllowedTaskPriorities(),
115 119 ]);
116 120 }
117 121 $taskData['priority'] = sanitize_text_field($params['priority']);
118 122 }
@@ -123,9 +127,9 @@
123 127 }
124 128 }
125 129
126 130 if (!empty($params['description'])) {
127 - $taskData['description'] = wp_kses_post($params['description']);
131 + $taskData['description'] = MCPHelper::sanitizeMarkdown($params['description']);
128 132 }
129 133
130 134 if (!empty($params['crm_contact_id'])) {
131 135 $taskData['crm_contact_id'] = absint($params['crm_contact_id']);
@@ -131,11 +135,12 @@
131 135 $taskData['crm_contact_id'] = absint($params['crm_contact_id']);
132 136 }
133 137
134 138 $task = (new TaskService())->createTask($taskData, $board->id);
139 + MCPHelper::loadTaskDetails($task);
135 140
136 141 return [
137 - 'task' => MCPHelper::formatTaskSummary($task),
142 + 'task' => MCPHelper::formatTask($task),
138 143 'message' => __('Task has been successfully created', 'fluent-boards'),
139 144 ];
140 145 }
141 146
@@ -149,12 +154,32 @@
149 154 if (!MCPHelper::canWriteBoard($task->board_id)) {
150 155 return MCPHelper::error('forbidden', __('You do not have permission to update this task', 'fluent-boards'));
151 156 }
152 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 +
153 178 $service = new TaskService();
154 179 $updatable = [
155 180 'title' => 'text',
156 - 'description' => 'html',
181 + 'description' => 'markdown',
157 182 'status' => 'text',
158 183 'priority' => 'nullable_text',
159 184 'due_at' => 'nullable_text',
160 185 'started_at' => 'nullable_text',
@@ -175,10 +200,10 @@
175 200 $value = self::sanitizeUpdateValue($params[$field], $type);
176 201 $task = $service->updateTaskProperty($field, $value, $task);
177 202 }
178 203
179 - if (array_key_exists('assignees', $params)) {
180 - $task = self::syncAssignees($task, MCPHelper::sanitizeIdArray($params['assignees']));
204 + if ($assigneeIds !== null) {
205 + $task = self::syncAssignees($task, $assigneeIds);
181 206 }
182 207
183 208 MCPHelper::loadTaskDetails($task);
184 209
@@ -253,8 +278,100 @@
253 278 'message' => __('Task has been moved', 'fluent-boards'),
254 279 ];
255 280 }
256 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 +
257 374 public static function archiveTask($params = [])
258 375 {
259 376 $task = MCPHelper::resolveTask($params);
260 377 if (is_wp_error($task)) {
@@ -276,8 +393,51 @@
276 393 'message' => $archived ? __('Task has been archived', 'fluent-boards') : __('Task has been restored', 'fluent-boards'),
277 394 ];
278 395 }
279 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 +
280 440 private static function sanitizeUpdateValue($value, $type)
281 441 {
282 442 if ($type === 'html') {
283 443 return wp_kses_post((string) $value);
@@ -282,8 +442,12 @@
282 442 if ($type === 'html') {
283 443 return wp_kses_post((string) $value);
284 444 }
285 445
446 + if ($type === 'markdown') {
447 + return MCPHelper::sanitizeMarkdown($value);
448 + }
449 +
286 450 if ($type === 'array') {
287 451 return is_array($value) ? self::sanitizeArray($value) : [];
288 452 }
289 453
@@ -305,15 +469,31 @@
305 469 'allowed' => ['open', 'closed'],
306 470 ]);
307 471 }
308 472
309 - if ($field === 'priority' && $value !== '' && $value !== null && !in_array($value, ['low', 'medium', 'high'], true)) {
473 + if ($field === 'priority' && $value !== '' && $value !== null && !in_array($value, self::getAllowedTaskPriorities(), true)) {
310 474 return MCPHelper::error('invalid_param', __('Invalid task priority', 'fluent-boards'), [
311 - 'allowed' => ['low', 'medium', 'high'],
475 + 'allowed' => self::getAllowedTaskPriorities(),
312 476 ]);
313 477 }
314 478
315 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 + ])));
316 496 }
317 497
318 498 private static function sanitizeArray($value)
319 499 {