| @@ -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 | } |