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
← All changes | app/Http/Controllers/TaskController.php +1300 -77 1.32trunk View file →
@@ -1,12 +1,14 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Http\Controllers;
4 4
5 -use DateTimeImmutable;
5 +use FluentBoards\App\Models\Meta;
6 6 use FluentBoards\App\Models\Stage;
7 7 use FluentBoards\App\Models\Task;
8 8 use FluentBoards\App\Models\Board;
9 +use FluentBoards\App\Models\TaskMeta;
10 +use FluentBoards\App\Services\CommentService;
9 11 use FluentBoards\App\Services\Constant;
10 12 use FluentBoards\App\Services\Helper;
11 13 use FluentBoards\App\Services\StageService;
12 14 use FluentBoards\App\Services\TaskService;
@@ -14,8 +16,11 @@
14 16 use FluentBoards\App\Services\UploadService;
15 17 use FluentBoards\Framework\Http\Request\Request;
16 18 use FluentBoards\App\Services\PermissionManager;
17 19 use FluentBoards\Framework\Support\Arr;
20 +use FluentBoardsPro\App\Services\AttachmentService;
21 +use FluentCrm\App\Models\Subscriber;
22 +
18 23 class TaskController extends Controller
19 24 {
20 25 private TaskService $taskService;
21 26
@@ -32,70 +37,283 @@
32 37 public function getTopTasksForBoards()
33 38 {
34 39 $userId = get_current_user_id();
35 40 $task_ids = PermissionManager::getTaskIdsWatchByUser($userId);
36 - $tasksArray = $this->taskService->getTasksForBoards(['overdue', 'upcoming'], 6, $task_ids);
41 + $boardIds = PermissionManager::getBoardIdsForUser($userId);
42 + $taskCategories = ['due_today', 'assigned', 'overdue', 'upcoming', 'mentioned', 'completed', 'others'];
43 + $tasksArray = $this->taskService->getTasksForBoards($taskCategories, 6, $task_ids);
44 + $taskCounts = $this->taskService->getTaskCountsForBoards($taskCategories, $task_ids);
45 + $taskCounts['all_boards'] = empty($boardIds)
46 + ? 0
47 + : (int) Board::whereIn('id', $boardIds)
48 + ->whereNull('archived_at')
49 + ->excludeTemplates()
50 + ->count();
51 + $taskCounts['all_tasks'] = empty($task_ids)
52 + ? 0
53 + : (int) Task::whereIn('id', $task_ids)
54 + ->whereNull('archived_at')
55 + ->whereNull('parent_id')
56 + ->onActiveAvailableBoards()
57 + ->count();
37 58
38 59 return [
39 60 'data' => $tasksArray,
61 + 'counts' => $taskCounts,
40 62 ];
41 63 }
42 64
43 - public function getTasksByBoard($board_id)
65 + public function getTasksByBoard(Request $request, $board_id)
44 66 {
67 + $board_id = absint($board_id);
45 68 $board = Board::findOrFail($board_id);
69 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
46 70
47 71 // Get stage IDs
48 - $stageIds = $this->getStageIdsByBoard($board_id);
72 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
49 73
50 74 // Fetch tasks for the board
51 - $tasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
75 + $tasksQuery = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
52 76 ->where('board_id', $board_id)
53 - ->whereNull('archived_at')
54 77 ->whereNull('parent_id')
55 78 ->whereIn('stage_id', $stageIds)
56 - ->orderBy('due_at', 'ASC')
57 - ->get();
79 + ->orderBy('due_at', 'ASC');
58 80
81 + if (!$includeArchived) {
82 + $tasksQuery->whereNull('archived_at');
83 + }
84 +
85 + $tasks = $tasksQuery->get();
86 +
59 87 // Process each task
60 88 $this->processTasks($tasks, $board);
61 89
90 + if ($board->type === 'roadmap') {
91 + $this->taskService->loadIdeaVoteStatistics($tasks);
92 + }
93 +
62 94 return [
63 95 'tasks' => $tasks,
64 96 ];
65 97 }
66 98
67 - public function getTasksByBoardStage($board_id)
99 + public function getTasksByBoardStage(Request $request, $board_id)
68 100 {
101 + $board_id = absint($board_id);
69 102 $board = Board::findOrFail($board_id);
103 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
70 104
71 105 // Get stage IDs
72 - $stageIds = $this->getStageIdsByBoard($board_id);
106 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
107 + $stageTaskCounts = $this->getStageTaskCounts($board_id, $stageIds, $includeArchived);
73 108
74 109 // Initialize tasks array
75 110 $tasks = [];
111 + $paginationByStage = [];
76 112
77 113 // Fetch and process tasks for each stage
78 114 foreach ($stageIds as $stageId) {
79 - $stageTasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
80 - ->where('board_id', $board_id)
81 - ->where('stage_id', $stageId)
82 - ->whereNull('archived_at')
83 - ->whereNull('parent_id')
115 + $stageTasks = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
84 116 ->orderBy('position', 'ASC')
85 117 ->limit(20)
86 118 ->get();
87 119
88 120 // Process each stage's tasks
89 - $this->processTasks($stageTasks, $board);
121 + $this->processTasks($stageTasks, $board, [
122 + 'includeContact' => false,
123 + 'includeObserverState' => false,
124 + 'includeRoadmapPopularity' => false,
125 + ]);
90 126 $tasks = array_merge($tasks, $stageTasks->toArray()); // Merge with the main task list
127 +
128 + $startCursor = $stageTasks->count() ? (float) $stageTasks->first()->position : null;
129 + $endCursor = $stageTasks->count() ? (float) $stageTasks->last()->position : null;
130 + $loadedCount = $stageTasks->count();
131 + $hasMoreAfter = (int) ($stageTaskCounts[$stageId] ?? 0) > $loadedCount;
132 +
133 + $paginationByStage[$stageId] = [
134 + 'stage_id' => (int) $stageId,
135 + 'total_count' => (int) ($stageTaskCounts[$stageId] ?? 0),
136 + 'limit' => 20,
137 + 'direction' => 'next',
138 + 'cursor' => null,
139 + 'has_more' => $hasMoreAfter,
140 + 'has_more_before' => false,
141 + 'has_more_after' => $hasMoreAfter,
142 + 'start_cursor' => $startCursor,
143 + 'end_cursor' => $endCursor,
144 + ];
91 145 }
92 146
93 147 return [
94 148 'tasks' => $tasks,
149 + 'pagination_by_stage' => $paginationByStage,
95 150 ];
96 151 }
97 152
153 + public function getTableTasks(Request $request, $board_id)
154 + {
155 + $board_id = absint($board_id);
156 + $board = Board::findOrFail($board_id);
157 + $args = [
158 + 'page' => $request->getSafe('page', 'intval', 1),
159 + 'per_page' => $request->getSafe('per_page', 'intval', 20),
160 + 'sort_by' => $request->getSafe('sort_by', 'sanitize_text_field', 'position'),
161 + 'sort_direction' => $request->getSafe('sort_direction', 'sanitize_text_field', 'asc'),
162 + 'search' => $request->getSafe('search', 'sanitize_text_field', ''),
163 + 'include_archived' => $request->getSafe('include_archived', 'boolval', false),
164 + 'stage' => $request->get('stage', []),
165 + 'task_status' => $request->get('task_status', []),
166 + 'priority' => $request->get('priority', []),
167 + 'assignee' => $request->get('assignee', []),
168 + 'labels' => $request->get('labels', []),
169 + 'watchers' => $request->get('watchers', []),
170 + 'contact' => $request->get('contact', []),
171 + 'custom_fields' => $request->get('custom_fields', []),
172 + 'due_date' => $request->get('due_date', []),
173 + ];
174 +
175 + $tasks = $this->taskService->getTableTasks($board_id, $args);
176 + $taskItems = $tasks->items();
177 + $this->processTasks($taskItems, $board, [
178 + 'includeContact' => false,
179 + 'includeObserverState' => false,
180 + 'includeRoadmapPopularity' => false,
181 + ]);
182 +
183 + return $this->sendSuccess([
184 + 'items' => $taskItems,
185 + 'pagination' => [
186 + 'total' => (int) $tasks->total(),
187 + 'current_page' => (int) $tasks->currentPage(),
188 + 'per_page' => (int) $tasks->perPage(),
189 + 'last_page' => (int) $tasks->lastPage(),
190 + ],
191 + ], 200);
192 + }
193 +
194 + public function getFilteredBoardTasks(Request $request, $board_id)
195 + {
196 + $board_id = absint($board_id);
197 + $board = Board::findOrFail($board_id);
198 + $args = [
199 + 'search' => $request->getSafe('search', 'sanitize_text_field', ''),
200 + 'include_archived' => $request->getSafe('include_archived', 'boolval', false),
201 + 'stage' => $request->get('stage', []),
202 + 'task_status' => $request->get('task_status', []),
203 + 'priority' => $request->get('priority', []),
204 + 'assignee' => $request->get('assignee', []),
205 + 'labels' => $request->get('labels', []),
206 + 'watchers' => $request->get('watchers', []),
207 + 'contact' => $request->get('contact', []),
208 + 'custom_fields' => $request->get('custom_fields', []),
209 + 'due_date' => $request->get('due_date', []),
210 + ];
211 +
212 + $tasks = $this->taskService->getBoardViewTasks($board_id, $args);
213 + $this->processTasks($tasks, $board, [
214 + 'includeContact' => false,
215 + 'includeObserverState' => false,
216 + 'includeRoadmapPopularity' => false,
217 + ]);
218 +
219 + return [
220 + 'tasks' => $tasks,
221 + ];
222 + }
223 +
224 + public function getStageTasksPage(Request $request, $board_id)
225 + {
226 + $board_id = absint($board_id);
227 + $board = Board::findOrFail($board_id);
228 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
229 + $stageId = $request->getSafe('stage_id', 'intval');
230 + $limit = $request->getSafe('limit', 'intval', 20);
231 + $direction = $request->getSafe('direction', 'sanitize_text_field', 'next');
232 + $cursor = $request->getSafe('cursor', 'floatval');
233 +
234 + if (!$stageId) {
235 + return $this->sendError(esc_html__('Invalid Stage', 'fluent-boards'), 400);
236 + }
237 +
238 + if (!in_array($direction, ['next', 'prev'], true)) {
239 + return $this->sendError(esc_html__('Invalid direction', 'fluent-boards'), 400);
240 + }
241 +
242 + $limit = max(1, min(100, $limit));
243 +
244 + $stage = Stage::where('board_id', $board_id)
245 + ->where('id', $stageId)
246 + ->first();
247 +
248 + if (!$stage) {
249 + return $this->sendError(esc_html__('Stage not found', 'fluent-boards'), 404);
250 + }
251 +
252 + $stageTasksQuery = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived);
253 +
254 + if ($cursor !== null) {
255 + if ($direction === 'prev') {
256 + $stageTasksQuery->where('position', '<', $cursor);
257 + } else {
258 + $stageTasksQuery->where('position', '>', $cursor);
259 + }
260 + }
261 +
262 + $stageTasks = $stageTasksQuery
263 + ->orderBy('position', $direction === 'prev' ? 'DESC' : 'ASC')
264 + ->limit($limit + 1)
265 + ->get();
266 +
267 + $hasMoreInDirection = $stageTasks->count() > $limit;
268 + if ($hasMoreInDirection) {
269 + $stageTasks = $stageTasks->slice(0, $limit)->values();
270 + }
271 +
272 + if ($direction === 'prev') {
273 + $stageTasks = $stageTasks->sortBy('position')->values();
274 + }
275 +
276 + $this->processTasks($stageTasks, $board, [
277 + 'includeContact' => false,
278 + 'includeObserverState' => false,
279 + 'includeRoadmapPopularity' => false,
280 + ]);
281 +
282 + $startCursor = $stageTasks->count() ? (float) $stageTasks->first()->position : null;
283 + $endCursor = $stageTasks->count() ? (float) $stageTasks->last()->position : null;
284 +
285 + $hasMoreBefore = false;
286 + $hasMoreAfter = false;
287 +
288 + if ($startCursor !== null) {
289 + $hasMoreBefore = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
290 + ->where('position', '<', $startCursor)
291 + ->exists();
292 + }
293 +
294 + if ($endCursor !== null) {
295 + $hasMoreAfter = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
296 + ->where('position', '>', $endCursor)
297 + ->exists();
298 + }
299 +
300 + return [
301 + 'tasks' => $stageTasks,
302 + 'pagination' => [
303 + 'stage_id' => (int) $stageId,
304 + 'limit' => (int) $limit,
305 + 'direction' => $direction,
306 + 'cursor' => $cursor !== null ? (float) $cursor : null,
307 + 'has_more' => $hasMoreInDirection,
308 + 'has_more_before' => $hasMoreBefore,
309 + 'has_more_after' => $hasMoreAfter,
310 + 'start_cursor' => $startCursor,
311 + 'end_cursor' => $endCursor,
312 + ],
313 + ];
314 + }
315 +
98 316 /**
99 317 * Get Stage IDs by Board ID.
100 318 *
101 319 * @param int $board_id
@@ -100,44 +318,153 @@
100 318 *
101 319 * @param int $board_id
102 320 * @return array
103 321 */
104 - private function getStageIdsByBoard($board_id)
322 + private function getStageIdsByBoard($board_id, $includeArchived = false)
105 323 {
106 - return Stage::where('board_id', $board_id)
107 - ->whereNull('archived_at')
108 - ->pluck('id')
324 + $stageQuery = Stage::where('board_id', $board_id);
325 + if (!$includeArchived) {
326 + $stageQuery->whereNull('archived_at');
327 + }
328 +
329 + return $stageQuery->pluck('id')->toArray();
330 + }
331 +
332 + private function makeStageTasksQuery($board_id, $stageId, $includeArchived = false)
333 + {
334 + $stageTasksQuery = Task::query()
335 + // Kanban/List only need card-level task data here; full task detail is
336 + // fetched separately when the modal opens.
337 + ->select($this->getStageTaskCardColumns())
338 + ->with(['assignees', 'labels', 'watchers'])
339 + ->where('board_id', $board_id)
340 + ->where('stage_id', $stageId)
341 + ->whereNull('parent_id');
342 +
343 + if (!$includeArchived) {
344 + $stageTasksQuery->whereNull('archived_at');
345 + }
346 +
347 + return $stageTasksQuery;
348 + }
349 +
350 + private function getStageTaskCounts($board_id, array $stageIds, $includeArchived = false)
351 + {
352 + if (!$stageIds) {
353 + return [];
354 + }
355 +
356 + $query = Task::query()
357 + ->selectRaw('stage_id, COUNT(*) as total_count')
358 + ->where('board_id', $board_id)
359 + ->whereNull('parent_id')
360 + ->whereIn('stage_id', $stageIds);
361 +
362 + if (!$includeArchived) {
363 + $query->whereNull('archived_at');
364 + }
365 +
366 + return $query
367 + ->groupBy('stage_id')
368 + ->pluck('total_count', 'stage_id')
369 + ->map(function ($count) {
370 + return (int) $count;
371 + })
109 372 ->toArray();
110 373 }
111 374
375 + private function getStageTaskCardColumns()
376 + {
377 + return [
378 + 'id',
379 + 'title',
380 + 'slug',
381 + 'board_id',
382 + 'parent_id',
383 + 'crm_contact_id',
384 + 'type',
385 + 'stage_id',
386 + 'status',
387 + 'reminder_type',
388 + 'priority',
389 + 'archived_at',
390 + 'remind_at',
391 + 'started_at',
392 + 'due_at',
393 + 'last_completed_at',
394 + 'position',
395 + 'comments_count',
396 + 'created_by',
397 + 'settings',
398 + 'source',
399 + 'source_id',
400 + ];
401 + }
402 +
112 403 /**
113 404 * Process and append extra information for each task.
114 405 *
115 406 * @param \Illuminate\Database\Eloquent\Collection $tasks
116 407 * @param \App\Models\Board $board
408 + * @param array $options
117 409 */
118 - private function processTasks($tasks, $board)
410 + private function processTasks($tasks, $board, $options = [])
119 411 {
412 + $includeContact = Arr::get($options, 'includeContact', true);
413 + $includeObserverState = Arr::get($options, 'includeObserverState', true);
414 + $includeRoadmapPopularity = Arr::get($options, 'includeRoadmapPopularity', true);
415 + $taskIds = [];
416 +
120 417 foreach ($tasks as $task) {
418 + $taskIds[] = (int) $task->id;
419 + }
420 +
421 + $unreadNotificationCounts = $this->notificationService->getUnreadNotificationCountsByTaskIds($taskIds);
422 +
423 + foreach ($tasks as $task) {
121 424 $task->isOverdue = $task->isOverdue();
122 425 $task->isUpcoming = $task->upcoming();
123 - $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
124 - $task->is_watching = $task->isWatching();
426 + if ($includeContact) {
427 + $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
428 + }
429 + if ($includeObserverState) {
430 + $task->is_watching = $task->isWatching();
431 + }
125 432 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
126 433 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
127 - $task->notifications = $this->notificationService->getUnreadNotificationsOfTasks($task);
434 + $task->notifications = $unreadNotificationCounts[(int) $task->id] ?? 0;
128 435
129 436 // If the board type is 'roadmap', calculate popularity
130 - if ($board->type === 'roadmap') {
437 + if ($includeRoadmapPopularity && $board->type === 'roadmap') {
131 438 $task->popular = $task->getPopularCount();
132 439 }
133 440 }
134 441 }
135 442
136 -
443 + /**
444 + * Create a task with field-specific sanitization for its request data.
445 + *
446 + * @param Request $request
447 + * @param int $board_id
448 + * @return mixed
449 + */
137 450 public function create(Request $request, $board_id)
138 451 {
139 - $taskData = $this->taskSanitizeAndValidate($request->get('task'), [
452 + $board_id = absint($board_id);
453 + $safeTaskData = $request->getSafe('task');
454 + $rawTaskData = $request->get('task', []);
455 +
456 + // Milkdown serializes pasted URLs as <https://...>, which generic text
457 + // sanitization removes as a tag.
458 + if (
459 + is_array($safeTaskData) &&
460 + is_array($rawTaskData) &&
461 + array_key_exists('description', $rawTaskData)
462 + ) {
463 + $safeTaskData['description'] = fluent_boards_sanitize_description($rawTaskData['description']);
464 + }
465 +
466 + $taskData = $this->taskSanitizeAndValidate($safeTaskData, [
140 467 'title' => 'required|string',
141 468 'board_id' => 'required|numeric',
142 469 'stage_id' => 'required|numeric',
143 470 'priority' => 'nullable|string',
@@ -145,17 +472,28 @@
145 472 'is_template' => 'string',
146 473 ]);
147 474
148 475 try {
476 + if (isset($taskData['assignees'])) {
477 + $taskData['assignees'] = array_filter(array_map('intval', (array) $taskData['assignees']));
478 + }
479 +
480 + if (isset($taskData['labels'])) {
481 + $taskData['labels'] = array_filter(array_map('intval', (array) $taskData['labels']));
482 + }
483 +
149 484 if ($taskData['board_id'] != $board_id) {
150 - throw new \Exception(__('Board id is not valid', 'fluent-boards'));
485 + throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
151 486 }
152 487
153 488 $task = $this->taskService->createTask($taskData, $board_id);
489 + $message = $task->type === 'roadmap'
490 + ? __('Idea has been successfully created', 'fluent-boards')
491 + : __('Task has been successfully created', 'fluent-boards');
154 492
155 493 return $this->sendSuccess([
156 494 'task' => $task,
157 - 'message' => __('Task has been successfully created', 'fluent-boards'),
495 + 'message' => $message,
158 496 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
159 497 ], 201);
160 498 } catch (\Exception $e) {
161 499 return $this->sendError($e->getMessage(), 400);
@@ -163,20 +501,22 @@
163 501 }
164 502
165 503 public function find($board_id, $task_id)
166 504 {
505 + $board_id = absint($board_id);
506 + $task_id = absint($task_id);
167 507 try {
168 508
169 509 $stageService = new StageService();
170 510
171 - $task = Task::findOrFail($task_id);
511 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
172 512
173 513 if (isset($task->parent_id)) {
174 - $task = Task::findOrFail($task->parent_id);
514 + $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
175 515 }
176 516
177 517 if(!$task) {
178 - throw new \Exception(__('Task not found', 'fluent-boards'));
518 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
179 519 }
180 520
181 521 if (defined('FLUENT_BOARDS_PRO')) {
182 522 $task->load(['attachments']);
@@ -181,11 +521,12 @@
181 521 if (defined('FLUENT_BOARDS_PRO')) {
182 522 $task->load(['attachments']);
183 523 }
184 524
185 - $task->load(['board', 'stage', 'labels', 'assignees']);
525 + $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
186 526
187 527 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
528 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
188 529
189 530 $task->isOverdue = $task->isOverdue();
190 531 $task->contact = Task::lead_contact($task->crm_contact_id);
191 532 $task->board->stages = $stageService->stagesByBoardId($board_id);
@@ -209,9 +550,10 @@
209 550 }
210 551
211 552 public function getStageType(Request $request)
212 553 {
213 - $stage = Stage::findOrFail($request->stage_id);
554 + $stage_id = $request->getSafe('stage_id', 'intval');
555 + $stage = Stage::findOrFail($stage_id);
214 556
215 557 return [
216 558 'stage' => $stage,
217 559 ];
@@ -218,10 +560,13 @@
218 560 }
219 561
220 562 public function getActivities(Request $request, $board_id, $task_id)
221 563 {
222 - $filter = $request->getSafe('filter');
564 + $board_id = absint($board_id);
565 + $task_id = absint($task_id);
566 + $filter = $request->getSafe('filter', 'sanitize_text_field');
223 567 $per_page = 15; // Apparently, let's use a fixed number of items per page.
568 + $this->taskService->findTaskOnBoard($task_id, $board_id);
224 569
225 570 return [
226 571 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
227 572 ];
@@ -229,27 +574,236 @@
229 574 }
230 575
231 576 public function getArchivedTasks(Request $request, $board_id)
232 577 {
233 - $tasks = $this->taskService->getArchivedTasks($request->all(), $board_id);
578 + $board_id = absint($board_id);
579 + // Sanitize request parameters before passing to service
580 + $sanitizedParams = [
581 + 'per_page' => $request->getSafe('per_page', 'intval', 20),
582 + 'page' => $request->getSafe('page', 'intval', 1),
583 + 'query' => $request->getSafe('searchInput', 'sanitize_text_field', '')
584 + ];
234 585
586 +
587 + $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
588 +
235 589 foreach ($tasks as $task) {
236 590 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
237 591 }
238 592
593 +
239 594 return [
240 595 'tasks' => $tasks
241 596 ];
242 597 }
243 598
599 + public function bulkRestoreTasks(Request $request, $board_id)
600 + {
601 + $board_id = absint($board_id);
602 + try {
603 + $rawTaskIds = $request->getSafe('task_ids');
604 + // Sanitize task_ids array to integers
605 + $task_ids = [];
606 + if (is_array($rawTaskIds)) {
607 + $task_ids = array_filter(array_map('intval', $rawTaskIds));
608 + }
609 +
610 + if (empty($task_ids)) {
611 + return $this->response->sendError('No task IDs provided', 400);
612 + }
613 +
614 + $tasks = Task::where('board_id', $board_id)
615 + ->whereIn('id', $task_ids)
616 + ->whereNotNull('archived_at')
617 + ->get();
618 +
619 + if ($tasks->isEmpty()) {
620 + return $this->response->sendError('No archived tasks found with provided IDs', 404);
621 + }
622 +
623 + $restored_count = 0;
624 + $failed_count = 0;
625 + $failed_tasks = [];
626 +
627 + foreach ($tasks as $task) {
628 + try {
629 + // Use TaskService to properly restore the task (same as single task restoration)
630 + $this->taskService->updateTaskProperty('archived_at', null, $task);
631 +
632 + // Prepare task for response (same as single task update)
633 + $task->isOverdue = $task->isOverdue();
634 + $task->isUpcoming = $task->upcoming();
635 + $task->contact = Helper::crm_contact($task->crm_contact_id);
636 + $task->is_watching = $task->isWatching();
637 + $task->assignees = Helper::sanitizeUserCollections($task->assignees);
638 +
639 + $restored_count++;
640 + } catch (\Exception $e) {
641 + // Track failed tasks but continue processing others
642 + $failed_count++;
643 + $failed_tasks[] = [
644 + 'id' => $task->id,
645 + 'title' => $task->title,
646 + 'error' => $e->getMessage()
647 + ];
648 + }
649 + }
650 +
651 + // Get recently updated tasks (same as single task operations)
652 + $recentlyUpdatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
653 +
654 + // Build response with detailed results
655 + $response = [
656 + 'restored_count' => $restored_count,
657 + 'failed_count' => $failed_count,
658 + 'updatedTasks' => $recentlyUpdatedTasks
659 + ];
660 +
661 + if ($failed_count > 0) {
662 + $response['failed_tasks'] = $failed_tasks;
663 + if ($restored_count > 0) {
664 + $response['message'] = $restored_count . ' ' . ($restored_count === 1 ? 'task' : 'tasks') . ' restored successfully, ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks') . ' failed';
665 + } else {
666 + $response['message'] = 'Failed to restore ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks');
667 + }
668 + } else {
669 + $response['message'] = $restored_count . ' ' . ($restored_count === 1 ? 'task' : 'tasks') . ' restored successfully';
670 + }
671 +
672 + return $this->response->sendSuccess($response, 200);
673 +
674 + } catch (\Exception $e) {
675 + return $this->response->sendError($e->getMessage(), 500);
676 + }
677 + }
678 +
679 + public function bulkDeleteTasks(Request $request, $board_id)
680 + {
681 + $board_id = absint($board_id);
682 + try {
683 + $rawTaskIds = $request->getSafe('task_ids');
684 + // Sanitize task_ids array to integers
685 + $task_ids = [];
686 + if (is_array($rawTaskIds)) {
687 + $task_ids = array_filter(array_map('intval', $rawTaskIds));
688 + }
689 +
690 + if (empty($task_ids)) {
691 + return $this->response->sendError('No task IDs provided', 400);
692 + }
693 +
694 + $tasks = Task::where('board_id', $board_id)
695 + ->whereIn('id', $task_ids)
696 + ->get();
697 +
698 + if ($tasks->isEmpty()) {
699 + return $this->response->sendError('No tasks found with provided IDs', 404);
700 + }
701 +
702 + $deleted_count = 0;
703 + $failed_count = 0;
704 + $failed_tasks = [];
705 + $options = null;
706 +
707 + foreach ($tasks as $task) {
708 + try {
709 + // This handles all cleanup: subtasks, watchers, assignees, labels, notifications, attachments, etc.
710 + $this->taskService->deleteTaskForBulk($task);
711 + $deleted_count++;
712 + } catch (\Exception $e) {
713 + // Track failed tasks but continue processing others
714 + $failed_count++;
715 + $failed_tasks[] = [
716 + 'id' => $task->id,
717 + 'title' => $task->title,
718 + 'error' => $e->getMessage()
719 + ];
720 + }
721 + }
722 +
723 + // Get recently updated tasks (same as single task operations)
724 + $recentlyUpdatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
725 +
726 + // Build response with detailed results
727 + $response = [
728 + 'deleted_count' => $deleted_count,
729 + 'failed_count' => $failed_count,
730 + 'updatedTasks' => $recentlyUpdatedTasks
731 + ];
732 +
733 + if ($failed_count > 0) {
734 + $response['failed_tasks'] = $failed_tasks;
735 + if ($deleted_count > 0) {
736 + $response['message'] = $deleted_count . ' ' . ($deleted_count === 1 ? 'task' : 'tasks') . ' deleted successfully, ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks') . ' failed';
737 + } else {
738 + $response['message'] = 'Failed to delete ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks');
739 + }
740 + } else {
741 + $response['message'] = $deleted_count . ' ' . ($deleted_count === 1 ? 'task' : 'tasks') . ' deleted successfully';
742 + }
743 +
744 + return $this->response->sendSuccess($response, 200);
745 +
746 + } catch (\Exception $e) {
747 + return $this->response->sendError($e->getMessage(), 500);
748 + }
749 + }
750 +
244 751 public function updateTaskProperties(Request $request, $board_id, $task_id)
245 752 {
753 + $board_id = absint($board_id);
754 + $task_id = absint($task_id);
755 + //Properties in col: settings, assignees,crm_contact_id, archived_at(AUTO_SET_TIMESTAMP) , status, title, description, priority, is_watching, is_template
246 756 $col = $request->getSafe('property', 'sanitize_text_field');
247 - $value = $request->get('value');
757 + if ($col === 'description') {
758 + $value = $request->getSafe('value', 'fluent_boards_sanitize_description');
759 + } elseif ($col === 'settings' || $col === 'assignees') {
760 + $value = $request->get('value');
761 + if (is_array($value) && isset($value['cover']) && is_array($value['cover'])) {
762 + if (isset($value['cover']['backgroundColor'])) {
763 + $value['cover']['backgroundColor'] = sanitize_text_field($value['cover']['backgroundColor']);
764 + }
765 + }
766 + } elseif ($col === 'is_watching') {
767 + $value = $request->get('value');
768 + if (is_array($value)) {
769 + $action = isset($value['action']) ? sanitize_text_field($value['action']) : 'start';
770 + $value = [
771 + 'userId' => isset($value['userId']) ? absint($value['userId']) : 0,
772 + 'action' => in_array($action, ['start', 'stop'], true) ? $action : 'start',
773 + ];
774 + } else {
775 + $value = sanitize_text_field($value);
776 + }
777 + } else {
778 + $value = $request->getSafe('value', 'sanitize_text_field');
779 + }
248 780
249 781 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
250 - $task = Task::with(['board', 'labels', 'assignees'])->findOrFail($task_id);
782 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
783 + $task->load(['board', 'labels', 'assignees']);
251 784
785 + if ($col === 'board_id' && (int) $validatedData[$col] !== $board_id) {
786 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
787 + }
788 +
789 + if ($col === 'stage_id' && !Stage::where('id', (int) $validatedData[$col])->where('board_id', $board_id)->exists()) {
790 + throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
791 + }
792 +
793 + if ($col === 'parent_id' && $validatedData[$col]) {
794 + $this->taskService->findTaskOnBoard($validatedData[$col], $board_id, false);
795 + }
796 +
797 + if ($task->parent_id && $col === 'started_at') {
798 + $validatedData[$col] = null;
799 + }
800 +
801 + $oldDateValue = null;
802 + if (in_array($col, ['due_at', 'started_at'])) {
803 + $oldDateValue = $task->{$col};
804 + }
805 +
252 806 if ($task->parent_id && !$task->board_id) {
253 807 $task->board_id = $board_id;
254 808 $task->save();
255 809 }
@@ -260,11 +814,32 @@
260 814 $task->contact = Helper::crm_contact($task->crm_contact_id);
261 815 $task->is_watching = $task->isWatching();
262 816 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
263 817
818 + if ($col === 'is_watching') {
819 + $task->load('watchers');
820 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
821 + }
822 +
823 + if ($task->parent_id) {
824 + $task->subtask_group_id = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_CHILD)->value('value');
825 + }
826 +
264 827 // A recent update to a task might impact other tasks on the board.
265 828 $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
829 + $taskExists = false;
830 + foreach ($updatedTasks as $index => $updatedTask) {
831 + if ($updatedTask->id === $task->id) {
832 + $updatedTasks[$index] = $task; // Replace the existing task
833 + $taskExists = true;
834 + break;
835 + }
836 + }
266 837
838 + if (!$taskExists) {
839 + $updatedTasks[] = $task;
840 + }
841 +
267 842 return [
268 843 'message' => __('Task has been updated', 'fluent-boards'),
269 844 'task' => $task,
270 845 'updatedTasks' => $updatedTasks
@@ -270,35 +845,167 @@
270 845 'updatedTasks' => $updatedTasks
271 846 ];
272 847 }
273 848
849 + /**
850 + * Remove a Fluent Support association from a task without deleting the ticket.
851 + *
852 + * @param int $board_id
853 + * @param int $task_id
854 + * @return mixed
855 + */
856 + public function removeSupportTicketLink($board_id, $task_id)
857 + {
858 + $boardId = absint($board_id);
859 + $taskId = absint($task_id);
860 + $task = $this->taskService->removeSupportTicketLink($taskId, $boardId);
861 +
862 + return $this->sendSuccess([
863 + 'message' => __('Support ticket link has been removed', 'fluent-boards'),
864 + 'task' => $task,
865 + 'updatedTasks' => [$task],
866 + ]);
867 + }
868 +
274 869 public function updateTaskDates(Request $request, $board_id, $task_id)
275 870 {
276 - $task = Task::findOrFail($task_id);
871 + $board_id = absint($board_id);
872 + $task_id = absint($task_id);
873 + $task = Task::where('id', $task_id)->where('board_id', $board_id)->firstOrFail();
874 + $payload = $request->all();
277 875
278 - $startAt = $request->getSafe('started_at', 'sanitize_text_field', NULL);
279 - $dueAt = $request->getSafe('due_at', 'sanitize_text_field', NULL);
876 + // Capture old dates before updating
877 + $oldDates = [
878 + 'due_at' => $task->due_at,
879 + 'started_at' => $task->started_at,
880 + ];
280 881
281 - if ($startAt && $dueAt) {
882 +
883 +
884 + $hasStartAt = array_key_exists('started_at', $payload);
885 + $hasDueAt = array_key_exists('due_at', $payload);
886 + $hasReminderType = array_key_exists('reminder_type', $payload);
887 + $hasRemindAt = array_key_exists('remind_at', $payload);
888 +
889 + $startAt = $hasStartAt ? $request->getSafe('started_at', 'sanitize_text_field', NULL) : $task->started_at;
890 + $dueAt = $hasDueAt ? $request->getSafe('due_at', 'sanitize_text_field', NULL) : $task->due_at;
891 + $isSubtask = (bool) $task->parent_id;
892 +
893 + if ($isSubtask) {
894 + $startAt = null;
895 + $hasStartAt = $hasStartAt || (bool) $task->started_at;
896 + }
897 +
898 + if (!$isSubtask && $hasStartAt && $hasDueAt && $startAt && $dueAt) {
282 899 if (strtotime($startAt) > strtotime($dueAt)) {
283 - $startAt = gmdate('Y-m-d 00:00:00', strtotime($dueAt));
900 + $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
284 901 }
285 902 }
286 903
287 - $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
288 - $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
904 + if ($hasStartAt) {
905 + $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
906 + }
289 907
908 + if ($hasDueAt) {
909 + $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
910 + }
911 +
912 + // Only mutate reminder fields when the caller explicitly sends them.
913 + if ($hasReminderType) {
914 + $reminderType = $request->getSafe('reminder_type', 'sanitize_text_field', NULL);
915 + $task = $this->taskService->updateTaskProperty('reminder_type', $reminderType, $task);
916 + }
917 +
918 + if ($hasRemindAt) {
919 + $remindAt = $request->getSafe('remind_at', 'sanitize_text_field', NULL);
920 + $task = $this->taskService->updateTaskProperty('remind_at', $remindAt, $task);
921 + }
922 +
923 + $datesChanged = false;
924 + $changedDates = [];
925 +
926 + if ($oldDates['due_at'] !== $task->due_at) {
927 + $datesChanged = true;
928 + $changedDates['due_at'] = $oldDates['due_at'];
929 + }
930 +
931 + if ($oldDates['started_at'] !== $task->started_at) {
932 + $datesChanged = true;
933 + $changedDates['started_at'] = $oldDates['started_at'];
934 + }
935 +
936 + if ($datesChanged) {
937 + do_action('fluent_boards/task_date_changed', $task, $changedDates);
938 + }
939 +
290 940 return [
291 941 'task' => $task,
292 - 'message' => __('Dates has been updated', 'fluent-boards'),
942 + 'message' => __('Dates have been updated', 'fluent-boards'),
293 943 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
294 944 ];
295 945 }
296 946
947 + /**
948 + * Toggle task pinned state (meta only). Only top-level tasks can be pinned.
949 + *
950 + * @param Request $request Expects body: pinned (bool or "true"/"1" for pin, false/"false"/"0" for unpin)
951 + * @param int $board_id
952 + * @param int $task_id
953 + * @return array{task: \FluentBoards\App\Models\Task, message: string, updatedTasks: array}
954 + */
955 + public function toggleTaskPinned(Request $request, $board_id, $task_id)
956 + {
957 + $board_id = absint($board_id);
958 + $task_id = absint($task_id);
959 +
960 + $task = Task::where('board_id', $board_id)->findOrFail($task_id);
961 +
962 + if ($task->parent_id) {
963 + return $this->sendError(__('Subtasks cannot be pinned', 'fluent-boards'), 400);
964 + }
965 +
966 + $pinned = filter_var($request->getSafe('pinned', 'sanitize_text_field', false), FILTER_VALIDATE_BOOLEAN);
967 +
968 + if ((int) $task->is_pinned !== ($pinned ? 1 : 0)) {
969 + if ($pinned) {
970 + $task = $this->taskService->pinTask($task);
971 + $message = __('Task has been pinned', 'fluent-boards');
972 + } else {
973 + $task = $this->taskService->unpinTask($task);
974 + $message = __('Task has been unpinned', 'fluent-boards');
975 + }
976 + } else {
977 + $message = $pinned ? __('Task is already pinned', 'fluent-boards') : __('Task is already unpinned', 'fluent-boards');
978 + }
979 +
980 + // Pin state is stored in task meta, so task.updated_at may not change.
981 + // Ensure the toggled task is always present in the incremental payload.
982 + $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
983 + $taskExists = false;
984 + foreach ($updatedTasks as $index => $updatedTask) {
985 + if ($updatedTask->id === $task->id) {
986 + $updatedTasks[$index] = $task;
987 + $taskExists = true;
988 + break;
989 + }
990 + }
991 + if (!$taskExists) {
992 + $updatedTasks[] = $task;
993 + }
994 +
995 + return [
996 + 'task' => $task,
997 + 'message' => $message,
998 + 'updatedTasks' => $updatedTasks,
999 + ];
1000 + }
1001 +
297 1002 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
298 1003 {
299 - $imagePath = $request->thumbnail;
300 - $task = $this->taskService->taskCoverPhotoUpdate($task_id, $imagePath);
1004 + $board_id = absint($board_id);
1005 + $task_id = absint($task_id);
1006 + $imagePath = $request->getSafe('thumbnail', 'sanitize_text_field');
1007 + $task = $this->taskService->taskCoverPhotoUpdate($task_id, $imagePath, $board_id);
301 1008
302 1009 return [
303 1010 'message' => __('Task cover photo has been updated', 'fluent-boards'),
304 1011 'task' => $task,
@@ -307,17 +1014,22 @@
307 1014 }
308 1015
309 1016 public function taskStatusUpdate(Request $request, $board_id, $task_id)
310 1017 {
1018 + $board_id = absint($board_id);
1019 + $task_id = absint($task_id);
1020 + $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
311 1021 return [
312 1022 'message' => __('Task status has been updated', 'fluent-boards'),
313 - 'task' => $this->taskService->taskStatusUpdate($task_id, $request->integrationType),
1023 + 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
314 1024 ];
315 1025 }
316 1026
317 1027 public function deleteTask($board_id, $task_id)
318 1028 {
319 - $task = Task::findOrFail($task_id);
1029 + $board_id = absint($board_id);
1030 + $task_id = absint($task_id);
1031 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
320 1032 $options = null;
321 1033 //if we need to do something before a task is deleted
322 1034 do_action('fluent_boards/before_task_deleted', $task, $options);
323 1035
@@ -335,8 +1047,39 @@
335 1047
336 1048 return $this->validate($data, $rules);
337 1049 }
338 1050
1051 + /**
1052 + * Ensure write routes cannot pair an accessible route board with a task from another board.
1053 + *
1054 + * @param \FluentBoards\App\Models\Task $task
1055 + * @param int $boardId
1056 + * @return void
1057 + * @throws \Exception
1058 + */
1059 + private function assertTaskBelongsToBoard($task, $boardId)
1060 + {
1061 + $boardId = absint($boardId);
1062 +
1063 + if (!$task || !$boardId) {
1064 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1065 + }
1066 +
1067 + if ((int) $task->board_id === $boardId) {
1068 + return;
1069 + }
1070 +
1071 + if ($task->parent_id) {
1072 + $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
1073 +
1074 + if ((int) $parentBoardId === $boardId) {
1075 + return;
1076 + }
1077 + }
1078 +
1079 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1080 + }
1081 +
339 1082 private function updateTaskPropValidationAndSanitation($col, $value)
340 1083 {
341 1084 $rules = [
342 1085 'title' => 'required|string',
@@ -342,9 +1085,9 @@
342 1085 'title' => 'required|string',
343 1086 'board_id' => 'required',
344 1087 'parent_id' => 'required',
345 1088 'crm_contact_id' => 'nullable',
346 - 'task_type' => 'nullable|string',
1089 + 'type' => 'nullable|string',
347 1090 'status' => 'nullable|string',
348 1091 'stage_id' => 'required',
349 1092 'reminder_type' => 'nullable|string',
350 1093 'priority' => 'nullable|string',
@@ -359,9 +1102,9 @@
359 1102 'log_minutes' => 'nullable|integer|unsigned',
360 1103 'last_completed' => 'nullable|date',
361 1104 'assignees' => 'nullable|integer',
362 1105 'archived_at' => 'nullable|string',
363 - 'is_watching' => 'nullable|string',
1106 + 'is_watching' => 'nullable',
364 1107 'is_template' => 'string',
365 1108 'last_completed_at' => 'nullable',
366 1109 'settings' => 'nullable|array',
367 1110 ];
@@ -378,8 +1121,11 @@
378 1121 }
379 1122
380 1123 return [$col => $sanitizedAndValidatedValue];
381 1124 }
1125 + if ('is_watching' == $col && is_array($value)) {
1126 + return [$col => $value];
1127 + }
382 1128 $data = Helper::sanitizeTask([$col => $value]);
383 1129
384 1130 return $this->validate($data, [
385 1131 $col => $rule,
@@ -384,22 +1130,22 @@
384 1130 return $this->validate($data, [
385 1131 $col => $rule,
386 1132 ]);
387 1133 }
388 - }
389 1134
390 - public function getLabelsByTask($task_id)
391 - {
392 - $labels = $this->taskService->getLabelsByTask($task_id);
393 -
394 - return $this->sendSuccess([
395 - 'labels' => $labels,
396 - ], 200);
1135 + // If the column is not found in the rules array, throw an exception
1136 + // translators: %s is the property name
1137 + throw new \Exception(sprintf(esc_html__('Invalid property: %s', 'fluent-boards'), esc_html($col)));
397 1138 }
398 1139
399 1140 public function getStageByTask($task_id)
400 1141 {
401 - $stage = $this->taskService->getStageByTask($task_id);
1142 + $task_id = absint($task_id);
1143 + try {
1144 + $stage = $this->taskService->getStageByTask($task_id);
1145 + } catch (\Exception $e) {
1146 + return $this->sendError($e->getMessage(), 404);
1147 + }
402 1148
403 1149 return [
404 1150 'stage' => $stage,
405 1151 ];
@@ -406,8 +1152,10 @@
406 1152 }
407 1153
408 1154 public function assignYourselfInTask($board_id, $task_id)
409 1155 {
1156 + $board_id = absint($board_id);
1157 + $task_id = absint($task_id);
410 1158 $task = $this->taskService->assignYourselfInTask($board_id, $task_id);
411 1159 $task->is_watching = $task->isWatching();
412 1160
413 1161 return [
@@ -416,8 +1164,10 @@
416 1164 }
417 1165
418 1166 public function detachYourselfFromTask($board_id, $task_id)
419 1167 {
1168 + $board_id = absint($board_id);
1169 + $task_id = absint($task_id);
420 1170 $task = $this->taskService->detachYourselfFromTask($board_id, $task_id);
421 1171 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
422 1172 $task->is_watching = $task->isWatching();
423 1173
@@ -434,9 +1184,11 @@
434 1184 }
435 1185
436 1186 public function moveTaskToNextStage($board_id, $task_id)
437 1187 {
438 - $task = $this->taskService->moveTaskToNextStage($task_id);
1188 + $board_id = absint($board_id);
1189 + $task_id = absint($task_id);
1190 + $task = $this->taskService->moveTaskToNextStage($task_id, $board_id);
439 1191
440 1192 return [
441 1193 'task' => $task
442 1194 ];
@@ -446,32 +1198,76 @@
446 1198 * @throws \Exception
447 1199 */
448 1200 public function moveTask(Request $request, $board_id, $task_id)
449 1201 {
450 - $task = Task::findOrFail($task_id);
1202 + $board_id = absint($board_id);
1203 + $task_id = absint($task_id);
1204 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
451 1205 $oldStageId = $task->stage_id;
452 1206 $newStageId = $request->getSafe('newStageId', 'intval');
453 1207 $newIndex = $request->getSafe('newIndex', 'intval');
454 1208 $newBoardId = $request->getSafe('newBoardId', 'intval');
1209 + $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1210 + $nextTaskId = $request->getSafe('nextTaskId', 'intval');
455 1211
456 1212 if ((!is_numeric($newStageId) || $newStageId == 0)) {
457 - throw new \Exception(__('Invalid Stage', 'fluent-boards'));
1213 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
458 1214 }
459 - if ((!is_numeric($newIndex) || $newIndex == 0)) {
460 - throw new \Exception(__('Invalid Value', 'fluent-boards'));
1215 +
1216 + if (!$prevTaskId && !$nextTaskId && (!is_numeric($newIndex) || $newIndex == 0)) {
1217 + throw new \Exception(esc_html__('Invalid Value', 'fluent-boards'));
461 1218 }
1219 +
462 1220 if ($newBoardId) {
463 1221 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
464 - throw new \Exception(__('Invalid Board', 'fluent-boards'));
1222 + throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
465 1223 }
1224 +
1225 + if (!PermissionManager::userHasBoardPermission($newBoardId, 'PUT')) {
1226 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1227 + }
1228 + }
1229 +
1230 + $effectiveBoardId = $newBoardId ?: $task->board_id;
1231 + $targetStage = Stage::where('id', $newStageId)
1232 + ->where('board_id', $effectiveBoardId)
1233 + ->first();
1234 +
1235 + if (!$targetStage) {
1236 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
1237 + }
1238 +
1239 + foreach (array_filter([$prevTaskId, $nextTaskId]) as $neighborTaskId) {
1240 + $this->taskService->findTaskOnBoard($neighborTaskId, $effectiveBoardId);
1241 + }
1242 +
1243 + if ($newBoardId) {
466 1244 $task = $this->taskService->changeBoardByTask($task, $newBoardId);
1245 + // Load relationships to ensure frontend gets updated data after board move
1246 + $task->load(['assignees', 'labels', 'watchers', 'attachments']);
467 1247 }
468 1248
1249 + // Clean up archived_by_stage meta when task is moved to different stage
1250 + if ($oldStageId != $newStageId) {
1251 + TaskMeta::where('task_id', $task->id)
1252 + ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
1253 + ->delete();
1254 + }
1255 +
469 1256 $task->stage_id = $newStageId;
470 - $task = $task->moveToNewPosition($newIndex);
1257 + // New drag flows send neighbour ids so ordering stays correct even when
1258 + // the client only has a paged slice of the stage. Older move flows still
1259 + // rely on the legacy 1-based newIndex fallback.
1260 + if ($prevTaskId || $nextTaskId) {
1261 + $task = $task->moveBetweenTasks($prevTaskId, $nextTaskId);
1262 + } else {
1263 + $task = $task->moveToNewPosition($newIndex);
1264 + }
471 1265
472 1266 if ($oldStageId != $newStageId) {
473 1267
1268 + $this->taskService->manageDefaultAssignees($task, $newStageId);
1269 +
474 1270 $defaultPosition = $task->stage->defaultTaskStatus();
475 1271
476 1272 if ($defaultPosition == 'closed' && $task->status != 'closed') {
477 1273 $task = $task->close();
@@ -486,12 +1282,12 @@
486 1282 }
487 1283
488 1284 do_action('fluent_boards/task_updated', $task, 'position');
489 1285
490 - $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id, $request->get('last_boards_updated'));
1286 + $lastBoardsUpdated = $request->getSafe('last_boards_updated', 'sanitize_text_field');
1287 + $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id, $lastBoardsUpdated);
491 1288
492 1289 return [
493 - 'new_position' => $task,
494 1290 'message' => __('Task has been updated', 'fluent-boards'),
495 1291 'task' => $task,
496 1292 'updatedTasks' => $updatedTasks,
497 1293 'last_updated' => current_time('mysql')
@@ -507,15 +1303,17 @@
507 1303 * @return \WP_REST_Response The response containing paginated comments and activities, total count, current page, and items per page.
508 1304 */
509 1305 public function getCommentsAndActivities( Request $request, $board_id, $task_id)
510 1306 {
1307 + $board_id = absint($board_id);
1308 + $task_id = absint($task_id);
511 1309 try {
512 1310 // Pagination parameters
513 - $page = $request->get('page', 1);
514 - $perPage = $request->get('per_page', 10);
515 - $filter = $request->get('filter', 'newest'); // Filter for comments and activities
516 -
517 - $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter);
1311 + $page = $request->getSafe('page', 'intval', 1);
1312 + $perPage = $request->getSafe('per_page', 'intval', 10);
1313 + $filter = $request->getSafe('filter', 'sanitize_text_field', 'newest'); // Filter for comments and activities
1314 + $feedType = $request->getSafe('feed_type', 'sanitize_text_field', 'all');
1315 + $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter, $board_id, $feedType);
518 1316 // Return the response with the task, paginated comments and activities, total count, current page, and items per page
519 1317 return $this->sendSuccess([
520 1318 'comments_and_activities' => $commentsAndActivities,
521 1319 ]);
@@ -533,14 +1331,35 @@
533 1331 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
534 1332 }
535 1333 public function getAssociatedTasks($associated_id)
536 1334 {
1335 + if (!$this->currentUserCanReadCrmContacts()) {
1336 + return $this->sendError(esc_html__('You do not have permission to view CRM contact tasks', 'fluent-boards'), 403);
1337 + }
1338 +
1339 + $associated_id = absint($associated_id);
537 1340 return [
538 - 'tasks' => $this->taskService->getAssociatedTasks($associated_id)
1341 + 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
539 1342 ];
540 1343 }
541 1344
542 1345 /**
1346 + * Check FluentCRM contact read permission before exposing CRM-associated task data.
1347 + *
1348 + * @return bool
1349 + */
1350 + private function currentUserCanReadCrmContacts()
1351 + {
1352 + $permissionManager = 'FluentCrm\\App\\Services\\PermissionManager';
1353 +
1354 + if (!class_exists($permissionManager)) {
1355 + return false;
1356 + }
1357 +
1358 + return (bool) $permissionManager::currentUserCan('fcrm_read_contacts');
1359 + }
1360 +
1361 + /**
543 1362 * @param Request $request
544 1363 * @param $board_id
545 1364 * @param $task_id
546 1365 * @return \WP_REST_Response
@@ -546,9 +1365,12 @@
546 1365 * @return \WP_REST_Response
547 1366 */
548 1367 public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
549 1368 {
1369 + $board_id = absint($board_id);
1370 + $task_id = absint($task_id);
550 1371 try {
1372 + $this->taskService->findTaskOnBoard($task_id, $board_id);
551 1373
552 1374
553 1375 $file = Arr::get($request->files(), 'file')->toArray();
554 1376 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
@@ -556,11 +1378,19 @@
556 1378 $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
557 1379
558 1380 $fileData = $uploadInfo[0];
559 1381 $fileUploadedData = $this->taskService->uploadMediaFileFromWpEditor($task_id, $fileData, Constant::TASK_DESCRIPTION);
1382 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1383 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1384 + $fileUploadedData['driver'] = $mediaData['driver'];
1385 + $fileUploadedData['file_path'] = $mediaData['file_path'];
1386 + $fileUploadedData['full_url'] = $mediaData['full_url'];
1387 + $fileUploadedData->save();
1388 + }
1389 + $fileUploadedData['public_url'] = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
560 1390
561 1391 return $this->sendSuccess([
562 - 'message' => __('Image has been uploaded', 'fluent-boards-pro'),
1392 + 'message' => __('Image has been uploaded', 'fluent-boards'),
563 1393 'file' => $fileUploadedData
564 1394 ], 200);
565 1395
566 1396
@@ -568,5 +1398,398 @@
568 1398 return $this->sendError($e->getMessage(), 400);
569 1399 }
570 1400 }
571 1401
1402 + public function createTaskFromImage(Request $request, $board_id)
1403 + {
1404 + $board_id = absint($board_id);
1405 + $stageId = $request->getSafe('stage_id', 'intval');
1406 + if (!Stage::where('id', $stageId)->where('board_id', $board_id)->exists()) {
1407 + return $this->sendError(esc_html__('Stage not found', 'fluent-boards'), 400);
1408 + }
1409 +
1410 + $file = Arr::get($request->files(), 'file')->toArray();
1411 + (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1412 +
1413 + $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1414 + $task = $this->taskService->createTaskFromImage($board_id, $stageId, $uploadInfo, $file);
1415 + $message = $task->type === 'roadmap'
1416 + ? __('Idea has been created', 'fluent-boards')
1417 + : __('Task has been created', 'fluent-boards');
1418 +
1419 + return $this->sendSuccess([
1420 + 'task' => $task,
1421 + 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
1422 + 'message' => $message,
1423 + ], 200);
1424 +
1425 + }
1426 +
1427 + public function handleTaskCoverImageUpload(Request $request, $board_id, $task_id)
1428 + {
1429 + $board_id = absint($board_id);
1430 + $task_id = absint($task_id);
1431 + try {
1432 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1433 +
1434 + $file = Arr::get($request->files(), 'file')->toArray();
1435 + (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1436 +
1437 + $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1438 +
1439 + $fileData = $uploadInfo[0];
1440 + $fileUploadedData = $this->taskService->uploadMediaFileFromWpEditor($task_id, $fileData, Constant::TASK_DESCRIPTION);
1441 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1442 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1443 + $fileUploadedData['driver'] = $mediaData['driver'];
1444 + $fileUploadedData['file_path'] = $mediaData['file_path'];
1445 + $fileUploadedData['full_url'] = $mediaData['full_url'];
1446 + $fileUploadedData->save();
1447 + }
1448 +
1449 + $settings = $task->settings;
1450 + $this->taskService->deleteTaskCoverImage($settings);
1451 + $publicUrl = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
1452 +
1453 + $settings['cover'] = [
1454 + 'imageId' => $fileUploadedData['id'],
1455 + 'backgroundImage' => $publicUrl,
1456 + ];
1457 + $task->settings = $settings;
1458 + $task->save();
1459 +
1460 + return $this->sendSuccess([
1461 + 'message' => __('Image has been uploaded', 'fluent-boards'),
1462 + 'public_url' => $publicUrl
1463 + ], 200);
1464 +
1465 +
1466 + } catch (\Exception $e) {
1467 + return $this->sendError($e->getMessage(), 400);
1468 + }
1469 + }
1470 + public function removeTaskCover($board_id, $task_id)
1471 + {
1472 + $board_id = absint($board_id);
1473 + $task_id = absint($task_id);
1474 + try {
1475 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1476 + $settings = $task->settings;
1477 + $this->taskService->deleteTaskCoverImage($settings);
1478 + unset($settings['cover']);
1479 + $task->settings = $settings;
1480 + $task->save();
1481 + return $this->sendSuccess([
1482 + 'task' => $task,
1483 + 'message' => __('Task Cover removed successfully', 'fluent-boards'),
1484 + ]);
1485 + } catch (\Exception $e) {
1486 + return $this->sendError($e->getMessage(), 400);
1487 + }
1488 + }
1489 +
1490 + /**
1491 + * Get task tabs configuration
1492 + */
1493 + public function getTaskTabsConfig()
1494 + {
1495 + $default_config = [
1496 + [
1497 + 'name' => 'due_today',
1498 + 'label' => __('Due Today', 'fluent-boards'),
1499 + 'visible' => 'true',
1500 + 'order' => 1
1501 + ],
1502 + [
1503 + 'name' => 'assigned',
1504 + 'label' => __('Assigned', 'fluent-boards'),
1505 + 'visible' => 'true',
1506 + 'order' => 2
1507 + ],
1508 + [
1509 + 'name' => 'upcoming',
1510 + 'label' => __('Upcoming', 'fluent-boards'),
1511 + 'visible' => 'true',
1512 + 'order' => 3
1513 + ],
1514 + [
1515 + 'name' => 'overdue',
1516 + 'label' => __('Overdue', 'fluent-boards'),
1517 + 'visible' => 'true',
1518 + 'order' => 4
1519 + ],
1520 + [
1521 + 'name' => 'mentioned',
1522 + 'label' => __('Mentioned', 'fluent-boards'),
1523 + 'visible' => 'true',
1524 + 'order' => 5
1525 + ],
1526 + [
1527 + 'name' => 'completed',
1528 + 'label' => __('Completed', 'fluent-boards'),
1529 + 'visible' => 'true',
1530 + 'order' => 6
1531 + ],
1532 + [
1533 + 'name' => 'others',
1534 + 'label' => __('Others', 'fluent-boards'),
1535 + 'visible' => 'true',
1536 + 'order' => 7
1537 + ]
1538 + ];
1539 + $availableTabNames = array_column($default_config, 'name');
1540 +
1541 + $existConfig = Meta::where('object_id', get_current_user_id())->where('key', Constant::FBS_TASK_TABS_CONFIG)->first();
1542 + $config = $default_config;
1543 +
1544 + if ($existConfig && !empty($existConfig->value)) {
1545 + $storedConfig = $existConfig->value;
1546 + $configChanged = false;
1547 + $config = $storedConfig;
1548 + $config = array_values(array_filter($config, fn($tab) => in_array($tab['name'] ?? '', $availableTabNames, true)));
1549 + $configChanged = count($config) !== count($storedConfig);
1550 +
1551 + if (empty($config)) {
1552 + $config = $default_config;
1553 + $configChanged = true;
1554 + }
1555 +
1556 + $existingNames = array_column($config, 'name');
1557 + $missingTabs = [];
1558 + foreach ($default_config as $defaultTab) {
1559 + if (!in_array($defaultTab['name'], $existingNames)) {
1560 + $missingTabs[] = $defaultTab;
1561 + }
1562 + }
1563 +
1564 + if (!empty($missingTabs)) {
1565 + $newConfig = [];
1566 + $order = 1;
1567 + $addedDueToday = false;
1568 + $addedAssigned = false;
1569 + foreach ($config as $tab) {
1570 + if (!$addedDueToday) {
1571 + $dueTodayTab = array_filter($missingTabs, fn($t) => $t['name'] === 'due_today');
1572 + if (!empty($dueTodayTab)) {
1573 + $dueTodayTab = reset($dueTodayTab);
1574 + $dueTodayTab['order'] = $order++;
1575 + $newConfig[] = $dueTodayTab;
1576 + $addedDueToday = true;
1577 + }
1578 + }
1579 +
1580 + if ($tab['name'] === 'upcoming' && !$addedAssigned) {
1581 + $assignedTab = array_filter($missingTabs, fn($t) => $t['name'] === 'assigned');
1582 + if (!empty($assignedTab)) {
1583 + $assignedTab = reset($assignedTab);
1584 + $assignedTab['order'] = $order++;
1585 + $newConfig[] = $assignedTab;
1586 + $addedAssigned = true;
1587 + }
1588 + }
1589 + $tab['order'] = $order++;
1590 + $newConfig[] = $tab;
1591 + }
1592 + foreach ($missingTabs as $missingTab) {
1593 + if (!in_array($missingTab['name'], ['assigned', 'due_today'], true)) {
1594 + $missingTab['order'] = $order++;
1595 + $newConfig[] = $missingTab;
1596 + }
1597 + }
1598 + $config = $newConfig;
1599 + $configChanged = true;
1600 + }
1601 +
1602 + if ($configChanged) {
1603 + $existConfig->value = $config;
1604 + $existConfig->save();
1605 + }
1606 + }
1607 +
1608 + // Always apply fresh translations based on tab name
1609 + $labelMap = [
1610 + 'due_today' => __('Due Today', 'fluent-boards'),
1611 + 'assigned' => __('Assigned', 'fluent-boards'),
1612 + 'upcoming' => __('Upcoming', 'fluent-boards'),
1613 + 'overdue' => __('Overdue', 'fluent-boards'),
1614 + 'mentioned' => __('Mentioned', 'fluent-boards'),
1615 + 'completed' => __('Completed', 'fluent-boards'),
1616 + 'others' => __('Others', 'fluent-boards'),
1617 + ];
1618 +
1619 + foreach ($config as &$tab) {
1620 + if (isset($labelMap[$tab['name']])) {
1621 + $tab['label'] = $labelMap[$tab['name']];
1622 + }
1623 + }
1624 +
1625 + return $this->sendSuccess([
1626 + 'data' => $config
1627 + ]);
1628 + }
1629 +
1630 + /**
1631 + * Save task tabs configuration
1632 + */
1633 + public function saveTaskTabsConfig(Request $request)
1634 + {
1635 + $rawConfig = $request->getSafe('tabs');
1636 +
1637 + if (empty($rawConfig) || !is_array($rawConfig)) {
1638 + return $this->sendError([
1639 + 'message' => __('Invalid data format', 'fluent-boards')
1640 + ], 400);
1641 + }
1642 +
1643 + // Sanitize config array
1644 + $config = [];
1645 + foreach ($rawConfig as $tab) {
1646 + if (!is_array($tab)) {
1647 + continue;
1648 + }
1649 + $sanitizedTab = [
1650 + 'name' => isset($tab['name']) ? sanitize_text_field($tab['name']) : '',
1651 + 'label' => isset($tab['label']) ? sanitize_text_field($tab['label']) : '',
1652 + 'visible' => isset($tab['visible']) ? sanitize_text_field($tab['visible']) : 'false',
1653 + 'order' => isset($tab['order']) ? absint($tab['order']) : 0,
1654 + ];
1655 + $config[] = $sanitizedTab;
1656 + }
1657 +
1658 + if (count(array_filter($config, fn($tab) => $tab['visible'] == 'true')) == 0) {
1659 + return $this->sendError([
1660 + 'message' => __('At least one tab must be visible', 'fluent-boards')
1661 + ], 400);
1662 + }
1663 +
1664 + $userId = get_current_user_id();
1665 +
1666 + $exit = Meta::where('object_id', $userId)->where('key', 'fbs_task_tabs_config')->first();
1667 +
1668 + if ($exit) {
1669 + $exit->value = $config;
1670 + $exit->save();
1671 + } else {
1672 + $exit = Meta::create([
1673 + 'object_id' => $userId,
1674 + 'object_type' => 'option',
1675 + 'key' => Constant::FBS_TASK_TABS_CONFIG,
1676 + 'value' => $config
1677 + ]);
1678 + }
1679 + $config = $exit->value;
1680 +
1681 + return $this->sendSuccess([
1682 + 'message' => __('Configuration saved successfully', 'fluent-boards'),
1683 + 'config' => $config
1684 + ]);
1685 + }
1686 + public function getAssociatedCrmContacts($board_id)
1687 + {
1688 + $board_id = absint($board_id);
1689 + $contactsInTasks = Task::where('board_id', $board_id)
1690 + ->whereNotNull('crm_contact_id')
1691 + ->get();
1692 +
1693 + if ($contactsInTasks->isEmpty()) {
1694 + return $this->sendSuccess([]);
1695 + }
1696 +
1697 + $contactIds = $contactsInTasks->pluck('crm_contact_id')
1698 + ->unique()
1699 + ->toArray();
1700 +
1701 + $allContacts = Subscriber::whereIn('id', $contactIds)->get();
1702 +
1703 + if ($allContacts->isEmpty()) {
1704 + return $this->sendSuccess([]);
1705 + }
1706 +
1707 + $formattedContacts = [];
1708 + foreach ($allContacts as $contact) {
1709 + $name = trim($contact->first_name . ' ' . $contact->last_name);
1710 +
1711 + $formattedContacts[] = [
1712 + 'id' => $contact->id,
1713 + 'display_name' => $name,
1714 + 'email' => $contact->email,
1715 + 'photo' => fluent_boards_user_avatar($contact->user_email, $name),
1716 + ];
1717 + }
1718 + if (!empty($formattedContacts)) {
1719 + usort($formattedContacts, function ($a, $b) {
1720 + return strcmp($a['display_name'], $b['display_name']);
1721 + });
1722 + }
1723 +
1724 + return $this->sendSuccess($formattedContacts);
1725 + }
1726 +
1727 + public function cloneTask(Request $request, $board_id, $task_id)
1728 + {
1729 + $board_id = absint($board_id);
1730 + $task_id = absint($task_id);
1731 + $taskData = $this->taskSanitizeAndValidate($request->only(['title', 'stage_id', 'assignee', 'subtask', 'label', 'attachment', 'comment']), [
1732 + 'title' => 'required|string',
1733 + 'stage_id' => 'required|numeric',
1734 + 'assignee' => 'required',
1735 + 'subtask' => 'required',
1736 + 'label' => 'required',
1737 + 'attachment' => 'required',
1738 + 'comment' => 'required',
1739 + ]);
1740 + try {
1741 + $taskData = fluent_boards_string_to_bool($taskData);
1742 + $clonedTask = $this->taskService->cloneTask($task_id, $taskData, $board_id);
1743 +
1744 + return $this->sendSuccess([
1745 + 'message' => __('Task has been cloned successfully', 'fluent-boards'),
1746 + 'task' => $clonedTask,
1747 + 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($clonedTask->board_id)
1748 + ], 200);
1749 + } catch (\Exception $e) {
1750 + return $this->sendError($e->getMessage(), 400);
1751 + }
1752 + }
1753 +
1754 + public function bulkActions(Request $request, $board_id)
1755 + {
1756 + $board_id = absint($board_id);
1757 + try {
1758 + $rawTaskIds = $request->getSafe('task_ids');
1759 + // Sanitize task_ids array to integers
1760 + $taskIds = [];
1761 + if (is_array($rawTaskIds)) {
1762 + $taskIds = array_filter(array_map('intval', $rawTaskIds));
1763 + }
1764 + $action = $request->getSafe('action', 'sanitize_text_field');
1765 + // Sanitize params array
1766 + $rawParams = $request->except(['task_ids', 'action']);
1767 + // Ensure rawParams is sanitized
1768 + if (!is_array($rawParams)) {
1769 + $rawParams = [];
1770 + }
1771 + $params = [];
1772 + foreach ($rawParams as $key => $value) {
1773 + $sanitizedKey = sanitize_text_field($key);
1774 + if (is_array($value)) {
1775 + $params[$sanitizedKey] = array_map('sanitize_text_field', $value);
1776 + } else {
1777 + $params[$sanitizedKey] = sanitize_text_field($value);
1778 + }
1779 + }
1780 +
1781 + $result = $this->taskService->bulkActions($taskIds, $action, $params, $board_id);
1782 +
1783 + // Process successful tasks the same way as getTasksByBoard
1784 + if (!empty($result['successful_tasks'])) {
1785 + $board = Board::findOrFail($board_id);
1786 + $this->processTasks($result['successful_tasks'], $board);
1787 + }
1788 +
1789 + return $this->sendSuccess($result);
1790 +
1791 + } catch (\Exception $e) {
1792 + return $this->sendError($e->getMessage(), 500);
1793 + }
1794 + }
572 1795 }