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 +497 -170 2.0.0 → 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') {
@@ -205,9 +280,9 @@
205 280 $taskQuery = Task::whereIn('id', $taskIds)
206 281 ->with(['assignees', 'board', 'stage'])
207 282 ->whereNull('archived_at')
208 283 ->where('parent_id', null)
209 - ->excludeTemplateBoards()
284 + ->onActiveAvailableBoards()
210 285 ->orderBy('due_at', 'DESC');
211 286
212 287 switch ($category) {
213 288 case 'overdue':
@@ -234,9 +309,10 @@
234 309 ->with(['assignees', 'board', 'stage'])
235 310 ->whereIn('fbs_tasks.id', $taskIds)
236 311 ->whereNull('fbs_tasks.archived_at')
237 312 ->whereNull('fbs_tasks.parent_id')
238 - ->excludeTemplateBoards()
313 + ->where('fbs_tasks.status', '!=', 'closed')
314 + ->onActiveAvailableBoards()
239 315 ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
240 316 $join->on('rel.object_id', '=', 'fbs_tasks.id')
241 317 ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
242 318 ->where('rel.foreign_id', $currentUserId);
@@ -257,9 +333,9 @@
257 333 return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
258 334 })->pluck('notification.task_id')->unique();
259 335 $validTasks = Task::whereIn('id', $taskIds)
260 336 ->with(['assignees', 'board', 'stage'])
261 - ->excludeTemplateBoards()
337 + ->onActiveAvailableBoards()
262 338 ->get();
263 339
264 340 return $validTasks->toArray();
265 341 default:
@@ -280,9 +356,9 @@
280 356 $taskQuery = Task::query()
281 357 ->whereIn('id', $taskIds)
282 358 ->whereNull('archived_at')
283 359 ->whereNull('parent_id')
284 - ->excludeTemplateBoards();
360 + ->onActiveAvailableBoards();
285 361
286 362 switch ($category) {
287 363 case 'overdue':
288 364 $taskQuery->overdue();
@@ -305,9 +381,10 @@
305 381 ->select('fbs_tasks.id')
306 382 ->whereIn('fbs_tasks.id', $taskIds)
307 383 ->whereNull('fbs_tasks.archived_at')
308 384 ->whereNull('fbs_tasks.parent_id')
309 - ->excludeTemplateBoards()
385 + ->where('fbs_tasks.status', '!=', 'closed')
386 + ->onActiveAvailableBoards()
310 387 ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
311 388 $join->on('rel.object_id', '=', 'fbs_tasks.id')
312 389 ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
313 390 ->where('rel.foreign_id', $currentUserId);
@@ -329,9 +406,9 @@
329 406 })
330 407 ->pluck('notification.task_id')
331 408 ->unique();
332 409
333 - return Task::whereIn('id', $taskIds)->excludeTemplateBoards()->count();
410 + return Task::whereIn('id', $taskIds)->onActiveAvailableBoards()->count();
334 411 default:
335 412 return 0;
336 413 }
337 414
@@ -611,10 +688,12 @@
611 688 }
612 689 $task->archived_at = $value == null ? null : current_time('mysql');
613 690 $task->save();
614 691 do_action('fluent_boards/task_archived', $task);
615 - $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
616 - $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 + }
617 696 }
618 697
619 698 private function updateStatus($value, $task)
620 699 {
@@ -784,95 +863,206 @@
784 863 $this->deleteTask($subtask);
785 864 }
786 865 }
787 866
788 - $deleted = $task->delete();
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);
789 906 $dbInstance = App::getInstance('db');
790 - $dbInstance->beginTransaction();
791 907
792 - $deletedTask = clone $task;
793 - //cloning because after delete $task object will be useless
908 + if (!$manageTransaction && !$dbInstance->inTransaction()) {
909 + throw new \RuntimeException(__('An active transaction is required for caller-managed task deletion.', 'fluent-boards'));
910 + }
794 911
912 + if ($manageTransaction) {
913 + $dbInstance->beginTransaction();
914 + }
915 +
795 916 try {
796 - $deleted = $task->delete();
917 + $relationTypes = [
918 + Constant::OBJECT_TYPE_USER_TASK_WATCH,
919 + Constant::OBJECT_TYPE_TASK_ASSIGNEE,
920 + Constant::OBJECT_TYPE_TASK_LABEL,
921 + ];
797 922
798 - if ($deleted) {
799 - //task assignees watchers removed
800 - $task->watchers()->detach();
801 - $task->assignees()->detach();
923 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
924 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
925 + }
802 926
803 - //removing all task related notifications
804 - $notificationIds = $task->notifications->pluck('id');
805 - $task->notifications()->delete();
806 - NotificationUser::whereIn('notification_id', $notificationIds)->delete();
927 + Relation::whereIn('object_id', $taskIds)
928 + ->whereIn('object_type', $relationTypes)
929 + ->delete();
807 930
808 - //task labels removed
809 - $task->labels()->detach();
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();
810 937
811 - //task custom field value
812 - if (defined('FLUENT_BOARDS_PRO')) {
813 - $task->customFields()->detach();
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)) {
955 + throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards'));
814 956 }
815 - $this->deleteTaskAttachments($task);
816 - //task custom field value
817 - if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
818 - $task->customFields()->detach();
819 - $this->deleteTaskAttachments($task);
820 - }
821 957
822 - // Delete time tracking records for this task
823 - $this->deleteTimeTrackingRecords($task->id);
958 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
824 959
825 - do_action('fluent_boards/task_deleted', $task);
826 - TaskMeta::where('task_id', $task->id)->delete();
827 - do_action('fluent_boards/task_deleted', $deletedTask);
828 - TaskMeta::where('task_id', $task->id)->delete();
960 + if ($manageTransaction) {
961 + $dbInstance->commit();
829 962 }
963 + } catch (\Throwable $e) {
964 + if ($manageTransaction) {
965 + $dbInstance->rollBack();
966 + }
830 967
831 - $dbInstance->commit();
832 - } catch (\Exception $e) {
833 - $dbInstance->rollBack();
834 - throw $e; // Re-throw the exception after rolling back
968 + throw $e;
835 969 }
970 + }
836 971
837 - }
838 - public function deleteTaskForBulk($task)
972 + /** Remove comment rows transactionally; file cleanup must wait for commit. */
973 + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance)
839 974 {
840 - // If this is a parent task, delete all subtasks first
841 - if (!$task->parent_id) {
842 - $subtasks = Task::where('parent_id', $task->id)->get();
843 - foreach ($subtasks as $subtask) {
844 - // Recursively delete each subtask (cleans up all their relations)
845 - $this->deleteTaskForBulk($subtask);
846 - }
975 + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray();
976 + if (!$commentTasks) {
977 + return;
847 978 }
848 979
849 - $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();
850 987
851 - 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 + }
852 1008
853 - //task assignees watchers removed
854 - $task->watchers()->detach();
855 - $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 + }
856 1016
857 - //removing all task related notifications
858 - $notificationIds = $task->notifications->pluck('id');
859 - $task->notifications()->delete();
860 - 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 + }
861 1031
862 - //task labels removed
863 - $task->labels()->detach();
864 -
865 - //task custom field value
866 - if (defined('FLUENT_BOARDS_PRO_VERSION')) {
867 - $task->customFields()->detach();
868 - $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 + }
869 1052 }
1053 + });
1054 + }
870 1055
871 - // For bulk delete, you might want to avoid firing hooks/actions,
872 - // so 'fluent_boards/task_deleted' is not triggered here.
873 - 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;
874 1062 }
1063 +
1064 + $this->deleteTask($task);
875 1065 }
876 1066 // this is invoked when task is moved to another board
877 1067
878 1068 /**
@@ -909,8 +1099,9 @@
909 1099 $dbInstance->beginTransaction();
910 1100
911 1101 try {
912 1102 $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId);
1103 + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
913 1104
914 1105 $task->board_id = (int) $targetBoardId;
915 1106 $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task';
916 1107
@@ -919,19 +1110,16 @@
919 1110 $task->assignees()->detach();
920 1111 $task->watchers()->detach();
921 1112 $this->removeCustomFieldAssociations($task);
922 1113
923 - // REMOVE: User-specific data to prevent security issues
924 - $this->removeCommentsAndReplies($task->id);
925 - $this->removeTimeTrackingRecords($task->id);
926 -
927 1114 // REMOVE: Recurring task settings for security
928 1115 $this->removeRecurringTaskSettings($task->id);
929 1116
930 1117 $task->save();
1118 + do_action('fluent_boards/task_moved_update_time_tracking', $task);
931 1119
932 1120 // MOVE: Subtasks to new board (preserves subtask groups)
933 - $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type, $attachmentFileService);
1121 + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService);
934 1122
935 1123 $dbInstance->commit();
936 1124 $attachmentFileService->commitMovedOriginalFiles();
937 1125 } catch (\Exception $e) {
@@ -947,9 +1135,9 @@
947 1135 /**
948 1136 * Move all subtasks to the new board when parent task is moved
949 1137 * Preserves subtask groups and their relationships
950 1138 */
951 - private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
1139 + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
952 1140 {
953 1141 // Get all subtasks of the parent task
954 1142 $subtasks = Task::where('parent_id', $parentTaskId)->get();
955 1143
@@ -958,10 +1146,12 @@
958 1146 }
959 1147
960 1148 foreach ($subtasks as $subtask) {
961 1149 // Update board_id and type
962 - $oldBoardId = (int) $subtask->board_id;
1150 + // Legacy subtasks may not have their own board_id; inherit the parent's source board.
1151 + $oldBoardId = absint($subtask->board_id) ?: absint($sourceBoardId);
963 1152 $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId);
1153 + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
964 1154
965 1155 $subtask->board_id = (int) $targetBoardId;
966 1156 $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task';
967 1157
@@ -974,16 +1164,13 @@
974 1164 $subtask->taskMeta()
975 1165 ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD)
976 1166 ->delete();
977 1167
978 - // REMOVE: User-specific data for security
979 - $this->removeCommentsAndReplies($subtask->id);
980 - $this->removeTimeTrackingRecords($subtask->id);
981 -
982 1168 // REMOVE: Recurring task settings
983 1169 $this->removeRecurringTaskSettings($subtask->id);
984 1170
985 1171 $subtask->save();
1172 + do_action('fluent_boards/task_moved_update_time_tracking', $subtask);
986 1173 }
987 1174 }
988 1175
989 1176 /**
@@ -1029,24 +1216,26 @@
1029 1216 }
1030 1217 }
1031 1218
1032 1219 /**
1033 - * Remove comments and replies for security reasons
1034 - * Prevents exposing user-specific data to unauthorized users
1220 + * Move a task's complete comment history and images to another board.
1035 1221 */
1036 - private function removeCommentsAndReplies($taskId)
1222 + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService)
1037 1223 {
1038 - // Input validation
1039 - if (!is_numeric($taskId) || $taskId <= 0) {
1224 + $taskId = absint($taskId);
1225 + $sourceBoardId = absint($sourceBoardId);
1226 + $targetBoardId = absint($targetBoardId);
1227 +
1228 + if (!$taskId || !$sourceBoardId || !$targetBoardId || $sourceBoardId === $targetBoardId) {
1040 1229 return;
1041 1230 }
1042 -
1043 - // Remove all comments and replies for this task (delete individually to fire model events and clean up images)
1044 - $comments = Comment::where('task_id', (int) $taskId)->get();
1045 - foreach ($comments as $comment) {
1046 - $comment->delete();
1047 - }
1048 1231
1232 + $attachmentFileService->moveCommentImagesToBoard($taskId, $sourceBoardId, $targetBoardId);
1233 +
1234 + // Bypass ORM timestamps so only board ownership changes.
1235 + Comment::where('task_id', $taskId)
1236 + ->toBase()
1237 + ->update(['board_id' => $targetBoardId]);
1049 1238 }
1050 1239
1051 1240 /**
1052 1241 * Remove time tracking records for security reasons
@@ -1074,9 +1263,9 @@
1074 1263 return;
1075 1264 }
1076 1265
1077 1266 // Remove all attachments for this task
1078 - if (class_exists('FluentBoardsPro\App\Models\TaskAttachment')) {
1267 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
1079 1268 \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', (int) $taskId)
1080 1269 ->where('object_type', 'task')
1081 1270 ->delete();
1082 1271 }
@@ -1311,8 +1500,9 @@
1311 1500
1312 1501 return $tasks;
1313 1502 }
1314 1503
1504 + /** Fetch a page of board tasks, applying filters before pagination. */
1315 1505 public function getTableTasks($boardId, $data = [])
1316 1506 {
1317 1507 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20;
1318 1508 $page = isset($data['page']) ? intval($data['page']) : 1;
@@ -1320,8 +1510,10 @@
1320 1510 $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc';
1321 1511 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1322 1512 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1323 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', []));
1324 1516 $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1325 1517 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1326 1518 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1327 1519 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
@@ -1358,8 +1550,9 @@
1358 1550 'title',
1359 1551 'slug',
1360 1552 'board_id',
1361 1553 'parent_id',
1554 + 'crm_contact_id',
1362 1555 'type',
1363 1556 'stage_id',
1364 1557 'status',
1365 1558 'priority',
@@ -1409,8 +1602,10 @@
1409 1602 $this->applyTableTaskSearch($tasksQuery, $search);
1410 1603 $this->applyTableTaskFilters($tasksQuery, [
1411 1604 'stage' => $stageFilters,
1412 1605 'task_status' => $taskStatusFilters,
1606 + 'recurring' => $recurringFilters,
1607 + 'template' => $templateFilters,
1413 1608 'priority' => $priorityFilters,
1414 1609 'assignee' => $assigneeFilters,
1415 1610 'labels' => $labelFilters,
1416 1611 'watchers' => $watcherFilters,
@@ -1427,13 +1622,16 @@
1427 1622 ->orderBy($sortColumn, $sortDirection)
1428 1623 ->paginate($perPage, ['*'], 'page', $page);
1429 1624 }
1430 1625
1626 + /** Fetch matching board cards with the same filters as table view. */
1431 1627 public function getBoardViewTasks($boardId, $data = [])
1432 1628 {
1433 1629 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1434 1630 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1435 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', []));
1436 1634 $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1437 1635 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1438 1636 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1439 1637 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
@@ -1481,8 +1679,10 @@
1481 1679 $this->applyTableTaskSearch($tasksQuery, $search);
1482 1680 $this->applyTableTaskFilters($tasksQuery, [
1483 1681 'stage' => $stageFilters,
1484 1682 'task_status' => $taskStatusFilters,
1683 + 'recurring' => $recurringFilters,
1684 + 'template' => $templateFilters,
1485 1685 'priority' => $priorityFilters,
1486 1686 'assignee' => $assigneeFilters,
1487 1687 'labels' => $labelFilters,
1488 1688 'watchers' => $watcherFilters,
@@ -1532,8 +1732,9 @@
1532 1732 $escapedSearch = $wpdb->esc_like($search);
1533 1733 $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%');
1534 1734 }
1535 1735
1736 + /** Apply board filters to table and card queries, gating Pro-only options. */
1536 1737 private function applyTableTaskFilters($tasksQuery, $filters)
1537 1738 {
1538 1739 $stageFilters = Arr::get($filters, 'stage', []);
1539 1740 $taskStatusFilters = Arr::get($filters, 'task_status', []);
@@ -1544,8 +1745,22 @@
1544 1745 $contactFilters = Arr::get($filters, 'contact', []);
1545 1746 $customFieldFilters = Arr::get($filters, 'custom_fields', []);
1546 1747 $dueDateFilters = Arr::get($filters, 'due_date', []);
1547 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 +
1548 1763 if ($stageFilters) {
1549 1764 $this->applyTableStageFilters($tasksQuery, $stageFilters);
1550 1765 }
1551 1766
@@ -1784,8 +1999,12 @@
1784 1999 }
1785 2000
1786 2001 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
1787 2002 {
2003 + if (empty($assigneeIds)) {
2004 + return;
2005 + }
2006 +
1788 2007 $current_user_id = get_current_user_id();
1789 2008 /* this will run in background as soon as possible */
1790 2009 /* sending Model or Model Instance won't work here */
1791 2010
@@ -1849,8 +2068,9 @@
1849 2068 }
1850 2069
1851 2070 public function getActivities($taskId, $perPage, $filter = 'newest')
1852 2071 {
2072 + $perPage = Sanitize::perPage($perPage);
1853 2073 $activityQuery = Activity::where('object_id', $taskId)
1854 2074 ->where('object_type', Constant::ACTIVITY_TASK);
1855 2075 if ($filter == 'newest') {
1856 2076 $activityQuery = $activityQuery->latest();
@@ -2013,11 +2233,14 @@
2013 2233
2014 2234 return $subtaskGroupMap;
2015 2235 }
2016 2236
2017 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
2237 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = [])
2018 2238 {
2019 - $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();
2020 2243 $taskMap = [];
2021 2244 $subtaskGroupMap = [];
2022 2245 $parentTaskCount = 0;
2023 2246 $attachmentFileService = new AttachmentFileService();
@@ -2084,8 +2307,9 @@
2084 2307 'value' => $isTemplate->value
2085 2308 ]);
2086 2309 }
2087 2310 }
2311 + $this->copyTaskCustomFields($task, $newTask, $customFieldMap);
2088 2312 if(!$task->parent_id){
2089 2313 ++$parentTaskCount;
2090 2314 $taskMap[$task['id']] = $newTask->id;
2091 2315 //duplicate labels to task
@@ -2114,8 +2338,31 @@
2114 2338 throw $e;
2115 2339 }
2116 2340 }
2117 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 +
2118 2365 private function subtaskCountUpdate($taskId){
2119 2366 $parentTask = Task::findOrFail($taskId);
2120 2367 $settings = $parentTask->settings;
2121 2368 $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1;
@@ -2133,81 +2380,102 @@
2133 2380 * @return array
2134 2381 */
2135 2382 public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
2136 2383 {
2384 + $perPage = Sanitize::perPage($perPage);
2385 + $page = max(1, (int) $page);
2137 2386 // Fetch the task
2138 2387 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2139 2388 $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
2140 2389
2141 - // Fetch comments and activities separately
2142 - $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;
2143 2406 if ($feedType !== 'activities') {
2144 - $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 + }
2145 2411 }
2146 -
2147 - $activities = [];
2148 2412 if ($feedType !== 'comments') {
2149 - $activities = $task->activities()
2150 - ->with('user')
2151 - ->where(function($query) {
2152 - $query->whereNotIn('column', [ 'comment', 'a reply'])
2153 - ->orWhere(function($subQuery) {
2154 - $subQuery->whereNotIn('action', ['added', 'updated']);
2155 - });
2156 - })
2157 - ->orderBy('created_at', 'desc')
2158 - ->get()
2159 - ->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 + }
2160 2417 }
2161 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);
2162 2425
2163 - // Merge comments and activities into a single array
2164 - $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 + }
2165 2438
2166 - // Sort the merged array by created_at date in ascending or descending order
2167 - $order = $filter == 'newest' ? -1 : 1;
2168 - usort($commentsAndActivities, function ($a, $b) use ($order) {
2169 - return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
2170 - });
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 + }
2171 2445
2172 - // Paginate the results
2173 - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
2174 - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
2175 -
2176 - // Get the total count of comments and activities
2177 - $total = count($commentsAndActivities);
2178 2446 $lastPage = (int) ceil($total / $perPage);
2179 2447
2180 2448 // Construct pagination metadata
2181 - $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");
2182 2450 return [
2183 2451 'current_page' => (int) $page,
2184 2452 'data' => $paginatedResults,
2185 - 'first_page_url' => "{$path}?page=1",
2453 + 'first_page_url' => add_query_arg('page', 1, $path),
2186 2454 'from' => $total > 0 ? (int) ($offset + 1) : null,
2187 2455 'last_page' => (int) $lastPage,
2188 - 'last_page_url' => "{$path}?page={$lastPage}",
2456 + 'last_page_url' => add_query_arg('page', $lastPage, $path),
2189 2457 'links' => [
2190 2458 [
2191 - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2459 + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
2192 2460 'label' => 'pagination.previous',
2193 2461 'active' => false
2194 2462 ],
2195 2463 [
2196 - 'url' => "{$path}?page={$page}",
2464 + 'url' => add_query_arg('page', $page, $path),
2197 2465 'label' => (int) $page,
2198 2466 'active' => true
2199 2467 ],
2200 2468 [
2201 - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2469 + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
2202 2470 'label' => 'pagination.next',
2203 2471 'active' => false
2204 2472 ]
2205 2473 ],
2206 - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2474 + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
2207 2475 'path' => $path,
2208 2476 'per_page' => (int) $perPage,
2209 - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2477 + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
2210 2478 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
2211 2479 'total' => (int) $total
2212 2480 ];
2213 2481 }
@@ -2262,11 +2530,15 @@
2262 2530 }
2263 2531 return $title ?? $UrlMeta['title'] ?? '';
2264 2532 }
2265 2533
2266 - 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)
2267 2536 {
2268 - $stage = Stage::findOrFail($stageId);
2537 + if (!$stage || (int) $stage->id !== (int) $stageId) {
2538 + $stage = Stage::findOrFail($stageId);
2539 + }
2540 + $assignmentsChanged = false;
2269 2541 if ($stage && isset($stage->settings['default_task_assignees'])) {
2270 2542 $defaultAssignees = $stage->settings['default_task_assignees'];
2271 2543 foreach ($defaultAssignees as $assigneeId) {
2272 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
@@ -2272,11 +2544,13 @@
2272 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
2273 2545 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
2274 2546 if (!$IfAlreadyAssignee) {
2275 2547 $this->updateAssignee($assigneeId, $task);
2548 + $assignmentsChanged = true;
2276 2549 }
2277 2550 }
2278 2551 }
2552 + return $assignmentsChanged;
2279 2553 }
2280 2554
2281 2555 public function manageDefaultWatchers($task, $stageId)
2282 2556 {
@@ -2383,8 +2657,12 @@
2383 2657 }
2384 2658
2385 2659 private function deleteTaskAttachments($task)
2386 2660 {
2661 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2662 + return;
2663 + }
2664 +
2387 2665 $attachments = TaskAttachment::where('object_id', $task->id)
2388 2666 ->where('object_type', Constant::TASK_ATTACHMENT)
2389 2667 ->get();
2390 2668 foreach ($attachments as $attachment) {
@@ -2390,12 +2668,38 @@
2390 2668 foreach ($attachments as $attachment) {
2391 2669 $deletedAttachment = clone $attachment;
2392 2670 $attachment->delete();
2393 2671
2394 - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2672 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2395 2673 }
2396 2674 }
2397 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 +
2398 2702 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2399 2703 {
2400 2704 global $wpdb;
2401 2705 $attachmentFileService = new AttachmentFileService();
@@ -2422,9 +2726,10 @@
2422 2726 }
2423 2727
2424 2728 // Create new task with cloned data
2425 2729 $clonedTask = $task->replicate();
2426 - $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);
2427 2732
2428 2733 $settings = $clonedTask->settings ?? [];
2429 2734
2430 2735 unset(
@@ -2501,9 +2806,10 @@
2501 2806 $attachmentFileService->rollbackCreatedFiles();
2502 2807 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2503 2808 $wpdb->query('ROLLBACK');
2504 2809 throw new \Exception(
2505 - 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())),
2506 2812 (int) ($e->getCode() ?: 500)
2507 2813 );
2508 2814 }
2509 2815 }
@@ -2649,8 +2955,14 @@
2649 2955 if ($images->count() > 0) {
2650 2956 foreach ($images as $image) {
2651 2957 $clonedImage = $image->replicate();
2652 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 + );
2653 2965 $clonedImage->save();
2654 2966 }
2655 2967 }
2656 2968 }
@@ -2862,8 +3174,9 @@
2862 3174
2863 3175 $successfulTasks = [];
2864 3176
2865 3177 foreach ($tasks as $task) {
3178 + $oldStageId = $task->stage_id;
2866 3179 if ($isMovingToAnotherBoard) {
2867 3180 // Cross-board move - use existing method for data cleanup and security
2868 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2869 3182 $task->stage_id = $targetStageId;
@@ -2868,33 +3181,32 @@
2868 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2869 3182 $task->stage_id = $targetStageId;
2870 3183 $task = $task->moveToNewPosition(null);
2871 3184 } else {
2872 - // Same board - simple stage move
2873 - $oldStageId = $task->stage_id;
2874 3185 $task->stage_id = $targetStageId;
2875 3186 $task = $task->moveToNewPosition(1);
2876 -
2877 - // Only process stage-specific logic if stage actually changed
2878 - if ($oldStageId != $targetStageId) {
2879 - $this->manageDefaultAssignees($task, $targetStageId);
2880 -
2881 - $defaultPosition = $task->stage->defaultTaskStatus();
2882 - if ($defaultPosition == 'closed' && $task->status != 'closed') {
2883 - $task = $task->close();
2884 - }
2885 -
2886 - $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
2887 - $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();
2888 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);
2889 3204 }
2890 -
2891 - // Reload task with all relationships
2892 - $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2893 -
3205 +
2894 3206 $successfulTasks[] = $task;
2895 3207 }
2896 -
3208 +
2897 3209 $successCount = count($successfulTasks);
2898 3210
2899 3211 if ($isMovingToAnotherBoard) {
2900 3212 // translators: %d is the number of tasks successfully moved to another board
@@ -3235,35 +3547,50 @@
3235 3547 'message' => $message
3236 3548 ];
3237 3549 }
3238 3550
3239 - /* Delete time tracking records for one or multiple tasks
3240 - * Uses try-catch for better performance - avoids table existence check overhead
3551 + /**
3552 + * Delete time tracking records for one or multiple tasks when the table exists.
3241 3553 *
3242 - * @param int|array $taskIds Single task ID or array of task IDs
3554 + * @param int|array $taskIds Single task ID or array of task IDs.
3555 + * @param bool $suppressErrors Whether cleanup failures should be ignored.
3243 3556 * @return void
3244 3557 */
3245 - public function deleteTimeTrackingRecords($taskIds)
3558 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
3246 3559 {
3247 - // Check if FluentBoards Pro time tracking is available
3248 - if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
3560 + if (!class_exists(TimeTrack::class)) {
3249 3561 return;
3250 3562 }
3251 3563
3252 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 +
3253 3579 // Handle single task ID or array of task IDs
3254 3580 if (is_array($taskIds)) {
3255 3581 if (!empty($taskIds)) {
3256 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete();
3582 + TimeTrack::whereIn('task_id', $taskIds)->delete();
3257 3583 }
3258 3584 } else {
3259 3585 if (is_numeric($taskIds) && $taskIds > 0) {
3260 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
3586 + TimeTrack::where('task_id', (int) $taskIds)->delete();
3261 3587 }
3262 3588 }
3263 - } catch (\Exception $e) {
3264 - // Silently fail if table doesn't exist or any other error occurs
3265 - // This is intentional for cleanup operations
3589 + } catch (\Throwable $e) {
3590 + if (!$suppressErrors) {
3591 + throw $e;
3592 + }
3266 3593 }
3267 3594 }
3268 3595
3269 3596 }