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 +484 -163 2.0.4 → 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,95 +863,206 @@
786 863 $this->deleteTask($subtask);
787 864 }
788 865 }
789 866
790 - $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);
791 906 $dbInstance = App::getInstance('db');
792 - $dbInstance->beginTransaction();
793 907
794 - $deletedTask = clone $task;
795 - //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 + }
796 911
912 + if ($manageTransaction) {
913 + $dbInstance->beginTransaction();
914 + }
915 +
797 916 try {
798 - $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 + ];
799 922
800 - if ($deleted) {
801 - //task assignees watchers removed
802 - $task->watchers()->detach();
803 - $task->assignees()->detach();
923 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
924 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
925 + }
804 926
805 - //removing all task related notifications
806 - $notificationIds = $task->notifications->pluck('id');
807 - $task->notifications()->delete();
808 - NotificationUser::whereIn('notification_id', $notificationIds)->delete();
927 + Relation::whereIn('object_id', $taskIds)
928 + ->whereIn('object_type', $relationTypes)
929 + ->delete();
809 930
810 - //task labels removed
811 - $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();
812 937
813 - //task custom field value
814 - if (defined('FLUENT_BOARDS_PRO')) {
815 - $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'));
816 956 }
817 - $this->deleteTaskAttachments($task);
818 - //task custom field value
819 - if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
820 - $task->customFields()->detach();
821 - $this->deleteTaskAttachments($task);
822 - }
823 957
824 - // Delete time tracking records for this task
825 - $this->deleteTimeTrackingRecords($task->id);
958 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
826 959
827 - do_action('fluent_boards/task_deleted', $task);
828 - TaskMeta::where('task_id', $task->id)->delete();
829 - do_action('fluent_boards/task_deleted', $deletedTask);
830 - TaskMeta::where('task_id', $task->id)->delete();
960 + if ($manageTransaction) {
961 + $dbInstance->commit();
831 962 }
963 + } catch (\Throwable $e) {
964 + if ($manageTransaction) {
965 + $dbInstance->rollBack();
966 + }
832 967
833 - $dbInstance->commit();
834 - } catch (\Exception $e) {
835 - $dbInstance->rollBack();
836 - throw $e; // Re-throw the exception after rolling back
968 + throw $e;
837 969 }
970 + }
838 971
839 - }
840 - public function deleteTaskForBulk($task)
972 + /** Remove comment rows transactionally; file cleanup must wait for commit. */
973 + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance)
841 974 {
842 - // If this is a parent task, delete all subtasks first
843 - if (!$task->parent_id) {
844 - $subtasks = Task::where('parent_id', $task->id)->get();
845 - foreach ($subtasks as $subtask) {
846 - // Recursively delete each subtask (cleans up all their relations)
847 - $this->deleteTaskForBulk($subtask);
848 - }
975 + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray();
976 + if (!$commentTasks) {
977 + return;
849 978 }
850 979
851 - $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();
852 987
853 - 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 + }
854 1008
855 - //task assignees watchers removed
856 - $task->watchers()->detach();
857 - $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 + }
858 1016
859 - //removing all task related notifications
860 - $notificationIds = $task->notifications->pluck('id');
861 - $task->notifications()->delete();
862 - 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 + }
863 1031
864 - //task labels removed
865 - $task->labels()->detach();
866 -
867 - //task custom field value
868 - if (defined('FLUENT_BOARDS_PRO_VERSION')) {
869 - $task->customFields()->detach();
870 - $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 + }
871 1052 }
1053 + });
1054 + }
872 1055
873 - // For bulk delete, you might want to avoid firing hooks/actions,
874 - // so 'fluent_boards/task_deleted' is not triggered here.
875 - 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;
876 1062 }
1063 +
1064 + $this->deleteTask($task);
877 1065 }
878 1066 // this is invoked when task is moved to another board
879 1067
880 1068 /**
@@ -911,8 +1099,9 @@
911 1099 $dbInstance->beginTransaction();
912 1100
913 1101 try {
914 1102 $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId);
1103 + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
915 1104
916 1105 $task->board_id = (int) $targetBoardId;
917 1106 $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task';
918 1107
@@ -921,19 +1110,16 @@
921 1110 $task->assignees()->detach();
922 1111 $task->watchers()->detach();
923 1112 $this->removeCustomFieldAssociations($task);
924 1113
925 - // REMOVE: User-specific data to prevent security issues
926 - $this->removeCommentsAndReplies($task->id);
927 - $this->removeTimeTrackingRecords($task->id);
928 -
929 1114 // REMOVE: Recurring task settings for security
930 1115 $this->removeRecurringTaskSettings($task->id);
931 1116
932 1117 $task->save();
1118 + do_action('fluent_boards/task_moved_update_time_tracking', $task);
933 1119
934 1120 // MOVE: Subtasks to new board (preserves subtask groups)
935 - $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type, $attachmentFileService);
1121 + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService);
936 1122
937 1123 $dbInstance->commit();
938 1124 $attachmentFileService->commitMovedOriginalFiles();
939 1125 } catch (\Exception $e) {
@@ -949,9 +1135,9 @@
949 1135 /**
950 1136 * Move all subtasks to the new board when parent task is moved
951 1137 * Preserves subtask groups and their relationships
952 1138 */
953 - private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
1139 + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
954 1140 {
955 1141 // Get all subtasks of the parent task
956 1142 $subtasks = Task::where('parent_id', $parentTaskId)->get();
957 1143
@@ -960,10 +1146,12 @@
960 1146 }
961 1147
962 1148 foreach ($subtasks as $subtask) {
963 1149 // Update board_id and type
964 - $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);
965 1152 $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId);
1153 + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
966 1154
967 1155 $subtask->board_id = (int) $targetBoardId;
968 1156 $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task';
969 1157
@@ -976,16 +1164,13 @@
976 1164 $subtask->taskMeta()
977 1165 ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD)
978 1166 ->delete();
979 1167
980 - // REMOVE: User-specific data for security
981 - $this->removeCommentsAndReplies($subtask->id);
982 - $this->removeTimeTrackingRecords($subtask->id);
983 -
984 1168 // REMOVE: Recurring task settings
985 1169 $this->removeRecurringTaskSettings($subtask->id);
986 1170
987 1171 $subtask->save();
1172 + do_action('fluent_boards/task_moved_update_time_tracking', $subtask);
988 1173 }
989 1174 }
990 1175
991 1176 /**
@@ -1031,24 +1216,26 @@
1031 1216 }
1032 1217 }
1033 1218
1034 1219 /**
1035 - * Remove comments and replies for security reasons
1036 - * Prevents exposing user-specific data to unauthorized users
1220 + * Move a task's complete comment history and images to another board.
1037 1221 */
1038 - private function removeCommentsAndReplies($taskId)
1222 + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService)
1039 1223 {
1040 - // Input validation
1041 - 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) {
1042 1229 return;
1043 1230 }
1044 -
1045 - // Remove all comments and replies for this task (delete individually to fire model events and clean up images)
1046 - $comments = Comment::where('task_id', (int) $taskId)->get();
1047 - foreach ($comments as $comment) {
1048 - $comment->delete();
1049 - }
1050 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]);
1051 1238 }
1052 1239
1053 1240 /**
1054 1241 * Remove time tracking records for security reasons
@@ -1313,8 +1500,9 @@
1313 1500
1314 1501 return $tasks;
1315 1502 }
1316 1503
1504 + /** Fetch a page of board tasks, applying filters before pagination. */
1317 1505 public function getTableTasks($boardId, $data = [])
1318 1506 {
1319 1507 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20;
1320 1508 $page = isset($data['page']) ? intval($data['page']) : 1;
@@ -1322,8 +1510,10 @@
1322 1510 $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc';
1323 1511 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1324 1512 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1325 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', []));
1326 1516 $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1327 1517 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1328 1518 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1329 1519 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
@@ -1360,8 +1550,9 @@
1360 1550 'title',
1361 1551 'slug',
1362 1552 'board_id',
1363 1553 'parent_id',
1554 + 'crm_contact_id',
1364 1555 'type',
1365 1556 'stage_id',
1366 1557 'status',
1367 1558 'priority',
@@ -1411,8 +1602,10 @@
1411 1602 $this->applyTableTaskSearch($tasksQuery, $search);
1412 1603 $this->applyTableTaskFilters($tasksQuery, [
1413 1604 'stage' => $stageFilters,
1414 1605 'task_status' => $taskStatusFilters,
1606 + 'recurring' => $recurringFilters,
1607 + 'template' => $templateFilters,
1415 1608 'priority' => $priorityFilters,
1416 1609 'assignee' => $assigneeFilters,
1417 1610 'labels' => $labelFilters,
1418 1611 'watchers' => $watcherFilters,
@@ -1429,13 +1622,16 @@
1429 1622 ->orderBy($sortColumn, $sortDirection)
1430 1623 ->paginate($perPage, ['*'], 'page', $page);
1431 1624 }
1432 1625
1626 + /** Fetch matching board cards with the same filters as table view. */
1433 1627 public function getBoardViewTasks($boardId, $data = [])
1434 1628 {
1435 1629 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1436 1630 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1437 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', []));
1438 1634 $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1439 1635 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1440 1636 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1441 1637 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
@@ -1483,8 +1679,10 @@
1483 1679 $this->applyTableTaskSearch($tasksQuery, $search);
1484 1680 $this->applyTableTaskFilters($tasksQuery, [
1485 1681 'stage' => $stageFilters,
1486 1682 'task_status' => $taskStatusFilters,
1683 + 'recurring' => $recurringFilters,
1684 + 'template' => $templateFilters,
1487 1685 'priority' => $priorityFilters,
1488 1686 'assignee' => $assigneeFilters,
1489 1687 'labels' => $labelFilters,
1490 1688 'watchers' => $watcherFilters,
@@ -1534,8 +1732,9 @@
1534 1732 $escapedSearch = $wpdb->esc_like($search);
1535 1733 $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%');
1536 1734 }
1537 1735
1736 + /** Apply board filters to table and card queries, gating Pro-only options. */
1538 1737 private function applyTableTaskFilters($tasksQuery, $filters)
1539 1738 {
1540 1739 $stageFilters = Arr::get($filters, 'stage', []);
1541 1740 $taskStatusFilters = Arr::get($filters, 'task_status', []);
@@ -1546,8 +1745,22 @@
1546 1745 $contactFilters = Arr::get($filters, 'contact', []);
1547 1746 $customFieldFilters = Arr::get($filters, 'custom_fields', []);
1548 1747 $dueDateFilters = Arr::get($filters, 'due_date', []);
1549 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 +
1550 1763 if ($stageFilters) {
1551 1764 $this->applyTableStageFilters($tasksQuery, $stageFilters);
1552 1765 }
1553 1766
@@ -1786,8 +1999,12 @@
1786 1999 }
1787 2000
1788 2001 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
1789 2002 {
2003 + if (empty($assigneeIds)) {
2004 + return;
2005 + }
2006 +
1790 2007 $current_user_id = get_current_user_id();
1791 2008 /* this will run in background as soon as possible */
1792 2009 /* sending Model or Model Instance won't work here */
1793 2010
@@ -1851,8 +2068,9 @@
1851 2068 }
1852 2069
1853 2070 public function getActivities($taskId, $perPage, $filter = 'newest')
1854 2071 {
2072 + $perPage = Sanitize::perPage($perPage);
1855 2073 $activityQuery = Activity::where('object_id', $taskId)
1856 2074 ->where('object_type', Constant::ACTIVITY_TASK);
1857 2075 if ($filter == 'newest') {
1858 2076 $activityQuery = $activityQuery->latest();
@@ -2015,11 +2233,14 @@
2015 2233
2016 2234 return $subtaskGroupMap;
2017 2235 }
2018 2236
2019 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
2237 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = [])
2020 2238 {
2021 - $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();
2022 2243 $taskMap = [];
2023 2244 $subtaskGroupMap = [];
2024 2245 $parentTaskCount = 0;
2025 2246 $attachmentFileService = new AttachmentFileService();
@@ -2086,8 +2307,9 @@
2086 2307 'value' => $isTemplate->value
2087 2308 ]);
2088 2309 }
2089 2310 }
2311 + $this->copyTaskCustomFields($task, $newTask, $customFieldMap);
2090 2312 if(!$task->parent_id){
2091 2313 ++$parentTaskCount;
2092 2314 $taskMap[$task['id']] = $newTask->id;
2093 2315 //duplicate labels to task
@@ -2116,8 +2338,31 @@
2116 2338 throw $e;
2117 2339 }
2118 2340 }
2119 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 +
2120 2365 private function subtaskCountUpdate($taskId){
2121 2366 $parentTask = Task::findOrFail($taskId);
2122 2367 $settings = $parentTask->settings;
2123 2368 $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1;
@@ -2135,81 +2380,102 @@
2135 2380 * @return array
2136 2381 */
2137 2382 public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
2138 2383 {
2384 + $perPage = Sanitize::perPage($perPage);
2385 + $page = max(1, (int) $page);
2139 2386 // Fetch the task
2140 2387 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2141 2388 $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
2142 2389
2143 - // Fetch comments and activities separately
2144 - $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;
2145 2406 if ($feedType !== 'activities') {
2146 - $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 + }
2147 2411 }
2148 -
2149 - $activities = [];
2150 2412 if ($feedType !== 'comments') {
2151 - $activities = $task->activities()
2152 - ->with('user')
2153 - ->where(function($query) {
2154 - $query->whereNotIn('column', [ 'comment', 'a reply'])
2155 - ->orWhere(function($subQuery) {
2156 - $subQuery->whereNotIn('action', ['added', 'updated']);
2157 - });
2158 - })
2159 - ->orderBy('created_at', 'desc')
2160 - ->get()
2161 - ->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 + }
2162 2417 }
2163 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);
2164 2425
2165 - // Merge comments and activities into a single array
2166 - $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 + }
2167 2438
2168 - // Sort the merged array by created_at date in ascending or descending order
2169 - $order = $filter == 'newest' ? -1 : 1;
2170 - usort($commentsAndActivities, function ($a, $b) use ($order) {
2171 - return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
2172 - });
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 + }
2173 2445
2174 - // Paginate the results
2175 - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
2176 - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
2177 -
2178 - // Get the total count of comments and activities
2179 - $total = count($commentsAndActivities);
2180 2446 $lastPage = (int) ceil($total / $perPage);
2181 2447
2182 2448 // Construct pagination metadata
2183 - $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");
2184 2450 return [
2185 2451 'current_page' => (int) $page,
2186 2452 'data' => $paginatedResults,
2187 - 'first_page_url' => "{$path}?page=1",
2453 + 'first_page_url' => add_query_arg('page', 1, $path),
2188 2454 'from' => $total > 0 ? (int) ($offset + 1) : null,
2189 2455 'last_page' => (int) $lastPage,
2190 - 'last_page_url' => "{$path}?page={$lastPage}",
2456 + 'last_page_url' => add_query_arg('page', $lastPage, $path),
2191 2457 'links' => [
2192 2458 [
2193 - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2459 + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
2194 2460 'label' => 'pagination.previous',
2195 2461 'active' => false
2196 2462 ],
2197 2463 [
2198 - 'url' => "{$path}?page={$page}",
2464 + 'url' => add_query_arg('page', $page, $path),
2199 2465 'label' => (int) $page,
2200 2466 'active' => true
2201 2467 ],
2202 2468 [
2203 - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2469 + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
2204 2470 'label' => 'pagination.next',
2205 2471 'active' => false
2206 2472 ]
2207 2473 ],
2208 - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2474 + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
2209 2475 'path' => $path,
2210 2476 'per_page' => (int) $perPage,
2211 - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2477 + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
2212 2478 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
2213 2479 'total' => (int) $total
2214 2480 ];
2215 2481 }
@@ -2264,11 +2530,15 @@
2264 2530 }
2265 2531 return $title ?? $UrlMeta['title'] ?? '';
2266 2532 }
2267 2533
2268 - 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)
2269 2536 {
2270 - $stage = Stage::findOrFail($stageId);
2537 + if (!$stage || (int) $stage->id !== (int) $stageId) {
2538 + $stage = Stage::findOrFail($stageId);
2539 + }
2540 + $assignmentsChanged = false;
2271 2541 if ($stage && isset($stage->settings['default_task_assignees'])) {
2272 2542 $defaultAssignees = $stage->settings['default_task_assignees'];
2273 2543 foreach ($defaultAssignees as $assigneeId) {
2274 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
@@ -2274,11 +2544,13 @@
2274 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
2275 2545 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
2276 2546 if (!$IfAlreadyAssignee) {
2277 2547 $this->updateAssignee($assigneeId, $task);
2548 + $assignmentsChanged = true;
2278 2549 }
2279 2550 }
2280 2551 }
2552 + return $assignmentsChanged;
2281 2553 }
2282 2554
2283 2555 public function manageDefaultWatchers($task, $stageId)
2284 2556 {
@@ -2396,12 +2668,38 @@
2396 2668 foreach ($attachments as $attachment) {
2397 2669 $deletedAttachment = clone $attachment;
2398 2670 $attachment->delete();
2399 2671
2400 - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2672 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2401 2673 }
2402 2674 }
2403 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 +
2404 2702 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2405 2703 {
2406 2704 global $wpdb;
2407 2705 $attachmentFileService = new AttachmentFileService();
@@ -2428,9 +2726,10 @@
2428 2726 }
2429 2727
2430 2728 // Create new task with cloned data
2431 2729 $clonedTask = $task->replicate();
2432 - $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);
2433 2732
2434 2733 $settings = $clonedTask->settings ?? [];
2435 2734
2436 2735 unset(
@@ -2507,9 +2806,10 @@
2507 2806 $attachmentFileService->rollbackCreatedFiles();
2508 2807 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2509 2808 $wpdb->query('ROLLBACK');
2510 2809 throw new \Exception(
2511 - 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())),
2512 2812 (int) ($e->getCode() ?: 500)
2513 2813 );
2514 2814 }
2515 2815 }
@@ -2655,8 +2955,14 @@
2655 2955 if ($images->count() > 0) {
2656 2956 foreach ($images as $image) {
2657 2957 $clonedImage = $image->replicate();
2658 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 + );
2659 2965 $clonedImage->save();
2660 2966 }
2661 2967 }
2662 2968 }
@@ -2868,8 +3174,9 @@
2868 3174
2869 3175 $successfulTasks = [];
2870 3176
2871 3177 foreach ($tasks as $task) {
3178 + $oldStageId = $task->stage_id;
2872 3179 if ($isMovingToAnotherBoard) {
2873 3180 // Cross-board move - use existing method for data cleanup and security
2874 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2875 3182 $task->stage_id = $targetStageId;
@@ -2874,33 +3181,32 @@
2874 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2875 3182 $task->stage_id = $targetStageId;
2876 3183 $task = $task->moveToNewPosition(null);
2877 3184 } else {
2878 - // Same board - simple stage move
2879 - $oldStageId = $task->stage_id;
2880 3185 $task->stage_id = $targetStageId;
2881 3186 $task = $task->moveToNewPosition(1);
2882 -
2883 - // Only process stage-specific logic if stage actually changed
2884 - if ($oldStageId != $targetStageId) {
2885 - $this->manageDefaultAssignees($task, $targetStageId);
2886 -
2887 - $defaultPosition = $task->stage->defaultTaskStatus();
2888 - if ($defaultPosition == 'closed' && $task->status != 'closed') {
2889 - $task = $task->close();
2890 - }
2891 -
2892 - $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
2893 - $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();
2894 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);
2895 3204 }
2896 -
2897 - // Reload task with all relationships
2898 - $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2899 -
3205 +
2900 3206 $successfulTasks[] = $task;
2901 3207 }
2902 -
3208 +
2903 3209 $successCount = count($successfulTasks);
2904 3210
2905 3211 if ($isMovingToAnotherBoard) {
2906 3212 // translators: %d is the number of tasks successfully moved to another board
@@ -3241,35 +3547,50 @@
3241 3547 'message' => $message
3242 3548 ];
3243 3549 }
3244 3550
3245 - /* Delete time tracking records for one or multiple tasks
3246 - * 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.
3247 3553 *
3248 - * @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.
3249 3556 * @return void
3250 3557 */
3251 - public function deleteTimeTrackingRecords($taskIds)
3558 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
3252 3559 {
3253 - // Check if FluentBoards Pro time tracking is available
3254 - if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
3560 + if (!class_exists(TimeTrack::class)) {
3255 3561 return;
3256 3562 }
3257 3563
3258 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 +
3259 3579 // Handle single task ID or array of task IDs
3260 3580 if (is_array($taskIds)) {
3261 3581 if (!empty($taskIds)) {
3262 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete();
3582 + TimeTrack::whereIn('task_id', $taskIds)->delete();
3263 3583 }
3264 3584 } else {
3265 3585 if (is_numeric($taskIds) && $taskIds > 0) {
3266 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
3586 + TimeTrack::where('task_id', (int) $taskIds)->delete();
3267 3587 }
3268 3588 }
3269 - } catch (\Exception $e) {
3270 - // Silently fail if table doesn't exist or any other error occurs
3271 - // This is intentional for cleanup operations
3589 + } catch (\Throwable $e) {
3590 + if (!$suppressErrors) {
3591 + throw $e;
3592 + }
3272 3593 }
3273 3594 }
3274 3595
3275 3596 }