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 +1319 -79 1.32 → 2.2.0 View file →
@@ -1,12 +1,15 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Http\Controllers;
4 4
5 -use DateTimeImmutable;
5 +use FluentBoards\Framework\Database\Orm\ModelNotFoundException;
6 +use FluentBoards\App\Models\Meta;
6 7 use FluentBoards\App\Models\Stage;
7 8 use FluentBoards\App\Models\Task;
8 9 use FluentBoards\App\Models\Board;
10 +use FluentBoards\App\Models\TaskMeta;
11 +use FluentBoards\App\Services\CommentService;
9 12 use FluentBoards\App\Services\Constant;
10 13 use FluentBoards\App\Services\Helper;
11 14 use FluentBoards\App\Services\StageService;
12 15 use FluentBoards\App\Services\TaskService;
@@ -14,8 +17,11 @@
14 17 use FluentBoards\App\Services\UploadService;
15 18 use FluentBoards\Framework\Http\Request\Request;
16 19 use FluentBoards\App\Services\PermissionManager;
17 20 use FluentBoards\Framework\Support\Arr;
21 +use FluentBoardsPro\App\Services\AttachmentService;
22 +use FluentCrm\App\Models\Subscriber;
23 +
18 24 class TaskController extends Controller
19 25 {
20 26 private TaskService $taskService;
21 27
@@ -32,70 +38,295 @@
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 {
69 + $syncStartedAt = current_time('mysql');
70 + $board_id = absint($board_id);
45 71 $board = Board::findOrFail($board_id);
72 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
46 73
47 74 // Get stage IDs
48 - $stageIds = $this->getStageIdsByBoard($board_id);
75 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
49 76
50 77 // Fetch tasks for the board
51 - $tasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
78 + $tasksQuery = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
52 79 ->where('board_id', $board_id)
53 - ->whereNull('archived_at')
54 80 ->whereNull('parent_id')
55 81 ->whereIn('stage_id', $stageIds)
56 - ->orderBy('due_at', 'ASC')
57 - ->get();
82 + ->orderBy('due_at', 'ASC');
58 83
84 + if (!$includeArchived) {
85 + $tasksQuery->whereNull('archived_at');
86 + }
87 +
88 + $tasks = $tasksQuery->get();
89 +
59 90 // Process each task
60 91 $this->processTasks($tasks, $board);
61 92
93 + if ($board->type === 'roadmap') {
94 + $this->taskService->loadIdeaVoteStatistics($tasks);
95 + }
96 +
62 97 return [
63 98 'tasks' => $tasks,
99 + 'synced_at' => $syncStartedAt,
64 100 ];
65 101 }
66 102
67 - public function getTasksByBoardStage($board_id)
103 + /** Fetch each stage's first page, then batch metadata for the combined card list. */
104 + public function getTasksByBoardStage(Request $request, $board_id)
68 105 {
106 + $syncStartedAt = current_time('mysql');
107 + $board_id = absint($board_id);
69 108 $board = Board::findOrFail($board_id);
109 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
70 110
71 111 // Get stage IDs
72 - $stageIds = $this->getStageIdsByBoard($board_id);
112 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
113 + $stageTaskCounts = $this->getStageTaskCounts($board_id, $stageIds, $includeArchived);
73 114
74 115 // Initialize tasks array
75 116 $tasks = [];
117 + $paginationByStage = [];
76 118
77 119 // Fetch and process tasks for each stage
78 120 foreach ($stageIds as $stageId) {
79 - $stageTasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
80 - ->where('board_id', $board_id)
81 - ->where('stage_id', $stageId)
82 - ->whereNull('archived_at')
83 - ->whereNull('parent_id')
121 + $stageTasks = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
84 122 ->orderBy('position', 'ASC')
85 123 ->limit(20)
86 124 ->get();
87 125
88 - // Process each stage's tasks
89 - $this->processTasks($stageTasks, $board);
90 - $tasks = array_merge($tasks, $stageTasks->toArray()); // Merge with the main task list
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 + ];
91 145 }
92 146
147 + $this->processTasks($tasks, $board, [
148 + 'includeContact' => false,
149 + 'includeObserverState' => false,
150 + 'includeRoadmapPopularity' => false,
151 + ]);
152 +
93 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 [
94 233 'tasks' => $tasks,
95 234 ];
96 235 }
97 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)
278 + ->get();
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 +
98 329 /**
99 330 * Get Stage IDs by Board ID.
100 331 *
101 332 * @param int $board_id
@@ -100,44 +331,155 @@
100 331 *
101 332 * @param int $board_id
102 333 * @return array
103 334 */
104 - private function getStageIdsByBoard($board_id)
335 + private function getStageIdsByBoard($board_id, $includeArchived = false)
105 336 {
106 - return Stage::where('board_id', $board_id)
107 - ->whereNull('archived_at')
108 - ->pluck('id')
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 + })
109 385 ->toArray();
110 386 }
111 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 +
112 416 /**
113 417 * Process and append extra information for each task.
114 418 *
115 419 * @param \Illuminate\Database\Eloquent\Collection $tasks
116 420 * @param \App\Models\Board $board
421 + * @param array $options
117 422 */
118 - private function processTasks($tasks, $board)
423 + private function processTasks($tasks, $board, $options = [])
119 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 +
120 432 foreach ($tasks as $task) {
433 + $taskIds[] = (int) $task->id;
434 + }
435 +
436 + $unreadNotificationCounts = $this->notificationService->getUnreadNotificationCountsByTaskIds($taskIds);
437 +
438 + foreach ($tasks as $task) {
121 439 $task->isOverdue = $task->isOverdue();
122 440 $task->isUpcoming = $task->upcoming();
123 - $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
124 - $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 + }
125 447 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
126 448 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
127 - $task->notifications = $this->notificationService->getUnreadNotificationsOfTasks($task);
449 + $task->notifications = $unreadNotificationCounts[(int) $task->id] ?? 0;
128 450
129 451 // If the board type is 'roadmap', calculate popularity
130 - if ($board->type === 'roadmap') {
452 + if ($includeRoadmapPopularity && $board->type === 'roadmap') {
131 453 $task->popular = $task->getPopularCount();
132 454 }
133 455 }
134 456 }
135 457
136 -
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 + */
137 465 public function create(Request $request, $board_id)
138 466 {
139 - $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, [
140 482 'title' => 'required|string',
141 483 'board_id' => 'required|numeric',
142 484 'stage_id' => 'required|numeric',
143 485 'priority' => 'nullable|string',
@@ -145,17 +487,28 @@
145 487 'is_template' => 'string',
146 488 ]);
147 489
148 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 +
149 499 if ($taskData['board_id'] != $board_id) {
150 - throw new \Exception(__('Board id is not valid', 'fluent-boards'));
500 + throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
151 501 }
152 502
153 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');
154 507
155 508 return $this->sendSuccess([
156 509 'task' => $task,
157 - 'message' => __('Task has been successfully created', 'fluent-boards'),
510 + 'message' => $message,
158 511 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
159 512 ], 201);
160 513 } catch (\Exception $e) {
161 514 return $this->sendError($e->getMessage(), 400);
@@ -163,20 +516,22 @@
163 516 }
164 517
165 518 public function find($board_id, $task_id)
166 519 {
520 + $board_id = absint($board_id);
521 + $task_id = absint($task_id);
167 522 try {
168 523
169 524 $stageService = new StageService();
170 525
171 - $task = Task::findOrFail($task_id);
526 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
172 527
173 528 if (isset($task->parent_id)) {
174 - $task = Task::findOrFail($task->parent_id);
529 + $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
175 530 }
176 531
177 532 if(!$task) {
178 - throw new \Exception(__('Task not found', 'fluent-boards'));
533 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
179 534 }
180 535
181 536 if (defined('FLUENT_BOARDS_PRO')) {
182 537 $task->load(['attachments']);
@@ -181,11 +536,12 @@
181 536 if (defined('FLUENT_BOARDS_PRO')) {
182 537 $task->load(['attachments']);
183 538 }
184 539
185 - $task->load(['board', 'stage', 'labels', 'assignees']);
540 + $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
186 541
187 542 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
543 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
188 544
189 545 $task->isOverdue = $task->isOverdue();
190 546 $task->contact = Task::lead_contact($task->crm_contact_id);
191 547 $task->board->stages = $stageService->stagesByBoardId($board_id);
@@ -200,8 +556,10 @@
200 556 return [
201 557 'task' => $task
202 558 ];
203 559
560 + } catch (ModelNotFoundException $e) {
561 + throw $e;
204 562 } catch (\Exception $e ) {
205 563 return $this->sendError($e->getMessage(), 400);
206 564 }
207 565
@@ -209,9 +567,10 @@
209 567 }
210 568
211 569 public function getStageType(Request $request)
212 570 {
213 - $stage = Stage::findOrFail($request->stage_id);
571 + $stage_id = $request->getSafe('stage_id', 'intval');
572 + $stage = Stage::findOrFail($stage_id);
214 573
215 574 return [
216 575 'stage' => $stage,
217 576 ];
@@ -218,10 +577,13 @@
218 577 }
219 578
220 579 public function getActivities(Request $request, $board_id, $task_id)
221 580 {
222 - $filter = $request->getSafe('filter');
581 + $board_id = absint($board_id);
582 + $task_id = absint($task_id);
583 + $filter = $request->getSafe('filter', 'sanitize_text_field');
223 584 $per_page = 15; // Apparently, let's use a fixed number of items per page.
585 + $this->taskService->findTaskOnBoard($task_id, $board_id);
224 586
225 587 return [
226 588 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
227 589 ];
@@ -229,27 +591,236 @@
229 591 }
230 592
231 593 public function getArchivedTasks(Request $request, $board_id)
232 594 {
233 - $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 + ];
234 602
603 +
604 + $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
605 +
235 606 foreach ($tasks as $task) {
236 607 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
237 608 }
238 609
610 +
239 611 return [
240 612 'tasks' => $tasks
241 613 ];
242 614 }
243 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 +
244 768 public function updateTaskProperties(Request $request, $board_id, $task_id)
245 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
246 773 $col = $request->getSafe('property', 'sanitize_text_field');
247 - $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 + }
248 797
249 798 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
250 - $task = Task::with(['board', 'labels', 'assignees'])->findOrFail($task_id);
799 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
800 + $task->load(['board', 'labels', 'assignees']);
251 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 +
252 823 if ($task->parent_id && !$task->board_id) {
253 824 $task->board_id = $board_id;
254 825 $task->save();
255 826 }
@@ -260,11 +831,32 @@
260 831 $task->contact = Helper::crm_contact($task->crm_contact_id);
261 832 $task->is_watching = $task->isWatching();
262 833 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
263 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 +
264 844 // A recent update to a task might impact other tasks on the board.
265 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 + }
266 854
855 + if (!$taskExists) {
856 + $updatedTasks[] = $task;
857 + }
858 +
267 859 return [
268 860 'message' => __('Task has been updated', 'fluent-boards'),
269 861 'task' => $task,
270 862 'updatedTasks' => $updatedTasks
@@ -270,35 +862,167 @@
270 862 'updatedTasks' => $updatedTasks
271 863 ];
272 864 }
273 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 +
274 886 public function updateTaskDates(Request $request, $board_id, $task_id)
275 887 {
276 - $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();
277 892
278 - $startAt = $request->getSafe('started_at', 'sanitize_text_field', NULL);
279 - $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 + ];
280 898
281 - 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) {
282 916 if (strtotime($startAt) > strtotime($dueAt)) {
283 - $startAt = gmdate('Y-m-d 00:00:00', strtotime($dueAt));
917 + $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
284 918 }
285 919 }
286 920
287 - $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
288 - $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
921 + if ($hasStartAt) {
922 + $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
923 + }
289 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 +
290 957 return [
291 958 'task' => $task,
292 - 'message' => __('Dates has been updated', 'fluent-boards'),
959 + 'message' => __('Dates have been updated', 'fluent-boards'),
293 960 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
294 961 ];
295 962 }
296 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 +
297 1019 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
298 1020 {
299 - $imagePath = $request->thumbnail;
300 - $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);
301 1025
302 1026 return [
303 1027 'message' => __('Task cover photo has been updated', 'fluent-boards'),
304 1028 'task' => $task,
@@ -307,17 +1031,22 @@
307 1031 }
308 1032
309 1033 public function taskStatusUpdate(Request $request, $board_id, $task_id)
310 1034 {
1035 + $board_id = absint($board_id);
1036 + $task_id = absint($task_id);
1037 + $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
311 1038 return [
312 1039 'message' => __('Task status has been updated', 'fluent-boards'),
313 - 'task' => $this->taskService->taskStatusUpdate($task_id, $request->integrationType),
1040 + 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
314 1041 ];
315 1042 }
316 1043
317 1044 public function deleteTask($board_id, $task_id)
318 1045 {
319 - $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);
320 1049 $options = null;
321 1050 //if we need to do something before a task is deleted
322 1051 do_action('fluent_boards/before_task_deleted', $task, $options);
323 1052
@@ -335,8 +1064,39 @@
335 1064
336 1065 return $this->validate($data, $rules);
337 1066 }
338 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 +
339 1099 private function updateTaskPropValidationAndSanitation($col, $value)
340 1100 {
341 1101 $rules = [
342 1102 'title' => 'required|string',
@@ -342,9 +1102,9 @@
342 1102 'title' => 'required|string',
343 1103 'board_id' => 'required',
344 1104 'parent_id' => 'required',
345 1105 'crm_contact_id' => 'nullable',
346 - 'task_type' => 'nullable|string',
1106 + 'type' => 'nullable|string',
347 1107 'status' => 'nullable|string',
348 1108 'stage_id' => 'required',
349 1109 'reminder_type' => 'nullable|string',
350 1110 'priority' => 'nullable|string',
@@ -359,9 +1119,9 @@
359 1119 'log_minutes' => 'nullable|integer|unsigned',
360 1120 'last_completed' => 'nullable|date',
361 1121 'assignees' => 'nullable|integer',
362 1122 'archived_at' => 'nullable|string',
363 - 'is_watching' => 'nullable|string',
1123 + 'is_watching' => 'nullable',
364 1124 'is_template' => 'string',
365 1125 'last_completed_at' => 'nullable',
366 1126 'settings' => 'nullable|array',
367 1127 ];
@@ -378,8 +1138,11 @@
378 1138 }
379 1139
380 1140 return [$col => $sanitizedAndValidatedValue];
381 1141 }
1142 + if ('is_watching' == $col && is_array($value)) {
1143 + return [$col => $value];
1144 + }
382 1145 $data = Helper::sanitizeTask([$col => $value]);
383 1146
384 1147 return $this->validate($data, [
385 1148 $col => $rule,
@@ -384,22 +1147,22 @@
384 1147 return $this->validate($data, [
385 1148 $col => $rule,
386 1149 ]);
387 1150 }
388 - }
389 1151
390 - public function getLabelsByTask($task_id)
391 - {
392 - $labels = $this->taskService->getLabelsByTask($task_id);
393 -
394 - return $this->sendSuccess([
395 - 'labels' => $labels,
396 - ], 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)));
397 1155 }
398 1156
399 1157 public function getStageByTask($task_id)
400 1158 {
401 - $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 + }
402 1165
403 1166 return [
404 1167 'stage' => $stage,
405 1168 ];
@@ -406,8 +1169,10 @@
406 1169 }
407 1170
408 1171 public function assignYourselfInTask($board_id, $task_id)
409 1172 {
1173 + $board_id = absint($board_id);
1174 + $task_id = absint($task_id);
410 1175 $task = $this->taskService->assignYourselfInTask($board_id, $task_id);
411 1176 $task->is_watching = $task->isWatching();
412 1177
413 1178 return [
@@ -416,8 +1181,10 @@
416 1181 }
417 1182
418 1183 public function detachYourselfFromTask($board_id, $task_id)
419 1184 {
1185 + $board_id = absint($board_id);
1186 + $task_id = absint($task_id);
420 1187 $task = $this->taskService->detachYourselfFromTask($board_id, $task_id);
421 1188 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
422 1189 $task->is_watching = $task->isWatching();
423 1190
@@ -434,9 +1201,11 @@
434 1201 }
435 1202
436 1203 public function moveTaskToNextStage($board_id, $task_id)
437 1204 {
438 - $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);
439 1208
440 1209 return [
441 1210 'task' => $task
442 1211 ];
@@ -446,32 +1215,76 @@
446 1215 * @throws \Exception
447 1216 */
448 1217 public function moveTask(Request $request, $board_id, $task_id)
449 1218 {
450 - $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);
451 1222 $oldStageId = $task->stage_id;
452 1223 $newStageId = $request->getSafe('newStageId', 'intval');
453 1224 $newIndex = $request->getSafe('newIndex', 'intval');
454 1225 $newBoardId = $request->getSafe('newBoardId', 'intval');
1226 + $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1227 + $nextTaskId = $request->getSafe('nextTaskId', 'intval');
455 1228
456 1229 if ((!is_numeric($newStageId) || $newStageId == 0)) {
457 - throw new \Exception(__('Invalid Stage', 'fluent-boards'));
1230 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
458 1231 }
459 - if ((!is_numeric($newIndex) || $newIndex == 0)) {
460 - 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'));
461 1235 }
1236 +
462 1237 if ($newBoardId) {
463 1238 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
464 - throw new \Exception(__('Invalid Board', 'fluent-boards'));
1239 + throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
465 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) {
466 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']);
467 1264 }
468 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 +
469 1273 $task->stage_id = $newStageId;
470 - $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 + }
471 1282
472 1283 if ($oldStageId != $newStageId) {
473 1284
1285 + $this->taskService->manageDefaultAssignees($task, $newStageId);
1286 +
474 1287 $defaultPosition = $task->stage->defaultTaskStatus();
475 1288
476 1289 if ($defaultPosition == 'closed' && $task->status != 'closed') {
477 1290 $task = $task->close();
@@ -486,12 +1299,12 @@
486 1299 }
487 1300
488 1301 do_action('fluent_boards/task_updated', $task, 'position');
489 1302
490 - $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);
491 1305
492 1306 return [
493 - 'new_position' => $task,
494 1307 'message' => __('Task has been updated', 'fluent-boards'),
495 1308 'task' => $task,
496 1309 'updatedTasks' => $updatedTasks,
497 1310 'last_updated' => current_time('mysql')
@@ -507,15 +1320,17 @@
507 1320 * @return \WP_REST_Response The response containing paginated comments and activities, total count, current page, and items per page.
508 1321 */
509 1322 public function getCommentsAndActivities( Request $request, $board_id, $task_id)
510 1323 {
1324 + $board_id = absint($board_id);
1325 + $task_id = absint($task_id);
511 1326 try {
512 1327 // Pagination parameters
513 - $page = $request->get('page', 1);
514 - $perPage = $request->get('per_page', 10);
515 - $filter = $request->get('filter', 'newest'); // Filter for comments and activities
516 -
517 - $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter);
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);
518 1333 // Return the response with the task, paginated comments and activities, total count, current page, and items per page
519 1334 return $this->sendSuccess([
520 1335 'comments_and_activities' => $commentsAndActivities,
521 1336 ]);
@@ -533,14 +1348,35 @@
533 1348 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
534 1349 }
535 1350 public function getAssociatedTasks($associated_id)
536 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);
537 1357 return [
538 - 'tasks' => $this->taskService->getAssociatedTasks($associated_id)
1358 + 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
539 1359 ];
540 1360 }
541 1361
542 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 + /**
543 1379 * @param Request $request
544 1380 * @param $board_id
545 1381 * @param $task_id
546 1382 * @return \WP_REST_Response
@@ -546,9 +1382,12 @@
546 1382 * @return \WP_REST_Response
547 1383 */
548 1384 public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
549 1385 {
1386 + $board_id = absint($board_id);
1387 + $task_id = absint($task_id);
550 1388 try {
1389 + $this->taskService->findTaskOnBoard($task_id, $board_id);
551 1390
552 1391
553 1392 $file = Arr::get($request->files(), 'file')->toArray();
554 1393 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
@@ -556,11 +1395,19 @@
556 1395 $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
557 1396
558 1397 $fileData = $uploadInfo[0];
559 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);
560 1407
561 1408 return $this->sendSuccess([
562 - 'message' => __('Image has been uploaded', 'fluent-boards-pro'),
1409 + 'message' => __('Image has been uploaded', 'fluent-boards'),
563 1410 'file' => $fileUploadedData
564 1411 ], 200);
565 1412
566 1413
@@ -568,5 +1415,398 @@
568 1415 return $this->sendError($e->getMessage(), 400);
569 1416 }
570 1417 }
571 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 + }
572 1812 }