PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / trunk
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration vtrunk
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 trunk, at app/Http/Controllers/TaskController.php

1,796 lines 66.3 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 * Create a task with field-specific sanitization for its request data.
445 *
446 * @param Request $request
447 * @param int $board_id
448 * @return mixed
449 */
450 public function create(Request $request, $board_id)
451 {
452 $board_id = absint($board_id);
453 $safeTaskData = $request->getSafe('task');
454 $rawTaskData = $request->get('task', []);
455
456 // Milkdown serializes pasted URLs as <https://...>, which generic text
457 // sanitization removes as a tag.
458 if (
459 is_array($safeTaskData) &&
460 is_array($rawTaskData) &&
461 array_key_exists('description', $rawTaskData)
462 ) {
463 $safeTaskData['description'] = fluent_boards_sanitize_description($rawTaskData['description']);
464 }
465
466 $taskData = $this->taskSanitizeAndValidate($safeTaskData, [
467 'title' => 'required|string',
468 'board_id' => 'required|numeric',
469 'stage_id' => 'required|numeric',
470 'priority' => 'nullable|string',
471 'crm_contact_id' => 'nullable|numeric',
472 'is_template' => 'string',
473 ]);
474
475 try {
476 if (isset($taskData['assignees'])) {
477 $taskData['assignees'] = array_filter(array_map('intval', (array) $taskData['assignees']));
478 }
479
480 if (isset($taskData['labels'])) {
481 $taskData['labels'] = array_filter(array_map('intval', (array) $taskData['labels']));
482 }
483
484 if ($taskData['board_id'] != $board_id) {
485 throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
486 }
487
488 $task = $this->taskService->createTask($taskData, $board_id);
489 $message = $task->type === 'roadmap'
490 ? __('Idea has been successfully created', 'fluent-boards')
491 : __('Task has been successfully created', 'fluent-boards');
492
493 return $this->sendSuccess([
494 'task' => $task,
495 'message' => $message,
496 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
497 ], 201);
498 } catch (\Exception $e) {
499 return $this->sendError($e->getMessage(), 400);
500 }
501 }
502
503 public function find($board_id, $task_id)
504 {
505 $board_id = absint($board_id);
506 $task_id = absint($task_id);
507 try {
508
509 $stageService = new StageService();
510
511 $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
512
513 if (isset($task->parent_id)) {
514 $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
515 }
516
517 if(!$task) {
518 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
519 }
520
521 if (defined('FLUENT_BOARDS_PRO')) {
522 $task->load(['attachments']);
523 }
524
525 $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
526
527 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
528 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
529
530 $task->isOverdue = $task->isOverdue();
531 $task->contact = Task::lead_contact($task->crm_contact_id);
532 $task->board->stages = $stageService->stagesByBoardId($board_id);
533 $task->is_watching = $this->notificationService->isCurrentUserObservingTask($task);
534
535 $task = $this->taskService->loadNextStage($task);
536
537 if ($task->type == 'roadmap') {
538 $task->vote_statistics = $this->taskService->getIdeaVoteStatistics($task_id);
539 }
540
541 return [
542 'task' => $task
543 ];
544
545 } catch (\Exception $e ) {
546 return $this->sendError($e->getMessage(), 400);
547 }
548
549
550 }
551
552 public function getStageType(Request $request)
553 {
554 $stage_id = $request->getSafe('stage_id', 'intval');
555 $stage = Stage::findOrFail($stage_id);
556
557 return [
558 'stage' => $stage,
559 ];
560 }
561
562 public function getActivities(Request $request, $board_id, $task_id)
563 {
564 $board_id = absint($board_id);
565 $task_id = absint($task_id);
566 $filter = $request->getSafe('filter', 'sanitize_text_field');
567 $per_page = 15; // Apparently, let's use a fixed number of items per page.
568 $this->taskService->findTaskOnBoard($task_id, $board_id);
569
570 return [
571 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
572 ];
573
574 }
575
576 public function getArchivedTasks(Request $request, $board_id)
577 {
578 $board_id = absint($board_id);
579 // Sanitize request parameters before passing to service
580 $sanitizedParams = [
581 'per_page' => $request->getSafe('per_page', 'intval', 20),
582 'page' => $request->getSafe('page', 'intval', 1),
583 'query' => $request->getSafe('searchInput', 'sanitize_text_field', '')
584 ];
585
586
587 $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
588
589 foreach ($tasks as $task) {
590 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
591 }
592
593
594 return [
595 'tasks' => $tasks
596 ];
597 }
598
599 public function bulkRestoreTasks(Request $request, $board_id)
600 {
601 $board_id = absint($board_id);
602 try {
603 $rawTaskIds = $request->getSafe('task_ids');
604 // Sanitize task_ids array to integers
605 $task_ids = [];
606 if (is_array($rawTaskIds)) {
607 $task_ids = array_filter(array_map('intval', $rawTaskIds));
608 }
609
610 if (empty($task_ids)) {
611 return $this->response->sendError('No task IDs provided', 400);
612 }
613
614 $tasks = Task::where('board_id', $board_id)
615 ->whereIn('id', $task_ids)
616 ->whereNotNull('archived_at')
617 ->get();
618
619 if ($tasks->isEmpty()) {
620 return $this->response->sendError('No archived tasks found with provided IDs', 404);
621 }
622
623 $restored_count = 0;
624 $failed_count = 0;
625 $failed_tasks = [];
626
627 foreach ($tasks as $task) {
628 try {
629 // Use TaskService to properly restore the task (same as single task restoration)
630 $this->taskService->updateTaskProperty('archived_at', null, $task);
631
632 // Prepare task for response (same as single task update)
633 $task->isOverdue = $task->isOverdue();
634 $task->isUpcoming = $task->upcoming();
635 $task->contact = Helper::crm_contact($task->crm_contact_id);
636 $task->is_watching = $task->isWatching();
637 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
638
639 $restored_count++;
640 } catch (\Exception $e) {
641 // Track failed tasks but continue processing others
642 $failed_count++;
643 $failed_tasks[] = [
644 'id' => $task->id,
645 'title' => $task->title,
646 'error' => $e->getMessage()
647 ];
648 }
649 }
650
651 // Get recently updated tasks (same as single task operations)
652 $recentlyUpdatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
653
654 // Build response with detailed results
655 $response = [
656 'restored_count' => $restored_count,
657 'failed_count' => $failed_count,
658 'updatedTasks' => $recentlyUpdatedTasks
659 ];
660
661 if ($failed_count > 0) {
662 $response['failed_tasks'] = $failed_tasks;
663 if ($restored_count > 0) {
664 $response['message'] = $restored_count . ' ' . ($restored_count === 1 ? 'task' : 'tasks') . ' restored successfully, ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks') . ' failed';
665 } else {
666 $response['message'] = 'Failed to restore ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks');
667 }
668 } else {
669 $response['message'] = $restored_count . ' ' . ($restored_count === 1 ? 'task' : 'tasks') . ' restored successfully';
670 }
671
672 return $this->response->sendSuccess($response, 200);
673
674 } catch (\Exception $e) {
675 return $this->response->sendError($e->getMessage(), 500);
676 }
677 }
678
679 public function bulkDeleteTasks(Request $request, $board_id)
680 {
681 $board_id = absint($board_id);
682 try {
683 $rawTaskIds = $request->getSafe('task_ids');
684 // Sanitize task_ids array to integers
685 $task_ids = [];
686 if (is_array($rawTaskIds)) {
687 $task_ids = array_filter(array_map('intval', $rawTaskIds));
688 }
689
690 if (empty($task_ids)) {
691 return $this->response->sendError('No task IDs provided', 400);
692 }
693
694 $tasks = Task::where('board_id', $board_id)
695 ->whereIn('id', $task_ids)
696 ->get();
697
698 if ($tasks->isEmpty()) {
699 return $this->response->sendError('No tasks found with provided IDs', 404);
700 }
701
702 $deleted_count = 0;
703 $failed_count = 0;
704 $failed_tasks = [];
705 $options = null;
706
707 foreach ($tasks as $task) {
708 try {
709 // This handles all cleanup: subtasks, watchers, assignees, labels, notifications, attachments, etc.
710 $this->taskService->deleteTaskForBulk($task);
711 $deleted_count++;
712 } catch (\Exception $e) {
713 // Track failed tasks but continue processing others
714 $failed_count++;
715 $failed_tasks[] = [
716 'id' => $task->id,
717 'title' => $task->title,
718 'error' => $e->getMessage()
719 ];
720 }
721 }
722
723 // Get recently updated tasks (same as single task operations)
724 $recentlyUpdatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
725
726 // Build response with detailed results
727 $response = [
728 'deleted_count' => $deleted_count,
729 'failed_count' => $failed_count,
730 'updatedTasks' => $recentlyUpdatedTasks
731 ];
732
733 if ($failed_count > 0) {
734 $response['failed_tasks'] = $failed_tasks;
735 if ($deleted_count > 0) {
736 $response['message'] = $deleted_count . ' ' . ($deleted_count === 1 ? 'task' : 'tasks') . ' deleted successfully, ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks') . ' failed';
737 } else {
738 $response['message'] = 'Failed to delete ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks');
739 }
740 } else {
741 $response['message'] = $deleted_count . ' ' . ($deleted_count === 1 ? 'task' : 'tasks') . ' deleted successfully';
742 }
743
744 return $this->response->sendSuccess($response, 200);
745
746 } catch (\Exception $e) {
747 return $this->response->sendError($e->getMessage(), 500);
748 }
749 }
750
751 public function updateTaskProperties(Request $request, $board_id, $task_id)
752 {
753 $board_id = absint($board_id);
754 $task_id = absint($task_id);
755 //Properties in col: settings, assignees,crm_contact_id, archived_at(AUTO_SET_TIMESTAMP) , status, title, description, priority, is_watching, is_template
756 $col = $request->getSafe('property', 'sanitize_text_field');
757 if ($col === 'description') {
758 $value = $request->getSafe('value', 'fluent_boards_sanitize_description');
759 } elseif ($col === 'settings' || $col === 'assignees') {
760 $value = $request->get('value');
761 if (is_array($value) && isset($value['cover']) && is_array($value['cover'])) {
762 if (isset($value['cover']['backgroundColor'])) {
763 $value['cover']['backgroundColor'] = sanitize_text_field($value['cover']['backgroundColor']);
764 }
765 }
766 } elseif ($col === 'is_watching') {
767 $value = $request->get('value');
768 if (is_array($value)) {
769 $action = isset($value['action']) ? sanitize_text_field($value['action']) : 'start';
770 $value = [
771 'userId' => isset($value['userId']) ? absint($value['userId']) : 0,
772 'action' => in_array($action, ['start', 'stop'], true) ? $action : 'start',
773 ];
774 } else {
775 $value = sanitize_text_field($value);
776 }
777 } else {
778 $value = $request->getSafe('value', 'sanitize_text_field');
779 }
780
781 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
782 $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
783 $task->load(['board', 'labels', 'assignees']);
784
785 if ($col === 'board_id' && (int) $validatedData[$col] !== $board_id) {
786 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
787 }
788
789 if ($col === 'stage_id' && !Stage::where('id', (int) $validatedData[$col])->where('board_id', $board_id)->exists()) {
790 throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
791 }
792
793 if ($col === 'parent_id' && $validatedData[$col]) {
794 $this->taskService->findTaskOnBoard($validatedData[$col], $board_id, false);
795 }
796
797 if ($task->parent_id && $col === 'started_at') {
798 $validatedData[$col] = null;
799 }
800
801 $oldDateValue = null;
802 if (in_array($col, ['due_at', 'started_at'])) {
803 $oldDateValue = $task->{$col};
804 }
805
806 if ($task->parent_id && !$task->board_id) {
807 $task->board_id = $board_id;
808 $task->save();
809 }
810
811 $task = $this->taskService->updateTaskProperty($col, $validatedData[$col], $task);
812 $task->isOverdue = $task->isOverdue();
813 $task->isUpcoming = $task->upcoming();
814 $task->contact = Helper::crm_contact($task->crm_contact_id);
815 $task->is_watching = $task->isWatching();
816 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
817
818 if ($col === 'is_watching') {
819 $task->load('watchers');
820 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
821 }
822
823 if ($task->parent_id) {
824 $task->subtask_group_id = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_CHILD)->value('value');
825 }
826
827 // A recent update to a task might impact other tasks on the board.
828 $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
829 $taskExists = false;
830 foreach ($updatedTasks as $index => $updatedTask) {
831 if ($updatedTask->id === $task->id) {
832 $updatedTasks[$index] = $task; // Replace the existing task
833 $taskExists = true;
834 break;
835 }
836 }
837
838 if (!$taskExists) {
839 $updatedTasks[] = $task;
840 }
841
842 return [
843 'message' => __('Task has been updated', 'fluent-boards'),
844 'task' => $task,
845 'updatedTasks' => $updatedTasks
846 ];
847 }
848
849 /**
850 * Remove a Fluent Support association from a task without deleting the ticket.
851 *
852 * @param int $board_id
853 * @param int $task_id
854 * @return mixed
855 */
856 public function removeSupportTicketLink($board_id, $task_id)
857 {
858 $boardId = absint($board_id);
859 $taskId = absint($task_id);
860 $task = $this->taskService->removeSupportTicketLink($taskId, $boardId);
861
862 return $this->sendSuccess([
863 'message' => __('Support ticket link has been removed', 'fluent-boards'),
864 'task' => $task,
865 'updatedTasks' => [$task],
866 ]);
867 }
868
869 public function updateTaskDates(Request $request, $board_id, $task_id)
870 {
871 $board_id = absint($board_id);
872 $task_id = absint($task_id);
873 $task = Task::where('id', $task_id)->where('board_id', $board_id)->firstOrFail();
874 $payload = $request->all();
875
876 // Capture old dates before updating
877 $oldDates = [
878 'due_at' => $task->due_at,
879 'started_at' => $task->started_at,
880 ];
881
882
883
884 $hasStartAt = array_key_exists('started_at', $payload);
885 $hasDueAt = array_key_exists('due_at', $payload);
886 $hasReminderType = array_key_exists('reminder_type', $payload);
887 $hasRemindAt = array_key_exists('remind_at', $payload);
888
889 $startAt = $hasStartAt ? $request->getSafe('started_at', 'sanitize_text_field', NULL) : $task->started_at;
890 $dueAt = $hasDueAt ? $request->getSafe('due_at', 'sanitize_text_field', NULL) : $task->due_at;
891 $isSubtask = (bool) $task->parent_id;
892
893 if ($isSubtask) {
894 $startAt = null;
895 $hasStartAt = $hasStartAt || (bool) $task->started_at;
896 }
897
898 if (!$isSubtask && $hasStartAt && $hasDueAt && $startAt && $dueAt) {
899 if (strtotime($startAt) > strtotime($dueAt)) {
900 $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
901 }
902 }
903
904 if ($hasStartAt) {
905 $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
906 }
907
908 if ($hasDueAt) {
909 $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
910 }
911
912 // Only mutate reminder fields when the caller explicitly sends them.
913 if ($hasReminderType) {
914 $reminderType = $request->getSafe('reminder_type', 'sanitize_text_field', NULL);
915 $task = $this->taskService->updateTaskProperty('reminder_type', $reminderType, $task);
916 }
917
918 if ($hasRemindAt) {
919 $remindAt = $request->getSafe('remind_at', 'sanitize_text_field', NULL);
920 $task = $this->taskService->updateTaskProperty('remind_at', $remindAt, $task);
921 }
922
923 $datesChanged = false;
924 $changedDates = [];
925
926 if ($oldDates['due_at'] !== $task->due_at) {
927 $datesChanged = true;
928 $changedDates['due_at'] = $oldDates['due_at'];
929 }
930
931 if ($oldDates['started_at'] !== $task->started_at) {
932 $datesChanged = true;
933 $changedDates['started_at'] = $oldDates['started_at'];
934 }
935
936 if ($datesChanged) {
937 do_action('fluent_boards/task_date_changed', $task, $changedDates);
938 }
939
940 return [
941 'task' => $task,
942 'message' => __('Dates have been updated', 'fluent-boards'),
943 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
944 ];
945 }
946
947 /**
948 * Toggle task pinned state (meta only). Only top-level tasks can be pinned.
949 *
950 * @param Request $request Expects body: pinned (bool or "true"/"1" for pin, false/"false"/"0" for unpin)
951 * @param int $board_id
952 * @param int $task_id
953 * @return array{task: \FluentBoards\App\Models\Task, message: string, updatedTasks: array}
954 */
955 public function toggleTaskPinned(Request $request, $board_id, $task_id)
956 {
957 $board_id = absint($board_id);
958 $task_id = absint($task_id);
959
960 $task = Task::where('board_id', $board_id)->findOrFail($task_id);
961
962 if ($task->parent_id) {
963 return $this->sendError(__('Subtasks cannot be pinned', 'fluent-boards'), 400);
964 }
965
966 $pinned = filter_var($request->getSafe('pinned', 'sanitize_text_field', false), FILTER_VALIDATE_BOOLEAN);
967
968 if ((int) $task->is_pinned !== ($pinned ? 1 : 0)) {
969 if ($pinned) {
970 $task = $this->taskService->pinTask($task);
971 $message = __('Task has been pinned', 'fluent-boards');
972 } else {
973 $task = $this->taskService->unpinTask($task);
974 $message = __('Task has been unpinned', 'fluent-boards');
975 }
976 } else {
977 $message = $pinned ? __('Task is already pinned', 'fluent-boards') : __('Task is already unpinned', 'fluent-boards');
978 }
979
980 // Pin state is stored in task meta, so task.updated_at may not change.
981 // Ensure the toggled task is always present in the incremental payload.
982 $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
983 $taskExists = false;
984 foreach ($updatedTasks as $index => $updatedTask) {
985 if ($updatedTask->id === $task->id) {
986 $updatedTasks[$index] = $task;
987 $taskExists = true;
988 break;
989 }
990 }
991 if (!$taskExists) {
992 $updatedTasks[] = $task;
993 }
994
995 return [
996 'task' => $task,
997 'message' => $message,
998 'updatedTasks' => $updatedTasks,
999 ];
1000 }
1001
1002 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
1003 {
1004 $board_id = absint($board_id);
1005 $task_id = absint($task_id);
1006 $imagePath = $request->getSafe('thumbnail', 'sanitize_text_field');
1007 $task = $this->taskService->taskCoverPhotoUpdate($task_id, $imagePath, $board_id);
1008
1009 return [
1010 'message' => __('Task cover photo has been updated', 'fluent-boards'),
1011 'task' => $task,
1012 ];
1013
1014 }
1015
1016 public function taskStatusUpdate(Request $request, $board_id, $task_id)
1017 {
1018 $board_id = absint($board_id);
1019 $task_id = absint($task_id);
1020 $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
1021 return [
1022 'message' => __('Task status has been updated', 'fluent-boards'),
1023 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
1024 ];
1025 }
1026
1027 public function deleteTask($board_id, $task_id)
1028 {
1029 $board_id = absint($board_id);
1030 $task_id = absint($task_id);
1031 $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1032 $options = null;
1033 //if we need to do something before a task is deleted
1034 do_action('fluent_boards/before_task_deleted', $task, $options);
1035
1036 $this->taskService->deleteTask($task);
1037
1038 return [
1039 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
1040 'message' => __('Task has been deleted', 'fluent-boards'),
1041 ];
1042 }
1043
1044 private function taskSanitizeAndValidate($data, array $rules = [])
1045 {
1046 $data = Helper::sanitizeTask($data);
1047
1048 return $this->validate($data, $rules);
1049 }
1050
1051 /**
1052 * Ensure write routes cannot pair an accessible route board with a task from another board.
1053 *
1054 * @param \FluentBoards\App\Models\Task $task
1055 * @param int $boardId
1056 * @return void
1057 * @throws \Exception
1058 */
1059 private function assertTaskBelongsToBoard($task, $boardId)
1060 {
1061 $boardId = absint($boardId);
1062
1063 if (!$task || !$boardId) {
1064 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1065 }
1066
1067 if ((int) $task->board_id === $boardId) {
1068 return;
1069 }
1070
1071 if ($task->parent_id) {
1072 $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
1073
1074 if ((int) $parentBoardId === $boardId) {
1075 return;
1076 }
1077 }
1078
1079 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1080 }
1081
1082 private function updateTaskPropValidationAndSanitation($col, $value)
1083 {
1084 $rules = [
1085 'title' => 'required|string',
1086 'board_id' => 'required',
1087 'parent_id' => 'required',
1088 'crm_contact_id' => 'nullable',
1089 'type' => 'nullable|string',
1090 'status' => 'nullable|string',
1091 'stage_id' => 'required',
1092 'reminder_type' => 'nullable|string',
1093 'priority' => 'nullable|string',
1094 'lead_value' => 'nullable|numeric|between:0,9999999.99',
1095 'remind_at' => 'nullable|string',
1096 'scope' => 'nullable|string',
1097 'source' => 'nullable|string',
1098 'description' => 'nullable|string',
1099 'due_at' => 'nullable|string',
1100 'started_at' => 'nullable|string',
1101 'start_at' => 'nullable|string',
1102 'log_minutes' => 'nullable|integer|unsigned',
1103 'last_completed' => 'nullable|date',
1104 'assignees' => 'nullable|integer',
1105 'archived_at' => 'nullable|string',
1106 'is_watching' => 'nullable',
1107 'is_template' => 'string',
1108 'last_completed_at' => 'nullable',
1109 'settings' => 'nullable|array',
1110 ];
1111 if (array_key_exists($col, $rules)) {
1112 $rule = $rules[$col];
1113 if ('assignees' == $col && is_array($value)) {
1114 $sanitizedAndValidatedValue = [];
1115 foreach ($value as $val) {
1116 $sanitizeData = Helper::sanitizeTask([$col => $val]);
1117 $validatedData = $this->validate($sanitizeData, [
1118 $col => $rule,
1119 ]);
1120 array_push($sanitizedAndValidatedValue, $validatedData[$col]);
1121 }
1122
1123 return [$col => $sanitizedAndValidatedValue];
1124 }
1125 if ('is_watching' == $col && is_array($value)) {
1126 return [$col => $value];
1127 }
1128 $data = Helper::sanitizeTask([$col => $value]);
1129
1130 return $this->validate($data, [
1131 $col => $rule,
1132 ]);
1133 }
1134
1135 // If the column is not found in the rules array, throw an exception
1136 // translators: %s is the property name
1137 throw new \Exception(sprintf(esc_html__('Invalid property: %s', 'fluent-boards'), esc_html($col)));
1138 }
1139
1140 public function getStageByTask($task_id)
1141 {
1142 $task_id = absint($task_id);
1143 try {
1144 $stage = $this->taskService->getStageByTask($task_id);
1145 } catch (\Exception $e) {
1146 return $this->sendError($e->getMessage(), 404);
1147 }
1148
1149 return [
1150 'stage' => $stage,
1151 ];
1152 }
1153
1154 public function assignYourselfInTask($board_id, $task_id)
1155 {
1156 $board_id = absint($board_id);
1157 $task_id = absint($task_id);
1158 $task = $this->taskService->assignYourselfInTask($board_id, $task_id);
1159 $task->is_watching = $task->isWatching();
1160
1161 return [
1162 'task' => $task,
1163 ];
1164 }
1165
1166 public function detachYourselfFromTask($board_id, $task_id)
1167 {
1168 $board_id = absint($board_id);
1169 $task_id = absint($task_id);
1170 $task = $this->taskService->detachYourselfFromTask($board_id, $task_id);
1171 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1172 $task->is_watching = $task->isWatching();
1173
1174 return [
1175 'task' => $task,
1176 ];
1177 }
1178
1179 private function taskMetaSanitizeAndValidate($data, array $rules = [])
1180 {
1181 $data = Helper::sanitizeTaskMeta($data);
1182
1183 return $this->validate($data, $rules);
1184 }
1185
1186 public function moveTaskToNextStage($board_id, $task_id)
1187 {
1188 $board_id = absint($board_id);
1189 $task_id = absint($task_id);
1190 $task = $this->taskService->moveTaskToNextStage($task_id, $board_id);
1191
1192 return [
1193 'task' => $task
1194 ];
1195 }
1196
1197 /**
1198 * @throws \Exception
1199 */
1200 public function moveTask(Request $request, $board_id, $task_id)
1201 {
1202 $board_id = absint($board_id);
1203 $task_id = absint($task_id);
1204 $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1205 $oldStageId = $task->stage_id;
1206 $newStageId = $request->getSafe('newStageId', 'intval');
1207 $newIndex = $request->getSafe('newIndex', 'intval');
1208 $newBoardId = $request->getSafe('newBoardId', 'intval');
1209 $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1210 $nextTaskId = $request->getSafe('nextTaskId', 'intval');
1211
1212 if ((!is_numeric($newStageId) || $newStageId == 0)) {
1213 throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
1214 }
1215
1216 if (!$prevTaskId && !$nextTaskId && (!is_numeric($newIndex) || $newIndex == 0)) {
1217 throw new \Exception(esc_html__('Invalid Value', 'fluent-boards'));
1218 }
1219
1220 if ($newBoardId) {
1221 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
1222 throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
1223 }
1224
1225 if (!PermissionManager::userHasBoardPermission($newBoardId, 'PUT')) {
1226 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1227 }
1228 }
1229
1230 $effectiveBoardId = $newBoardId ?: $task->board_id;
1231 $targetStage = Stage::where('id', $newStageId)
1232 ->where('board_id', $effectiveBoardId)
1233 ->first();
1234
1235 if (!$targetStage) {
1236 throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
1237 }
1238
1239 foreach (array_filter([$prevTaskId, $nextTaskId]) as $neighborTaskId) {
1240 $this->taskService->findTaskOnBoard($neighborTaskId, $effectiveBoardId);
1241 }
1242
1243 if ($newBoardId) {
1244 $task = $this->taskService->changeBoardByTask($task, $newBoardId);
1245 // Load relationships to ensure frontend gets updated data after board move
1246 $task->load(['assignees', 'labels', 'watchers', 'attachments']);
1247 }
1248
1249 // Clean up archived_by_stage meta when task is moved to different stage
1250 if ($oldStageId != $newStageId) {
1251 TaskMeta::where('task_id', $task->id)
1252 ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
1253 ->delete();
1254 }
1255
1256 $task->stage_id = $newStageId;
1257 // New drag flows send neighbour ids so ordering stays correct even when
1258 // the client only has a paged slice of the stage. Older move flows still
1259 // rely on the legacy 1-based newIndex fallback.
1260 if ($prevTaskId || $nextTaskId) {
1261 $task = $task->moveBetweenTasks($prevTaskId, $nextTaskId);
1262 } else {
1263 $task = $task->moveToNewPosition($newIndex);
1264 }
1265
1266 if ($oldStageId != $newStageId) {
1267
1268 $this->taskService->manageDefaultAssignees($task, $newStageId);
1269
1270 $defaultPosition = $task->stage->defaultTaskStatus();
1271
1272 if ($defaultPosition == 'closed' && $task->status != 'closed') {
1273 $task = $task->close();
1274 }
1275
1276 // do_action('fluent_boards/task_moved_to_new_stage', $task, $oldStageId);
1277
1278 do_action('fluent_boards/task_stage_updated', $task, $oldStageId);
1279
1280 $usersToSendEmail = $this->notificationService->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
1281 $this->taskService->sendMailAfterTaskModify('stage_change', $usersToSendEmail, $task->id);
1282 }
1283
1284 do_action('fluent_boards/task_updated', $task, 'position');
1285
1286 $lastBoardsUpdated = $request->getSafe('last_boards_updated', 'sanitize_text_field');
1287 $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id, $lastBoardsUpdated);
1288
1289 return [
1290 'message' => __('Task has been updated', 'fluent-boards'),
1291 'task' => $task,
1292 'updatedTasks' => $updatedTasks,
1293 'last_updated' => current_time('mysql')
1294 ];
1295 }
1296
1297 /**
1298 * Get comments and activities for a task, merged into a single array, sorted by creation date, and paginated.
1299 *
1300 * @param Request $request The HTTP request instance.
1301 * @param int $board_id The ID of the board.
1302 * @param int $task_id The ID of the task.
1303 * @return \WP_REST_Response The response containing paginated comments and activities, total count, current page, and items per page.
1304 */
1305 public function getCommentsAndActivities( Request $request, $board_id, $task_id)
1306 {
1307 $board_id = absint($board_id);
1308 $task_id = absint($task_id);
1309 try {
1310 // Pagination parameters
1311 $page = $request->getSafe('page', 'intval', 1);
1312 $perPage = $request->getSafe('per_page', 'intval', 10);
1313 $filter = $request->getSafe('filter', 'sanitize_text_field', 'newest'); // Filter for comments and activities
1314 $feedType = $request->getSafe('feed_type', 'sanitize_text_field', 'all');
1315 $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter, $board_id, $feedType);
1316 // Return the response with the task, paginated comments and activities, total count, current page, and items per page
1317 return $this->sendSuccess([
1318 'comments_and_activities' => $commentsAndActivities,
1319 ]);
1320 } catch (\Exception $e) {
1321 return $this->sendError($e->getMessage(), 500);
1322 }
1323 }
1324
1325 public function sendMailAfterStageChange($usersToSendEmail, $taskId)
1326 {
1327 $current_user_id = get_current_user_id();
1328
1329 /* this will run in background as soon as possible */
1330 /* sending Model or Model Instance won't work here */
1331 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
1332 }
1333 public function getAssociatedTasks($associated_id)
1334 {
1335 if (!$this->currentUserCanReadCrmContacts()) {
1336 return $this->sendError(esc_html__('You do not have permission to view CRM contact tasks', 'fluent-boards'), 403);
1337 }
1338
1339 $associated_id = absint($associated_id);
1340 return [
1341 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
1342 ];
1343 }
1344
1345 /**
1346 * Check FluentCRM contact read permission before exposing CRM-associated task data.
1347 *
1348 * @return bool
1349 */
1350 private function currentUserCanReadCrmContacts()
1351 {
1352 $permissionManager = 'FluentCrm\\App\\Services\\PermissionManager';
1353
1354 if (!class_exists($permissionManager)) {
1355 return false;
1356 }
1357
1358 return (bool) $permissionManager::currentUserCan('fcrm_read_contacts');
1359 }
1360
1361 /**
1362 * @param Request $request
1363 * @param $board_id
1364 * @param $task_id
1365 * @return \WP_REST_Response
1366 */
1367 public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
1368 {
1369 $board_id = absint($board_id);
1370 $task_id = absint($task_id);
1371 try {
1372 $this->taskService->findTaskOnBoard($task_id, $board_id);
1373
1374
1375 $file = Arr::get($request->files(), 'file')->toArray();
1376 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1377
1378 $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1379
1380 $fileData = $uploadInfo[0];
1381 $fileUploadedData = $this->taskService->uploadMediaFileFromWpEditor($task_id, $fileData, Constant::TASK_DESCRIPTION);
1382 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1383 $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1384 $fileUploadedData['driver'] = $mediaData['driver'];
1385 $fileUploadedData['file_path'] = $mediaData['file_path'];
1386 $fileUploadedData['full_url'] = $mediaData['full_url'];
1387 $fileUploadedData->save();
1388 }
1389 $fileUploadedData['public_url'] = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
1390
1391 return $this->sendSuccess([
1392 'message' => __('Image has been uploaded', 'fluent-boards'),
1393 'file' => $fileUploadedData
1394 ], 200);
1395
1396
1397 } catch (\Exception $e) {
1398 return $this->sendError($e->getMessage(), 400);
1399 }
1400 }
1401
1402 public function createTaskFromImage(Request $request, $board_id)
1403 {
1404 $board_id = absint($board_id);
1405 $stageId = $request->getSafe('stage_id', 'intval');
1406 if (!Stage::where('id', $stageId)->where('board_id', $board_id)->exists()) {
1407 return $this->sendError(esc_html__('Stage not found', 'fluent-boards'), 400);
1408 }
1409
1410 $file = Arr::get($request->files(), 'file')->toArray();
1411 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1412
1413 $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1414 $task = $this->taskService->createTaskFromImage($board_id, $stageId, $uploadInfo, $file);
1415 $message = $task->type === 'roadmap'
1416 ? __('Idea has been created', 'fluent-boards')
1417 : __('Task has been created', 'fluent-boards');
1418
1419 return $this->sendSuccess([
1420 'task' => $task,
1421 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
1422 'message' => $message,
1423 ], 200);
1424
1425 }
1426
1427 public function handleTaskCoverImageUpload(Request $request, $board_id, $task_id)
1428 {
1429 $board_id = absint($board_id);
1430 $task_id = absint($task_id);
1431 try {
1432 $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1433
1434 $file = Arr::get($request->files(), 'file')->toArray();
1435 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1436
1437 $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1438
1439 $fileData = $uploadInfo[0];
1440 $fileUploadedData = $this->taskService->uploadMediaFileFromWpEditor($task_id, $fileData, Constant::TASK_DESCRIPTION);
1441 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1442 $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1443 $fileUploadedData['driver'] = $mediaData['driver'];
1444 $fileUploadedData['file_path'] = $mediaData['file_path'];
1445 $fileUploadedData['full_url'] = $mediaData['full_url'];
1446 $fileUploadedData->save();
1447 }
1448
1449 $settings = $task->settings;
1450 $this->taskService->deleteTaskCoverImage($settings);
1451 $publicUrl = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
1452
1453 $settings['cover'] = [
1454 'imageId' => $fileUploadedData['id'],
1455 'backgroundImage' => $publicUrl,
1456 ];
1457 $task->settings = $settings;
1458 $task->save();
1459
1460 return $this->sendSuccess([
1461 'message' => __('Image has been uploaded', 'fluent-boards'),
1462 'public_url' => $publicUrl
1463 ], 200);
1464
1465
1466 } catch (\Exception $e) {
1467 return $this->sendError($e->getMessage(), 400);
1468 }
1469 }
1470 public function removeTaskCover($board_id, $task_id)
1471 {
1472 $board_id = absint($board_id);
1473 $task_id = absint($task_id);
1474 try {
1475 $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1476 $settings = $task->settings;
1477 $this->taskService->deleteTaskCoverImage($settings);
1478 unset($settings['cover']);
1479 $task->settings = $settings;
1480 $task->save();
1481 return $this->sendSuccess([
1482 'task' => $task,
1483 'message' => __('Task Cover removed successfully', 'fluent-boards'),
1484 ]);
1485 } catch (\Exception $e) {
1486 return $this->sendError($e->getMessage(), 400);
1487 }
1488 }
1489
1490 /**
1491 * Get task tabs configuration
1492 */
1493 public function getTaskTabsConfig()
1494 {
1495 $default_config = [
1496 [
1497 'name' => 'due_today',
1498 'label' => __('Due Today', 'fluent-boards'),
1499 'visible' => 'true',
1500 'order' => 1
1501 ],
1502 [
1503 'name' => 'assigned',
1504 'label' => __('Assigned', 'fluent-boards'),
1505 'visible' => 'true',
1506 'order' => 2
1507 ],
1508 [
1509 'name' => 'upcoming',
1510 'label' => __('Upcoming', 'fluent-boards'),
1511 'visible' => 'true',
1512 'order' => 3
1513 ],
1514 [
1515 'name' => 'overdue',
1516 'label' => __('Overdue', 'fluent-boards'),
1517 'visible' => 'true',
1518 'order' => 4
1519 ],
1520 [
1521 'name' => 'mentioned',
1522 'label' => __('Mentioned', 'fluent-boards'),
1523 'visible' => 'true',
1524 'order' => 5
1525 ],
1526 [
1527 'name' => 'completed',
1528 'label' => __('Completed', 'fluent-boards'),
1529 'visible' => 'true',
1530 'order' => 6
1531 ],
1532 [
1533 'name' => 'others',
1534 'label' => __('Others', 'fluent-boards'),
1535 'visible' => 'true',
1536 'order' => 7
1537 ]
1538 ];
1539 $availableTabNames = array_column($default_config, 'name');
1540
1541 $existConfig = Meta::where('object_id', get_current_user_id())->where('key', Constant::FBS_TASK_TABS_CONFIG)->first();
1542 $config = $default_config;
1543
1544 if ($existConfig && !empty($existConfig->value)) {
1545 $storedConfig = $existConfig->value;
1546 $configChanged = false;
1547 $config = $storedConfig;
1548 $config = array_values(array_filter($config, fn($tab) => in_array($tab['name'] ?? '', $availableTabNames, true)));
1549 $configChanged = count($config) !== count($storedConfig);
1550
1551 if (empty($config)) {
1552 $config = $default_config;
1553 $configChanged = true;
1554 }
1555
1556 $existingNames = array_column($config, 'name');
1557 $missingTabs = [];
1558 foreach ($default_config as $defaultTab) {
1559 if (!in_array($defaultTab['name'], $existingNames)) {
1560 $missingTabs[] = $defaultTab;
1561 }
1562 }
1563
1564 if (!empty($missingTabs)) {
1565 $newConfig = [];
1566 $order = 1;
1567 $addedDueToday = false;
1568 $addedAssigned = false;
1569 foreach ($config as $tab) {
1570 if (!$addedDueToday) {
1571 $dueTodayTab = array_filter($missingTabs, fn($t) => $t['name'] === 'due_today');
1572 if (!empty($dueTodayTab)) {
1573 $dueTodayTab = reset($dueTodayTab);
1574 $dueTodayTab['order'] = $order++;
1575 $newConfig[] = $dueTodayTab;
1576 $addedDueToday = true;
1577 }
1578 }
1579
1580 if ($tab['name'] === 'upcoming' && !$addedAssigned) {
1581 $assignedTab = array_filter($missingTabs, fn($t) => $t['name'] === 'assigned');
1582 if (!empty($assignedTab)) {
1583 $assignedTab = reset($assignedTab);
1584 $assignedTab['order'] = $order++;
1585 $newConfig[] = $assignedTab;
1586 $addedAssigned = true;
1587 }
1588 }
1589 $tab['order'] = $order++;
1590 $newConfig[] = $tab;
1591 }
1592 foreach ($missingTabs as $missingTab) {
1593 if (!in_array($missingTab['name'], ['assigned', 'due_today'], true)) {
1594 $missingTab['order'] = $order++;
1595 $newConfig[] = $missingTab;
1596 }
1597 }
1598 $config = $newConfig;
1599 $configChanged = true;
1600 }
1601
1602 if ($configChanged) {
1603 $existConfig->value = $config;
1604 $existConfig->save();
1605 }
1606 }
1607
1608 // Always apply fresh translations based on tab name
1609 $labelMap = [
1610 'due_today' => __('Due Today', 'fluent-boards'),
1611 'assigned' => __('Assigned', 'fluent-boards'),
1612 'upcoming' => __('Upcoming', 'fluent-boards'),
1613 'overdue' => __('Overdue', 'fluent-boards'),
1614 'mentioned' => __('Mentioned', 'fluent-boards'),
1615 'completed' => __('Completed', 'fluent-boards'),
1616 'others' => __('Others', 'fluent-boards'),
1617 ];
1618
1619 foreach ($config as &$tab) {
1620 if (isset($labelMap[$tab['name']])) {
1621 $tab['label'] = $labelMap[$tab['name']];
1622 }
1623 }
1624
1625 return $this->sendSuccess([
1626 'data' => $config
1627 ]);
1628 }
1629
1630 /**
1631 * Save task tabs configuration
1632 */
1633 public function saveTaskTabsConfig(Request $request)
1634 {
1635 $rawConfig = $request->getSafe('tabs');
1636
1637 if (empty($rawConfig) || !is_array($rawConfig)) {
1638 return $this->sendError([
1639 'message' => __('Invalid data format', 'fluent-boards')
1640 ], 400);
1641 }
1642
1643 // Sanitize config array
1644 $config = [];
1645 foreach ($rawConfig as $tab) {
1646 if (!is_array($tab)) {
1647 continue;
1648 }
1649 $sanitizedTab = [
1650 'name' => isset($tab['name']) ? sanitize_text_field($tab['name']) : '',
1651 'label' => isset($tab['label']) ? sanitize_text_field($tab['label']) : '',
1652 'visible' => isset($tab['visible']) ? sanitize_text_field($tab['visible']) : 'false',
1653 'order' => isset($tab['order']) ? absint($tab['order']) : 0,
1654 ];
1655 $config[] = $sanitizedTab;
1656 }
1657
1658 if (count(array_filter($config, fn($tab) => $tab['visible'] == 'true')) == 0) {
1659 return $this->sendError([
1660 'message' => __('At least one tab must be visible', 'fluent-boards')
1661 ], 400);
1662 }
1663
1664 $userId = get_current_user_id();
1665
1666 $exit = Meta::where('object_id', $userId)->where('key', 'fbs_task_tabs_config')->first();
1667
1668 if ($exit) {
1669 $exit->value = $config;
1670 $exit->save();
1671 } else {
1672 $exit = Meta::create([
1673 'object_id' => $userId,
1674 'object_type' => 'option',
1675 'key' => Constant::FBS_TASK_TABS_CONFIG,
1676 'value' => $config
1677 ]);
1678 }
1679 $config = $exit->value;
1680
1681 return $this->sendSuccess([
1682 'message' => __('Configuration saved successfully', 'fluent-boards'),
1683 'config' => $config
1684 ]);
1685 }
1686 public function getAssociatedCrmContacts($board_id)
1687 {
1688 $board_id = absint($board_id);
1689 $contactsInTasks = Task::where('board_id', $board_id)
1690 ->whereNotNull('crm_contact_id')
1691 ->get();
1692
1693 if ($contactsInTasks->isEmpty()) {
1694 return $this->sendSuccess([]);
1695 }
1696
1697 $contactIds = $contactsInTasks->pluck('crm_contact_id')
1698 ->unique()
1699 ->toArray();
1700
1701 $allContacts = Subscriber::whereIn('id', $contactIds)->get();
1702
1703 if ($allContacts->isEmpty()) {
1704 return $this->sendSuccess([]);
1705 }
1706
1707 $formattedContacts = [];
1708 foreach ($allContacts as $contact) {
1709 $name = trim($contact->first_name . ' ' . $contact->last_name);
1710
1711 $formattedContacts[] = [
1712 'id' => $contact->id,
1713 'display_name' => $name,
1714 'email' => $contact->email,
1715 'photo' => fluent_boards_user_avatar($contact->user_email, $name),
1716 ];
1717 }
1718 if (!empty($formattedContacts)) {
1719 usort($formattedContacts, function ($a, $b) {
1720 return strcmp($a['display_name'], $b['display_name']);
1721 });
1722 }
1723
1724 return $this->sendSuccess($formattedContacts);
1725 }
1726
1727 public function cloneTask(Request $request, $board_id, $task_id)
1728 {
1729 $board_id = absint($board_id);
1730 $task_id = absint($task_id);
1731 $taskData = $this->taskSanitizeAndValidate($request->only(['title', 'stage_id', 'assignee', 'subtask', 'label', 'attachment', 'comment']), [
1732 'title' => 'required|string',
1733 'stage_id' => 'required|numeric',
1734 'assignee' => 'required',
1735 'subtask' => 'required',
1736 'label' => 'required',
1737 'attachment' => 'required',
1738 'comment' => 'required',
1739 ]);
1740 try {
1741 $taskData = fluent_boards_string_to_bool($taskData);
1742 $clonedTask = $this->taskService->cloneTask($task_id, $taskData, $board_id);
1743
1744 return $this->sendSuccess([
1745 'message' => __('Task has been cloned successfully', 'fluent-boards'),
1746 'task' => $clonedTask,
1747 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($clonedTask->board_id)
1748 ], 200);
1749 } catch (\Exception $e) {
1750 return $this->sendError($e->getMessage(), 400);
1751 }
1752 }
1753
1754 public function bulkActions(Request $request, $board_id)
1755 {
1756 $board_id = absint($board_id);
1757 try {
1758 $rawTaskIds = $request->getSafe('task_ids');
1759 // Sanitize task_ids array to integers
1760 $taskIds = [];
1761 if (is_array($rawTaskIds)) {
1762 $taskIds = array_filter(array_map('intval', $rawTaskIds));
1763 }
1764 $action = $request->getSafe('action', 'sanitize_text_field');
1765 // Sanitize params array
1766 $rawParams = $request->except(['task_ids', 'action']);
1767 // Ensure rawParams is sanitized
1768 if (!is_array($rawParams)) {
1769 $rawParams = [];
1770 }
1771 $params = [];
1772 foreach ($rawParams as $key => $value) {
1773 $sanitizedKey = sanitize_text_field($key);
1774 if (is_array($value)) {
1775 $params[$sanitizedKey] = array_map('sanitize_text_field', $value);
1776 } else {
1777 $params[$sanitizedKey] = sanitize_text_field($value);
1778 }
1779 }
1780
1781 $result = $this->taskService->bulkActions($taskIds, $action, $params, $board_id);
1782
1783 // Process successful tasks the same way as getTasksByBoard
1784 if (!empty($result['successful_tasks'])) {
1785 $board = Board::findOrFail($board_id);
1786 $this->processTasks($result['successful_tasks'], $board);
1787 }
1788
1789 return $this->sendSuccess($result);
1790
1791 } catch (\Exception $e) {
1792 return $this->sendError($e->getMessage(), 500);
1793 }
1794 }
1795 }
1796