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/Services/TaskService.php +309 -104 2.1.0 → 2.2.0 View file →
@@ -3,8 +3,9 @@
3 3 namespace FluentBoards\App\Services;
4 4
5 5 use FluentBoards\Framework\Database\Orm\ModelNotFoundException;
6 6 use FluentBoards\App\App;
7 +use FluentBoards\App\Hooks\Handlers\FileHandler;
7 8 use FluentBoards\App\Models\Attachment;
8 9 use FluentBoards\App\Models\Comment;
9 10 use FluentBoards\App\Models\Notification;
10 11 use FluentBoards\App\Models\NotificationUser;
@@ -20,8 +21,10 @@
20 21 use FluentBoards\App\Models\CommentImage;
21 22 use FluentBoards\App\Models\Relation;
22 23 use FluentBoards\Framework\Support\Arr;
23 24 use FluentBoardsPro\App\Models\TaskAttachment;
25 +use FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack;
26 +use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper;
24 27 use FluentBoardsPro\App\Services\AttachmentService;
25 28 use FluentBoardsPro\App\Services\RemoteUrlParser;
26 29 use FluentRoadmap\App\Models\IdeaReaction;
27 30
@@ -28,9 +31,65 @@
28 31 class TaskService
29 32 {
30 33 private static $physicalTableNameCache = [];
31 34
35 + /** Load appended task metadata in two queries without exposing internal relation rows. */
36 + public function loadTaskListMetadata($tasks)
37 + {
38 + $items = [];
39 + foreach ($tasks as $task) {
40 + $items[] = $task;
41 + }
42 +
43 + $collection = (new Task())->newCollection($items);
44 + $collection->load(['taskMeta', 'repeatTaskMeta']);
45 +
46 + foreach ($collection as $task) {
47 + // Keep the existing meta/repeat_task_meta API fields, not duplicate raw relations.
48 + $task->makeHidden(['taskMeta', 'repeatTaskMeta']);
49 + }
50 + }
51 +
32 52 /**
53 + * Preserve the original CRM template copy when Pro lacks automation-context support.
54 + * Copies parent data, assignees and labels; full related-data copying requires updated Pro.
55 + * Context is provided by the automation handler, not directly from an HTTP request.
56 + */
57 + public function createAutomationTaskFromLegacyTemplate(Task $template, array $data, array $context): Task
58 + {
59 + $stage = Stage::where('id', absint($data['stage_id']))
60 + ->where('board_id', absint($data['board_id']))->firstOrFail();
61 + $task = new Task();
62 + $task->fill($template->toArray());
63 + $task->title = sanitize_text_field($data['title']);
64 + $task->board_id = $stage->board_id;
65 + $task->stage_id = $stage->id;
66 + $task->parent_id = null;
67 + $task->created_by = absint($context['created_by']);
68 + $task->crm_contact_id = absint($context['crm_contact_id']);
69 + $task->source = sanitize_key($context['source']);
70 + $task->comments_count = 0;
71 + $settings = $task->settings ?: [];
72 + $settings['subtask_count'] = 0;
73 + $settings['subtask_completed_count'] = 0;
74 + $task->settings = $settings;
75 + $task->moveToNewPosition(1);
76 + $task->save();
77 +
78 + $assigneeIds = array_key_exists('assignees', $context)
79 + ? $context['assignees']
80 + : $template->assignees()->get()->pluck('ID')->toArray();
81 + foreach (array_unique(array_filter(array_map('absint', (array) $assigneeIds))) as $assigneeId) {
82 + $this->updateAssignee($assigneeId, $task);
83 + }
84 + foreach ($template->labels as $label) {
85 + $task->labels()->syncWithoutDetaching([$label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]]);
86 + }
87 +
88 + return $task;
89 + }
90 +
91 + /**
33 92 * Resolve a task only when it belongs to the requested board.
34 93 *
35 94 * Subtasks normally carry the same board_id as their parent, but the parent
36 95 * fallback protects older data where that relationship may be incomplete.
@@ -83,8 +142,11 @@
83 142
84 143 return $task;
85 144 }
86 145
146 + /**
147 + * Create a board task and notify eligible selected assignees before applying stage defaults.
148 + */
87 149 public function createTask($data, $boardId)
88 150 {
89 151 $board = Board::select('id', 'type')->find($boardId);
90 152
@@ -141,8 +203,18 @@
141 203
142 204 $data = array_filter($data);
143 205 $task = (new Task())->createTask($data);
144 206
207 + // Selected assignees are already attached; toggling them via updateAssignee would remove them.
208 + $task->load('assignees');
209 + $currentUserId = get_current_user_id();
210 + $assigneeIds = array_diff($task->assignees->pluck('ID')->all(), [$currentUserId]);
211 + $notificationService = new NotificationService();
212 + $recipientIds = $notificationService->getEmailEnabledUserIds($task->board_id, $assigneeIds, Constant::BOARD_EMAIL_TASK_ASSIGN);
213 + foreach ($recipientIds as $recipientId) {
214 + $this->sendMailAfterTaskModify('add_assignee', $recipientId, $task->id);
215 + }
216 +
145 217 $this->manageDefaultAssignees($task, $stage->id);
146 218 $this->manageDefaultWatchers($task, $stage->id);
147 219
148 220 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
@@ -616,10 +688,12 @@
616 688 }
617 689 $task->archived_at = $value == null ? null : current_time('mysql');
618 690 $task->save();
619 691 do_action('fluent_boards/task_archived', $task);
620 - $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
621 - $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
692 + if ($value != null) {
693 + $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
694 + $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
695 + }
622 696 }
623 697
624 698 private function updateStatus($value, $task)
625 699 {
@@ -865,8 +939,9 @@
865 939 NotificationUser::whereIn('notification_id', $notificationIds)->delete();
866 940 Notification::whereIn('task_id', $taskIds)->delete();
867 941
868 942 $this->deleteTaskAttachmentsBatch($taskIds, $taskBoardIds);
943 + $this->deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance);
869 944 Activity::whereIn('object_id', $taskIds)
870 945 ->where('object_type', Constant::ACTIVITY_TASK)
871 946 ->delete();
872 947 Meta::whereIn('object_id', $taskIds)
@@ -893,8 +968,68 @@
893 968 throw $e;
894 969 }
895 970 }
896 971
972 + /** Remove comment rows transactionally; file cleanup must wait for commit. */
973 + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance)
974 + {
975 + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray();
976 + if (!$commentTasks) {
977 + return;
978 + }
979 +
980 + $commentIds = array_keys($commentTasks);
981 + $images = CommentImage::where('object_type', Constant::COMMENT_IMAGE)
982 + ->whereIn('object_id', $commentIds)->get();
983 + CommentImage::where('object_type', Constant::COMMENT_IMAGE)
984 + ->whereIn('id', $images->pluck('id')->toArray())->delete();
985 + // Bulk deletion bypasses model file hooks and counters for the deleted task.
986 + Comment::whereIn('id', $commentIds)->delete();
987 +
988 + $dbInstance->afterCommit(function () use ($images, $commentTasks, $taskBoardIds) {
989 + $fileHandler = new FileHandler();
990 + $remoteFiles = [];
991 + $localImages = $images->filter(function ($image) {
992 + return $image->attachment_type !== 'url' && $image->file_path && (!$image->driver || $image->driver === 'local');
993 + });
994 + try {
995 + $sharedReferences = $fileHandler->getSharedLocalReferences($localImages);
996 + } catch (\Throwable $e) {
997 + // Null falls back to per-file reference checks.
998 + $sharedReferences = null;
999 + }
1000 + foreach ($images as $image) {
1001 + if ($image->attachment_type === 'url' || !$image->file_path) {
1002 + continue;
1003 + }
1004 + if ($image->driver && $image->driver !== 'local') {
1005 + $remoteFiles[] = ['file_path' => $image->file_path, 'driver' => $image->driver];
1006 + continue;
1007 + }
1008 +
1009 + try {
1010 + $boardId = $taskBoardIds[$commentTasks[$image->object_id]];
1011 + $fileHandler->deleteAttachmentFile($image, $boardId, $sharedReferences);
1012 + } catch (\Throwable $e) {
1013 + error_log('FluentBoards: Failed to delete committed comment image ' . (int) $image->id . ': ' . sanitize_text_field($e->getMessage()));
1014 + }
1015 + }
1016 +
1017 + // Use the existing remote cleanup worker, which rechecks shared references.
1018 + foreach (array_chunk($remoteFiles, 50) as $batch) {
1019 + try {
1020 + if (function_exists('as_enqueue_async_action')) {
1021 + as_enqueue_async_action('fluent_boards/delete_remote_attachment_files', [$batch], 'fluent-boards');
1022 + } else {
1023 + $fileHandler->deleteRemoteAttachmentFiles($batch);
1024 + }
1025 + } catch (\Throwable $e) {
1026 + error_log('FluentBoards: Failed to queue committed comment image cleanup: ' . sanitize_text_field($e->getMessage()));
1027 + }
1028 + }
1029 + });
1030 + }
1031 +
897 1032 /**
898 1033 * Dispatch task deletion hooks after the outermost transaction commits.
899 1034 *
900 1035 * @param mixed $dbInstance
@@ -917,47 +1052,17 @@
917 1052 }
918 1053 });
919 1054 }
920 1055
1056 + /** Keep legacy bulk callers on the normal task deletion lifecycle. */
921 1057 public function deleteTaskForBulk($task)
922 1058 {
923 - // If this is a parent task, delete all subtasks first
924 - if (!$task->parent_id) {
925 - $subtasks = Task::where('parent_id', $task->id)->get();
926 - foreach ($subtasks as $subtask) {
927 - // Recursively delete each subtask (cleans up all their relations)
928 - $this->deleteTaskForBulk($subtask);
929 - }
1059 + // A selected subtask may already have been deleted with its parent.
1060 + if (!Task::where('id', $task->id)->exists()) {
1061 + return;
930 1062 }
931 1063
932 - $this->deleteTimeTrackingRecords($task->id, false);
933 -
934 - $deleted = $task->delete();
935 -
936 - if ($deleted) {
937 -
938 - //task assignees watchers removed
939 - $task->watchers()->detach();
940 - $task->assignees()->detach();
941 -
942 - //removing all task related notifications
943 - $notificationIds = $task->notifications->pluck('id');
944 - $task->notifications()->delete();
945 - NotificationUser::whereIn('notification_id', $notificationIds)->delete();
946 -
947 - //task labels removed
948 - $task->labels()->detach();
949 -
950 - //task custom field value
951 - if (defined('FLUENT_BOARDS_PRO_VERSION')) {
952 - $task->customFields()->detach();
953 - $this->deleteTaskAttachments($task);
954 - }
955 -
956 - // For bulk delete, you might want to avoid firing hooks/actions,
957 - // so 'fluent_boards/task_deleted' is not triggered here.
958 - TaskMeta::where('task_id', $task->id)->delete();
959 - }
1064 + $this->deleteTask($task);
960 1065 }
961 1066 // this is invoked when task is moved to another board
962 1067
963 1068 /**
@@ -1395,8 +1500,9 @@
1395 1500
1396 1501 return $tasks;
1397 1502 }
1398 1503
1504 + /** Fetch a page of board tasks, applying filters before pagination. */
1399 1505 public function getTableTasks($boardId, $data = [])
1400 1506 {
1401 1507 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20;
1402 1508 $page = isset($data['page']) ? intval($data['page']) : 1;
@@ -1404,8 +1510,10 @@
1404 1510 $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc';
1405 1511 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1406 1512 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1407 1513 $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', []));
1514 + $recurringFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'recurring', []));
1515 + $templateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'template', []));
1408 1516 $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1409 1517 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1410 1518 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1411 1519 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
@@ -1442,8 +1550,9 @@
1442 1550 'title',
1443 1551 'slug',
1444 1552 'board_id',
1445 1553 'parent_id',
1554 + 'crm_contact_id',
1446 1555 'type',
1447 1556 'stage_id',
1448 1557 'status',
1449 1558 'priority',
@@ -1493,8 +1602,10 @@
1493 1602 $this->applyTableTaskSearch($tasksQuery, $search);
1494 1603 $this->applyTableTaskFilters($tasksQuery, [
1495 1604 'stage' => $stageFilters,
1496 1605 'task_status' => $taskStatusFilters,
1606 + 'recurring' => $recurringFilters,
1607 + 'template' => $templateFilters,
1497 1608 'priority' => $priorityFilters,
1498 1609 'assignee' => $assigneeFilters,
1499 1610 'labels' => $labelFilters,
1500 1611 'watchers' => $watcherFilters,
@@ -1511,13 +1622,16 @@
1511 1622 ->orderBy($sortColumn, $sortDirection)
1512 1623 ->paginate($perPage, ['*'], 'page', $page);
1513 1624 }
1514 1625
1626 + /** Fetch matching board cards with the same filters as table view. */
1515 1627 public function getBoardViewTasks($boardId, $data = [])
1516 1628 {
1517 1629 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1518 1630 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1519 1631 $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', []));
1632 + $recurringFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'recurring', []));
1633 + $templateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'template', []));
1520 1634 $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1521 1635 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1522 1636 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1523 1637 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
@@ -1565,8 +1679,10 @@
1565 1679 $this->applyTableTaskSearch($tasksQuery, $search);
1566 1680 $this->applyTableTaskFilters($tasksQuery, [
1567 1681 'stage' => $stageFilters,
1568 1682 'task_status' => $taskStatusFilters,
1683 + 'recurring' => $recurringFilters,
1684 + 'template' => $templateFilters,
1569 1685 'priority' => $priorityFilters,
1570 1686 'assignee' => $assigneeFilters,
1571 1687 'labels' => $labelFilters,
1572 1688 'watchers' => $watcherFilters,
@@ -1616,8 +1732,9 @@
1616 1732 $escapedSearch = $wpdb->esc_like($search);
1617 1733 $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%');
1618 1734 }
1619 1735
1736 + /** Apply board filters to table and card queries, gating Pro-only options. */
1620 1737 private function applyTableTaskFilters($tasksQuery, $filters)
1621 1738 {
1622 1739 $stageFilters = Arr::get($filters, 'stage', []);
1623 1740 $taskStatusFilters = Arr::get($filters, 'task_status', []);
@@ -1628,8 +1745,22 @@
1628 1745 $contactFilters = Arr::get($filters, 'contact', []);
1629 1746 $customFieldFilters = Arr::get($filters, 'custom_fields', []);
1630 1747 $dueDateFilters = Arr::get($filters, 'due_date', []);
1631 1748
1749 + $hasPro = defined('FLUENT_BOARDS_PRO_VERSION');
1750 +
1751 + if ($hasPro && in_array('yes', Arr::get($filters, 'template', []), true)) {
1752 + $tasksQuery->whereHas('taskMeta', function ($metaQuery) {
1753 + $metaQuery->where('key', Constant::IS_TASK_TEMPLATE)->where('value', 'yes');
1754 + });
1755 + }
1756 +
1757 + if ($hasPro && in_array('yes', Arr::get($filters, 'recurring', []), true)
1758 + && Arr::get(fluent_boards_get_pref_settings(), 'recurring_task.enabled') === 'yes') {
1759 + // Only the schedule owner repeats; generated copies may have no schedule.
1760 + $tasksQuery->whereNull('archived_at')->whereHas('repeatTaskMeta');
1761 + }
1762 +
1632 1763 if ($stageFilters) {
1633 1764 $this->applyTableStageFilters($tasksQuery, $stageFilters);
1634 1765 }
1635 1766
@@ -1868,8 +1999,12 @@
1868 1999 }
1869 2000
1870 2001 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
1871 2002 {
2003 + if (empty($assigneeIds)) {
2004 + return;
2005 + }
2006 +
1872 2007 $current_user_id = get_current_user_id();
1873 2008 /* this will run in background as soon as possible */
1874 2009 /* sending Model or Model Instance won't work here */
1875 2010
@@ -1933,8 +2068,9 @@
1933 2068 }
1934 2069
1935 2070 public function getActivities($taskId, $perPage, $filter = 'newest')
1936 2071 {
2072 + $perPage = Sanitize::perPage($perPage);
1937 2073 $activityQuery = Activity::where('object_id', $taskId)
1938 2074 ->where('object_type', Constant::ACTIVITY_TASK);
1939 2075 if ($filter == 'newest') {
1940 2076 $activityQuery = $activityQuery->latest();
@@ -2097,11 +2233,14 @@
2097 2233
2098 2234 return $subtaskGroupMap;
2099 2235 }
2100 2236
2101 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
2237 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = [])
2102 2238 {
2103 - $allActiveTasks = Task::where('board_id', $boardId)->whereNull('archived_at')->get();
2239 + $allActiveTasks = Task::where('board_id', $boardId)
2240 + ->whereNull('archived_at')
2241 + ->with(['labels', 'taskCustomFields'])
2242 + ->get();
2104 2243 $taskMap = [];
2105 2244 $subtaskGroupMap = [];
2106 2245 $parentTaskCount = 0;
2107 2246 $attachmentFileService = new AttachmentFileService();
@@ -2168,8 +2307,9 @@
2168 2307 'value' => $isTemplate->value
2169 2308 ]);
2170 2309 }
2171 2310 }
2311 + $this->copyTaskCustomFields($task, $newTask, $customFieldMap);
2172 2312 if(!$task->parent_id){
2173 2313 ++$parentTaskCount;
2174 2314 $taskMap[$task['id']] = $newTask->id;
2175 2315 //duplicate labels to task
@@ -2198,8 +2338,31 @@
2198 2338 throw $e;
2199 2339 }
2200 2340 }
2201 2341
2342 + /**
2343 + * Copy task custom field values to the duplicated task using copied board field IDs.
2344 + */
2345 + private function copyTaskCustomFields($task, $newTask, array $customFieldMap)
2346 + {
2347 + if (!$customFieldMap || !$task->taskCustomFields) {
2348 + return;
2349 + }
2350 +
2351 + foreach ($task->taskCustomFields as $customField) {
2352 + $newCustomFieldId = $customFieldMap[(int) $customField->foreign_id] ?? null;
2353 +
2354 + if (!$newCustomFieldId) {
2355 + continue;
2356 + }
2357 +
2358 + $clonedField = $customField->replicate();
2359 + $clonedField->object_id = $newTask->id;
2360 + $clonedField->foreign_id = $newCustomFieldId;
2361 + $clonedField->save();
2362 + }
2363 + }
2364 +
2202 2365 private function subtaskCountUpdate($taskId){
2203 2366 $parentTask = Task::findOrFail($taskId);
2204 2367 $settings = $parentTask->settings;
2205 2368 $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1;
@@ -2217,81 +2380,102 @@
2217 2380 * @return array
2218 2381 */
2219 2382 public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
2220 2383 {
2384 + $perPage = Sanitize::perPage($perPage);
2385 + $page = max(1, (int) $page);
2221 2386 // Fetch the task
2222 2387 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2223 2388 $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
2224 2389
2225 - // Fetch comments and activities separately
2226 - $comments = [];
2390 + $direction = $filter == 'newest' ? 'desc' : 'asc';
2391 + $offset = ($page - 1) * $perPage;
2392 + // Each source needs at most offset + perPage rows to fill the requested page.
2393 + $limit = $offset + $perPage;
2394 +
2395 + $commentQuery = $task->comments();
2396 + $activityQuery = $task->activities()
2397 + ->where(function($query) {
2398 + $query->whereNotIn('column', [ 'comment', 'a reply'])
2399 + ->orWhere(function($subQuery) {
2400 + $subQuery->whereNotIn('action', ['added', 'updated']);
2401 + });
2402 + });
2403 +
2404 + $entries = [];
2405 + $total = 0;
2227 2406 if ($feedType !== 'activities') {
2228 - $comments = $task->comments()->with(['user', 'replies.user'])->orderBy('created_at', 'desc')->get()->toArray();
2407 + $total += (clone $commentQuery)->count();
2408 + foreach ((clone $commentQuery)->reorder('created_at', $direction)->orderBy('id', $direction)->limit($limit)->get(['id', 'created_at']) as $row) {
2409 + $entries[] = ['comment', (int) $row->id, $row->created_at];
2410 + }
2229 2411 }
2230 -
2231 - $activities = [];
2232 2412 if ($feedType !== 'comments') {
2233 - $activities = $task->activities()
2234 - ->with('user')
2235 - ->where(function($query) {
2236 - $query->whereNotIn('column', [ 'comment', 'a reply'])
2237 - ->orWhere(function($subQuery) {
2238 - $subQuery->whereNotIn('action', ['added', 'updated']);
2239 - });
2240 - })
2241 - ->orderBy('created_at', 'desc')
2242 - ->get()
2243 - ->toArray();
2413 + $total += (clone $activityQuery)->count();
2414 + foreach ((clone $activityQuery)->reorder('created_at', $direction)->orderBy('id', $direction)->limit($limit)->get(['id', 'created_at']) as $row) {
2415 + $entries[] = ['activity', (int) $row->id, $row->created_at];
2416 + }
2244 2417 }
2245 2418
2419 + // Stable sort keeps comments ahead of activities on equal timestamps, as before.
2420 + $order = $direction === 'desc' ? -1 : 1;
2421 + usort($entries, function ($a, $b) use ($order) {
2422 + return $order * (strtotime($a[2]) - strtotime($b[2]));
2423 + });
2424 + $entries = array_slice($entries, $offset, $perPage);
2246 2425
2247 - // Merge comments and activities into a single array
2248 - $commentsAndActivities = array_merge($comments, $activities);
2426 + // Hydrate relations only for the selected page.
2427 + $selectedIds = ['comment' => [], 'activity' => []];
2428 + foreach ($entries as $entry) {
2429 + $selectedIds[$entry[0]][] = $entry[1];
2430 + }
2431 + $models = ['comment' => [], 'activity' => []];
2432 + if ($selectedIds['comment']) {
2433 + $models['comment'] = $commentQuery->with(['user', 'replies.user'])->whereIn('id', $selectedIds['comment'])->get()->keyBy('id')->all();
2434 + }
2435 + if ($selectedIds['activity']) {
2436 + $models['activity'] = $activityQuery->with('user')->whereIn('id', $selectedIds['activity'])->get()->keyBy('id')->all();
2437 + }
2249 2438
2250 - // Sort the merged array by created_at date in ascending or descending order
2251 - $order = $filter == 'newest' ? -1 : 1;
2252 - usort($commentsAndActivities, function ($a, $b) use ($order) {
2253 - return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
2254 - });
2439 + $paginatedResults = [];
2440 + foreach ($entries as $entry) {
2441 + if (isset($models[$entry[0]][$entry[1]])) {
2442 + $paginatedResults[] = $models[$entry[0]][$entry[1]]->toArray();
2443 + }
2444 + }
2255 2445
2256 - // Paginate the results
2257 - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
2258 - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
2259 -
2260 - // Get the total count of comments and activities
2261 - $total = count($commentsAndActivities);
2262 2446 $lastPage = (int) ceil($total / $perPage);
2263 2447
2264 2448 // Construct pagination metadata
2265 - $path = "https://wordpress.test/wp-json/fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities";
2449 + $path = rest_url("fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities");
2266 2450 return [
2267 2451 'current_page' => (int) $page,
2268 2452 'data' => $paginatedResults,
2269 - 'first_page_url' => "{$path}?page=1",
2453 + 'first_page_url' => add_query_arg('page', 1, $path),
2270 2454 'from' => $total > 0 ? (int) ($offset + 1) : null,
2271 2455 'last_page' => (int) $lastPage,
2272 - 'last_page_url' => "{$path}?page={$lastPage}",
2456 + 'last_page_url' => add_query_arg('page', $lastPage, $path),
2273 2457 'links' => [
2274 2458 [
2275 - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2459 + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
2276 2460 'label' => 'pagination.previous',
2277 2461 'active' => false
2278 2462 ],
2279 2463 [
2280 - 'url' => "{$path}?page={$page}",
2464 + 'url' => add_query_arg('page', $page, $path),
2281 2465 'label' => (int) $page,
2282 2466 'active' => true
2283 2467 ],
2284 2468 [
2285 - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2469 + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
2286 2470 'label' => 'pagination.next',
2287 2471 'active' => false
2288 2472 ]
2289 2473 ],
2290 - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2474 + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
2291 2475 'path' => $path,
2292 2476 'per_page' => (int) $perPage,
2293 - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2477 + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
2294 2478 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
2295 2479 'total' => (int) $total
2296 2480 ];
2297 2481 }
@@ -2346,11 +2530,15 @@
2346 2530 }
2347 2531 return $title ?? $UrlMeta['title'] ?? '';
2348 2532 }
2349 2533
2350 - public function manageDefaultAssignees($task, $stageId)
2534 + /** Apply missing stage assignees and report whether any assignments were added. */
2535 + public function manageDefaultAssignees($task, $stageId, ?Stage $stage = null)
2351 2536 {
2352 - $stage = Stage::findOrFail($stageId);
2537 + if (!$stage || (int) $stage->id !== (int) $stageId) {
2538 + $stage = Stage::findOrFail($stageId);
2539 + }
2540 + $assignmentsChanged = false;
2353 2541 if ($stage && isset($stage->settings['default_task_assignees'])) {
2354 2542 $defaultAssignees = $stage->settings['default_task_assignees'];
2355 2543 foreach ($defaultAssignees as $assigneeId) {
2356 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
@@ -2356,11 +2544,13 @@
2356 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
2357 2545 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
2358 2546 if (!$IfAlreadyAssignee) {
2359 2547 $this->updateAssignee($assigneeId, $task);
2548 + $assignmentsChanged = true;
2360 2549 }
2361 2550 }
2362 2551 }
2552 + return $assignmentsChanged;
2363 2553 }
2364 2554
2365 2555 public function manageDefaultWatchers($task, $stageId)
2366 2556 {
@@ -2536,9 +2726,10 @@
2536 2726 }
2537 2727
2538 2728 // Create new task with cloned data
2539 2729 $clonedTask = $task->replicate();
2540 - $clonedTask->title = $taskData['title'] ?? $task->title . ' (' . \__('cloned', 'fluent-boards') . ')';
2730 + /* translators: %s is the title of the task being cloned */
2731 + $clonedTask->title = $taskData['title'] ?? sprintf(\__('%s (cloned)', 'fluent-boards'), $task->title);
2541 2732
2542 2733 $settings = $clonedTask->settings ?? [];
2543 2734
2544 2735 unset(
@@ -2615,9 +2806,10 @@
2615 2806 $attachmentFileService->rollbackCreatedFiles();
2616 2807 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2617 2808 $wpdb->query('ROLLBACK');
2618 2809 throw new \Exception(
2619 - esc_html(\__('Failed to clone task: ', 'fluent-boards') . $e->getMessage()),
2810 + /* translators: %s is the error message */
2811 + esc_html(sprintf(\__('Failed to clone task: %s', 'fluent-boards'), $e->getMessage())),
2620 2812 (int) ($e->getCode() ?: 500)
2621 2813 );
2622 2814 }
2623 2815 }
@@ -2982,8 +3174,9 @@
2982 3174
2983 3175 $successfulTasks = [];
2984 3176
2985 3177 foreach ($tasks as $task) {
3178 + $oldStageId = $task->stage_id;
2986 3179 if ($isMovingToAnotherBoard) {
2987 3180 // Cross-board move - use existing method for data cleanup and security
2988 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2989 3182 $task->stage_id = $targetStageId;
@@ -2988,33 +3181,32 @@
2988 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2989 3182 $task->stage_id = $targetStageId;
2990 3183 $task = $task->moveToNewPosition(null);
2991 3184 } else {
2992 - // Same board - simple stage move
2993 - $oldStageId = $task->stage_id;
2994 3185 $task->stage_id = $targetStageId;
2995 3186 $task = $task->moveToNewPosition(1);
2996 -
2997 - // Only process stage-specific logic if stage actually changed
2998 - if ($oldStageId != $targetStageId) {
2999 - $this->manageDefaultAssignees($task, $targetStageId);
3000 -
3001 - $defaultPosition = $task->stage->defaultTaskStatus();
3002 - if ($defaultPosition == 'closed' && $task->status != 'closed') {
3003 - $task = $task->close();
3004 - }
3005 -
3006 - $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
3007 - $this->sendMailAfterTaskModify('stage_change', $usersToSendEmail, $task->id);
3187 + }
3188 +
3189 + // Board moves detach relations; listeners must see the destination state.
3190 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3191 +
3192 + if ($oldStageId != $targetStageId) {
3193 + $this->manageDefaultAssignees($task, $targetStageId);
3194 + if ($targetStage->defaultTaskStatus() == 'closed' && $task->status != 'closed') {
3195 + $task = $task->close();
3008 3196 }
3197 +
3198 + // Default assignees may also become watchers.
3199 + $task->load(['assignees', 'watchers']);
3200 + do_action('fluent_boards/task_stage_updated', $task, $oldStageId);
3201 +
3202 + $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
3203 + $this->sendMailAfterTaskModify('stage_change', $usersToSendEmail, $task->id);
3009 3204 }
3010 -
3011 - // Reload task with all relationships
3012 - $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3013 -
3205 +
3014 3206 $successfulTasks[] = $task;
3015 3207 }
3016 -
3208 +
3017 3209 $successCount = count($successfulTasks);
3018 3210
3019 3211 if ($isMovingToAnotherBoard) {
3020 3212 // translators: %d is the number of tasks successfully moved to another board
@@ -3356,9 +3548,9 @@
3356 3548 ];
3357 3549 }
3358 3550
3359 3551 /**
3360 - * Delete time tracking records for one or multiple tasks.
3552 + * Delete time tracking records for one or multiple tasks when the table exists.
3361 3553 *
3362 3554 * @param int|array $taskIds Single task ID or array of task IDs.
3363 3555 * @param bool $suppressErrors Whether cleanup failures should be ignored.
3364 3556 * @return void
@@ -3364,22 +3556,35 @@
3364 3556 * @return void
3365 3557 */
3366 3558 public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
3367 3559 {
3368 - // Check if FluentBoards Pro time tracking is available
3369 - if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
3560 + if (!class_exists(TimeTrack::class)) {
3370 3561 return;
3371 3562 }
3372 3563
3373 3564 try {
3565 + // Disabled modules still need cleanup, but never-enabled modules may have no table.
3566 + if (method_exists(TimeTrackingHelper::class, 'timeTrackingTableExists')) {
3567 + $tableExists = TimeTrackingHelper::timeTrackingTableExists();
3568 + } else {
3569 + // Older Pro versions do not provide the table-existence helper.
3570 + global $wpdb;
3571 + $table = $wpdb->prefix . (new TimeTrack())->getTable();
3572 + $tableExists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table;
3573 + }
3574 +
3575 + if (!$tableExists) {
3576 + return;
3577 + }
3578 +
3374 3579 // Handle single task ID or array of task IDs
3375 3580 if (is_array($taskIds)) {
3376 3581 if (!empty($taskIds)) {
3377 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete();
3582 + TimeTrack::whereIn('task_id', $taskIds)->delete();
3378 3583 }
3379 3584 } else {
3380 3585 if (is_numeric($taskIds) && $taskIds > 0) {
3381 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
3586 + TimeTrack::where('task_id', (int) $taskIds)->delete();
3382 3587 }
3383 3588 }
3384 3589 } catch (\Throwable $e) {
3385 3590 if (!$suppressErrors) {