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 +198 -76 2.0.12.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 {
@@ -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 }