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