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
← All changes | app/Http/Controllers/TaskController.php +160 -27 1.95.3 → 2.2.0 View file →
@@ -1,8 +1,9 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Http\Controllers;
4 4
5 +use FluentBoards\Framework\Database\Orm\ModelNotFoundException;
5 6 use FluentBoards\App\Models\Meta;
6 7 use FluentBoards\App\Models\Stage;
7 8 use FluentBoards\App\Models\Task;
8 9 use FluentBoards\App\Models\Board;
@@ -37,10 +38,25 @@
37 38 public function getTopTasksForBoards()
38 39 {
39 40 $userId = get_current_user_id();
40 41 $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);
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();
43 59
44 60 return [
45 61 'data' => $tasksArray,
46 62 'counts' => $taskCounts,
@@ -46,10 +62,12 @@
46 62 'counts' => $taskCounts,
47 63 ];
48 64 }
49 65
66 + /** Return all board tasks with a cursor captured before reading their state. */
50 67 public function getTasksByBoard(Request $request, $board_id)
51 68 {
69 + $syncStartedAt = current_time('mysql');
52 70 $board_id = absint($board_id);
53 71 $board = Board::findOrFail($board_id);
54 72 $includeArchived = $request->getSafe('include_archived', 'boolval', false);
55 73
@@ -72,20 +90,21 @@
72 90 // Process each task
73 91 $this->processTasks($tasks, $board);
74 92
75 93 if ($board->type === 'roadmap') {
76 - foreach ($tasks as $task) {
77 - $task->vote_statistics = $this->taskService->getIdeaVoteStatistics($task->id);
78 - }
94 + $this->taskService->loadIdeaVoteStatistics($tasks);
79 95 }
80 96
81 97 return [
82 98 'tasks' => $tasks,
99 + 'synced_at' => $syncStartedAt,
83 100 ];
84 101 }
85 102
103 + /** Fetch each stage's first page, then batch metadata for the combined card list. */
86 104 public function getTasksByBoardStage(Request $request, $board_id)
87 105 {
106 + $syncStartedAt = current_time('mysql');
88 107 $board_id = absint($board_id);
89 108 $board = Board::findOrFail($board_id);
90 109 $includeArchived = $request->getSafe('include_archived', 'boolval', false);
91 110
@@ -103,15 +122,9 @@
103 122 ->orderBy('position', 'ASC')
104 123 ->limit(20)
105 124 ->get();
106 125
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
126 + $tasks = array_merge($tasks, $stageTasks->all());
114 127
115 128 $startCursor = $stageTasks->count() ? (float) $stageTasks->first()->position : null;
116 129 $endCursor = $stageTasks->count() ? (float) $stageTasks->last()->position : null;
117 130 $loadedCount = $stageTasks->count();
@@ -130,14 +143,22 @@
130 143 'end_cursor' => $endCursor,
131 144 ];
132 145 }
133 146
147 + $this->processTasks($tasks, $board, [
148 + 'includeContact' => false,
149 + 'includeObserverState' => false,
150 + 'includeRoadmapPopularity' => false,
151 + ]);
152 +
134 153 return [
135 - 'tasks' => $tasks,
154 + 'tasks' => (new Task())->newCollection($tasks)->toArray(),
136 155 'pagination_by_stage' => $paginationByStage,
156 + 'synced_at' => $syncStartedAt,
137 157 ];
138 158 }
139 159
160 + /** Return paginated tasks matching the board filters, including recurrence and templates. */
140 161 public function getTableTasks(Request $request, $board_id)
141 162 {
142 163 $board_id = absint($board_id);
143 164 $board = Board::findOrFail($board_id);
@@ -149,8 +170,10 @@
149 170 'search' => $request->getSafe('search', 'sanitize_text_field', ''),
150 171 'include_archived' => $request->getSafe('include_archived', 'boolval', false),
151 172 'stage' => $request->get('stage', []),
152 173 'task_status' => $request->get('task_status', []),
174 + 'recurring' => $request->get('recurring', []),
175 + 'template' => $request->get('template', []),
153 176 'priority' => $request->get('priority', []),
154 177 'assignee' => $request->get('assignee', []),
155 178 'labels' => $request->get('labels', []),
156 179 'watchers' => $request->get('watchers', []),
@@ -177,8 +200,9 @@
177 200 ],
178 201 ], 200);
179 202 }
180 203
204 + /** Return board tasks matching the requested filters, including recurrence and templates. */
181 205 public function getFilteredBoardTasks(Request $request, $board_id)
182 206 {
183 207 $board_id = absint($board_id);
184 208 $board = Board::findOrFail($board_id);
@@ -186,8 +210,10 @@
186 210 'search' => $request->getSafe('search', 'sanitize_text_field', ''),
187 211 'include_archived' => $request->getSafe('include_archived', 'boolval', false),
188 212 'stage' => $request->get('stage', []),
189 213 'task_status' => $request->get('task_status', []),
214 + 'recurring' => $request->get('recurring', []),
215 + 'template' => $request->get('template', []),
190 216 'priority' => $request->get('priority', []),
191 217 'assignee' => $request->get('assignee', []),
192 218 'labels' => $request->get('labels', []),
193 219 'watchers' => $request->get('watchers', []),
@@ -395,8 +421,10 @@
395 421 * @param array $options
396 422 */
397 423 private function processTasks($tasks, $board, $options = [])
398 424 {
425 + $this->taskService->loadTaskListMetadata($tasks);
426 +
399 427 $includeContact = Arr::get($options, 'includeContact', true);
400 428 $includeObserverState = Arr::get($options, 'includeObserverState', true);
401 429 $includeRoadmapPopularity = Arr::get($options, 'includeRoadmapPopularity', true);
402 430 $taskIds = [];
@@ -426,13 +454,32 @@
426 454 }
427 455 }
428 456 }
429 457
430 -
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 + */
431 465 public function create(Request $request, $board_id)
432 466 {
433 467 $board_id = absint($board_id);
434 - $taskData = $this->taskSanitizeAndValidate($request->getSafe('task'), [
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, [
435 482 'title' => 'required|string',
436 483 'board_id' => 'required|numeric',
437 484 'stage_id' => 'required|numeric',
438 485 'priority' => 'nullable|string',
@@ -440,17 +487,28 @@
440 487 'is_template' => 'string',
441 488 ]);
442 489
443 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 +
444 499 if ($taskData['board_id'] != $board_id) {
445 500 throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
446 501 }
447 502
448 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');
449 507
450 508 return $this->sendSuccess([
451 509 'task' => $task,
452 - 'message' => __('Task has been successfully created', 'fluent-boards'),
510 + 'message' => $message,
453 511 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
454 512 ], 201);
455 513 } catch (\Exception $e) {
456 514 return $this->sendError($e->getMessage(), 400);
@@ -481,8 +539,9 @@
481 539
482 540 $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
483 541
484 542 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
543 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
485 544
486 545 $task->isOverdue = $task->isOverdue();
487 546 $task->contact = Task::lead_contact($task->crm_contact_id);
488 547 $task->board->stages = $stageService->stagesByBoardId($board_id);
@@ -497,8 +556,10 @@
497 556 return [
498 557 'task' => $task
499 558 ];
500 559
560 + } catch (ModelNotFoundException $e) {
561 + throw $e;
501 562 } catch (\Exception $e ) {
502 563 return $this->sendError($e->getMessage(), 400);
503 564 }
504 565
@@ -710,9 +771,9 @@
710 771 $task_id = absint($task_id);
711 772 //Properties in col: settings, assignees,crm_contact_id, archived_at(AUTO_SET_TIMESTAMP) , status, title, description, priority, is_watching, is_template
712 773 $col = $request->getSafe('property', 'sanitize_text_field');
713 774 if ($col === 'description') {
714 - $value = $request->getSafe('value', 'wp_kses_post');
775 + $value = $request->getSafe('value', 'fluent_boards_sanitize_description');
715 776 } elseif ($col === 'settings' || $col === 'assignees') {
716 777 $value = $request->get('value');
717 778 if (is_array($value) && isset($value['cover']) && is_array($value['cover'])) {
718 779 if (isset($value['cover']['backgroundColor'])) {
@@ -718,8 +779,19 @@
718 779 if (isset($value['cover']['backgroundColor'])) {
719 780 $value['cover']['backgroundColor'] = sanitize_text_field($value['cover']['backgroundColor']);
720 781 }
721 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 + }
722 794 } else {
723 795 $value = $request->getSafe('value', 'sanitize_text_field');
724 796 }
725 797
@@ -738,8 +810,12 @@
738 810 if ($col === 'parent_id' && $validatedData[$col]) {
739 811 $this->taskService->findTaskOnBoard($validatedData[$col], $board_id, false);
740 812 }
741 813
814 + if ($task->parent_id && $col === 'started_at') {
815 + $validatedData[$col] = null;
816 + }
817 +
742 818 $oldDateValue = null;
743 819 if (in_array($col, ['due_at', 'started_at'])) {
744 820 $oldDateValue = $task->{$col};
745 821 }
@@ -755,8 +831,13 @@
755 831 $task->contact = Helper::crm_contact($task->crm_contact_id);
756 832 $task->is_watching = $task->isWatching();
757 833 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
758 834
835 + if ($col === 'is_watching') {
836 + $task->load('watchers');
837 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
838 + }
839 +
759 840 if ($task->parent_id) {
760 841 $task->subtask_group_id = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_CHILD)->value('value');
761 842 }
762 843
@@ -781,8 +862,28 @@
781 862 'updatedTasks' => $updatedTasks
782 863 ];
783 864 }
784 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 +
785 886 public function updateTaskDates(Request $request, $board_id, $task_id)
786 887 {
787 888 $board_id = absint($board_id);
788 889 $task_id = absint($task_id);
@@ -803,10 +904,16 @@
803 904 $hasRemindAt = array_key_exists('remind_at', $payload);
804 905
805 906 $startAt = $hasStartAt ? $request->getSafe('started_at', 'sanitize_text_field', NULL) : $task->started_at;
806 907 $dueAt = $hasDueAt ? $request->getSafe('due_at', 'sanitize_text_field', NULL) : $task->due_at;
908 + $isSubtask = (bool) $task->parent_id;
807 909
808 - if ($hasStartAt && $hasDueAt && $startAt && $dueAt) {
910 + if ($isSubtask) {
911 + $startAt = null;
912 + $hasStartAt = $hasStartAt || (bool) $task->started_at;
913 + }
914 +
915 + if (!$isSubtask && $hasStartAt && $hasDueAt && $startAt && $dueAt) {
809 916 if (strtotime($startAt) > strtotime($dueAt)) {
810 917 $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
811 918 }
812 919 }
@@ -1031,8 +1138,11 @@
1031 1138 }
1032 1139
1033 1140 return [$col => $sanitizedAndValidatedValue];
1034 1141 }
1142 + if ('is_watching' == $col && is_array($value)) {
1143 + return [$col => $value];
1144 + }
1035 1145 $data = Helper::sanitizeTask([$col => $value]);
1036 1146
1037 1147 return $this->validate($data, [
1038 1148 $col => $rule,
@@ -1217,9 +1327,10 @@
1217 1327 // Pagination parameters
1218 1328 $page = $request->getSafe('page', 'intval', 1);
1219 1329 $perPage = $request->getSafe('per_page', 'intval', 10);
1220 1330 $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);
1331 + $feedType = $request->getSafe('feed_type', 'sanitize_text_field', 'all');
1332 + $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter, $board_id, $feedType);
1222 1333 // Return the response with the task, paginated comments and activities, total count, current page, and items per page
1223 1334 return $this->sendSuccess([
1224 1335 'comments_and_activities' => $commentsAndActivities,
1225 1336 ]);
@@ -1317,12 +1428,16 @@
1317 1428 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1318 1429
1319 1430 $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1320 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 +
1321 1436 return $this->sendSuccess([
1322 1437 'task' => $task,
1323 1438 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
1324 - 'message' => __('Task has been created', 'fluent-boards'),
1439 + 'message' => $message,
1325 1440 ], 200);
1326 1441
1327 1442 }
1328 1443
@@ -1395,42 +1510,48 @@
1395 1510 public function getTaskTabsConfig()
1396 1511 {
1397 1512 $default_config = [
1398 1513 [
1514 + 'name' => 'due_today',
1515 + 'label' => __('Due Today', 'fluent-boards'),
1516 + 'visible' => 'true',
1517 + 'order' => 1
1518 + ],
1519 + [
1399 1520 'name' => 'assigned',
1400 1521 'label' => __('Assigned', 'fluent-boards'),
1401 1522 'visible' => 'true',
1402 - 'order' => 1
1523 + 'order' => 2
1403 1524 ],
1404 1525 [
1405 1526 'name' => 'upcoming',
1406 1527 'label' => __('Upcoming', 'fluent-boards'),
1407 1528 'visible' => 'true',
1408 - 'order' => 2
1529 + 'order' => 3
1409 1530 ],
1410 1531 [
1411 1532 'name' => 'overdue',
1412 1533 'label' => __('Overdue', 'fluent-boards'),
1413 1534 'visible' => 'true',
1414 - 'order' => 3
1535 + 'order' => 4
1415 1536 ],
1416 1537 [
1417 1538 'name' => 'mentioned',
1418 1539 'label' => __('Mentioned', 'fluent-boards'),
1419 1540 'visible' => 'true',
1420 - 'order' => 4
1541 + 'order' => 5
1421 1542 ],
1422 1543 [
1423 1544 'name' => 'completed',
1424 1545 'label' => __('Completed', 'fluent-boards'),
1425 1546 'visible' => 'true',
1426 - 'order' => 5
1547 + 'order' => 6
1427 1548 ],
1428 1549 [
1429 1550 'name' => 'others',
1430 1551 'label' => __('Others', 'fluent-boards'),
1431 1552 'visible' => 'true',
1432 - 'order' => 6
1553 + 'order' => 7
1433 1554 ]
1434 1555 ];
1435 1556 $availableTabNames = array_column($default_config, 'name');
1436 1557
@@ -1459,10 +1580,21 @@
1459 1580
1460 1581 if (!empty($missingTabs)) {
1461 1582 $newConfig = [];
1462 1583 $order = 1;
1584 + $addedDueToday = false;
1463 1585 $addedAssigned = false;
1464 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 +
1465 1597 if ($tab['name'] === 'upcoming' && !$addedAssigned) {
1466 1598 $assignedTab = array_filter($missingTabs, fn($t) => $t['name'] === 'assigned');
1467 1599 if (!empty($assignedTab)) {
1468 1600 $assignedTab = reset($assignedTab);
@@ -1474,9 +1606,9 @@
1474 1606 $tab['order'] = $order++;
1475 1607 $newConfig[] = $tab;
1476 1608 }
1477 1609 foreach ($missingTabs as $missingTab) {
1478 - if ($missingTab['name'] !== 'assigned') {
1610 + if (!in_array($missingTab['name'], ['assigned', 'due_today'], true)) {
1479 1611 $missingTab['order'] = $order++;
1480 1612 $newConfig[] = $missingTab;
1481 1613 }
1482 1614 }
@@ -1491,8 +1623,9 @@
1491 1623 }
1492 1624
1493 1625 // Always apply fresh translations based on tab name
1494 1626 $labelMap = [
1627 + 'due_today' => __('Due Today', 'fluent-boards'),
1495 1628 'assigned' => __('Assigned', 'fluent-boards'),
1496 1629 'upcoming' => __('Upcoming', 'fluent-boards'),
1497 1630 'overdue' => __('Overdue', 'fluent-boards'),
1498 1631 'mentioned' => __('Mentioned', 'fluent-boards'),