PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / trunk
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration vtrunk
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 1.41 1.45 All 41 releases
← All changes | app/Services/TaskService.php +179 -65 2.0.10trunk View file →
@@ -4,8 +4,9 @@
4 4
5 5 use FluentBoards\App\App;
6 6 use FluentBoards\App\Models\Attachment;
7 7 use FluentBoards\App\Models\Comment;
8 +use FluentBoards\App\Models\Notification;
8 9 use FluentBoards\App\Models\NotificationUser;
9 10 use FluentBoards\App\Models\TaskImage;
10 11 use FluentBoards\App\Services\Constant;
11 12 use FluentBoards\App\Models\Label;
@@ -786,58 +787,136 @@
786 787 $this->deleteTask($subtask);
787 788 }
788 789 }
789 790
790 - $deleted = $task->delete();
791 + $this->deleteTasksBatch([$task]);
792 + }
793 +
794 + /**
795 + * Delete the supplied tasks and their owned records without discovering children.
796 + *
797 + * @param iterable $tasks
798 + * @param bool $manageTransaction Set false only when the caller owns an active transaction.
799 + * @return void
800 + * @throws \Throwable
801 + */
802 + public function deleteTasksBatch($tasks, $manageTransaction = true)
803 + {
804 + if (!is_array($tasks) && !($tasks instanceof \Traversable)) {
805 + $tasks = [$tasks];
806 + }
807 +
808 + $deletedTasks = [];
809 + $taskBoardIds = [];
810 + foreach ($tasks as $task) {
811 + if (!$task instanceof Task) {
812 + continue;
813 + }
814 +
815 + $taskId = (int) $task->id;
816 + if ($taskId < 1) {
817 + continue;
818 + }
819 +
820 + $deletedTasks[$taskId] = clone $task;
821 + $taskBoardIds[$taskId] = (int) $task->board_id;
822 + }
823 +
824 + if (!$deletedTasks) {
825 + return;
826 + }
827 +
828 + ksort($deletedTasks, SORT_NUMERIC);
829 + $taskIds = array_keys($deletedTasks);
791 830 $dbInstance = App::getInstance('db');
792 - $dbInstance->beginTransaction();
793 831
794 - $deletedTask = clone $task;
795 - //cloning because after delete $task object will be useless
832 + if (!$manageTransaction && !$dbInstance->inTransaction()) {
833 + throw new \RuntimeException(__('An active transaction is required for caller-managed task deletion.', 'fluent-boards'));
834 + }
796 835
836 + if ($manageTransaction) {
837 + $dbInstance->beginTransaction();
838 + }
839 +
797 840 try {
798 - $deleted = $task->delete();
841 + $relationTypes = [
842 + Constant::OBJECT_TYPE_USER_TASK_WATCH,
843 + Constant::OBJECT_TYPE_TASK_ASSIGNEE,
844 + Constant::OBJECT_TYPE_TASK_LABEL,
845 + ];
799 846
800 - if ($deleted) {
801 - //task assignees watchers removed
802 - $task->watchers()->detach();
803 - $task->assignees()->detach();
847 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
848 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
849 + }
804 850
805 - //removing all task related notifications
806 - $notificationIds = $task->notifications->pluck('id');
807 - $task->notifications()->delete();
808 - NotificationUser::whereIn('notification_id', $notificationIds)->delete();
851 + Relation::whereIn('object_id', $taskIds)
852 + ->whereIn('object_type', $relationTypes)
853 + ->delete();
809 854
810 - //task labels removed
811 - $task->labels()->detach();
855 + Relation::where('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
856 + ->where(function ($query) use ($taskIds) {
857 + $query->whereIn('object_id', $taskIds)
858 + ->orWhereIn('foreign_id', $taskIds);
859 + })
860 + ->delete();
812 861
813 - //task custom field value
814 - if (defined('FLUENT_BOARDS_PRO')) {
815 - $task->customFields()->detach();
862 + $notificationIds = Notification::whereIn('task_id', $taskIds)->pluck('id')->toArray();
863 + NotificationUser::whereIn('notification_id', $notificationIds)->delete();
864 + Notification::whereIn('task_id', $taskIds)->delete();
865 +
866 + $this->deleteTaskAttachmentsBatch($taskIds, $taskBoardIds);
867 + Activity::whereIn('object_id', $taskIds)
868 + ->where('object_type', Constant::ACTIVITY_TASK)
869 + ->delete();
870 + Meta::whereIn('object_id', $taskIds)
871 + ->where('object_type', Constant::REPEAT_TASK_META)
872 + ->delete();
873 + $this->deleteTimeTrackingRecords($taskIds, false);
874 + TaskMeta::whereIn('task_id', $taskIds)->delete();
875 +
876 + $deletedCount = Task::whereIn('id', $taskIds)->delete();
877 + if ($deletedCount !== count($taskIds)) {
878 + throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards'));
816 879 }
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 880
824 - // Delete time tracking records for this task
825 - $this->deleteTimeTrackingRecords($task->id);
881 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
826 882
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();
883 + if ($manageTransaction) {
884 + $dbInstance->commit();
831 885 }
886 + } catch (\Throwable $e) {
887 + if ($manageTransaction) {
888 + $dbInstance->rollBack();
889 + }
832 890
833 - $dbInstance->commit();
834 - } catch (\Exception $e) {
835 - $dbInstance->rollBack();
836 - throw $e; // Re-throw the exception after rolling back
891 + throw $e;
837 892 }
893 + }
838 894
895 + /**
896 + * Dispatch task deletion hooks after the outermost transaction commits.
897 + *
898 + * @param mixed $dbInstance
899 + * @param array $deletedTasks
900 + * @return void
901 + */
902 + private function dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks)
903 + {
904 + $dbInstance->afterCommit(function () use ($deletedTasks) {
905 + foreach ($deletedTasks as $deletedTask) {
906 + try {
907 + do_action('fluent_boards/task_deleted', $deletedTask);
908 + } catch (\Throwable $e) {
909 + error_log(sprintf(
910 + 'FluentBoards: Failed to dispatch committed task deletion hook for task %d: %s',
911 + (int) $deletedTask->id,
912 + sanitize_text_field($e->getMessage())
913 + ));
914 + }
915 + }
916 + });
839 917 }
918 +
840 919 public function deleteTaskForBulk($task)
841 920 {
842 921 // If this is a parent task, delete all subtasks first
843 922 if (!$task->parent_id) {
@@ -847,8 +926,10 @@
847 926 $this->deleteTaskForBulk($subtask);
848 927 }
849 928 }
850 929
930 + $this->deleteTimeTrackingRecords($task->id, false);
931 +
851 932 $deleted = $task->delete();
852 933
853 934 if ($deleted) {
854 935
@@ -864,9 +945,9 @@
864 945 //task labels removed
865 946 $task->labels()->detach();
866 947
867 948 //task custom field value
868 - if (defined('FLUENT_BOARDS_PRO_VERSION')) {
949 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
869 950 $task->customFields()->detach();
870 951 $this->deleteTaskAttachments($task);
871 952 }
872 953
@@ -911,8 +992,9 @@
911 992 $dbInstance->beginTransaction();
912 993
913 994 try {
914 995 $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId);
996 + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
915 997
916 998 $task->board_id = (int) $targetBoardId;
917 999 $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task';
918 1000
@@ -921,19 +1003,16 @@
921 1003 $task->assignees()->detach();
922 1004 $task->watchers()->detach();
923 1005 $this->removeCustomFieldAssociations($task);
924 1006
925 - // REMOVE: User-specific data to prevent security issues
926 - $this->removeCommentsAndReplies($task->id);
927 - $this->removeTimeTrackingRecords($task->id);
928 -
929 1007 // REMOVE: Recurring task settings for security
930 1008 $this->removeRecurringTaskSettings($task->id);
931 1009
932 1010 $task->save();
1011 + do_action('fluent_boards/task_moved_update_time_tracking', $task);
933 1012
934 1013 // MOVE: Subtasks to new board (preserves subtask groups)
935 - $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type, $attachmentFileService);
1014 + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService);
936 1015
937 1016 $dbInstance->commit();
938 1017 $attachmentFileService->commitMovedOriginalFiles();
939 1018 } catch (\Exception $e) {
@@ -949,9 +1028,9 @@
949 1028 /**
950 1029 * Move all subtasks to the new board when parent task is moved
951 1030 * Preserves subtask groups and their relationships
952 1031 */
953 - private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
1032 + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
954 1033 {
955 1034 // Get all subtasks of the parent task
956 1035 $subtasks = Task::where('parent_id', $parentTaskId)->get();
957 1036
@@ -960,10 +1039,12 @@
960 1039 }
961 1040
962 1041 foreach ($subtasks as $subtask) {
963 1042 // Update board_id and type
964 - $oldBoardId = (int) $subtask->board_id;
1043 + // Legacy subtasks may not have their own board_id; inherit the parent's source board.
1044 + $oldBoardId = absint($subtask->board_id) ?: absint($sourceBoardId);
965 1045 $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId);
1046 + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
966 1047
967 1048 $subtask->board_id = (int) $targetBoardId;
968 1049 $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task';
969 1050
@@ -976,16 +1057,13 @@
976 1057 $subtask->taskMeta()
977 1058 ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD)
978 1059 ->delete();
979 1060
980 - // REMOVE: User-specific data for security
981 - $this->removeCommentsAndReplies($subtask->id);
982 - $this->removeTimeTrackingRecords($subtask->id);
983 -
984 1061 // REMOVE: Recurring task settings
985 1062 $this->removeRecurringTaskSettings($subtask->id);
986 1063
987 1064 $subtask->save();
1065 + do_action('fluent_boards/task_moved_update_time_tracking', $subtask);
988 1066 }
989 1067 }
990 1068
991 1069 /**
@@ -1031,24 +1109,26 @@
1031 1109 }
1032 1110 }
1033 1111
1034 1112 /**
1035 - * Remove comments and replies for security reasons
1036 - * Prevents exposing user-specific data to unauthorized users
1113 + * Move a task's complete comment history and images to another board.
1037 1114 */
1038 - private function removeCommentsAndReplies($taskId)
1115 + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService)
1039 1116 {
1040 - // Input validation
1041 - if (!is_numeric($taskId) || $taskId <= 0) {
1117 + $taskId = absint($taskId);
1118 + $sourceBoardId = absint($sourceBoardId);
1119 + $targetBoardId = absint($targetBoardId);
1120 +
1121 + if (!$taskId || !$sourceBoardId || !$targetBoardId || $sourceBoardId === $targetBoardId) {
1042 1122 return;
1043 1123 }
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 1124
1125 + $attachmentFileService->moveCommentImagesToBoard($taskId, $sourceBoardId, $targetBoardId);
1126 +
1127 + // Bypass ORM timestamps so only board ownership changes.
1128 + Comment::where('task_id', $taskId)
1129 + ->toBase()
1130 + ->update(['board_id' => $targetBoardId]);
1051 1131 }
1052 1132
1053 1133 /**
1054 1134 * Remove time tracking records for security reasons
@@ -2396,12 +2476,38 @@
2396 2476 foreach ($attachments as $attachment) {
2397 2477 $deletedAttachment = clone $attachment;
2398 2478 $attachment->delete();
2399 2479
2400 - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2480 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2401 2481 }
2402 2482 }
2403 2483
2484 + /**
2485 + * Delete task attachments one at a time so each attachment-deleted hook is preserved.
2486 + *
2487 + * @param array $taskIds
2488 + * @param array $taskBoardIds
2489 + * @return void
2490 + */
2491 + private function deleteTaskAttachmentsBatch($taskIds, $taskBoardIds)
2492 + {
2493 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2494 + return;
2495 + }
2496 +
2497 + $attachments = TaskAttachment::whereIn('object_id', $taskIds)
2498 + ->where('object_type', Constant::TASK_ATTACHMENT)
2499 + ->get();
2500 +
2501 + foreach ($attachments as $attachment) {
2502 + $deletedAttachment = clone $attachment;
2503 + $attachment->delete();
2504 + $boardId = $taskBoardIds[(int) $attachment->object_id] ?? null;
2505 +
2506 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $boardId);
2507 + }
2508 + }
2509 +
2404 2510 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2405 2511 {
2406 2512 global $wpdb;
2407 2513 $attachmentFileService = new AttachmentFileService();
@@ -2655,8 +2761,14 @@
2655 2761 if ($images->count() > 0) {
2656 2762 foreach ($images as $image) {
2657 2763 $clonedImage = $image->replicate();
2658 2764 $clonedImage->object_id = $clonedCommentOrReply->id;
2765 + (new CommentService())->applyCommentImageScope(
2766 + $clonedImage,
2767 + $clonedCommentOrReply->board_id,
2768 + $clonedCommentOrReply->task_id,
2769 + $clonedCommentOrReply->created_by
2770 + );
2659 2771 $clonedImage->save();
2660 2772 }
2661 2773 }
2662 2774 }
@@ -3241,15 +3353,16 @@
3241 3353 'message' => $message
3242 3354 ];
3243 3355 }
3244 3356
3245 - /* Delete time tracking records for one or multiple tasks
3246 - * Uses try-catch for better performance - avoids table existence check overhead
3357 + /**
3358 + * Delete time tracking records for one or multiple tasks.
3247 3359 *
3248 - * @param int|array $taskIds Single task ID or array of task IDs
3360 + * @param int|array $taskIds Single task ID or array of task IDs.
3361 + * @param bool $suppressErrors Whether cleanup failures should be ignored.
3249 3362 * @return void
3250 3363 */
3251 - public function deleteTimeTrackingRecords($taskIds)
3364 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
3252 3365 {
3253 3366 // Check if FluentBoards Pro time tracking is available
3254 3367 if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
3255 3368 return;
@@ -3265,11 +3378,12 @@
3265 3378 if (is_numeric($taskIds) && $taskIds > 0) {
3266 3379 \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
3267 3380 }
3268 3381 }
3269 - } catch (\Exception $e) {
3270 - // Silently fail if table doesn't exist or any other error occurs
3271 - // This is intentional for cleanup operations
3382 + } catch (\Throwable $e) {
3383 + if (!$suppressErrors) {
3384 + throw $e;
3385 + }
3272 3386 }
3273 3387 }
3274 3388
3275 3389 }