PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.0.12
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.0.12
2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 1.45 All 41 releases
fluent-boards / app / Http / Controllers / TaskController.php

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

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