| @@ -1,11 +1,13 @@ | ||
| 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; |
| 6 | 7 | use FluentBoards\App\Models\Attachment; |
| 7 | 8 | use FluentBoards\App\Models\Comment; |
| 9 | +use FluentBoards\App\Models\Notification; | |
| 8 | 10 | use FluentBoards\App\Models\NotificationUser; |
| 9 | 11 | use FluentBoards\App\Models\TaskImage; |
| 10 | 12 | use FluentBoards\App\Services\Constant; |
| 11 | 13 | use FluentBoards\App\Models\Label; |
| @@ -31,14 +33,15 @@ | ||
| 31 | 33 | * Resolve a task only when it belongs to the requested board. |
| 32 | 34 | * |
| 33 | 35 | * Subtasks normally carry the same board_id as their parent, but the parent |
| 34 | 36 | * fallback protects older data where that relationship may be incomplete. |
| 37 | + * Missing or mismatched tasks use the router's deliberate 404 response. | |
| 35 | 38 | * |
| 36 | 39 | * @param int $taskId |
| 37 | 40 | * @param int $boardId |
| 38 | 41 | * @param bool $allowParentFallback |
| 39 | 42 | * @return Task |
| 40 | - * @throws \Exception | |
| 43 | + * @throws ModelNotFoundException | |
| 41 | 44 | */ |
| 42 | 45 | public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true) |
| 43 | 46 | { |
| 44 | 47 | $taskId = absint($taskId); |
| @@ -44,9 +47,9 @@ | ||
| 44 | 47 | $taskId = absint($taskId); |
| 45 | 48 | $boardId = absint($boardId); |
| 46 | 49 | |
| 47 | 50 | if (!$taskId || !$boardId) { |
| 48 | - throw new \Exception(esc_html__('Task not found', 'fluent-boards')); | |
| 51 | + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards')); | |
| 49 | 52 | } |
| 50 | 53 | |
| 51 | 54 | $task = Task::where('id', $taskId) |
| 52 | 55 | ->where('board_id', $boardId) |
| @@ -70,9 +73,9 @@ | ||
| 70 | 73 | } |
| 71 | 74 | } |
| 72 | 75 | } |
| 73 | 76 | |
| 74 | - throw new \Exception(esc_html__('Task not found', 'fluent-boards')); | |
| 77 | + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards')); | |
| 75 | 78 | } |
| 76 | 79 | |
| 77 | 80 | private function normalizeTaskDescriptionForEditor(Task $task) |
| 78 | 81 | { |
| @@ -205,9 +208,9 @@ | ||
| 205 | 208 | $taskQuery = Task::whereIn('id', $taskIds) |
| 206 | 209 | ->with(['assignees', 'board', 'stage']) |
| 207 | 210 | ->whereNull('archived_at') |
| 208 | 211 | ->where('parent_id', null) |
| 209 | - ->excludeTemplateBoards() | |
| 212 | + ->onActiveAvailableBoards() | |
| 210 | 213 | ->orderBy('due_at', 'DESC'); |
| 211 | 214 | |
| 212 | 215 | switch ($category) { |
| 213 | 216 | case 'overdue': |
| @@ -234,9 +237,10 @@ | ||
| 234 | 237 | ->with(['assignees', 'board', 'stage']) |
| 235 | 238 | ->whereIn('fbs_tasks.id', $taskIds) |
| 236 | 239 | ->whereNull('fbs_tasks.archived_at') |
| 237 | 240 | ->whereNull('fbs_tasks.parent_id') |
| 238 | - ->excludeTemplateBoards() | |
| 241 | + ->where('fbs_tasks.status', '!=', 'closed') | |
| 242 | + ->onActiveAvailableBoards() | |
| 239 | 243 | ->join('fbs_relations as rel', function ($join) use ($currentUserId) { |
| 240 | 244 | $join->on('rel.object_id', '=', 'fbs_tasks.id') |
| 241 | 245 | ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE) |
| 242 | 246 | ->where('rel.foreign_id', $currentUserId); |
| @@ -257,9 +261,9 @@ | ||
| 257 | 261 | return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id); |
| 258 | 262 | })->pluck('notification.task_id')->unique(); |
| 259 | 263 | $validTasks = Task::whereIn('id', $taskIds) |
| 260 | 264 | ->with(['assignees', 'board', 'stage']) |
| 261 | - ->excludeTemplateBoards() | |
| 265 | + ->onActiveAvailableBoards() | |
| 262 | 266 | ->get(); |
| 263 | 267 | |
| 264 | 268 | return $validTasks->toArray(); |
| 265 | 269 | default: |
| @@ -280,9 +284,9 @@ | ||
| 280 | 284 | $taskQuery = Task::query() |
| 281 | 285 | ->whereIn('id', $taskIds) |
| 282 | 286 | ->whereNull('archived_at') |
| 283 | 287 | ->whereNull('parent_id') |
| 284 | - ->excludeTemplateBoards(); | |
| 288 | + ->onActiveAvailableBoards(); | |
| 285 | 289 | |
| 286 | 290 | switch ($category) { |
| 287 | 291 | case 'overdue': |
| 288 | 292 | $taskQuery->overdue(); |
| @@ -305,9 +309,10 @@ | ||
| 305 | 309 | ->select('fbs_tasks.id') |
| 306 | 310 | ->whereIn('fbs_tasks.id', $taskIds) |
| 307 | 311 | ->whereNull('fbs_tasks.archived_at') |
| 308 | 312 | ->whereNull('fbs_tasks.parent_id') |
| 309 | - ->excludeTemplateBoards() | |
| 313 | + ->where('fbs_tasks.status', '!=', 'closed') | |
| 314 | + ->onActiveAvailableBoards() | |
| 310 | 315 | ->join('fbs_relations as rel', function ($join) use ($currentUserId) { |
| 311 | 316 | $join->on('rel.object_id', '=', 'fbs_tasks.id') |
| 312 | 317 | ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE) |
| 313 | 318 | ->where('rel.foreign_id', $currentUserId); |
| @@ -329,9 +334,9 @@ | ||
| 329 | 334 | }) |
| 330 | 335 | ->pluck('notification.task_id') |
| 331 | 336 | ->unique(); |
| 332 | 337 | |
| 333 | - return Task::whereIn('id', $taskIds)->excludeTemplateBoards()->count(); | |
| 338 | + return Task::whereIn('id', $taskIds)->onActiveAvailableBoards()->count(); | |
| 334 | 339 | default: |
| 335 | 340 | return 0; |
| 336 | 341 | } |
| 337 | 342 | |
| @@ -784,58 +789,136 @@ | ||
| 784 | 789 | $this->deleteTask($subtask); |
| 785 | 790 | } |
| 786 | 791 | } |
| 787 | 792 | |
| 788 | - $deleted = $task->delete(); | |
| 793 | + $this->deleteTasksBatch([$task]); | |
| 794 | + } | |
| 795 | + | |
| 796 | + /** | |
| 797 | + * Delete the supplied tasks and their owned records without discovering children. | |
| 798 | + * | |
| 799 | + * @param iterable $tasks | |
| 800 | + * @param bool $manageTransaction Set false only when the caller owns an active transaction. | |
| 801 | + * @return void | |
| 802 | + * @throws \Throwable | |
| 803 | + */ | |
| 804 | + public function deleteTasksBatch($tasks, $manageTransaction = true) | |
| 805 | + { | |
| 806 | + if (!is_array($tasks) && !($tasks instanceof \Traversable)) { | |
| 807 | + $tasks = [$tasks]; | |
| 808 | + } | |
| 809 | + | |
| 810 | + $deletedTasks = []; | |
| 811 | + $taskBoardIds = []; | |
| 812 | + foreach ($tasks as $task) { | |
| 813 | + if (!$task instanceof Task) { | |
| 814 | + continue; | |
| 815 | + } | |
| 816 | + | |
| 817 | + $taskId = (int) $task->id; | |
| 818 | + if ($taskId < 1) { | |
| 819 | + continue; | |
| 820 | + } | |
| 821 | + | |
| 822 | + $deletedTasks[$taskId] = clone $task; | |
| 823 | + $taskBoardIds[$taskId] = (int) $task->board_id; | |
| 824 | + } | |
| 825 | + | |
| 826 | + if (!$deletedTasks) { | |
| 827 | + return; | |
| 828 | + } | |
| 829 | + | |
| 830 | + ksort($deletedTasks, SORT_NUMERIC); | |
| 831 | + $taskIds = array_keys($deletedTasks); | |
| 789 | 832 | $dbInstance = App::getInstance('db'); |
| 790 | - $dbInstance->beginTransaction(); | |
| 791 | 833 | |
| 792 | - $deletedTask = clone $task; | |
| 793 | - //cloning because after delete $task object will be useless | |
| 834 | + if (!$manageTransaction && !$dbInstance->inTransaction()) { | |
| 835 | + throw new \RuntimeException(__('An active transaction is required for caller-managed task deletion.', 'fluent-boards')); | |
| 836 | + } | |
| 794 | 837 | |
| 838 | + if ($manageTransaction) { | |
| 839 | + $dbInstance->beginTransaction(); | |
| 840 | + } | |
| 841 | + | |
| 795 | 842 | try { |
| 796 | - $deleted = $task->delete(); | |
| 843 | + $relationTypes = [ | |
| 844 | + Constant::OBJECT_TYPE_USER_TASK_WATCH, | |
| 845 | + Constant::OBJECT_TYPE_TASK_ASSIGNEE, | |
| 846 | + Constant::OBJECT_TYPE_TASK_LABEL, | |
| 847 | + ]; | |
| 797 | 848 | |
| 798 | - if ($deleted) { | |
| 799 | - //task assignees watchers removed | |
| 800 | - $task->watchers()->detach(); | |
| 801 | - $task->assignees()->detach(); | |
| 849 | + if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 850 | + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD; | |
| 851 | + } | |
| 802 | 852 | |
| 803 | - //removing all task related notifications | |
| 804 | - $notificationIds = $task->notifications->pluck('id'); | |
| 805 | - $task->notifications()->delete(); | |
| 806 | - NotificationUser::whereIn('notification_id', $notificationIds)->delete(); | |
| 853 | + Relation::whereIn('object_id', $taskIds) | |
| 854 | + ->whereIn('object_type', $relationTypes) | |
| 855 | + ->delete(); | |
| 807 | 856 | |
| 808 | - //task labels removed | |
| 809 | - $task->labels()->detach(); | |
| 857 | + Relation::where('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY) | |
| 858 | + ->where(function ($query) use ($taskIds) { | |
| 859 | + $query->whereIn('object_id', $taskIds) | |
| 860 | + ->orWhereIn('foreign_id', $taskIds); | |
| 861 | + }) | |
| 862 | + ->delete(); | |
| 810 | 863 | |
| 811 | - //task custom field value | |
| 812 | - if (defined('FLUENT_BOARDS_PRO')) { | |
| 813 | - $task->customFields()->detach(); | |
| 864 | + $notificationIds = Notification::whereIn('task_id', $taskIds)->pluck('id')->toArray(); | |
| 865 | + NotificationUser::whereIn('notification_id', $notificationIds)->delete(); | |
| 866 | + Notification::whereIn('task_id', $taskIds)->delete(); | |
| 867 | + | |
| 868 | + $this->deleteTaskAttachmentsBatch($taskIds, $taskBoardIds); | |
| 869 | + Activity::whereIn('object_id', $taskIds) | |
| 870 | + ->where('object_type', Constant::ACTIVITY_TASK) | |
| 871 | + ->delete(); | |
| 872 | + Meta::whereIn('object_id', $taskIds) | |
| 873 | + ->where('object_type', Constant::REPEAT_TASK_META) | |
| 874 | + ->delete(); | |
| 875 | + $this->deleteTimeTrackingRecords($taskIds, false); | |
| 876 | + TaskMeta::whereIn('task_id', $taskIds)->delete(); | |
| 877 | + | |
| 878 | + $deletedCount = Task::whereIn('id', $taskIds)->delete(); | |
| 879 | + if ($deletedCount !== count($taskIds)) { | |
| 880 | + throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards')); | |
| 814 | 881 | } |
| 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 | 882 | |
| 822 | - // Delete time tracking records for this task | |
| 823 | - $this->deleteTimeTrackingRecords($task->id); | |
| 883 | + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks); | |
| 824 | 884 | |
| 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(); | |
| 885 | + if ($manageTransaction) { | |
| 886 | + $dbInstance->commit(); | |
| 829 | 887 | } |
| 888 | + } catch (\Throwable $e) { | |
| 889 | + if ($manageTransaction) { | |
| 890 | + $dbInstance->rollBack(); | |
| 891 | + } | |
| 830 | 892 | |
| 831 | - $dbInstance->commit(); | |
| 832 | - } catch (\Exception $e) { | |
| 833 | - $dbInstance->rollBack(); | |
| 834 | - throw $e; // Re-throw the exception after rolling back | |
| 893 | + throw $e; | |
| 835 | 894 | } |
| 895 | + } | |
| 836 | 896 | |
| 897 | + /** | |
| 898 | + * Dispatch task deletion hooks after the outermost transaction commits. | |
| 899 | + * | |
| 900 | + * @param mixed $dbInstance | |
| 901 | + * @param array $deletedTasks | |
| 902 | + * @return void | |
| 903 | + */ | |
| 904 | + private function dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks) | |
| 905 | + { | |
| 906 | + $dbInstance->afterCommit(function () use ($deletedTasks) { | |
| 907 | + foreach ($deletedTasks as $deletedTask) { | |
| 908 | + try { | |
| 909 | + do_action('fluent_boards/task_deleted', $deletedTask); | |
| 910 | + } catch (\Throwable $e) { | |
| 911 | + error_log(sprintf( | |
| 912 | + 'FluentBoards: Failed to dispatch committed task deletion hook for task %d: %s', | |
| 913 | + (int) $deletedTask->id, | |
| 914 | + sanitize_text_field($e->getMessage()) | |
| 915 | + )); | |
| 916 | + } | |
| 917 | + } | |
| 918 | + }); | |
| 837 | 919 | } |
| 920 | + | |
| 838 | 921 | public function deleteTaskForBulk($task) |
| 839 | 922 | { |
| 840 | 923 | // If this is a parent task, delete all subtasks first |
| 841 | 924 | if (!$task->parent_id) { |
| @@ -845,8 +928,10 @@ | ||
| 845 | 928 | $this->deleteTaskForBulk($subtask); |
| 846 | 929 | } |
| 847 | 930 | } |
| 848 | 931 | |
| 932 | + $this->deleteTimeTrackingRecords($task->id, false); | |
| 933 | + | |
| 849 | 934 | $deleted = $task->delete(); |
| 850 | 935 | |
| 851 | 936 | if ($deleted) { |
| 852 | 937 | |
| @@ -862,9 +947,9 @@ | ||
| 862 | 947 | //task labels removed |
| 863 | 948 | $task->labels()->detach(); |
| 864 | 949 | |
| 865 | 950 | //task custom field value |
| 866 | - if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 951 | + if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 867 | 952 | $task->customFields()->detach(); |
| 868 | 953 | $this->deleteTaskAttachments($task); |
| 869 | 954 | } |
| 870 | 955 | |
| @@ -909,8 +994,9 @@ | ||
| 909 | 994 | $dbInstance->beginTransaction(); |
| 910 | 995 | |
| 911 | 996 | try { |
| 912 | 997 | $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId); |
| 998 | + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService); | |
| 913 | 999 | |
| 914 | 1000 | $task->board_id = (int) $targetBoardId; |
| 915 | 1001 | $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task'; |
| 916 | 1002 | |
| @@ -919,19 +1005,16 @@ | ||
| 919 | 1005 | $task->assignees()->detach(); |
| 920 | 1006 | $task->watchers()->detach(); |
| 921 | 1007 | $this->removeCustomFieldAssociations($task); |
| 922 | 1008 | |
| 923 | - // REMOVE: User-specific data to prevent security issues | |
| 924 | - $this->removeCommentsAndReplies($task->id); | |
| 925 | - $this->removeTimeTrackingRecords($task->id); | |
| 926 | - | |
| 927 | 1009 | // REMOVE: Recurring task settings for security |
| 928 | 1010 | $this->removeRecurringTaskSettings($task->id); |
| 929 | 1011 | |
| 930 | 1012 | $task->save(); |
| 1013 | + do_action('fluent_boards/task_moved_update_time_tracking', $task); | |
| 931 | 1014 | |
| 932 | 1015 | // MOVE: Subtasks to new board (preserves subtask groups) |
| 933 | - $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type, $attachmentFileService); | |
| 1016 | + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService); | |
| 934 | 1017 | |
| 935 | 1018 | $dbInstance->commit(); |
| 936 | 1019 | $attachmentFileService->commitMovedOriginalFiles(); |
| 937 | 1020 | } catch (\Exception $e) { |
| @@ -947,9 +1030,9 @@ | ||
| 947 | 1030 | /** |
| 948 | 1031 | * Move all subtasks to the new board when parent task is moved |
| 949 | 1032 | * Preserves subtask groups and their relationships |
| 950 | 1033 | */ |
| 951 | - private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService) | |
| 1034 | + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService) | |
| 952 | 1035 | { |
| 953 | 1036 | // Get all subtasks of the parent task |
| 954 | 1037 | $subtasks = Task::where('parent_id', $parentTaskId)->get(); |
| 955 | 1038 | |
| @@ -958,10 +1041,12 @@ | ||
| 958 | 1041 | } |
| 959 | 1042 | |
| 960 | 1043 | foreach ($subtasks as $subtask) { |
| 961 | 1044 | // Update board_id and type |
| 962 | - $oldBoardId = (int) $subtask->board_id; | |
| 1045 | + // Legacy subtasks may not have their own board_id; inherit the parent's source board. | |
| 1046 | + $oldBoardId = absint($subtask->board_id) ?: absint($sourceBoardId); | |
| 963 | 1047 | $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId); |
| 1048 | + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService); | |
| 964 | 1049 | |
| 965 | 1050 | $subtask->board_id = (int) $targetBoardId; |
| 966 | 1051 | $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task'; |
| 967 | 1052 | |
| @@ -974,16 +1059,13 @@ | ||
| 974 | 1059 | $subtask->taskMeta() |
| 975 | 1060 | ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD) |
| 976 | 1061 | ->delete(); |
| 977 | 1062 | |
| 978 | - // REMOVE: User-specific data for security | |
| 979 | - $this->removeCommentsAndReplies($subtask->id); | |
| 980 | - $this->removeTimeTrackingRecords($subtask->id); | |
| 981 | - | |
| 982 | 1063 | // REMOVE: Recurring task settings |
| 983 | 1064 | $this->removeRecurringTaskSettings($subtask->id); |
| 984 | 1065 | |
| 985 | 1066 | $subtask->save(); |
| 1067 | + do_action('fluent_boards/task_moved_update_time_tracking', $subtask); | |
| 986 | 1068 | } |
| 987 | 1069 | } |
| 988 | 1070 | |
| 989 | 1071 | /** |
| @@ -1029,24 +1111,26 @@ | ||
| 1029 | 1111 | } |
| 1030 | 1112 | } |
| 1031 | 1113 | |
| 1032 | 1114 | /** |
| 1033 | - * Remove comments and replies for security reasons | |
| 1034 | - * Prevents exposing user-specific data to unauthorized users | |
| 1115 | + * Move a task's complete comment history and images to another board. | |
| 1035 | 1116 | */ |
| 1036 | - private function removeCommentsAndReplies($taskId) | |
| 1117 | + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService) | |
| 1037 | 1118 | { |
| 1038 | - // Input validation | |
| 1039 | - if (!is_numeric($taskId) || $taskId <= 0) { | |
| 1119 | + $taskId = absint($taskId); | |
| 1120 | + $sourceBoardId = absint($sourceBoardId); | |
| 1121 | + $targetBoardId = absint($targetBoardId); | |
| 1122 | + | |
| 1123 | + if (!$taskId || !$sourceBoardId || !$targetBoardId || $sourceBoardId === $targetBoardId) { | |
| 1040 | 1124 | return; |
| 1041 | 1125 | } |
| 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 | 1126 | |
| 1127 | + $attachmentFileService->moveCommentImagesToBoard($taskId, $sourceBoardId, $targetBoardId); | |
| 1128 | + | |
| 1129 | + // Bypass ORM timestamps so only board ownership changes. | |
| 1130 | + Comment::where('task_id', $taskId) | |
| 1131 | + ->toBase() | |
| 1132 | + ->update(['board_id' => $targetBoardId]); | |
| 1049 | 1133 | } |
| 1050 | 1134 | |
| 1051 | 1135 | /** |
| 1052 | 1136 | * Remove time tracking records for security reasons |
| @@ -1074,9 +1158,9 @@ | ||
| 1074 | 1158 | return; |
| 1075 | 1159 | } |
| 1076 | 1160 | |
| 1077 | 1161 | // Remove all attachments for this task |
| 1078 | - if (class_exists('FluentBoardsPro\App\Models\TaskAttachment')) { | |
| 1162 | + if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 1079 | 1163 | \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', (int) $taskId) |
| 1080 | 1164 | ->where('object_type', 'task') |
| 1081 | 1165 | ->delete(); |
| 1082 | 1166 | } |
| @@ -2140,9 +2224,9 @@ | ||
| 2140 | 2224 | |
| 2141 | 2225 | // Fetch comments and activities separately |
| 2142 | 2226 | $comments = []; |
| 2143 | 2227 | if ($feedType !== 'activities') { |
| 2144 | - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray(); | |
| 2228 | + $comments = $task->comments()->with(['user', 'replies.user'])->orderBy('created_at', 'desc')->get()->toArray(); | |
| 2145 | 2229 | } |
| 2146 | 2230 | |
| 2147 | 2231 | $activities = []; |
| 2148 | 2232 | if ($feedType !== 'comments') { |
| @@ -2383,8 +2467,12 @@ | ||
| 2383 | 2467 | } |
| 2384 | 2468 | |
| 2385 | 2469 | private function deleteTaskAttachments($task) |
| 2386 | 2470 | { |
| 2471 | + if (!defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 2472 | + return; | |
| 2473 | + } | |
| 2474 | + | |
| 2387 | 2475 | $attachments = TaskAttachment::where('object_id', $task->id) |
| 2388 | 2476 | ->where('object_type', Constant::TASK_ATTACHMENT) |
| 2389 | 2477 | ->get(); |
| 2390 | 2478 | foreach ($attachments as $attachment) { |
| @@ -2390,12 +2478,38 @@ | ||
| 2390 | 2478 | foreach ($attachments as $attachment) { |
| 2391 | 2479 | $deletedAttachment = clone $attachment; |
| 2392 | 2480 | $attachment->delete(); |
| 2393 | 2481 | |
| 2394 | - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment); | |
| 2482 | + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id); | |
| 2395 | 2483 | } |
| 2396 | 2484 | } |
| 2397 | 2485 | |
| 2486 | + /** | |
| 2487 | + * Delete task attachments one at a time so each attachment-deleted hook is preserved. | |
| 2488 | + * | |
| 2489 | + * @param array $taskIds | |
| 2490 | + * @param array $taskBoardIds | |
| 2491 | + * @return void | |
| 2492 | + */ | |
| 2493 | + private function deleteTaskAttachmentsBatch($taskIds, $taskBoardIds) | |
| 2494 | + { | |
| 2495 | + if (!defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 2496 | + return; | |
| 2497 | + } | |
| 2498 | + | |
| 2499 | + $attachments = TaskAttachment::whereIn('object_id', $taskIds) | |
| 2500 | + ->where('object_type', Constant::TASK_ATTACHMENT) | |
| 2501 | + ->get(); | |
| 2502 | + | |
| 2503 | + foreach ($attachments as $attachment) { | |
| 2504 | + $deletedAttachment = clone $attachment; | |
| 2505 | + $attachment->delete(); | |
| 2506 | + $boardId = $taskBoardIds[(int) $attachment->object_id] ?? null; | |
| 2507 | + | |
| 2508 | + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $boardId); | |
| 2509 | + } | |
| 2510 | + } | |
| 2511 | + | |
| 2398 | 2512 | public function cloneTask(int $taskId, $taskData, $boardId = null): Task |
| 2399 | 2513 | { |
| 2400 | 2514 | global $wpdb; |
| 2401 | 2515 | $attachmentFileService = new AttachmentFileService(); |
| @@ -2649,8 +2763,14 @@ | ||
| 2649 | 2763 | if ($images->count() > 0) { |
| 2650 | 2764 | foreach ($images as $image) { |
| 2651 | 2765 | $clonedImage = $image->replicate(); |
| 2652 | 2766 | $clonedImage->object_id = $clonedCommentOrReply->id; |
| 2767 | + (new CommentService())->applyCommentImageScope( | |
| 2768 | + $clonedImage, | |
| 2769 | + $clonedCommentOrReply->board_id, | |
| 2770 | + $clonedCommentOrReply->task_id, | |
| 2771 | + $clonedCommentOrReply->created_by | |
| 2772 | + ); | |
| 2653 | 2773 | $clonedImage->save(); |
| 2654 | 2774 | } |
| 2655 | 2775 | } |
| 2656 | 2776 | } |
| @@ -3235,15 +3355,16 @@ | ||
| 3235 | 3355 | 'message' => $message |
| 3236 | 3356 | ]; |
| 3237 | 3357 | } |
| 3238 | 3358 | |
| 3239 | - /* Delete time tracking records for one or multiple tasks | |
| 3240 | - * Uses try-catch for better performance - avoids table existence check overhead | |
| 3359 | + /** | |
| 3360 | + * Delete time tracking records for one or multiple tasks. | |
| 3241 | 3361 | * |
| 3242 | - * @param int|array $taskIds Single task ID or array of task IDs | |
| 3362 | + * @param int|array $taskIds Single task ID or array of task IDs. | |
| 3363 | + * @param bool $suppressErrors Whether cleanup failures should be ignored. | |
| 3243 | 3364 | * @return void |
| 3244 | 3365 | */ |
| 3245 | - public function deleteTimeTrackingRecords($taskIds) | |
| 3366 | + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true) | |
| 3246 | 3367 | { |
| 3247 | 3368 | // Check if FluentBoards Pro time tracking is available |
| 3248 | 3369 | if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) { |
| 3249 | 3370 | return; |
| @@ -3259,11 +3380,12 @@ | ||
| 3259 | 3380 | if (is_numeric($taskIds) && $taskIds > 0) { |
| 3260 | 3381 | \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete(); |
| 3261 | 3382 | } |
| 3262 | 3383 | } |
| 3263 | - } catch (\Exception $e) { | |
| 3264 | - // Silently fail if table doesn't exist or any other error occurs | |
| 3265 | - // This is intentional for cleanup operations | |
| 3384 | + } catch (\Throwable $e) { | |
| 3385 | + if (!$suppressErrors) { | |
| 3386 | + throw $e; | |
| 3387 | + } | |
| 3266 | 3388 | } |
| 3267 | 3389 | } |
| 3268 | 3390 | |
| 3269 | 3391 | } |