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 +1464 -74 1.11 → 2.2.0 View file →
@@ -1,20 +1,26 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Http\Controllers;
4 4
5 -use DateTimeImmutable;
5 +use FluentBoards\Framework\Database\Orm\ModelNotFoundException;
6 +use FluentBoards\App\Models\Meta;
6 7 use FluentBoards\App\Models\Stage;
7 8 use FluentBoards\App\Models\Task;
8 9 use FluentBoards\App\Models\Board;
10 +use FluentBoards\App\Models\TaskMeta;
11 +use FluentBoards\App\Services\CommentService;
9 12 use FluentBoards\App\Services\Constant;
10 13 use FluentBoards\App\Services\Helper;
11 14 use FluentBoards\App\Services\StageService;
12 15 use FluentBoards\App\Services\TaskService;
13 16 use FluentBoards\App\Services\NotificationService;
17 +use FluentBoards\App\Services\UploadService;
14 18 use FluentBoards\Framework\Http\Request\Request;
15 19 use FluentBoards\App\Services\PermissionManager;
16 20 use FluentBoards\Framework\Support\Arr;
21 +use FluentBoardsPro\App\Services\AttachmentService;
22 +use FluentCrm\App\Models\Subscriber;
17 23
18 24 class TaskController extends Controller
19 25 {
20 26 private TaskService $taskService;
@@ -32,41 +38,448 @@
32 38 public function getTopTasksForBoards()
33 39 {
34 40 $userId = get_current_user_id();
35 41 $task_ids = PermissionManager::getTaskIdsWatchByUser($userId);
36 - $tasksArray = $this->taskService->getTasksForBoards(['overdue', 'upcoming'], 5, $task_ids);
42 + $boardIds = PermissionManager::getBoardIdsForUser($userId);
43 + $taskCategories = ['due_today', 'assigned', 'overdue', 'upcoming', 'mentioned', 'completed', 'others'];
44 + $tasksArray = $this->taskService->getTasksForBoards($taskCategories, 6, $task_ids);
45 + $taskCounts = $this->taskService->getTaskCountsForBoards($taskCategories, $task_ids);
46 + $taskCounts['all_boards'] = empty($boardIds)
47 + ? 0
48 + : (int) Board::whereIn('id', $boardIds)
49 + ->whereNull('archived_at')
50 + ->excludeTemplates()
51 + ->count();
52 + $taskCounts['all_tasks'] = empty($task_ids)
53 + ? 0
54 + : (int) Task::whereIn('id', $task_ids)
55 + ->whereNull('archived_at')
56 + ->whereNull('parent_id')
57 + ->onActiveAvailableBoards()
58 + ->count();
37 59
38 60 return [
39 61 'data' => $tasksArray,
62 + 'counts' => $taskCounts,
40 63 ];
41 64 }
42 65
43 - public function getTasksByBoard($board_id)
66 + /** Return all board tasks with a cursor captured before reading their state. */
67 + public function getTasksByBoard(Request $request, $board_id)
44 68 {
45 - $tasks = Task::with(['assignees', 'labels', 'watchers'])
69 + $syncStartedAt = current_time('mysql');
70 + $board_id = absint($board_id);
71 + $board = Board::findOrFail($board_id);
72 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
73 +
74 + // Get stage IDs
75 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
76 +
77 + // Fetch tasks for the board
78 + $tasksQuery = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
46 79 ->where('board_id', $board_id)
47 - ->whereNull('archived_at')
48 80 ->whereNull('parent_id')
49 - ->orderBy('due_at', 'ASC')
81 + ->whereIn('stage_id', $stageIds)
82 + ->orderBy('due_at', 'ASC');
83 +
84 + if (!$includeArchived) {
85 + $tasksQuery->whereNull('archived_at');
86 + }
87 +
88 + $tasks = $tasksQuery->get();
89 +
90 + // Process each task
91 + $this->processTasks($tasks, $board);
92 +
93 + if ($board->type === 'roadmap') {
94 + $this->taskService->loadIdeaVoteStatistics($tasks);
95 + }
96 +
97 + return [
98 + 'tasks' => $tasks,
99 + 'synced_at' => $syncStartedAt,
100 + ];
101 + }
102 +
103 + /** Fetch each stage's first page, then batch metadata for the combined card list. */
104 + public function getTasksByBoardStage(Request $request, $board_id)
105 + {
106 + $syncStartedAt = current_time('mysql');
107 + $board_id = absint($board_id);
108 + $board = Board::findOrFail($board_id);
109 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
110 +
111 + // Get stage IDs
112 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
113 + $stageTaskCounts = $this->getStageTaskCounts($board_id, $stageIds, $includeArchived);
114 +
115 + // Initialize tasks array
116 + $tasks = [];
117 + $paginationByStage = [];
118 +
119 + // Fetch and process tasks for each stage
120 + foreach ($stageIds as $stageId) {
121 + $stageTasks = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
122 + ->orderBy('position', 'ASC')
123 + ->limit(20)
124 + ->get();
125 +
126 + $tasks = array_merge($tasks, $stageTasks->all());
127 +
128 + $startCursor = $stageTasks->count() ? (float) $stageTasks->first()->position : null;
129 + $endCursor = $stageTasks->count() ? (float) $stageTasks->last()->position : null;
130 + $loadedCount = $stageTasks->count();
131 + $hasMoreAfter = (int) ($stageTaskCounts[$stageId] ?? 0) > $loadedCount;
132 +
133 + $paginationByStage[$stageId] = [
134 + 'stage_id' => (int) $stageId,
135 + 'total_count' => (int) ($stageTaskCounts[$stageId] ?? 0),
136 + 'limit' => 20,
137 + 'direction' => 'next',
138 + 'cursor' => null,
139 + 'has_more' => $hasMoreAfter,
140 + 'has_more_before' => false,
141 + 'has_more_after' => $hasMoreAfter,
142 + 'start_cursor' => $startCursor,
143 + 'end_cursor' => $endCursor,
144 + ];
145 + }
146 +
147 + $this->processTasks($tasks, $board, [
148 + 'includeContact' => false,
149 + 'includeObserverState' => false,
150 + 'includeRoadmapPopularity' => false,
151 + ]);
152 +
153 + return [
154 + 'tasks' => (new Task())->newCollection($tasks)->toArray(),
155 + 'pagination_by_stage' => $paginationByStage,
156 + 'synced_at' => $syncStartedAt,
157 + ];
158 + }
159 +
160 + /** Return paginated tasks matching the board filters, including recurrence and templates. */
161 + public function getTableTasks(Request $request, $board_id)
162 + {
163 + $board_id = absint($board_id);
164 + $board = Board::findOrFail($board_id);
165 + $args = [
166 + 'page' => $request->getSafe('page', 'intval', 1),
167 + 'per_page' => $request->getSafe('per_page', 'intval', 20),
168 + 'sort_by' => $request->getSafe('sort_by', 'sanitize_text_field', 'position'),
169 + 'sort_direction' => $request->getSafe('sort_direction', 'sanitize_text_field', 'asc'),
170 + 'search' => $request->getSafe('search', 'sanitize_text_field', ''),
171 + 'include_archived' => $request->getSafe('include_archived', 'boolval', false),
172 + 'stage' => $request->get('stage', []),
173 + 'task_status' => $request->get('task_status', []),
174 + 'recurring' => $request->get('recurring', []),
175 + 'template' => $request->get('template', []),
176 + 'priority' => $request->get('priority', []),
177 + 'assignee' => $request->get('assignee', []),
178 + 'labels' => $request->get('labels', []),
179 + 'watchers' => $request->get('watchers', []),
180 + 'contact' => $request->get('contact', []),
181 + 'custom_fields' => $request->get('custom_fields', []),
182 + 'due_date' => $request->get('due_date', []),
183 + ];
184 +
185 + $tasks = $this->taskService->getTableTasks($board_id, $args);
186 + $taskItems = $tasks->items();
187 + $this->processTasks($taskItems, $board, [
188 + 'includeContact' => false,
189 + 'includeObserverState' => false,
190 + 'includeRoadmapPopularity' => false,
191 + ]);
192 +
193 + return $this->sendSuccess([
194 + 'items' => $taskItems,
195 + 'pagination' => [
196 + 'total' => (int) $tasks->total(),
197 + 'current_page' => (int) $tasks->currentPage(),
198 + 'per_page' => (int) $tasks->perPage(),
199 + 'last_page' => (int) $tasks->lastPage(),
200 + ],
201 + ], 200);
202 + }
203 +
204 + /** Return board tasks matching the requested filters, including recurrence and templates. */
205 + public function getFilteredBoardTasks(Request $request, $board_id)
206 + {
207 + $board_id = absint($board_id);
208 + $board = Board::findOrFail($board_id);
209 + $args = [
210 + 'search' => $request->getSafe('search', 'sanitize_text_field', ''),
211 + 'include_archived' => $request->getSafe('include_archived', 'boolval', false),
212 + 'stage' => $request->get('stage', []),
213 + 'task_status' => $request->get('task_status', []),
214 + 'recurring' => $request->get('recurring', []),
215 + 'template' => $request->get('template', []),
216 + 'priority' => $request->get('priority', []),
217 + 'assignee' => $request->get('assignee', []),
218 + 'labels' => $request->get('labels', []),
219 + 'watchers' => $request->get('watchers', []),
220 + 'contact' => $request->get('contact', []),
221 + 'custom_fields' => $request->get('custom_fields', []),
222 + 'due_date' => $request->get('due_date', []),
223 + ];
224 +
225 + $tasks = $this->taskService->getBoardViewTasks($board_id, $args);
226 + $this->processTasks($tasks, $board, [
227 + 'includeContact' => false,
228 + 'includeObserverState' => false,
229 + 'includeRoadmapPopularity' => false,
230 + ]);
231 +
232 + return [
233 + 'tasks' => $tasks,
234 + ];
235 + }
236 +
237 + public function getStageTasksPage(Request $request, $board_id)
238 + {
239 + $board_id = absint($board_id);
240 + $board = Board::findOrFail($board_id);
241 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
242 + $stageId = $request->getSafe('stage_id', 'intval');
243 + $limit = $request->getSafe('limit', 'intval', 20);
244 + $direction = $request->getSafe('direction', 'sanitize_text_field', 'next');
245 + $cursor = $request->getSafe('cursor', 'floatval');
246 +
247 + if (!$stageId) {
248 + return $this->sendError(esc_html__('Invalid Stage', 'fluent-boards'), 400);
249 + }
250 +
251 + if (!in_array($direction, ['next', 'prev'], true)) {
252 + return $this->sendError(esc_html__('Invalid direction', 'fluent-boards'), 400);
253 + }
254 +
255 + $limit = max(1, min(100, $limit));
256 +
257 + $stage = Stage::where('board_id', $board_id)
258 + ->where('id', $stageId)
259 + ->first();
260 +
261 + if (!$stage) {
262 + return $this->sendError(esc_html__('Stage not found', 'fluent-boards'), 404);
263 + }
264 +
265 + $stageTasksQuery = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived);
266 +
267 + if ($cursor !== null) {
268 + if ($direction === 'prev') {
269 + $stageTasksQuery->where('position', '<', $cursor);
270 + } else {
271 + $stageTasksQuery->where('position', '>', $cursor);
272 + }
273 + }
274 +
275 + $stageTasks = $stageTasksQuery
276 + ->orderBy('position', $direction === 'prev' ? 'DESC' : 'ASC')
277 + ->limit($limit + 1)
50 278 ->get();
51 279
280 + $hasMoreInDirection = $stageTasks->count() > $limit;
281 + if ($hasMoreInDirection) {
282 + $stageTasks = $stageTasks->slice(0, $limit)->values();
283 + }
284 +
285 + if ($direction === 'prev') {
286 + $stageTasks = $stageTasks->sortBy('position')->values();
287 + }
288 +
289 + $this->processTasks($stageTasks, $board, [
290 + 'includeContact' => false,
291 + 'includeObserverState' => false,
292 + 'includeRoadmapPopularity' => false,
293 + ]);
294 +
295 + $startCursor = $stageTasks->count() ? (float) $stageTasks->first()->position : null;
296 + $endCursor = $stageTasks->count() ? (float) $stageTasks->last()->position : null;
297 +
298 + $hasMoreBefore = false;
299 + $hasMoreAfter = false;
300 +
301 + if ($startCursor !== null) {
302 + $hasMoreBefore = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
303 + ->where('position', '<', $startCursor)
304 + ->exists();
305 + }
306 +
307 + if ($endCursor !== null) {
308 + $hasMoreAfter = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
309 + ->where('position', '>', $endCursor)
310 + ->exists();
311 + }
312 +
313 + return [
314 + 'tasks' => $stageTasks,
315 + 'pagination' => [
316 + 'stage_id' => (int) $stageId,
317 + 'limit' => (int) $limit,
318 + 'direction' => $direction,
319 + 'cursor' => $cursor !== null ? (float) $cursor : null,
320 + 'has_more' => $hasMoreInDirection,
321 + 'has_more_before' => $hasMoreBefore,
322 + 'has_more_after' => $hasMoreAfter,
323 + 'start_cursor' => $startCursor,
324 + 'end_cursor' => $endCursor,
325 + ],
326 + ];
327 + }
328 +
329 + /**
330 + * Get Stage IDs by Board ID.
331 + *
332 + * @param int $board_id
333 + * @return array
334 + */
335 + private function getStageIdsByBoard($board_id, $includeArchived = false)
336 + {
337 + $stageQuery = Stage::where('board_id', $board_id);
338 + if (!$includeArchived) {
339 + $stageQuery->whereNull('archived_at');
340 + }
341 +
342 + return $stageQuery->pluck('id')->toArray();
343 + }
344 +
345 + private function makeStageTasksQuery($board_id, $stageId, $includeArchived = false)
346 + {
347 + $stageTasksQuery = Task::query()
348 + // Kanban/List only need card-level task data here; full task detail is
349 + // fetched separately when the modal opens.
350 + ->select($this->getStageTaskCardColumns())
351 + ->with(['assignees', 'labels', 'watchers'])
352 + ->where('board_id', $board_id)
353 + ->where('stage_id', $stageId)
354 + ->whereNull('parent_id');
355 +
356 + if (!$includeArchived) {
357 + $stageTasksQuery->whereNull('archived_at');
358 + }
359 +
360 + return $stageTasksQuery;
361 + }
362 +
363 + private function getStageTaskCounts($board_id, array $stageIds, $includeArchived = false)
364 + {
365 + if (!$stageIds) {
366 + return [];
367 + }
368 +
369 + $query = Task::query()
370 + ->selectRaw('stage_id, COUNT(*) as total_count')
371 + ->where('board_id', $board_id)
372 + ->whereNull('parent_id')
373 + ->whereIn('stage_id', $stageIds);
374 +
375 + if (!$includeArchived) {
376 + $query->whereNull('archived_at');
377 + }
378 +
379 + return $query
380 + ->groupBy('stage_id')
381 + ->pluck('total_count', 'stage_id')
382 + ->map(function ($count) {
383 + return (int) $count;
384 + })
385 + ->toArray();
386 + }
387 +
388 + private function getStageTaskCardColumns()
389 + {
390 + return [
391 + 'id',
392 + 'title',
393 + 'slug',
394 + 'board_id',
395 + 'parent_id',
396 + 'crm_contact_id',
397 + 'type',
398 + 'stage_id',
399 + 'status',
400 + 'reminder_type',
401 + 'priority',
402 + 'archived_at',
403 + 'remind_at',
404 + 'started_at',
405 + 'due_at',
406 + 'last_completed_at',
407 + 'position',
408 + 'comments_count',
409 + 'created_by',
410 + 'settings',
411 + 'source',
412 + 'source_id',
413 + ];
414 + }
415 +
416 + /**
417 + * Process and append extra information for each task.
418 + *
419 + * @param \Illuminate\Database\Eloquent\Collection $tasks
420 + * @param \App\Models\Board $board
421 + * @param array $options
422 + */
423 + private function processTasks($tasks, $board, $options = [])
424 + {
425 + $this->taskService->loadTaskListMetadata($tasks);
426 +
427 + $includeContact = Arr::get($options, 'includeContact', true);
428 + $includeObserverState = Arr::get($options, 'includeObserverState', true);
429 + $includeRoadmapPopularity = Arr::get($options, 'includeRoadmapPopularity', true);
430 + $taskIds = [];
431 +
52 432 foreach ($tasks as $task) {
433 + $taskIds[] = (int) $task->id;
434 + }
435 +
436 + $unreadNotificationCounts = $this->notificationService->getUnreadNotificationCountsByTaskIds($taskIds);
437 +
438 + foreach ($tasks as $task) {
53 439 $task->isOverdue = $task->isOverdue();
54 440 $task->isUpcoming = $task->upcoming();
55 - $task->contact = Helper::crm_contact($task->crm_contact_id);
56 - $task->is_watching = $task->isWatching();
441 + if ($includeContact) {
442 + $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
443 + }
444 + if ($includeObserverState) {
445 + $task->is_watching = $task->isWatching();
446 + }
57 447 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
58 448 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
449 + $task->notifications = $unreadNotificationCounts[(int) $task->id] ?? 0;
450 +
451 + // If the board type is 'roadmap', calculate popularity
452 + if ($includeRoadmapPopularity && $board->type === 'roadmap') {
453 + $task->popular = $task->getPopularCount();
454 + }
59 455 }
60 -
61 - return [
62 - 'tasks' => $tasks,
63 - ];
64 456 }
65 457
458 + /**
459 + * Create a task with field-specific sanitization for its request data.
460 + *
461 + * @param Request $request
462 + * @param int $board_id
463 + * @return mixed
464 + */
66 465 public function create(Request $request, $board_id)
67 466 {
68 - $taskData = $this->taskSanitizeAndValidate($request->get('task'), [
467 + $board_id = absint($board_id);
468 + $safeTaskData = $request->getSafe('task');
469 + $rawTaskData = $request->get('task', []);
470 +
471 + // Milkdown serializes pasted URLs as <https://...>, which generic text
472 + // sanitization removes as a tag.
473 + if (
474 + is_array($safeTaskData) &&
475 + is_array($rawTaskData) &&
476 + array_key_exists('description', $rawTaskData)
477 + ) {
478 + $safeTaskData['description'] = fluent_boards_sanitize_description($rawTaskData['description']);
479 + }
480 +
481 + $taskData = $this->taskSanitizeAndValidate($safeTaskData, [
69 482 'title' => 'required|string',
70 483 'board_id' => 'required|numeric',
71 484 'stage_id' => 'required|numeric',
72 485 'priority' => 'nullable|string',
@@ -74,17 +487,28 @@
74 487 'is_template' => 'string',
75 488 ]);
76 489
77 490 try {
491 + if (isset($taskData['assignees'])) {
492 + $taskData['assignees'] = array_filter(array_map('intval', (array) $taskData['assignees']));
493 + }
494 +
495 + if (isset($taskData['labels'])) {
496 + $taskData['labels'] = array_filter(array_map('intval', (array) $taskData['labels']));
497 + }
498 +
78 499 if ($taskData['board_id'] != $board_id) {
79 - throw new \Exception(__('Board id is not valid', 'fluent-boards'));
500 + throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
80 501 }
81 502
82 503 $task = $this->taskService->createTask($taskData, $board_id);
504 + $message = $task->type === 'roadmap'
505 + ? __('Idea has been successfully created', 'fluent-boards')
506 + : __('Task has been successfully created', 'fluent-boards');
83 507
84 508 return $this->sendSuccess([
85 509 'task' => $task,
86 - 'message' => __('Task has been successfully created', 'fluent-boards'),
510 + 'message' => $message,
87 511 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
88 512 ], 201);
89 513 } catch (\Exception $e) {
90 514 return $this->sendError($e->getMessage(), 400);
@@ -92,38 +516,61 @@
92 516 }
93 517
94 518 public function find($board_id, $task_id)
95 519 {
96 - $stageService = new StageService();
97 - $task = Task::findOrFail($task_id);
520 + $board_id = absint($board_id);
521 + $task_id = absint($task_id);
522 + try {
98 523
99 - if (defined('FLUENT_BOARDS_PRO')) {
100 - $task->load(['attachments']);
101 - }
524 + $stageService = new StageService();
102 525
103 - $task->load(['board', 'stage', 'labels', 'assignees']);
526 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
104 527
105 - $task->assignees = Helper::sanitizeUserCollections($task->assignees);
528 + if (isset($task->parent_id)) {
529 + $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
530 + }
106 531
107 - $task->isOverdue = $task->isOverdue();
108 - $task->contact = Task::lead_contact($task->crm_contact_id);
109 - $task->board->stages = $stageService->stagesByBoardId($board_id);
110 - $task->is_watching = $this->notificationService->isCurrentUserObservingTask($task);
532 + if(!$task) {
533 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
534 + }
111 535
112 - $task = $this->taskService->loadNextStage($task);
536 + if (defined('FLUENT_BOARDS_PRO')) {
537 + $task->load(['attachments']);
538 + }
113 539
114 - if ($task->type == 'roadmap') {
115 - $task->vote_statistics = $this->taskService->getIdeaVoteStatistics($task_id);
540 + $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
541 +
542 + $task->assignees = Helper::sanitizeUserCollections($task->assignees);
543 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
544 +
545 + $task->isOverdue = $task->isOverdue();
546 + $task->contact = Task::lead_contact($task->crm_contact_id);
547 + $task->board->stages = $stageService->stagesByBoardId($board_id);
548 + $task->is_watching = $this->notificationService->isCurrentUserObservingTask($task);
549 +
550 + $task = $this->taskService->loadNextStage($task);
551 +
552 + if ($task->type == 'roadmap') {
553 + $task->vote_statistics = $this->taskService->getIdeaVoteStatistics($task_id);
554 + }
555 +
556 + return [
557 + 'task' => $task
558 + ];
559 +
560 + } catch (ModelNotFoundException $e) {
561 + throw $e;
562 + } catch (\Exception $e ) {
563 + return $this->sendError($e->getMessage(), 400);
116 564 }
117 565
118 - return [
119 - 'task' => $task
120 - ];
566 +
121 567 }
122 568
123 569 public function getStageType(Request $request)
124 570 {
125 - $stage = Stage::findOrFail($request->stage_id);
571 + $stage_id = $request->getSafe('stage_id', 'intval');
572 + $stage = Stage::findOrFail($stage_id);
126 573
127 574 return [
128 575 'stage' => $stage,
129 576 ];
@@ -130,10 +577,13 @@
130 577 }
131 578
132 579 public function getActivities(Request $request, $board_id, $task_id)
133 580 {
134 - $filter = $request->getSafe('filter');
581 + $board_id = absint($board_id);
582 + $task_id = absint($task_id);
583 + $filter = $request->getSafe('filter', 'sanitize_text_field');
135 584 $per_page = 15; // Apparently, let's use a fixed number of items per page.
585 + $this->taskService->findTaskOnBoard($task_id, $board_id);
136 586
137 587 return [
138 588 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
139 589 ];
@@ -141,27 +591,241 @@
141 591 }
142 592
143 593 public function getArchivedTasks(Request $request, $board_id)
144 594 {
145 - $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 + ];
146 602
603 +
604 + $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
605 +
147 606 foreach ($tasks as $task) {
148 607 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
149 608 }
150 609
610 +
151 611 return [
152 612 'tasks' => $tasks
153 613 ];
154 614 }
155 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 +
156 768 public function updateTaskProperties(Request $request, $board_id, $task_id)
157 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
158 773 $col = $request->getSafe('property', 'sanitize_text_field');
159 - $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 + }
160 797
161 798 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
162 - $task = Task::with(['board', 'labels', 'assignees'])->findOrFail($task_id);
799 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
800 + $task->load(['board', 'labels', 'assignees']);
163 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 +
823 + if ($task->parent_id && !$task->board_id) {
824 + $task->board_id = $board_id;
825 + $task->save();
826 + }
827 +
164 828 $task = $this->taskService->updateTaskProperty($col, $validatedData[$col], $task);
165 829 $task->isOverdue = $task->isOverdue();
166 830 $task->isUpcoming = $task->upcoming();
167 831 $task->contact = Helper::crm_contact($task->crm_contact_id);
@@ -167,11 +831,32 @@
167 831 $task->contact = Helper::crm_contact($task->crm_contact_id);
168 832 $task->is_watching = $task->isWatching();
169 833 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
170 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 +
171 844 // A recent update to a task might impact other tasks on the board.
172 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 + }
173 854
855 + if (!$taskExists) {
856 + $updatedTasks[] = $task;
857 + }
858 +
174 859 return [
175 860 'message' => __('Task has been updated', 'fluent-boards'),
176 861 'task' => $task,
177 862 'updatedTasks' => $updatedTasks
@@ -177,35 +862,167 @@
177 862 'updatedTasks' => $updatedTasks
178 863 ];
179 864 }
180 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 +
181 886 public function updateTaskDates(Request $request, $board_id, $task_id)
182 887 {
183 - $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();
184 892
185 - $startAt = $request->getSafe('started_at', 'sanitize_text_field', NULL);
186 - $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 + ];
187 898
188 - 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) {
189 916 if (strtotime($startAt) > strtotime($dueAt)) {
190 - $startAt = gmdate('Y-m-d 00:00:00', strtotime($dueAt));
917 + $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
191 918 }
192 919 }
193 920
194 - $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
195 - $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
921 + if ($hasStartAt) {
922 + $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
923 + }
196 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 +
197 957 return [
198 958 'task' => $task,
199 - 'message' => __('Dates has been updated', 'fluent-boards'),
959 + 'message' => __('Dates have been updated', 'fluent-boards'),
200 960 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
201 961 ];
202 962 }
203 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 +
204 1019 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
205 1020 {
206 - $imagePath = $request->thumbnail;
207 - $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);
208 1025
209 1026 return [
210 1027 'message' => __('Task cover photo has been updated', 'fluent-boards'),
211 1028 'task' => $task,
@@ -214,17 +1031,22 @@
214 1031 }
215 1032
216 1033 public function taskStatusUpdate(Request $request, $board_id, $task_id)
217 1034 {
1035 + $board_id = absint($board_id);
1036 + $task_id = absint($task_id);
1037 + $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
218 1038 return [
219 1039 'message' => __('Task status has been updated', 'fluent-boards'),
220 - 'task' => $this->taskService->taskStatusUpdate($task_id, $request->integrationType),
1040 + 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
221 1041 ];
222 1042 }
223 1043
224 - public function delete($board_id, $task_id)
1044 + public function deleteTask($board_id, $task_id)
225 1045 {
226 - $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);
227 1049 $options = null;
228 1050 //if we need to do something before a task is deleted
229 1051 do_action('fluent_boards/before_task_deleted', $task, $options);
230 1052
@@ -242,8 +1064,39 @@
242 1064
243 1065 return $this->validate($data, $rules);
244 1066 }
245 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 +
246 1099 private function updateTaskPropValidationAndSanitation($col, $value)
247 1100 {
248 1101 $rules = [
249 1102 'title' => 'required|string',
@@ -249,9 +1102,9 @@
249 1102 'title' => 'required|string',
250 1103 'board_id' => 'required',
251 1104 'parent_id' => 'required',
252 1105 'crm_contact_id' => 'nullable',
253 - 'task_type' => 'nullable|string',
1106 + 'type' => 'nullable|string',
254 1107 'status' => 'nullable|string',
255 1108 'stage_id' => 'required',
256 1109 'reminder_type' => 'nullable|string',
257 1110 'priority' => 'nullable|string',
@@ -266,9 +1119,9 @@
266 1119 'log_minutes' => 'nullable|integer|unsigned',
267 1120 'last_completed' => 'nullable|date',
268 1121 'assignees' => 'nullable|integer',
269 1122 'archived_at' => 'nullable|string',
270 - 'is_watching' => 'nullable|string',
1123 + 'is_watching' => 'nullable',
271 1124 'is_template' => 'string',
272 1125 'last_completed_at' => 'nullable',
273 1126 'settings' => 'nullable|array',
274 1127 ];
@@ -285,8 +1138,11 @@
285 1138 }
286 1139
287 1140 return [$col => $sanitizedAndValidatedValue];
288 1141 }
1142 + if ('is_watching' == $col && is_array($value)) {
1143 + return [$col => $value];
1144 + }
289 1145 $data = Helper::sanitizeTask([$col => $value]);
290 1146
291 1147 return $this->validate($data, [
292 1148 $col => $rule,
@@ -291,22 +1147,22 @@
291 1147 return $this->validate($data, [
292 1148 $col => $rule,
293 1149 ]);
294 1150 }
295 - }
296 1151
297 - public function getLabelsByTask($task_id)
298 - {
299 - $labels = $this->taskService->getLabelsByTask($task_id);
300 -
301 - return $this->sendSuccess([
302 - 'labels' => $labels,
303 - ], 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)));
304 1155 }
305 1156
306 1157 public function getStageByTask($task_id)
307 1158 {
308 - $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 + }
309 1165
310 1166 return [
311 1167 'stage' => $stage,
312 1168 ];
@@ -313,9 +1169,12 @@
313 1169 }
314 1170
315 1171 public function assignYourselfInTask($board_id, $task_id)
316 1172 {
1173 + $board_id = absint($board_id);
1174 + $task_id = absint($task_id);
317 1175 $task = $this->taskService->assignYourselfInTask($board_id, $task_id);
1176 + $task->is_watching = $task->isWatching();
318 1177
319 1178 return [
320 1179 'task' => $task,
321 1180 ];
@@ -322,10 +1181,13 @@
322 1181 }
323 1182
324 1183 public function detachYourselfFromTask($board_id, $task_id)
325 1184 {
1185 + $board_id = absint($board_id);
1186 + $task_id = absint($task_id);
326 1187 $task = $this->taskService->detachYourselfFromTask($board_id, $task_id);
327 1188 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1189 + $task->is_watching = $task->isWatching();
328 1190
329 1191 return [
330 1192 'task' => $task,
331 1193 ];
@@ -339,9 +1201,11 @@
339 1201 }
340 1202
341 1203 public function moveTaskToNextStage($board_id, $task_id)
342 1204 {
343 - $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);
344 1208
345 1209 return [
346 1210 'task' => $task
347 1211 ];
@@ -351,32 +1215,76 @@
351 1215 * @throws \Exception
352 1216 */
353 1217 public function moveTask(Request $request, $board_id, $task_id)
354 1218 {
355 - $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);
356 1222 $oldStageId = $task->stage_id;
357 1223 $newStageId = $request->getSafe('newStageId', 'intval');
358 1224 $newIndex = $request->getSafe('newIndex', 'intval');
359 1225 $newBoardId = $request->getSafe('newBoardId', 'intval');
1226 + $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1227 + $nextTaskId = $request->getSafe('nextTaskId', 'intval');
360 1228
361 1229 if ((!is_numeric($newStageId) || $newStageId == 0)) {
362 - throw new \Exception(__('Invalid Stage', 'fluent-boards'));
1230 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
363 1231 }
364 - if ((!is_numeric($newIndex) || $newIndex == 0)) {
365 - 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'));
366 1235 }
1236 +
367 1237 if ($newBoardId) {
368 1238 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
369 - throw new \Exception(__('Invalid Board', 'fluent-boards'));
1239 + throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
370 1240 }
371 - $task->board_id = $newBoardId;
1241 +
1242 + if (!PermissionManager::userHasBoardPermission($newBoardId, 'PUT')) {
1243 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1244 + }
372 1245 }
373 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) {
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']);
1264 + }
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 +
374 1273 $task->stage_id = $newStageId;
375 - $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 + }
376 1282
377 1283 if ($oldStageId != $newStageId) {
378 1284
1285 + $this->taskService->manageDefaultAssignees($task, $newStageId);
1286 +
379 1287 $defaultPosition = $task->stage->defaultTaskStatus();
380 1288
381 1289 if ($defaultPosition == 'closed' && $task->status != 'closed') {
382 1290 $task = $task->close();
@@ -381,9 +1289,9 @@
381 1289 if ($defaultPosition == 'closed' && $task->status != 'closed') {
382 1290 $task = $task->close();
383 1291 }
384 1292
385 - do_action('fluent_boards/task_moved_to_new_stage', $task, $oldStageId);
1293 +// do_action('fluent_boards/task_moved_to_new_stage', $task, $oldStageId);
386 1294
387 1295 do_action('fluent_boards/task_stage_updated', $task, $oldStageId);
388 1296
389 1297 $usersToSendEmail = $this->notificationService->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
@@ -391,12 +1299,12 @@
391 1299 }
392 1300
393 1301 do_action('fluent_boards/task_updated', $task, 'position');
394 1302
395 - $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);
396 1305
397 1306 return [
398 - 'new_position' => $task,
399 1307 'message' => __('Task has been updated', 'fluent-boards'),
400 1308 'task' => $task,
401 1309 'updatedTasks' => $updatedTasks,
402 1310 'last_updated' => current_time('mysql')
@@ -402,8 +1310,36 @@
402 1310 'last_updated' => current_time('mysql')
403 1311 ];
404 1312 }
405 1313
1314 + /**
1315 + * Get comments and activities for a task, merged into a single array, sorted by creation date, and paginated.
1316 + *
1317 + * @param Request $request The HTTP request instance.
1318 + * @param int $board_id The ID of the board.
1319 + * @param int $task_id The ID of the task.
1320 + * @return \WP_REST_Response The response containing paginated comments and activities, total count, current page, and items per page.
1321 + */
1322 + public function getCommentsAndActivities( Request $request, $board_id, $task_id)
1323 + {
1324 + $board_id = absint($board_id);
1325 + $task_id = absint($task_id);
1326 + try {
1327 + // Pagination parameters
1328 + $page = $request->getSafe('page', 'intval', 1);
1329 + $perPage = $request->getSafe('per_page', 'intval', 10);
1330 + $filter = $request->getSafe('filter', 'sanitize_text_field', 'newest'); // Filter for comments and activities
1331 + $feedType = $request->getSafe('feed_type', 'sanitize_text_field', 'all');
1332 + $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter, $board_id, $feedType);
1333 + // Return the response with the task, paginated comments and activities, total count, current page, and items per page
1334 + return $this->sendSuccess([
1335 + 'comments_and_activities' => $commentsAndActivities,
1336 + ]);
1337 + } catch (\Exception $e) {
1338 + return $this->sendError($e->getMessage(), 500);
1339 + }
1340 + }
1341 +
406 1342 public function sendMailAfterStageChange($usersToSendEmail, $taskId)
407 1343 {
408 1344 $current_user_id = get_current_user_id();
409 1345
@@ -410,13 +1346,467 @@
410 1346 /* this will run in background as soon as possible */
411 1347 /* sending Model or Model Instance won't work here */
412 1348 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
413 1349 }
414 -
415 1350 public function getAssociatedTasks($associated_id)
416 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);
417 1357 return [
418 - 'tasks' => $this->taskService->getAssociatedTasks($associated_id)
1358 + 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
419 1359 ];
420 1360 }
421 1361
1362 + /**
1363 + * Check FluentCRM contact read permission before exposing CRM-associated task data.
1364 + *
1365 + * @return bool
1366 + */
1367 + private function currentUserCanReadCrmContacts()
1368 + {
1369 + $permissionManager = 'FluentCrm\\App\\Services\\PermissionManager';
1370 +
1371 + if (!class_exists($permissionManager)) {
1372 + return false;
1373 + }
1374 +
1375 + return (bool) $permissionManager::currentUserCan('fcrm_read_contacts');
1376 + }
1377 +
1378 + /**
1379 + * @param Request $request
1380 + * @param $board_id
1381 + * @param $task_id
1382 + * @return \WP_REST_Response
1383 + */
1384 + public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
1385 + {
1386 + $board_id = absint($board_id);
1387 + $task_id = absint($task_id);
1388 + try {
1389 + $this->taskService->findTaskOnBoard($task_id, $board_id);
1390 +
1391 +
1392 + $file = Arr::get($request->files(), 'file')->toArray();
1393 + (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1394 +
1395 + $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1396 +
1397 + $fileData = $uploadInfo[0];
1398 + $fileUploadedData = $this->taskService->uploadMediaFileFromWpEditor($task_id, $fileData, Constant::TASK_DESCRIPTION);
1399 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1400 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1401 + $fileUploadedData['driver'] = $mediaData['driver'];
1402 + $fileUploadedData['file_path'] = $mediaData['file_path'];
1403 + $fileUploadedData['full_url'] = $mediaData['full_url'];
1404 + $fileUploadedData->save();
1405 + }
1406 + $fileUploadedData['public_url'] = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
1407 +
1408 + return $this->sendSuccess([
1409 + 'message' => __('Image has been uploaded', 'fluent-boards'),
1410 + 'file' => $fileUploadedData
1411 + ], 200);
1412 +
1413 +
1414 + } catch (\Exception $e) {
1415 + return $this->sendError($e->getMessage(), 400);
1416 + }
1417 + }
1418 +
1419 + public function createTaskFromImage(Request $request, $board_id)
1420 + {
1421 + $board_id = absint($board_id);
1422 + $stageId = $request->getSafe('stage_id', 'intval');
1423 + if (!Stage::where('id', $stageId)->where('board_id', $board_id)->exists()) {
1424 + return $this->sendError(esc_html__('Stage not found', 'fluent-boards'), 400);
1425 + }
1426 +
1427 + $file = Arr::get($request->files(), 'file')->toArray();
1428 + (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1429 +
1430 + $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1431 + $task = $this->taskService->createTaskFromImage($board_id, $stageId, $uploadInfo, $file);
1432 + $message = $task->type === 'roadmap'
1433 + ? __('Idea has been created', 'fluent-boards')
1434 + : __('Task has been created', 'fluent-boards');
1435 +
1436 + return $this->sendSuccess([
1437 + 'task' => $task,
1438 + 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
1439 + 'message' => $message,
1440 + ], 200);
1441 +
1442 + }
1443 +
1444 + public function handleTaskCoverImageUpload(Request $request, $board_id, $task_id)
1445 + {
1446 + $board_id = absint($board_id);
1447 + $task_id = absint($task_id);
1448 + try {
1449 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1450 +
1451 + $file = Arr::get($request->files(), 'file')->toArray();
1452 + (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1453 +
1454 + $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1455 +
1456 + $fileData = $uploadInfo[0];
1457 + $fileUploadedData = $this->taskService->uploadMediaFileFromWpEditor($task_id, $fileData, Constant::TASK_DESCRIPTION);
1458 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1459 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1460 + $fileUploadedData['driver'] = $mediaData['driver'];
1461 + $fileUploadedData['file_path'] = $mediaData['file_path'];
1462 + $fileUploadedData['full_url'] = $mediaData['full_url'];
1463 + $fileUploadedData->save();
1464 + }
1465 +
1466 + $settings = $task->settings;
1467 + $this->taskService->deleteTaskCoverImage($settings);
1468 + $publicUrl = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
1469 +
1470 + $settings['cover'] = [
1471 + 'imageId' => $fileUploadedData['id'],
1472 + 'backgroundImage' => $publicUrl,
1473 + ];
1474 + $task->settings = $settings;
1475 + $task->save();
1476 +
1477 + return $this->sendSuccess([
1478 + 'message' => __('Image has been uploaded', 'fluent-boards'),
1479 + 'public_url' => $publicUrl
1480 + ], 200);
1481 +
1482 +
1483 + } catch (\Exception $e) {
1484 + return $this->sendError($e->getMessage(), 400);
1485 + }
1486 + }
1487 + public function removeTaskCover($board_id, $task_id)
1488 + {
1489 + $board_id = absint($board_id);
1490 + $task_id = absint($task_id);
1491 + try {
1492 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1493 + $settings = $task->settings;
1494 + $this->taskService->deleteTaskCoverImage($settings);
1495 + unset($settings['cover']);
1496 + $task->settings = $settings;
1497 + $task->save();
1498 + return $this->sendSuccess([
1499 + 'task' => $task,
1500 + 'message' => __('Task Cover removed successfully', 'fluent-boards'),
1501 + ]);
1502 + } catch (\Exception $e) {
1503 + return $this->sendError($e->getMessage(), 400);
1504 + }
1505 + }
1506 +
1507 + /**
1508 + * Get task tabs configuration
1509 + */
1510 + public function getTaskTabsConfig()
1511 + {
1512 + $default_config = [
1513 + [
1514 + 'name' => 'due_today',
1515 + 'label' => __('Due Today', 'fluent-boards'),
1516 + 'visible' => 'true',
1517 + 'order' => 1
1518 + ],
1519 + [
1520 + 'name' => 'assigned',
1521 + 'label' => __('Assigned', 'fluent-boards'),
1522 + 'visible' => 'true',
1523 + 'order' => 2
1524 + ],
1525 + [
1526 + 'name' => 'upcoming',
1527 + 'label' => __('Upcoming', 'fluent-boards'),
1528 + 'visible' => 'true',
1529 + 'order' => 3
1530 + ],
1531 + [
1532 + 'name' => 'overdue',
1533 + 'label' => __('Overdue', 'fluent-boards'),
1534 + 'visible' => 'true',
1535 + 'order' => 4
1536 + ],
1537 + [
1538 + 'name' => 'mentioned',
1539 + 'label' => __('Mentioned', 'fluent-boards'),
1540 + 'visible' => 'true',
1541 + 'order' => 5
1542 + ],
1543 + [
1544 + 'name' => 'completed',
1545 + 'label' => __('Completed', 'fluent-boards'),
1546 + 'visible' => 'true',
1547 + 'order' => 6
1548 + ],
1549 + [
1550 + 'name' => 'others',
1551 + 'label' => __('Others', 'fluent-boards'),
1552 + 'visible' => 'true',
1553 + 'order' => 7
1554 + ]
1555 + ];
1556 + $availableTabNames = array_column($default_config, 'name');
1557 +
1558 + $existConfig = Meta::where('object_id', get_current_user_id())->where('key', Constant::FBS_TASK_TABS_CONFIG)->first();
1559 + $config = $default_config;
1560 +
1561 + if ($existConfig && !empty($existConfig->value)) {
1562 + $storedConfig = $existConfig->value;
1563 + $configChanged = false;
1564 + $config = $storedConfig;
1565 + $config = array_values(array_filter($config, fn($tab) => in_array($tab['name'] ?? '', $availableTabNames, true)));
1566 + $configChanged = count($config) !== count($storedConfig);
1567 +
1568 + if (empty($config)) {
1569 + $config = $default_config;
1570 + $configChanged = true;
1571 + }
1572 +
1573 + $existingNames = array_column($config, 'name');
1574 + $missingTabs = [];
1575 + foreach ($default_config as $defaultTab) {
1576 + if (!in_array($defaultTab['name'], $existingNames)) {
1577 + $missingTabs[] = $defaultTab;
1578 + }
1579 + }
1580 +
1581 + if (!empty($missingTabs)) {
1582 + $newConfig = [];
1583 + $order = 1;
1584 + $addedDueToday = false;
1585 + $addedAssigned = false;
1586 + foreach ($config as $tab) {
1587 + if (!$addedDueToday) {
1588 + $dueTodayTab = array_filter($missingTabs, fn($t) => $t['name'] === 'due_today');
1589 + if (!empty($dueTodayTab)) {
1590 + $dueTodayTab = reset($dueTodayTab);
1591 + $dueTodayTab['order'] = $order++;
1592 + $newConfig[] = $dueTodayTab;
1593 + $addedDueToday = true;
1594 + }
1595 + }
1596 +
1597 + if ($tab['name'] === 'upcoming' && !$addedAssigned) {
1598 + $assignedTab = array_filter($missingTabs, fn($t) => $t['name'] === 'assigned');
1599 + if (!empty($assignedTab)) {
1600 + $assignedTab = reset($assignedTab);
1601 + $assignedTab['order'] = $order++;
1602 + $newConfig[] = $assignedTab;
1603 + $addedAssigned = true;
1604 + }
1605 + }
1606 + $tab['order'] = $order++;
1607 + $newConfig[] = $tab;
1608 + }
1609 + foreach ($missingTabs as $missingTab) {
1610 + if (!in_array($missingTab['name'], ['assigned', 'due_today'], true)) {
1611 + $missingTab['order'] = $order++;
1612 + $newConfig[] = $missingTab;
1613 + }
1614 + }
1615 + $config = $newConfig;
1616 + $configChanged = true;
1617 + }
1618 +
1619 + if ($configChanged) {
1620 + $existConfig->value = $config;
1621 + $existConfig->save();
1622 + }
1623 + }
1624 +
1625 + // Always apply fresh translations based on tab name
1626 + $labelMap = [
1627 + 'due_today' => __('Due Today', 'fluent-boards'),
1628 + 'assigned' => __('Assigned', 'fluent-boards'),
1629 + 'upcoming' => __('Upcoming', 'fluent-boards'),
1630 + 'overdue' => __('Overdue', 'fluent-boards'),
1631 + 'mentioned' => __('Mentioned', 'fluent-boards'),
1632 + 'completed' => __('Completed', 'fluent-boards'),
1633 + 'others' => __('Others', 'fluent-boards'),
1634 + ];
1635 +
1636 + foreach ($config as &$tab) {
1637 + if (isset($labelMap[$tab['name']])) {
1638 + $tab['label'] = $labelMap[$tab['name']];
1639 + }
1640 + }
1641 +
1642 + return $this->sendSuccess([
1643 + 'data' => $config
1644 + ]);
1645 + }
1646 +
1647 + /**
1648 + * Save task tabs configuration
1649 + */
1650 + public function saveTaskTabsConfig(Request $request)
1651 + {
1652 + $rawConfig = $request->getSafe('tabs');
1653 +
1654 + if (empty($rawConfig) || !is_array($rawConfig)) {
1655 + return $this->sendError([
1656 + 'message' => __('Invalid data format', 'fluent-boards')
1657 + ], 400);
1658 + }
1659 +
1660 + // Sanitize config array
1661 + $config = [];
1662 + foreach ($rawConfig as $tab) {
1663 + if (!is_array($tab)) {
1664 + continue;
1665 + }
1666 + $sanitizedTab = [
1667 + 'name' => isset($tab['name']) ? sanitize_text_field($tab['name']) : '',
1668 + 'label' => isset($tab['label']) ? sanitize_text_field($tab['label']) : '',
1669 + 'visible' => isset($tab['visible']) ? sanitize_text_field($tab['visible']) : 'false',
1670 + 'order' => isset($tab['order']) ? absint($tab['order']) : 0,
1671 + ];
1672 + $config[] = $sanitizedTab;
1673 + }
1674 +
1675 + if (count(array_filter($config, fn($tab) => $tab['visible'] == 'true')) == 0) {
1676 + return $this->sendError([
1677 + 'message' => __('At least one tab must be visible', 'fluent-boards')
1678 + ], 400);
1679 + }
1680 +
1681 + $userId = get_current_user_id();
1682 +
1683 + $exit = Meta::where('object_id', $userId)->where('key', 'fbs_task_tabs_config')->first();
1684 +
1685 + if ($exit) {
1686 + $exit->value = $config;
1687 + $exit->save();
1688 + } else {
1689 + $exit = Meta::create([
1690 + 'object_id' => $userId,
1691 + 'object_type' => 'option',
1692 + 'key' => Constant::FBS_TASK_TABS_CONFIG,
1693 + 'value' => $config
1694 + ]);
1695 + }
1696 + $config = $exit->value;
1697 +
1698 + return $this->sendSuccess([
1699 + 'message' => __('Configuration saved successfully', 'fluent-boards'),
1700 + 'config' => $config
1701 + ]);
1702 + }
1703 + public function getAssociatedCrmContacts($board_id)
1704 + {
1705 + $board_id = absint($board_id);
1706 + $contactsInTasks = Task::where('board_id', $board_id)
1707 + ->whereNotNull('crm_contact_id')
1708 + ->get();
1709 +
1710 + if ($contactsInTasks->isEmpty()) {
1711 + return $this->sendSuccess([]);
1712 + }
1713 +
1714 + $contactIds = $contactsInTasks->pluck('crm_contact_id')
1715 + ->unique()
1716 + ->toArray();
1717 +
1718 + $allContacts = Subscriber::whereIn('id', $contactIds)->get();
1719 +
1720 + if ($allContacts->isEmpty()) {
1721 + return $this->sendSuccess([]);
1722 + }
1723 +
1724 + $formattedContacts = [];
1725 + foreach ($allContacts as $contact) {
1726 + $name = trim($contact->first_name . ' ' . $contact->last_name);
1727 +
1728 + $formattedContacts[] = [
1729 + 'id' => $contact->id,
1730 + 'display_name' => $name,
1731 + 'email' => $contact->email,
1732 + 'photo' => fluent_boards_user_avatar($contact->user_email, $name),
1733 + ];
1734 + }
1735 + if (!empty($formattedContacts)) {
1736 + usort($formattedContacts, function ($a, $b) {
1737 + return strcmp($a['display_name'], $b['display_name']);
1738 + });
1739 + }
1740 +
1741 + return $this->sendSuccess($formattedContacts);
1742 + }
1743 +
1744 + public function cloneTask(Request $request, $board_id, $task_id)
1745 + {
1746 + $board_id = absint($board_id);
1747 + $task_id = absint($task_id);
1748 + $taskData = $this->taskSanitizeAndValidate($request->only(['title', 'stage_id', 'assignee', 'subtask', 'label', 'attachment', 'comment']), [
1749 + 'title' => 'required|string',
1750 + 'stage_id' => 'required|numeric',
1751 + 'assignee' => 'required',
1752 + 'subtask' => 'required',
1753 + 'label' => 'required',
1754 + 'attachment' => 'required',
1755 + 'comment' => 'required',
1756 + ]);
1757 + try {
1758 + $taskData = fluent_boards_string_to_bool($taskData);
1759 + $clonedTask = $this->taskService->cloneTask($task_id, $taskData, $board_id);
1760 +
1761 + return $this->sendSuccess([
1762 + 'message' => __('Task has been cloned successfully', 'fluent-boards'),
1763 + 'task' => $clonedTask,
1764 + 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($clonedTask->board_id)
1765 + ], 200);
1766 + } catch (\Exception $e) {
1767 + return $this->sendError($e->getMessage(), 400);
1768 + }
1769 + }
1770 +
1771 + public function bulkActions(Request $request, $board_id)
1772 + {
1773 + $board_id = absint($board_id);
1774 + try {
1775 + $rawTaskIds = $request->getSafe('task_ids');
1776 + // Sanitize task_ids array to integers
1777 + $taskIds = [];
1778 + if (is_array($rawTaskIds)) {
1779 + $taskIds = array_filter(array_map('intval', $rawTaskIds));
1780 + }
1781 + $action = $request->getSafe('action', 'sanitize_text_field');
1782 + // Sanitize params array
1783 + $rawParams = $request->except(['task_ids', 'action']);
1784 + // Ensure rawParams is sanitized
1785 + if (!is_array($rawParams)) {
1786 + $rawParams = [];
1787 + }
1788 + $params = [];
1789 + foreach ($rawParams as $key => $value) {
1790 + $sanitizedKey = sanitize_text_field($key);
1791 + if (is_array($value)) {
1792 + $params[$sanitizedKey] = array_map('sanitize_text_field', $value);
1793 + } else {
1794 + $params[$sanitizedKey] = sanitize_text_field($value);
1795 + }
1796 + }
1797 +
1798 + $result = $this->taskService->bulkActions($taskIds, $action, $params, $board_id);
1799 +
1800 + // Process successful tasks the same way as getTasksByBoard
1801 + if (!empty($result['successful_tasks'])) {
1802 + $board = Board::findOrFail($board_id);
1803 + $this->processTasks($result['successful_tasks'], $board);
1804 + }
1805 +
1806 + return $this->sendSuccess($result);
1807 +
1808 + } catch (\Exception $e) {
1809 + return $this->sendError($e->getMessage(), 500);
1810 + }
1811 + }
422 1812 }