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 +1303 -77 1.322.1.0 View file →
@@ -1,12 +1,15 @@
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;
@@ -14,8 +17,11 @@
14 17 use FluentBoards\App\Services\UploadService;
15 18 use FluentBoards\Framework\Http\Request\Request;
16 19 use FluentBoards\App\Services\PermissionManager;
17 20 use FluentBoards\Framework\Support\Arr;
21 +use FluentBoardsPro\App\Services\AttachmentService;
22 +use FluentCrm\App\Models\Subscriber;
23 +
18 24 class TaskController extends Controller
19 25 {
20 26 private TaskService $taskService;
21 27
@@ -32,70 +38,283 @@
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'], 6, $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 {
68 + $board_id = absint($board_id);
45 69 $board = Board::findOrFail($board_id);
70 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
46 71
47 72 // Get stage IDs
48 - $stageIds = $this->getStageIdsByBoard($board_id);
73 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
49 74
50 75 // Fetch tasks for the board
51 - $tasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
76 + $tasksQuery = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
52 77 ->where('board_id', $board_id)
53 - ->whereNull('archived_at')
54 78 ->whereNull('parent_id')
55 79 ->whereIn('stage_id', $stageIds)
56 - ->orderBy('due_at', 'ASC')
57 - ->get();
80 + ->orderBy('due_at', 'ASC');
58 81
82 + if (!$includeArchived) {
83 + $tasksQuery->whereNull('archived_at');
84 + }
85 +
86 + $tasks = $tasksQuery->get();
87 +
59 88 // Process each task
60 89 $this->processTasks($tasks, $board);
61 90
91 + if ($board->type === 'roadmap') {
92 + $this->taskService->loadIdeaVoteStatistics($tasks);
93 + }
94 +
62 95 return [
63 96 'tasks' => $tasks,
64 97 ];
65 98 }
66 99
67 - public function getTasksByBoardStage($board_id)
100 + public function getTasksByBoardStage(Request $request, $board_id)
68 101 {
102 + $board_id = absint($board_id);
69 103 $board = Board::findOrFail($board_id);
104 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
70 105
71 106 // Get stage IDs
72 - $stageIds = $this->getStageIdsByBoard($board_id);
107 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
108 + $stageTaskCounts = $this->getStageTaskCounts($board_id, $stageIds, $includeArchived);
73 109
74 110 // Initialize tasks array
75 111 $tasks = [];
112 + $paginationByStage = [];
76 113
77 114 // Fetch and process tasks for each stage
78 115 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')
116 + $stageTasks = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
84 117 ->orderBy('position', 'ASC')
85 118 ->limit(20)
86 119 ->get();
87 120
88 121 // Process each stage's tasks
89 - $this->processTasks($stageTasks, $board);
122 + $this->processTasks($stageTasks, $board, [
123 + 'includeContact' => false,
124 + 'includeObserverState' => false,
125 + 'includeRoadmapPopularity' => false,
126 + ]);
90 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 + ];
91 146 }
92 147
93 148 return [
94 149 'tasks' => $tasks,
150 + 'pagination_by_stage' => $paginationByStage,
95 151 ];
96 152 }
97 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)
266 + ->get();
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 +
98 317 /**
99 318 * Get Stage IDs by Board ID.
100 319 *
101 320 * @param int $board_id
@@ -100,44 +319,153 @@
100 319 *
101 320 * @param int $board_id
102 321 * @return array
103 322 */
104 - private function getStageIdsByBoard($board_id)
323 + private function getStageIdsByBoard($board_id, $includeArchived = false)
105 324 {
106 - return Stage::where('board_id', $board_id)
107 - ->whereNull('archived_at')
108 - ->pluck('id')
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 + })
109 373 ->toArray();
110 374 }
111 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 +
112 404 /**
113 405 * Process and append extra information for each task.
114 406 *
115 407 * @param \Illuminate\Database\Eloquent\Collection $tasks
116 408 * @param \App\Models\Board $board
409 + * @param array $options
117 410 */
118 - private function processTasks($tasks, $board)
411 + private function processTasks($tasks, $board, $options = [])
119 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 +
120 418 foreach ($tasks as $task) {
419 + $taskIds[] = (int) $task->id;
420 + }
421 +
422 + $unreadNotificationCounts = $this->notificationService->getUnreadNotificationCountsByTaskIds($taskIds);
423 +
424 + foreach ($tasks as $task) {
121 425 $task->isOverdue = $task->isOverdue();
122 426 $task->isUpcoming = $task->upcoming();
123 - $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
124 - $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 + }
125 433 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
126 434 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
127 - $task->notifications = $this->notificationService->getUnreadNotificationsOfTasks($task);
435 + $task->notifications = $unreadNotificationCounts[(int) $task->id] ?? 0;
128 436
129 437 // If the board type is 'roadmap', calculate popularity
130 - if ($board->type === 'roadmap') {
438 + if ($includeRoadmapPopularity && $board->type === 'roadmap') {
131 439 $task->popular = $task->getPopularCount();
132 440 }
133 441 }
134 442 }
135 443
136 -
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 + */
137 451 public function create(Request $request, $board_id)
138 452 {
139 - $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, [
140 468 'title' => 'required|string',
141 469 'board_id' => 'required|numeric',
142 470 'stage_id' => 'required|numeric',
143 471 'priority' => 'nullable|string',
@@ -145,17 +473,28 @@
145 473 'is_template' => 'string',
146 474 ]);
147 475
148 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 +
149 485 if ($taskData['board_id'] != $board_id) {
150 - throw new \Exception(__('Board id is not valid', 'fluent-boards'));
486 + throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
151 487 }
152 488
153 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');
154 493
155 494 return $this->sendSuccess([
156 495 'task' => $task,
157 - 'message' => __('Task has been successfully created', 'fluent-boards'),
496 + 'message' => $message,
158 497 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
159 498 ], 201);
160 499 } catch (\Exception $e) {
161 500 return $this->sendError($e->getMessage(), 400);
@@ -163,20 +502,22 @@
163 502 }
164 503
165 504 public function find($board_id, $task_id)
166 505 {
506 + $board_id = absint($board_id);
507 + $task_id = absint($task_id);
167 508 try {
168 509
169 510 $stageService = new StageService();
170 511
171 - $task = Task::findOrFail($task_id);
512 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
172 513
173 514 if (isset($task->parent_id)) {
174 - $task = Task::findOrFail($task->parent_id);
515 + $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
175 516 }
176 517
177 518 if(!$task) {
178 - throw new \Exception(__('Task not found', 'fluent-boards'));
519 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
179 520 }
180 521
181 522 if (defined('FLUENT_BOARDS_PRO')) {
182 523 $task->load(['attachments']);
@@ -181,11 +522,12 @@
181 522 if (defined('FLUENT_BOARDS_PRO')) {
182 523 $task->load(['attachments']);
183 524 }
184 525
185 - $task->load(['board', 'stage', 'labels', 'assignees']);
526 + $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
186 527
187 528 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
529 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
188 530
189 531 $task->isOverdue = $task->isOverdue();
190 532 $task->contact = Task::lead_contact($task->crm_contact_id);
191 533 $task->board->stages = $stageService->stagesByBoardId($board_id);
@@ -200,8 +542,10 @@
200 542 return [
201 543 'task' => $task
202 544 ];
203 545
546 + } catch (ModelNotFoundException $e) {
547 + throw $e;
204 548 } catch (\Exception $e ) {
205 549 return $this->sendError($e->getMessage(), 400);
206 550 }
207 551
@@ -209,9 +553,10 @@
209 553 }
210 554
211 555 public function getStageType(Request $request)
212 556 {
213 - $stage = Stage::findOrFail($request->stage_id);
557 + $stage_id = $request->getSafe('stage_id', 'intval');
558 + $stage = Stage::findOrFail($stage_id);
214 559
215 560 return [
216 561 'stage' => $stage,
217 562 ];
@@ -218,10 +563,13 @@
218 563 }
219 564
220 565 public function getActivities(Request $request, $board_id, $task_id)
221 566 {
222 - $filter = $request->getSafe('filter');
567 + $board_id = absint($board_id);
568 + $task_id = absint($task_id);
569 + $filter = $request->getSafe('filter', 'sanitize_text_field');
223 570 $per_page = 15; // Apparently, let's use a fixed number of items per page.
571 + $this->taskService->findTaskOnBoard($task_id, $board_id);
224 572
225 573 return [
226 574 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
227 575 ];
@@ -229,27 +577,236 @@
229 577 }
230 578
231 579 public function getArchivedTasks(Request $request, $board_id)
232 580 {
233 - $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 + ];
234 588
589 +
590 + $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
591 +
235 592 foreach ($tasks as $task) {
236 593 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
237 594 }
238 595
596 +
239 597 return [
240 598 'tasks' => $tasks
241 599 ];
242 600 }
243 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 +
244 754 public function updateTaskProperties(Request $request, $board_id, $task_id)
245 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
246 759 $col = $request->getSafe('property', 'sanitize_text_field');
247 - $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 + }
248 783
249 784 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
250 - $task = Task::with(['board', 'labels', 'assignees'])->findOrFail($task_id);
785 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
786 + $task->load(['board', 'labels', 'assignees']);
251 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 +
252 809 if ($task->parent_id && !$task->board_id) {
253 810 $task->board_id = $board_id;
254 811 $task->save();
255 812 }
@@ -260,11 +817,32 @@
260 817 $task->contact = Helper::crm_contact($task->crm_contact_id);
261 818 $task->is_watching = $task->isWatching();
262 819 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
263 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 +
264 830 // A recent update to a task might impact other tasks on the board.
265 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 + }
266 840
841 + if (!$taskExists) {
842 + $updatedTasks[] = $task;
843 + }
844 +
267 845 return [
268 846 'message' => __('Task has been updated', 'fluent-boards'),
269 847 'task' => $task,
270 848 'updatedTasks' => $updatedTasks
@@ -270,35 +848,167 @@
270 848 'updatedTasks' => $updatedTasks
271 849 ];
272 850 }
273 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 +
274 872 public function updateTaskDates(Request $request, $board_id, $task_id)
275 873 {
276 - $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();
277 878
278 - $startAt = $request->getSafe('started_at', 'sanitize_text_field', NULL);
279 - $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 + ];
280 884
281 - 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) {
282 902 if (strtotime($startAt) > strtotime($dueAt)) {
283 - $startAt = gmdate('Y-m-d 00:00:00', strtotime($dueAt));
903 + $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
284 904 }
285 905 }
286 906
287 - $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
288 - $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
907 + if ($hasStartAt) {
908 + $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
909 + }
289 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 +
290 943 return [
291 944 'task' => $task,
292 - 'message' => __('Dates has been updated', 'fluent-boards'),
945 + 'message' => __('Dates have been updated', 'fluent-boards'),
293 946 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
294 947 ];
295 948 }
296 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 +
297 1005 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
298 1006 {
299 - $imagePath = $request->thumbnail;
300 - $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);
301 1011
302 1012 return [
303 1013 'message' => __('Task cover photo has been updated', 'fluent-boards'),
304 1014 'task' => $task,
@@ -307,17 +1017,22 @@
307 1017 }
308 1018
309 1019 public function taskStatusUpdate(Request $request, $board_id, $task_id)
310 1020 {
1021 + $board_id = absint($board_id);
1022 + $task_id = absint($task_id);
1023 + $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
311 1024 return [
312 1025 'message' => __('Task status has been updated', 'fluent-boards'),
313 - 'task' => $this->taskService->taskStatusUpdate($task_id, $request->integrationType),
1026 + 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
314 1027 ];
315 1028 }
316 1029
317 1030 public function deleteTask($board_id, $task_id)
318 1031 {
319 - $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);
320 1035 $options = null;
321 1036 //if we need to do something before a task is deleted
322 1037 do_action('fluent_boards/before_task_deleted', $task, $options);
323 1038
@@ -335,8 +1050,39 @@
335 1050
336 1051 return $this->validate($data, $rules);
337 1052 }
338 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 +
339 1085 private function updateTaskPropValidationAndSanitation($col, $value)
340 1086 {
341 1087 $rules = [
342 1088 'title' => 'required|string',
@@ -342,9 +1088,9 @@
342 1088 'title' => 'required|string',
343 1089 'board_id' => 'required',
344 1090 'parent_id' => 'required',
345 1091 'crm_contact_id' => 'nullable',
346 - 'task_type' => 'nullable|string',
1092 + 'type' => 'nullable|string',
347 1093 'status' => 'nullable|string',
348 1094 'stage_id' => 'required',
349 1095 'reminder_type' => 'nullable|string',
350 1096 'priority' => 'nullable|string',
@@ -359,9 +1105,9 @@
359 1105 'log_minutes' => 'nullable|integer|unsigned',
360 1106 'last_completed' => 'nullable|date',
361 1107 'assignees' => 'nullable|integer',
362 1108 'archived_at' => 'nullable|string',
363 - 'is_watching' => 'nullable|string',
1109 + 'is_watching' => 'nullable',
364 1110 'is_template' => 'string',
365 1111 'last_completed_at' => 'nullable',
366 1112 'settings' => 'nullable|array',
367 1113 ];
@@ -378,8 +1124,11 @@
378 1124 }
379 1125
380 1126 return [$col => $sanitizedAndValidatedValue];
381 1127 }
1128 + if ('is_watching' == $col && is_array($value)) {
1129 + return [$col => $value];
1130 + }
382 1131 $data = Helper::sanitizeTask([$col => $value]);
383 1132
384 1133 return $this->validate($data, [
385 1134 $col => $rule,
@@ -384,22 +1133,22 @@
384 1133 return $this->validate($data, [
385 1134 $col => $rule,
386 1135 ]);
387 1136 }
388 - }
389 1137
390 - public function getLabelsByTask($task_id)
391 - {
392 - $labels = $this->taskService->getLabelsByTask($task_id);
393 -
394 - return $this->sendSuccess([
395 - 'labels' => $labels,
396 - ], 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)));
397 1141 }
398 1142
399 1143 public function getStageByTask($task_id)
400 1144 {
401 - $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 + }
402 1151
403 1152 return [
404 1153 'stage' => $stage,
405 1154 ];
@@ -406,8 +1155,10 @@
406 1155 }
407 1156
408 1157 public function assignYourselfInTask($board_id, $task_id)
409 1158 {
1159 + $board_id = absint($board_id);
1160 + $task_id = absint($task_id);
410 1161 $task = $this->taskService->assignYourselfInTask($board_id, $task_id);
411 1162 $task->is_watching = $task->isWatching();
412 1163
413 1164 return [
@@ -416,8 +1167,10 @@
416 1167 }
417 1168
418 1169 public function detachYourselfFromTask($board_id, $task_id)
419 1170 {
1171 + $board_id = absint($board_id);
1172 + $task_id = absint($task_id);
420 1173 $task = $this->taskService->detachYourselfFromTask($board_id, $task_id);
421 1174 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
422 1175 $task->is_watching = $task->isWatching();
423 1176
@@ -434,9 +1187,11 @@
434 1187 }
435 1188
436 1189 public function moveTaskToNextStage($board_id, $task_id)
437 1190 {
438 - $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);
439 1194
440 1195 return [
441 1196 'task' => $task
442 1197 ];
@@ -446,32 +1201,76 @@
446 1201 * @throws \Exception
447 1202 */
448 1203 public function moveTask(Request $request, $board_id, $task_id)
449 1204 {
450 - $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);
451 1208 $oldStageId = $task->stage_id;
452 1209 $newStageId = $request->getSafe('newStageId', 'intval');
453 1210 $newIndex = $request->getSafe('newIndex', 'intval');
454 1211 $newBoardId = $request->getSafe('newBoardId', 'intval');
1212 + $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1213 + $nextTaskId = $request->getSafe('nextTaskId', 'intval');
455 1214
456 1215 if ((!is_numeric($newStageId) || $newStageId == 0)) {
457 - throw new \Exception(__('Invalid Stage', 'fluent-boards'));
1216 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
458 1217 }
459 - if ((!is_numeric($newIndex) || $newIndex == 0)) {
460 - 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'));
461 1221 }
1222 +
462 1223 if ($newBoardId) {
463 1224 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
464 - throw new \Exception(__('Invalid Board', 'fluent-boards'));
1225 + throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
465 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) {
466 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']);
467 1250 }
468 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 +
469 1259 $task->stage_id = $newStageId;
470 - $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 + }
471 1268
472 1269 if ($oldStageId != $newStageId) {
473 1270
1271 + $this->taskService->manageDefaultAssignees($task, $newStageId);
1272 +
474 1273 $defaultPosition = $task->stage->defaultTaskStatus();
475 1274
476 1275 if ($defaultPosition == 'closed' && $task->status != 'closed') {
477 1276 $task = $task->close();
@@ -486,12 +1285,12 @@
486 1285 }
487 1286
488 1287 do_action('fluent_boards/task_updated', $task, 'position');
489 1288
490 - $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);
491 1291
492 1292 return [
493 - 'new_position' => $task,
494 1293 'message' => __('Task has been updated', 'fluent-boards'),
495 1294 'task' => $task,
496 1295 'updatedTasks' => $updatedTasks,
497 1296 'last_updated' => current_time('mysql')
@@ -507,15 +1306,17 @@
507 1306 * @return \WP_REST_Response The response containing paginated comments and activities, total count, current page, and items per page.
508 1307 */
509 1308 public function getCommentsAndActivities( Request $request, $board_id, $task_id)
510 1309 {
1310 + $board_id = absint($board_id);
1311 + $task_id = absint($task_id);
511 1312 try {
512 1313 // 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);
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);
518 1319 // Return the response with the task, paginated comments and activities, total count, current page, and items per page
519 1320 return $this->sendSuccess([
520 1321 'comments_and_activities' => $commentsAndActivities,
521 1322 ]);
@@ -533,14 +1334,35 @@
533 1334 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
534 1335 }
535 1336 public function getAssociatedTasks($associated_id)
536 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);
537 1343 return [
538 - 'tasks' => $this->taskService->getAssociatedTasks($associated_id)
1344 + 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
539 1345 ];
540 1346 }
541 1347
542 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 + /**
543 1365 * @param Request $request
544 1366 * @param $board_id
545 1367 * @param $task_id
546 1368 * @return \WP_REST_Response
@@ -546,9 +1368,12 @@
546 1368 * @return \WP_REST_Response
547 1369 */
548 1370 public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
549 1371 {
1372 + $board_id = absint($board_id);
1373 + $task_id = absint($task_id);
550 1374 try {
1375 + $this->taskService->findTaskOnBoard($task_id, $board_id);
551 1376
552 1377
553 1378 $file = Arr::get($request->files(), 'file')->toArray();
554 1379 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
@@ -556,11 +1381,19 @@
556 1381 $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
557 1382
558 1383 $fileData = $uploadInfo[0];
559 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);
560 1393
561 1394 return $this->sendSuccess([
562 - 'message' => __('Image has been uploaded', 'fluent-boards-pro'),
1395 + 'message' => __('Image has been uploaded', 'fluent-boards'),
563 1396 'file' => $fileUploadedData
564 1397 ], 200);
565 1398
566 1399
@@ -568,5 +1401,398 @@
568 1401 return $this->sendError($e->getMessage(), 400);
569 1402 }
570 1403 }
571 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 + }
572 1798 }