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