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