PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.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 1.41 All 42 releases
← All changes | app/Services/TaskService.php +185 -69 2.0.102.1.0 View file →
@@ -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 {
@@ -786,58 +789,136 @@
786 789 $this->deleteTask($subtask);
787 790 }
788 791 }
789 792
790 - $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);
791 832 $dbInstance = App::getInstance('db');
792 - $dbInstance->beginTransaction();
793 833
794 - $deletedTask = clone $task;
795 - //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 + }
796 837
838 + if ($manageTransaction) {
839 + $dbInstance->beginTransaction();
840 + }
841 +
797 842 try {
798 - $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 + ];
799 848
800 - if ($deleted) {
801 - //task assignees watchers removed
802 - $task->watchers()->detach();
803 - $task->assignees()->detach();
849 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
850 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
851 + }
804 852
805 - //removing all task related notifications
806 - $notificationIds = $task->notifications->pluck('id');
807 - $task->notifications()->delete();
808 - NotificationUser::whereIn('notification_id', $notificationIds)->delete();
853 + Relation::whereIn('object_id', $taskIds)
854 + ->whereIn('object_type', $relationTypes)
855 + ->delete();
809 856
810 - //task labels removed
811 - $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();
812 863
813 - //task custom field value
814 - if (defined('FLUENT_BOARDS_PRO')) {
815 - $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'));
816 881 }
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 882
824 - // Delete time tracking records for this task
825 - $this->deleteTimeTrackingRecords($task->id);
883 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
826 884
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();
885 + if ($manageTransaction) {
886 + $dbInstance->commit();
831 887 }
888 + } catch (\Throwable $e) {
889 + if ($manageTransaction) {
890 + $dbInstance->rollBack();
891 + }
832 892
833 - $dbInstance->commit();
834 - } catch (\Exception $e) {
835 - $dbInstance->rollBack();
836 - throw $e; // Re-throw the exception after rolling back
893 + throw $e;
837 894 }
895 + }
838 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 + });
839 919 }
920 +
840 921 public function deleteTaskForBulk($task)
841 922 {
842 923 // If this is a parent task, delete all subtasks first
843 924 if (!$task->parent_id) {
@@ -847,8 +928,10 @@
847 928 $this->deleteTaskForBulk($subtask);
848 929 }
849 930 }
850 931
932 + $this->deleteTimeTrackingRecords($task->id, false);
933 +
851 934 $deleted = $task->delete();
852 935
853 936 if ($deleted) {
854 937
@@ -864,9 +947,9 @@
864 947 //task labels removed
865 948 $task->labels()->detach();
866 949
867 950 //task custom field value
868 - if (defined('FLUENT_BOARDS_PRO_VERSION')) {
951 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
869 952 $task->customFields()->detach();
870 953 $this->deleteTaskAttachments($task);
871 954 }
872 955
@@ -911,8 +994,9 @@
911 994 $dbInstance->beginTransaction();
912 995
913 996 try {
914 997 $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId);
998 + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
915 999
916 1000 $task->board_id = (int) $targetBoardId;
917 1001 $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task';
918 1002
@@ -921,19 +1005,16 @@
921 1005 $task->assignees()->detach();
922 1006 $task->watchers()->detach();
923 1007 $this->removeCustomFieldAssociations($task);
924 1008
925 - // REMOVE: User-specific data to prevent security issues
926 - $this->removeCommentsAndReplies($task->id);
927 - $this->removeTimeTrackingRecords($task->id);
928 -
929 1009 // REMOVE: Recurring task settings for security
930 1010 $this->removeRecurringTaskSettings($task->id);
931 1011
932 1012 $task->save();
1013 + do_action('fluent_boards/task_moved_update_time_tracking', $task);
933 1014
934 1015 // MOVE: Subtasks to new board (preserves subtask groups)
935 - $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type, $attachmentFileService);
1016 + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService);
936 1017
937 1018 $dbInstance->commit();
938 1019 $attachmentFileService->commitMovedOriginalFiles();
939 1020 } catch (\Exception $e) {
@@ -949,9 +1030,9 @@
949 1030 /**
950 1031 * Move all subtasks to the new board when parent task is moved
951 1032 * Preserves subtask groups and their relationships
952 1033 */
953 - private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
1034 + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
954 1035 {
955 1036 // Get all subtasks of the parent task
956 1037 $subtasks = Task::where('parent_id', $parentTaskId)->get();
957 1038
@@ -960,10 +1041,12 @@
960 1041 }
961 1042
962 1043 foreach ($subtasks as $subtask) {
963 1044 // Update board_id and type
964 - $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);
965 1047 $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId);
1048 + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
966 1049
967 1050 $subtask->board_id = (int) $targetBoardId;
968 1051 $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task';
969 1052
@@ -976,16 +1059,13 @@
976 1059 $subtask->taskMeta()
977 1060 ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD)
978 1061 ->delete();
979 1062
980 - // REMOVE: User-specific data for security
981 - $this->removeCommentsAndReplies($subtask->id);
982 - $this->removeTimeTrackingRecords($subtask->id);
983 -
984 1063 // REMOVE: Recurring task settings
985 1064 $this->removeRecurringTaskSettings($subtask->id);
986 1065
987 1066 $subtask->save();
1067 + do_action('fluent_boards/task_moved_update_time_tracking', $subtask);
988 1068 }
989 1069 }
990 1070
991 1071 /**
@@ -1031,24 +1111,26 @@
1031 1111 }
1032 1112 }
1033 1113
1034 1114 /**
1035 - * Remove comments and replies for security reasons
1036 - * Prevents exposing user-specific data to unauthorized users
1115 + * Move a task's complete comment history and images to another board.
1037 1116 */
1038 - private function removeCommentsAndReplies($taskId)
1117 + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService)
1039 1118 {
1040 - // Input validation
1041 - 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) {
1042 1124 return;
1043 1125 }
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 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]);
1051 1133 }
1052 1134
1053 1135 /**
1054 1136 * Remove time tracking records for security reasons
@@ -2142,9 +2224,9 @@
2142 2224
2143 2225 // Fetch comments and activities separately
2144 2226 $comments = [];
2145 2227 if ($feedType !== 'activities') {
2146 - $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();
2147 2229 }
2148 2230
2149 2231 $activities = [];
2150 2232 if ($feedType !== 'comments') {
@@ -2396,12 +2478,38 @@
2396 2478 foreach ($attachments as $attachment) {
2397 2479 $deletedAttachment = clone $attachment;
2398 2480 $attachment->delete();
2399 2481
2400 - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2482 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2401 2483 }
2402 2484 }
2403 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 +
2404 2512 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2405 2513 {
2406 2514 global $wpdb;
2407 2515 $attachmentFileService = new AttachmentFileService();
@@ -2655,8 +2763,14 @@
2655 2763 if ($images->count() > 0) {
2656 2764 foreach ($images as $image) {
2657 2765 $clonedImage = $image->replicate();
2658 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 + );
2659 2773 $clonedImage->save();
2660 2774 }
2661 2775 }
2662 2776 }
@@ -3241,15 +3355,16 @@
3241 3355 'message' => $message
3242 3356 ];
3243 3357 }
3244 3358
3245 - /* Delete time tracking records for one or multiple tasks
3246 - * Uses try-catch for better performance - avoids table existence check overhead
3359 + /**
3360 + * Delete time tracking records for one or multiple tasks.
3247 3361 *
3248 - * @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.
3249 3364 * @return void
3250 3365 */
3251 - public function deleteTimeTrackingRecords($taskIds)
3366 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
3252 3367 {
3253 3368 // Check if FluentBoards Pro time tracking is available
3254 3369 if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
3255 3370 return;
@@ -3265,11 +3380,12 @@
3265 3380 if (is_numeric($taskIds) && $taskIds > 0) {
3266 3381 \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
3267 3382 }
3268 3383 }
3269 - } catch (\Exception $e) {
3270 - // Silently fail if table doesn't exist or any other error occurs
3271 - // This is intentional for cleanup operations
3384 + } catch (\Throwable $e) {
3385 + if (!$suppressErrors) {
3386 + throw $e;
3387 + }
3272 3388 }
3273 3389 }
3274 3390
3275 3391 }