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.1 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 All 44 releases
← All changes | app/Services/TaskService.php +455 -119 2.0.12 → 2.2.0 View file →
@@ -1,11 +1,14 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\Framework\Database\Orm\ModelNotFoundException;
5 6 use FluentBoards\App\App;
7 +use FluentBoards\App\Hooks\Handlers\FileHandler;
6 8 use FluentBoards\App\Models\Attachment;
7 9 use FluentBoards\App\Models\Comment;
10 +use FluentBoards\App\Models\Notification;
8 11 use FluentBoards\App\Models\NotificationUser;
9 12 use FluentBoards\App\Models\TaskImage;
10 13 use FluentBoards\App\Services\Constant;
11 14 use FluentBoards\App\Models\Label;
@@ -18,8 +21,10 @@
18 21 use FluentBoards\App\Models\CommentImage;
19 22 use FluentBoards\App\Models\Relation;
20 23 use FluentBoards\Framework\Support\Arr;
21 24 use FluentBoardsPro\App\Models\TaskAttachment;
25 +use FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack;
26 +use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper;
22 27 use FluentBoardsPro\App\Services\AttachmentService;
23 28 use FluentBoardsPro\App\Services\RemoteUrlParser;
24 29 use FluentRoadmap\App\Models\IdeaReaction;
25 30
@@ -26,19 +31,76 @@
26 31 class TaskService
27 32 {
28 33 private static $physicalTableNameCache = [];
29 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 +
30 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 + /**
31 92 * Resolve a task only when it belongs to the requested board.
32 93 *
33 94 * Subtasks normally carry the same board_id as their parent, but the parent
34 95 * fallback protects older data where that relationship may be incomplete.
96 + * Missing or mismatched tasks use the router's deliberate 404 response.
35 97 *
36 98 * @param int $taskId
37 99 * @param int $boardId
38 100 * @param bool $allowParentFallback
39 101 * @return Task
40 - * @throws \Exception
102 + * @throws ModelNotFoundException
41 103 */
42 104 public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true)
43 105 {
44 106 $taskId = absint($taskId);
@@ -44,9 +106,9 @@
44 106 $taskId = absint($taskId);
45 107 $boardId = absint($boardId);
46 108
47 109 if (!$taskId || !$boardId) {
48 - throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
110 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
49 111 }
50 112
51 113 $task = Task::where('id', $taskId)
52 114 ->where('board_id', $boardId)
@@ -70,9 +132,9 @@
70 132 }
71 133 }
72 134 }
73 135
74 - throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
136 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
75 137 }
76 138
77 139 private function normalizeTaskDescriptionForEditor(Task $task)
78 140 {
@@ -80,8 +142,11 @@
80 142
81 143 return $task;
82 144 }
83 145
146 + /**
147 + * Create a board task and notify eligible selected assignees before applying stage defaults.
148 + */
84 149 public function createTask($data, $boardId)
85 150 {
86 151 $board = Board::select('id', 'type')->find($boardId);
87 152
@@ -138,8 +203,18 @@
138 203
139 204 $data = array_filter($data);
140 205 $task = (new Task())->createTask($data);
141 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 +
142 217 $this->manageDefaultAssignees($task, $stage->id);
143 218 $this->manageDefaultWatchers($task, $stage->id);
144 219
145 220 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
@@ -613,10 +688,12 @@
613 688 }
614 689 $task->archived_at = $value == null ? null : current_time('mysql');
615 690 $task->save();
616 691 do_action('fluent_boards/task_archived', $task);
617 - $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
618 - $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 + }
619 696 }
620 697
621 698 private function updateStatus($value, $task)
622 699 {
@@ -786,79 +863,206 @@
786 863 $this->deleteTask($subtask);
787 864 }
788 865 }
789 866
867 + $this->deleteTasksBatch([$task]);
868 + }
869 +
870 + /**
871 + * Delete the supplied tasks and their owned records without discovering children.
872 + *
873 + * @param iterable $tasks
874 + * @param bool $manageTransaction Set false only when the caller owns an active transaction.
875 + * @return void
876 + * @throws \Throwable
877 + */
878 + public function deleteTasksBatch($tasks, $manageTransaction = true)
879 + {
880 + if (!is_array($tasks) && !($tasks instanceof \Traversable)) {
881 + $tasks = [$tasks];
882 + }
883 +
884 + $deletedTasks = [];
885 + $taskBoardIds = [];
886 + foreach ($tasks as $task) {
887 + if (!$task instanceof Task) {
888 + continue;
889 + }
890 +
891 + $taskId = (int) $task->id;
892 + if ($taskId < 1) {
893 + continue;
894 + }
895 +
896 + $deletedTasks[$taskId] = clone $task;
897 + $taskBoardIds[$taskId] = (int) $task->board_id;
898 + }
899 +
900 + if (!$deletedTasks) {
901 + return;
902 + }
903 +
904 + ksort($deletedTasks, SORT_NUMERIC);
905 + $taskIds = array_keys($deletedTasks);
790 906 $dbInstance = App::getInstance('db');
791 - $dbInstance->beginTransaction();
792 907
793 - try {
794 - $deletedTask = clone $task;
795 - $task->watchers()->detach();
796 - $task->assignees()->detach();
908 + if (!$manageTransaction && !$dbInstance->inTransaction()) {
909 + throw new \RuntimeException(__('An active transaction is required for caller-managed task deletion.', 'fluent-boards'));
910 + }
797 911
798 - $notificationIds = $task->notifications->pluck('id');
799 - $task->notifications()->delete();
800 - NotificationUser::whereIn('notification_id', $notificationIds)->delete();
912 + if ($manageTransaction) {
913 + $dbInstance->beginTransaction();
914 + }
801 915
802 - $task->labels()->detach();
916 + try {
917 + $relationTypes = [
918 + Constant::OBJECT_TYPE_USER_TASK_WATCH,
919 + Constant::OBJECT_TYPE_TASK_ASSIGNEE,
920 + Constant::OBJECT_TYPE_TASK_LABEL,
921 + ];
803 922
804 923 if (defined('FLUENT_BOARDS_PRO_VERSION')) {
805 - $task->customFields()->detach();
806 - $this->deleteTaskAttachments($task);
924 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
807 925 }
808 926
809 - $this->deleteTimeTrackingRecords($task->id, false);
810 - TaskMeta::where('task_id', $task->id)->delete();
927 + Relation::whereIn('object_id', $taskIds)
928 + ->whereIn('object_type', $relationTypes)
929 + ->delete();
811 930
812 - if (!$task->delete()) {
931 + Relation::where('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
932 + ->where(function ($query) use ($taskIds) {
933 + $query->whereIn('object_id', $taskIds)
934 + ->orWhereIn('foreign_id', $taskIds);
935 + })
936 + ->delete();
937 +
938 + $notificationIds = Notification::whereIn('task_id', $taskIds)->pluck('id')->toArray();
939 + NotificationUser::whereIn('notification_id', $notificationIds)->delete();
940 + Notification::whereIn('task_id', $taskIds)->delete();
941 +
942 + $this->deleteTaskAttachmentsBatch($taskIds, $taskBoardIds);
943 + $this->deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance);
944 + Activity::whereIn('object_id', $taskIds)
945 + ->where('object_type', Constant::ACTIVITY_TASK)
946 + ->delete();
947 + Meta::whereIn('object_id', $taskIds)
948 + ->where('object_type', Constant::REPEAT_TASK_META)
949 + ->delete();
950 + $this->deleteTimeTrackingRecords($taskIds, false);
951 + TaskMeta::whereIn('task_id', $taskIds)->delete();
952 +
953 + $deletedCount = Task::whereIn('id', $taskIds)->delete();
954 + if ($deletedCount !== count($taskIds)) {
813 955 throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards'));
814 956 }
815 957
816 - do_action('fluent_boards/task_deleted', $deletedTask);
817 - $dbInstance->commit();
958 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
959 +
960 + if ($manageTransaction) {
961 + $dbInstance->commit();
962 + }
818 963 } catch (\Throwable $e) {
819 - $dbInstance->rollBack();
820 - throw $e; // Re-throw the exception after rolling back
964 + if ($manageTransaction) {
965 + $dbInstance->rollBack();
966 + }
967 +
968 + throw $e;
821 969 }
970 + }
822 971
823 - }
824 - public function deleteTaskForBulk($task)
972 + /** Remove comment rows transactionally; file cleanup must wait for commit. */
973 + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance)
825 974 {
826 - // If this is a parent task, delete all subtasks first
827 - if (!$task->parent_id) {
828 - $subtasks = Task::where('parent_id', $task->id)->get();
829 - foreach ($subtasks as $subtask) {
830 - // Recursively delete each subtask (cleans up all their relations)
831 - $this->deleteTaskForBulk($subtask);
832 - }
975 + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray();
976 + if (!$commentTasks) {
977 + return;
833 978 }
834 979
835 - $deleted = $task->delete();
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();
836 987
837 - if ($deleted) {
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 + }
838 1008
839 - //task assignees watchers removed
840 - $task->watchers()->detach();
841 - $task->assignees()->detach();
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 + }
842 1016
843 - //removing all task related notifications
844 - $notificationIds = $task->notifications->pluck('id');
845 - $task->notifications()->delete();
846 - NotificationUser::whereIn('notification_id', $notificationIds)->delete();
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 + }
847 1031
848 - //task labels removed
849 - $task->labels()->detach();
850 -
851 - //task custom field value
852 - if (defined('FLUENT_BOARDS_PRO_VERSION')) {
853 - $task->customFields()->detach();
854 - $this->deleteTaskAttachments($task);
1032 + /**
1033 + * Dispatch task deletion hooks after the outermost transaction commits.
1034 + *
1035 + * @param mixed $dbInstance
1036 + * @param array $deletedTasks
1037 + * @return void
1038 + */
1039 + private function dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks)
1040 + {
1041 + $dbInstance->afterCommit(function () use ($deletedTasks) {
1042 + foreach ($deletedTasks as $deletedTask) {
1043 + try {
1044 + do_action('fluent_boards/task_deleted', $deletedTask);
1045 + } catch (\Throwable $e) {
1046 + error_log(sprintf(
1047 + 'FluentBoards: Failed to dispatch committed task deletion hook for task %d: %s',
1048 + (int) $deletedTask->id,
1049 + sanitize_text_field($e->getMessage())
1050 + ));
1051 + }
855 1052 }
1053 + });
1054 + }
856 1055
857 - // For bulk delete, you might want to avoid firing hooks/actions,
858 - // so 'fluent_boards/task_deleted' is not triggered here.
859 - TaskMeta::where('task_id', $task->id)->delete();
1056 + /** Keep legacy bulk callers on the normal task deletion lifecycle. */
1057 + public function deleteTaskForBulk($task)
1058 + {
1059 + // A selected subtask may already have been deleted with its parent.
1060 + if (!Task::where('id', $task->id)->exists()) {
1061 + return;
860 1062 }
1063 +
1064 + $this->deleteTask($task);
861 1065 }
862 1066 // this is invoked when task is moved to another board
863 1067
864 1068 /**
@@ -1296,8 +1500,9 @@
1296 1500
1297 1501 return $tasks;
1298 1502 }
1299 1503
1504 + /** Fetch a page of board tasks, applying filters before pagination. */
1300 1505 public function getTableTasks($boardId, $data = [])
1301 1506 {
1302 1507 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20;
1303 1508 $page = isset($data['page']) ? intval($data['page']) : 1;
@@ -1305,8 +1510,10 @@
1305 1510 $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc';
1306 1511 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1307 1512 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1308 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', []));
1309 1516 $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1310 1517 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1311 1518 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1312 1519 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
@@ -1343,8 +1550,9 @@
1343 1550 'title',
1344 1551 'slug',
1345 1552 'board_id',
1346 1553 'parent_id',
1554 + 'crm_contact_id',
1347 1555 'type',
1348 1556 'stage_id',
1349 1557 'status',
1350 1558 'priority',
@@ -1394,8 +1602,10 @@
1394 1602 $this->applyTableTaskSearch($tasksQuery, $search);
1395 1603 $this->applyTableTaskFilters($tasksQuery, [
1396 1604 'stage' => $stageFilters,
1397 1605 'task_status' => $taskStatusFilters,
1606 + 'recurring' => $recurringFilters,
1607 + 'template' => $templateFilters,
1398 1608 'priority' => $priorityFilters,
1399 1609 'assignee' => $assigneeFilters,
1400 1610 'labels' => $labelFilters,
1401 1611 'watchers' => $watcherFilters,
@@ -1412,13 +1622,16 @@
1412 1622 ->orderBy($sortColumn, $sortDirection)
1413 1623 ->paginate($perPage, ['*'], 'page', $page);
1414 1624 }
1415 1625
1626 + /** Fetch matching board cards with the same filters as table view. */
1416 1627 public function getBoardViewTasks($boardId, $data = [])
1417 1628 {
1418 1629 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1419 1630 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1420 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', []));
1421 1634 $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1422 1635 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1423 1636 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1424 1637 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
@@ -1466,8 +1679,10 @@
1466 1679 $this->applyTableTaskSearch($tasksQuery, $search);
1467 1680 $this->applyTableTaskFilters($tasksQuery, [
1468 1681 'stage' => $stageFilters,
1469 1682 'task_status' => $taskStatusFilters,
1683 + 'recurring' => $recurringFilters,
1684 + 'template' => $templateFilters,
1470 1685 'priority' => $priorityFilters,
1471 1686 'assignee' => $assigneeFilters,
1472 1687 'labels' => $labelFilters,
1473 1688 'watchers' => $watcherFilters,
@@ -1517,8 +1732,9 @@
1517 1732 $escapedSearch = $wpdb->esc_like($search);
1518 1733 $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%');
1519 1734 }
1520 1735
1736 + /** Apply board filters to table and card queries, gating Pro-only options. */
1521 1737 private function applyTableTaskFilters($tasksQuery, $filters)
1522 1738 {
1523 1739 $stageFilters = Arr::get($filters, 'stage', []);
1524 1740 $taskStatusFilters = Arr::get($filters, 'task_status', []);
@@ -1529,8 +1745,22 @@
1529 1745 $contactFilters = Arr::get($filters, 'contact', []);
1530 1746 $customFieldFilters = Arr::get($filters, 'custom_fields', []);
1531 1747 $dueDateFilters = Arr::get($filters, 'due_date', []);
1532 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 +
1533 1763 if ($stageFilters) {
1534 1764 $this->applyTableStageFilters($tasksQuery, $stageFilters);
1535 1765 }
1536 1766
@@ -1769,8 +1999,12 @@
1769 1999 }
1770 2000
1771 2001 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
1772 2002 {
2003 + if (empty($assigneeIds)) {
2004 + return;
2005 + }
2006 +
1773 2007 $current_user_id = get_current_user_id();
1774 2008 /* this will run in background as soon as possible */
1775 2009 /* sending Model or Model Instance won't work here */
1776 2010
@@ -1834,8 +2068,9 @@
1834 2068 }
1835 2069
1836 2070 public function getActivities($taskId, $perPage, $filter = 'newest')
1837 2071 {
2072 + $perPage = Sanitize::perPage($perPage);
1838 2073 $activityQuery = Activity::where('object_id', $taskId)
1839 2074 ->where('object_type', Constant::ACTIVITY_TASK);
1840 2075 if ($filter == 'newest') {
1841 2076 $activityQuery = $activityQuery->latest();
@@ -1998,11 +2233,14 @@
1998 2233
1999 2234 return $subtaskGroupMap;
2000 2235 }
2001 2236
2002 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
2237 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = [])
2003 2238 {
2004 - $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();
2005 2243 $taskMap = [];
2006 2244 $subtaskGroupMap = [];
2007 2245 $parentTaskCount = 0;
2008 2246 $attachmentFileService = new AttachmentFileService();
@@ -2069,8 +2307,9 @@
2069 2307 'value' => $isTemplate->value
2070 2308 ]);
2071 2309 }
2072 2310 }
2311 + $this->copyTaskCustomFields($task, $newTask, $customFieldMap);
2073 2312 if(!$task->parent_id){
2074 2313 ++$parentTaskCount;
2075 2314 $taskMap[$task['id']] = $newTask->id;
2076 2315 //duplicate labels to task
@@ -2099,8 +2338,31 @@
2099 2338 throw $e;
2100 2339 }
2101 2340 }
2102 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 +
2103 2365 private function subtaskCountUpdate($taskId){
2104 2366 $parentTask = Task::findOrFail($taskId);
2105 2367 $settings = $parentTask->settings;
2106 2368 $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1;
@@ -2118,81 +2380,102 @@
2118 2380 * @return array
2119 2381 */
2120 2382 public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
2121 2383 {
2384 + $perPage = Sanitize::perPage($perPage);
2385 + $page = max(1, (int) $page);
2122 2386 // Fetch the task
2123 2387 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2124 2388 $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
2125 2389
2126 - // Fetch comments and activities separately
2127 - $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;
2128 2406 if ($feedType !== 'activities') {
2129 - $comments = $task->comments()->with('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 + }
2130 2411 }
2131 -
2132 - $activities = [];
2133 2412 if ($feedType !== 'comments') {
2134 - $activities = $task->activities()
2135 - ->with('user')
2136 - ->where(function($query) {
2137 - $query->whereNotIn('column', [ 'comment', 'a reply'])
2138 - ->orWhere(function($subQuery) {
2139 - $subQuery->whereNotIn('action', ['added', 'updated']);
2140 - });
2141 - })
2142 - ->orderBy('created_at', 'desc')
2143 - ->get()
2144 - ->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 + }
2145 2417 }
2146 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);
2147 2425
2148 - // Merge comments and activities into a single array
2149 - $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 + }
2150 2438
2151 - // Sort the merged array by created_at date in ascending or descending order
2152 - $order = $filter == 'newest' ? -1 : 1;
2153 - usort($commentsAndActivities, function ($a, $b) use ($order) {
2154 - return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
2155 - });
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 + }
2156 2445
2157 - // Paginate the results
2158 - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
2159 - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
2160 -
2161 - // Get the total count of comments and activities
2162 - $total = count($commentsAndActivities);
2163 2446 $lastPage = (int) ceil($total / $perPage);
2164 2447
2165 2448 // Construct pagination metadata
2166 - $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");
2167 2450 return [
2168 2451 'current_page' => (int) $page,
2169 2452 'data' => $paginatedResults,
2170 - 'first_page_url' => "{$path}?page=1",
2453 + 'first_page_url' => add_query_arg('page', 1, $path),
2171 2454 'from' => $total > 0 ? (int) ($offset + 1) : null,
2172 2455 'last_page' => (int) $lastPage,
2173 - 'last_page_url' => "{$path}?page={$lastPage}",
2456 + 'last_page_url' => add_query_arg('page', $lastPage, $path),
2174 2457 'links' => [
2175 2458 [
2176 - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2459 + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
2177 2460 'label' => 'pagination.previous',
2178 2461 'active' => false
2179 2462 ],
2180 2463 [
2181 - 'url' => "{$path}?page={$page}",
2464 + 'url' => add_query_arg('page', $page, $path),
2182 2465 'label' => (int) $page,
2183 2466 'active' => true
2184 2467 ],
2185 2468 [
2186 - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2469 + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
2187 2470 'label' => 'pagination.next',
2188 2471 'active' => false
2189 2472 ]
2190 2473 ],
2191 - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2474 + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
2192 2475 'path' => $path,
2193 2476 'per_page' => (int) $perPage,
2194 - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2477 + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
2195 2478 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
2196 2479 'total' => (int) $total
2197 2480 ];
2198 2481 }
@@ -2247,11 +2530,15 @@
2247 2530 }
2248 2531 return $title ?? $UrlMeta['title'] ?? '';
2249 2532 }
2250 2533
2251 - 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)
2252 2536 {
2253 - $stage = Stage::findOrFail($stageId);
2537 + if (!$stage || (int) $stage->id !== (int) $stageId) {
2538 + $stage = Stage::findOrFail($stageId);
2539 + }
2540 + $assignmentsChanged = false;
2254 2541 if ($stage && isset($stage->settings['default_task_assignees'])) {
2255 2542 $defaultAssignees = $stage->settings['default_task_assignees'];
2256 2543 foreach ($defaultAssignees as $assigneeId) {
2257 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
@@ -2257,11 +2544,13 @@
2257 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
2258 2545 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
2259 2546 if (!$IfAlreadyAssignee) {
2260 2547 $this->updateAssignee($assigneeId, $task);
2548 + $assignmentsChanged = true;
2261 2549 }
2262 2550 }
2263 2551 }
2552 + return $assignmentsChanged;
2264 2553 }
2265 2554
2266 2555 public function manageDefaultWatchers($task, $stageId)
2267 2556 {
@@ -2379,12 +2668,38 @@
2379 2668 foreach ($attachments as $attachment) {
2380 2669 $deletedAttachment = clone $attachment;
2381 2670 $attachment->delete();
2382 2671
2383 - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2672 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2384 2673 }
2385 2674 }
2386 2675
2676 + /**
2677 + * Delete task attachments one at a time so each attachment-deleted hook is preserved.
2678 + *
2679 + * @param array $taskIds
2680 + * @param array $taskBoardIds
2681 + * @return void
2682 + */
2683 + private function deleteTaskAttachmentsBatch($taskIds, $taskBoardIds)
2684 + {
2685 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2686 + return;
2687 + }
2688 +
2689 + $attachments = TaskAttachment::whereIn('object_id', $taskIds)
2690 + ->where('object_type', Constant::TASK_ATTACHMENT)
2691 + ->get();
2692 +
2693 + foreach ($attachments as $attachment) {
2694 + $deletedAttachment = clone $attachment;
2695 + $attachment->delete();
2696 + $boardId = $taskBoardIds[(int) $attachment->object_id] ?? null;
2697 +
2698 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $boardId);
2699 + }
2700 + }
2701 +
2387 2702 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2388 2703 {
2389 2704 global $wpdb;
2390 2705 $attachmentFileService = new AttachmentFileService();
@@ -2411,9 +2726,10 @@
2411 2726 }
2412 2727
2413 2728 // Create new task with cloned data
2414 2729 $clonedTask = $task->replicate();
2415 - $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);
2416 2732
2417 2733 $settings = $clonedTask->settings ?? [];
2418 2734
2419 2735 unset(
@@ -2490,9 +2806,10 @@
2490 2806 $attachmentFileService->rollbackCreatedFiles();
2491 2807 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2492 2808 $wpdb->query('ROLLBACK');
2493 2809 throw new \Exception(
2494 - 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())),
2495 2812 (int) ($e->getCode() ?: 500)
2496 2813 );
2497 2814 }
2498 2815 }
@@ -2638,8 +2955,14 @@
2638 2955 if ($images->count() > 0) {
2639 2956 foreach ($images as $image) {
2640 2957 $clonedImage = $image->replicate();
2641 2958 $clonedImage->object_id = $clonedCommentOrReply->id;
2959 + (new CommentService())->applyCommentImageScope(
2960 + $clonedImage,
2961 + $clonedCommentOrReply->board_id,
2962 + $clonedCommentOrReply->task_id,
2963 + $clonedCommentOrReply->created_by
2964 + );
2642 2965 $clonedImage->save();
2643 2966 }
2644 2967 }
2645 2968 }
@@ -2851,8 +3174,9 @@
2851 3174
2852 3175 $successfulTasks = [];
2853 3176
2854 3177 foreach ($tasks as $task) {
3178 + $oldStageId = $task->stage_id;
2855 3179 if ($isMovingToAnotherBoard) {
2856 3180 // Cross-board move - use existing method for data cleanup and security
2857 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2858 3182 $task->stage_id = $targetStageId;
@@ -2857,33 +3181,32 @@
2857 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2858 3182 $task->stage_id = $targetStageId;
2859 3183 $task = $task->moveToNewPosition(null);
2860 3184 } else {
2861 - // Same board - simple stage move
2862 - $oldStageId = $task->stage_id;
2863 3185 $task->stage_id = $targetStageId;
2864 3186 $task = $task->moveToNewPosition(1);
2865 -
2866 - // Only process stage-specific logic if stage actually changed
2867 - if ($oldStageId != $targetStageId) {
2868 - $this->manageDefaultAssignees($task, $targetStageId);
2869 -
2870 - $defaultPosition = $task->stage->defaultTaskStatus();
2871 - if ($defaultPosition == 'closed' && $task->status != 'closed') {
2872 - $task = $task->close();
2873 - }
2874 -
2875 - $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
2876 - $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();
2877 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);
2878 3204 }
2879 -
2880 - // Reload task with all relationships
2881 - $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2882 -
3205 +
2883 3206 $successfulTasks[] = $task;
2884 3207 }
2885 -
3208 +
2886 3209 $successCount = count($successfulTasks);
2887 3210
2888 3211 if ($isMovingToAnotherBoard) {
2889 3212 // translators: %d is the number of tasks successfully moved to another board
@@ -3225,9 +3548,9 @@
3225 3548 ];
3226 3549 }
3227 3550
3228 3551 /**
3229 - * Delete time tracking records for one or multiple tasks.
3552 + * Delete time tracking records for one or multiple tasks when the table exists.
3230 3553 *
3231 3554 * @param int|array $taskIds Single task ID or array of task IDs.
3232 3555 * @param bool $suppressErrors Whether cleanup failures should be ignored.
3233 3556 * @return void
@@ -3233,22 +3556,35 @@
3233 3556 * @return void
3234 3557 */
3235 3558 public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
3236 3559 {
3237 - // Check if FluentBoards Pro time tracking is available
3238 - if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
3560 + if (!class_exists(TimeTrack::class)) {
3239 3561 return;
3240 3562 }
3241 3563
3242 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 +
3243 3579 // Handle single task ID or array of task IDs
3244 3580 if (is_array($taskIds)) {
3245 3581 if (!empty($taskIds)) {
3246 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete();
3582 + TimeTrack::whereIn('task_id', $taskIds)->delete();
3247 3583 }
3248 3584 } else {
3249 3585 if (is_numeric($taskIds) && $taskIds > 0) {
3250 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
3586 + TimeTrack::where('task_id', (int) $taskIds)->delete();
3251 3587 }
3252 3588 }
3253 3589 } catch (\Throwable $e) {
3254 3590 if (!$suppressErrors) {