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 / TaskQueryTools.php

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

214 lines 8.2 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\Task;
6 use FluentBoards\App\Modules\MCP\Helpers\MCPHelper;
7 use FluentBoards\App\Services\PermissionManager;
8 use FluentBoards\App\Services\UserService;
9
10 /**
11 * Cross-board task queries: the caller's own work list and global search.
12 */
13 class TaskQueryTools
14 {
15 const TASK_TYPES = ['assigned', 'mentioned', 'upcoming', 'due_today', 'overdue', 'completed', 'others'];
16 const ORDER_BY = ['priority', 'due_at', 'position', 'created_at', 'title'];
17 const MIN_TERM_LENGTH = 3;
18
19 private static function shortTermError()
20 {
21 return MCPHelper::error('invalid_param', __('Search terms need at least three characters. Use "id:123" to look up one task.', 'fluent-boards'), [
22 'min_length' => self::MIN_TERM_LENGTH,
23 ]);
24 }
25
26 public static function listMyTasks($params = [])
27 {
28 $userId = !empty($params['user_id']) ? absint($params['user_id']) : get_current_user_id();
29 if (!$userId) {
30 return MCPHelper::error('forbidden', __('No user context available', 'fluent-boards'));
31 }
32
33 $taskType = !empty($params['task_type']) ? sanitize_text_field($params['task_type']) : 'assigned';
34 if (!in_array($taskType, self::TASK_TYPES, true)) {
35 return MCPHelper::error('invalid_param', __('Invalid task type', 'fluent-boards'), [
36 'allowed' => self::TASK_TYPES,
37 ]);
38 }
39
40 $orderBy = !empty($params['order_by']) ? sanitize_text_field($params['order_by']) : 'due_at';
41 if (!in_array($orderBy, self::ORDER_BY, true)) {
42 return MCPHelper::error('invalid_param', __('Invalid order_by value', 'fluent-boards'), [
43 'allowed' => self::ORDER_BY,
44 ]);
45 }
46
47 $order = !empty($params['order']) && strtoupper($params['order']) === 'DESC' ? 'DESC' : 'ASC';
48 $pagination = MCPHelper::normalizePagination($params, 20, 50);
49
50 // 'others' is the service's default branch (tasks with no due date).
51 $serviceTaskType = $taskType === 'others' ? '' : $taskType;
52
53 try {
54 $result = (new UserService())->getMemberAssociatedTasks($userId, [
55 'page' => $pagination['page'],
56 'per_page' => $pagination['per_page'],
57 'taskType' => $serviceTaskType,
58 'boardIds' => MCPHelper::sanitizeIdArray($params['board_ids'] ?? []),
59 'orderBy' => $orderBy,
60 'order' => $order,
61 ]);
62 } catch (\Exception $e) {
63 return MCPHelper::error('invalid_param', $e->getMessage());
64 }
65
66 $items = [];
67 foreach ($result['tasks'] as $task) {
68 $items[] = self::formatRow($task);
69 }
70
71 return [
72 'user_id' => $userId,
73 'task_type' => $taskType,
74 'items' => $items,
75 'pagination' => [
76 'total' => (int) ($result['paginationInfo']['total'] ?? 0),
77 'current_page' => (int) ($result['paginationInfo']['current_page'] ?? 1),
78 'per_page' => (int) ($result['paginationInfo']['per_page'] ?? $pagination['per_page']),
79 'last_page' => (int) ($result['paginationInfo']['last_page'] ?? 1),
80 ],
81 ];
82 }
83
84 public static function searchTasks($params = [])
85 {
86 $query = isset($params['query']) ? strtolower(sanitize_text_field($params['query'])) : '';
87 if ($query === '') {
88 return MCPHelper::error('invalid_param', __('Provide a search query', 'fluent-boards'));
89 }
90
91 $pagination = MCPHelper::normalizePagination($params, 20, 50);
92 $includeArchived = !empty($params['include_archived']);
93
94 // Same prefixes the product's global search box accepts. Title matching relies on the
95 // column's case-insensitive collation rather than LOWER(), which would rule out any
96 // index, and short terms are rejected so a one-character query cannot scan everything.
97 if (strpos($query, 'id:') === 0) {
98 $taskId = absint(preg_replace('/[^0-9]/', '', substr($query, 3)));
99 if (!$taskId) {
100 return MCPHelper::error('invalid_param', __('Provide a numeric task id after "id:"', 'fluent-boards'));
101 }
102 $tasksQuery = Task::query()->whereNull('parent_id')->where('id', $taskId);
103 } elseif (strpos($query, 'archived:') === 0) {
104 $term = trim(substr($query, 9));
105 if (mb_strlen($term) < self::MIN_TERM_LENGTH) {
106 return self::shortTermError();
107 }
108 $includeArchived = true;
109 $tasksQuery = Task::query()->whereNull('parent_id')->whereNotNull('archived_at')
110 ->where('title', 'LIKE', '%' . $term . '%');
111 } else {
112 if (mb_strlen($query) < self::MIN_TERM_LENGTH) {
113 return self::shortTermError();
114 }
115 $tasksQuery = Task::query()->whereNull('parent_id')
116 ->where('title', 'LIKE', '%' . $query . '%');
117 }
118
119 $currentUserId = get_current_user_id();
120 $allowedBoardIds = PermissionManager::getBoardIdsForUser($currentUserId);
121
122 if (!PermissionManager::isAdmin($currentUserId)) {
123 if (!$allowedBoardIds) {
124 return [
125 'query' => $query,
126 'items' => [],
127 'pagination' => ['total' => 0, 'current_page' => 1, 'per_page' => $pagination['per_page'], 'last_page' => 0],
128 ];
129 }
130 $tasksQuery->whereIn('board_id', $allowedBoardIds);
131 }
132
133 $boardIds = MCPHelper::sanitizeIdArray($params['board_ids'] ?? []);
134 if ($boardIds) {
135 $tasksQuery->whereIn('board_id', $boardIds);
136 }
137
138 if (!$includeArchived) {
139 $tasksQuery->whereNull('archived_at');
140 }
141
142 $paginated = $tasksQuery->with(['board', 'stage', 'labels'])
143 ->orderBy('updated_at', 'DESC')
144 ->paginate($pagination['per_page'], ['*'], 'page', $pagination['page']);
145
146 $items = [];
147 foreach ($paginated->items() as $task) {
148 $items[] = self::formatRow($task);
149 }
150
151 return [
152 'query' => $query,
153 'items' => $items,
154 'pagination' => [
155 'total' => (int) $paginated->total(),
156 'current_page' => (int) $paginated->currentPage(),
157 'per_page' => (int) $paginated->perPage(),
158 'last_page' => (int) $paginated->lastPage(),
159 ],
160 ];
161 }
162
163 /**
164 * Compact row shape. UserService returns plain arrays while the search query returns models,
165 * so read both through the same accessor. Kept deliberately light: no avatars or emails.
166 */
167 private static function formatRow($task)
168 {
169 $get = function ($key) use ($task) {
170 if (is_array($task)) {
171 return $task[$key] ?? null;
172 }
173 return $task->{$key} ?? null;
174 };
175
176 $nested = function ($value, $key) {
177 if (is_array($value)) {
178 return $value[$key] ?? null;
179 }
180 if (is_object($value)) {
181 return $value->{$key} ?? null;
182 }
183 return null;
184 };
185
186 $board = $get('board');
187 $stage = $get('stage');
188 $labels = $get('labels') ?: [];
189
190 $labelTitles = [];
191 foreach ($labels as $label) {
192 $title = $nested($label, 'title');
193 if ($title) {
194 $labelTitles[] = $title;
195 }
196 }
197
198 return [
199 'id' => (int) $get('id'),
200 'title' => $get('title'),
201 'board_id' => (int) $get('board_id'),
202 'board_title' => $nested($board, 'title'),
203 'stage_id' => (int) $get('stage_id'),
204 'stage_title' => $nested($stage, 'title'),
205 'status' => $get('status'),
206 'priority' => $get('priority'),
207 'due_at' => MCPHelper::toIso8601($get('due_at')),
208 'started_at' => MCPHelper::toIso8601($get('started_at')),
209 'archived_at' => MCPHelper::toIso8601($get('archived_at')),
210 'labels' => $labelTitles,
211 ];
212 }
213 }
214