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