PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.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 1.41 All 42 releases
fluent-boards / app / Http / Controllers / TaskController.php

TaskController.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.1.0, at app/Http/Controllers/TaskController.php

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