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

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