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

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

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