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 +587 -138 1.95.32.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;
@@ -24,19 +26,22 @@
24 26 use FluentRoadmap\App\Models\IdeaReaction;
25 27
26 28 class TaskService
27 29 {
30 + private static $physicalTableNameCache = [];
31 +
28 32 /**
29 33 * Resolve a task only when it belongs to the requested board.
30 34 *
31 35 * Subtasks normally carry the same board_id as their parent, but the parent
32 36 * fallback protects older data where that relationship may be incomplete.
37 + * Missing or mismatched tasks use the router's deliberate 404 response.
33 38 *
34 39 * @param int $taskId
35 40 * @param int $boardId
36 41 * @param bool $allowParentFallback
37 42 * @return Task
38 - * @throws \Exception
43 + * @throws ModelNotFoundException
39 44 */
40 45 public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true)
41 46 {
42 47 $taskId = absint($taskId);
@@ -42,9 +47,9 @@
42 47 $taskId = absint($taskId);
43 48 $boardId = absint($boardId);
44 49
45 50 if (!$taskId || !$boardId) {
46 - throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
51 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
47 52 }
48 53
49 54 $task = Task::where('id', $taskId)
50 55 ->where('board_id', $boardId)
@@ -50,9 +55,9 @@
50 55 ->where('board_id', $boardId)
51 56 ->first();
52 57
53 58 if ($task) {
54 - return $task;
59 + return $this->normalizeTaskDescriptionForEditor($task);
55 60 }
56 61
57 62 if ($allowParentFallback) {
58 63 $task = Task::where('id', $taskId)
@@ -63,16 +68,23 @@
63 68 if ($task) {
64 69 $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
65 70
66 71 if ((int) $parentBoardId === $boardId) {
67 - return $task;
72 + return $this->normalizeTaskDescriptionForEditor($task);
68 73 }
69 74 }
70 75 }
71 76
72 - throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
77 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
73 78 }
74 79
80 + private function normalizeTaskDescriptionForEditor(Task $task)
81 + {
82 + $task->description = DescriptionMarkdownConverter::normalize($task->description);
83 +
84 + return $task;
85 + }
86 +
75 87 public function createTask($data, $boardId)
76 88 {
77 89 $board = Board::select('id', 'type')->find($boardId);
78 90
@@ -90,8 +102,17 @@
90 102 }
91 103
92 104 $data['status'] = $stage->defaultTaskStatus();
93 105
106 + // Image covers require a persisted task, so creation only accepts a sanitized color cover.
107 + $coverColor = sanitize_hex_color(Arr::get($data, 'settings.cover.backgroundColor', ''));
108 + $taskSettings = [];
109 + if ($coverColor) {
110 + $taskSettings['cover'] = [
111 + 'backgroundColor' => $coverColor,
112 + ];
113 + }
114 +
94 115 if ($board->type == 'roadmap') {
95 116 $current_user = wp_get_current_user();
96 117 $settingData = array(
97 118 'integration_type' => 'feature',
@@ -99,10 +120,14 @@
99 120 'author' => [
100 121 'email' => $current_user->user_email // email of who posted this feature
101 122 ],
102 123 );
103 - $data['settings'] = $settingData;
124 + $data['settings'] = array_merge($taskSettings, $settingData);
104 125 $data['type'] = 'roadmap';
126 + } elseif ($taskSettings) {
127 + $data['settings'] = $taskSettings;
128 + } else {
129 + unset($data['settings']);
105 130 }
106 131
107 132 $providerPosition = Arr::get($data, 'position');
108 133
@@ -109,8 +134,11 @@
109 134 $data['position'] = $this->getLastPositionOfTasks($stage->id);
110 135
111 136 $data['board_id'] = $boardId;
112 137 $data = Helper::normalizeDates($data, ['due_at', 'started_at', 'last_completed_at', 'archived_at', 'remind_at']);
138 + if (isset($data['description'])) {
139 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
140 + }
113 141
114 142 $data = array_filter($data);
115 143 $task = (new Task())->createTask($data);
116 144
@@ -180,8 +208,9 @@
180 208 $taskQuery = Task::whereIn('id', $taskIds)
181 209 ->with(['assignees', 'board', 'stage'])
182 210 ->whereNull('archived_at')
183 211 ->where('parent_id', null)
212 + ->onActiveAvailableBoards()
184 213 ->orderBy('due_at', 'DESC');
185 214
186 215 switch ($category) {
187 216 case 'overdue':
@@ -208,8 +237,10 @@
208 237 ->with(['assignees', 'board', 'stage'])
209 238 ->whereIn('fbs_tasks.id', $taskIds)
210 239 ->whereNull('fbs_tasks.archived_at')
211 240 ->whereNull('fbs_tasks.parent_id')
241 + ->where('fbs_tasks.status', '!=', 'closed')
242 + ->onActiveAvailableBoards()
212 243 ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
213 244 $join->on('rel.object_id', '=', 'fbs_tasks.id')
214 245 ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
215 246 ->where('rel.foreign_id', $currentUserId);
@@ -230,8 +261,9 @@
230 261 return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
231 262 })->pluck('notification.task_id')->unique();
232 263 $validTasks = Task::whereIn('id', $taskIds)
233 264 ->with(['assignees', 'board', 'stage'])
265 + ->onActiveAvailableBoards()
234 266 ->get();
235 267
236 268 return $validTasks->toArray();
237 269 default:
@@ -251,9 +283,10 @@
251 283
252 284 $taskQuery = Task::query()
253 285 ->whereIn('id', $taskIds)
254 286 ->whereNull('archived_at')
255 - ->whereNull('parent_id');
287 + ->whereNull('parent_id')
288 + ->onActiveAvailableBoards();
256 289
257 290 switch ($category) {
258 291 case 'overdue':
259 292 $taskQuery->overdue();
@@ -276,8 +309,10 @@
276 309 ->select('fbs_tasks.id')
277 310 ->whereIn('fbs_tasks.id', $taskIds)
278 311 ->whereNull('fbs_tasks.archived_at')
279 312 ->whereNull('fbs_tasks.parent_id')
313 + ->where('fbs_tasks.status', '!=', 'closed')
314 + ->onActiveAvailableBoards()
280 315 ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
281 316 $join->on('rel.object_id', '=', 'fbs_tasks.id')
282 317 ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
283 318 ->where('rel.foreign_id', $currentUserId);
@@ -283,8 +318,25 @@
283 318 ->where('rel.foreign_id', $currentUserId);
284 319 });
285 320
286 321 return (int) $taskQuery->distinct()->count('fbs_tasks.id');
322 + case 'mentioned':
323 + $currentUserId = get_current_user_id();
324 + $taskIds = NotificationUser::where('user_id', $currentUserId)
325 + ->with(['notification' => function ($query) {
326 + $query->where('action', 'task_comment_mentioned')
327 + ->with('task');
328 + }])
329 + ->get()
330 + ->filter(function ($userNotification) {
331 + $notification = $userNotification->notification;
332 +
333 + return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
334 + })
335 + ->pluck('notification.task_id')
336 + ->unique();
337 +
338 + return Task::whereIn('id', $taskIds)->onActiveAvailableBoards()->count();
287 339 default:
288 340 return 0;
289 341 }
290 342
@@ -290,8 +342,40 @@
290 342
291 343 return (int) $taskQuery->count();
292 344 }
293 345
346 + /**
347 + * Unlink a Fluent Support ticket from a board-scoped task.
348 + *
349 + * @param int $taskId
350 + * @param int $boardId
351 + * @return Task
352 + * @throws \Exception
353 + */
354 + public function removeSupportTicketLink($taskId, $boardId)
355 + {
356 + $taskId = absint($taskId);
357 + $boardId = absint($boardId);
358 + $task = $this->findTaskOnBoard($taskId, $boardId);
359 +
360 + if ($task->source !== Constant::TASK_SOURCE_FLUENT_SUPPORT || !$task->source_id) {
361 + return $task;
362 + }
363 +
364 + $ticketId = $task->source_id;
365 + $settings = is_array($task->settings) ? $task->settings : [];
366 + unset($settings['author']);
367 +
368 + $task->source = null;
369 + $task->source_id = null;
370 + $task->settings = $settings ?: null;
371 + $task->save();
372 +
373 + do_action('fluent_boards/support_ticket_unlinked', $task, $ticketId);
374 +
375 + return $task;
376 + }
377 +
294 378 /*
295 379 * TODO: Refactor this function - For me.
296 380 */
297 381 public function updateTaskProperty($col, $value, $task)
@@ -302,11 +386,17 @@
302 386 'type',
303 387 // 'reminder_type',
304 388 'remind_at',
305 389 'log_minutes',
390 + 'source',
391 + 'source_id',
306 392 'settings'
307 393 ];
308 394
395 + if ($col === 'description') {
396 + $value = DescriptionMarkdownConverter::normalize($value);
397 + }
398 +
309 399 if (in_array($col, $validColumns) && $task->{$col} != $value) {
310 400 if ($col === 'remind_at') {
311 401 $value = Helper::normalizeDateValue($value);
312 402 }
@@ -556,9 +646,9 @@
556 646 }
557 647
558 648 private function updateDescription($col, $value, $task, $oldTask)
559 649 {
560 - $task->description = $value;
650 + $task->description = DescriptionMarkdownConverter::normalize($value);
561 651 $task->save();
562 652 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
563 653 }
564 654
@@ -699,58 +789,136 @@
699 789 $this->deleteTask($subtask);
700 790 }
701 791 }
702 792
703 - $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);
704 832 $dbInstance = App::getInstance('db');
705 - $dbInstance->beginTransaction();
706 833
707 - $deletedTask = clone $task;
708 - //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 + }
709 837
838 + if ($manageTransaction) {
839 + $dbInstance->beginTransaction();
840 + }
841 +
710 842 try {
711 - $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 + ];
712 848
713 - if ($deleted) {
714 - //task assignees watchers removed
715 - $task->watchers()->detach();
716 - $task->assignees()->detach();
849 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
850 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
851 + }
717 852
718 - //removing all task related notifications
719 - $notificationIds = $task->notifications->pluck('id');
720 - $task->notifications()->delete();
721 - NotificationUser::whereIn('notification_id', $notificationIds)->delete();
853 + Relation::whereIn('object_id', $taskIds)
854 + ->whereIn('object_type', $relationTypes)
855 + ->delete();
722 856
723 - //task labels removed
724 - $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();
725 863
726 - //task custom field value
727 - if (defined('FLUENT_BOARDS_PRO')) {
728 - $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'));
729 881 }
730 - $this->deleteTaskAttachments($task);
731 - //task custom field value
732 - if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
733 - $task->customFields()->detach();
734 - $this->deleteTaskAttachments($task);
735 - }
736 882
737 - // Delete time tracking records for this task
738 - $this->deleteTimeTrackingRecords($task->id);
883 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
739 884
740 - do_action('fluent_boards/task_deleted', $task);
741 - TaskMeta::where('task_id', $task->id)->delete();
742 - do_action('fluent_boards/task_deleted', $deletedTask);
743 - TaskMeta::where('task_id', $task->id)->delete();
885 + if ($manageTransaction) {
886 + $dbInstance->commit();
744 887 }
888 + } catch (\Throwable $e) {
889 + if ($manageTransaction) {
890 + $dbInstance->rollBack();
891 + }
745 892
746 - $dbInstance->commit();
747 - } catch (\Exception $e) {
748 - $dbInstance->rollBack();
749 - throw $e; // Re-throw the exception after rolling back
893 + throw $e;
750 894 }
895 + }
751 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 + });
752 919 }
920 +
753 921 public function deleteTaskForBulk($task)
754 922 {
755 923 // If this is a parent task, delete all subtasks first
756 924 if (!$task->parent_id) {
@@ -760,8 +928,10 @@
760 928 $this->deleteTaskForBulk($subtask);
761 929 }
762 930 }
763 931
932 + $this->deleteTimeTrackingRecords($task->id, false);
933 +
764 934 $deleted = $task->delete();
765 935
766 936 if ($deleted) {
767 937
@@ -777,9 +947,9 @@
777 947 //task labels removed
778 948 $task->labels()->detach();
779 949
780 950 //task custom field value
781 - if (defined('FLUENT_BOARDS_PRO_VERSION')) {
951 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
782 952 $task->customFields()->detach();
783 953 $this->deleteTaskAttachments($task);
784 954 }
785 955
@@ -824,8 +994,9 @@
824 994 $dbInstance->beginTransaction();
825 995
826 996 try {
827 997 $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId);
998 + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
828 999
829 1000 $task->board_id = (int) $targetBoardId;
830 1001 $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task';
831 1002
@@ -834,19 +1005,16 @@
834 1005 $task->assignees()->detach();
835 1006 $task->watchers()->detach();
836 1007 $this->removeCustomFieldAssociations($task);
837 1008
838 - // REMOVE: User-specific data to prevent security issues
839 - $this->removeCommentsAndReplies($task->id);
840 - $this->removeTimeTrackingRecords($task->id);
841 -
842 1009 // REMOVE: Recurring task settings for security
843 1010 $this->removeRecurringTaskSettings($task->id);
844 1011
845 1012 $task->save();
1013 + do_action('fluent_boards/task_moved_update_time_tracking', $task);
846 1014
847 1015 // MOVE: Subtasks to new board (preserves subtask groups)
848 - $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type, $attachmentFileService);
1016 + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService);
849 1017
850 1018 $dbInstance->commit();
851 1019 $attachmentFileService->commitMovedOriginalFiles();
852 1020 } catch (\Exception $e) {
@@ -862,9 +1030,9 @@
862 1030 /**
863 1031 * Move all subtasks to the new board when parent task is moved
864 1032 * Preserves subtask groups and their relationships
865 1033 */
866 - private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
1034 + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
867 1035 {
868 1036 // Get all subtasks of the parent task
869 1037 $subtasks = Task::where('parent_id', $parentTaskId)->get();
870 1038
@@ -873,10 +1041,12 @@
873 1041 }
874 1042
875 1043 foreach ($subtasks as $subtask) {
876 1044 // Update board_id and type
877 - $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);
878 1047 $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId);
1048 + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
879 1049
880 1050 $subtask->board_id = (int) $targetBoardId;
881 1051 $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task';
882 1052
@@ -889,16 +1059,13 @@
889 1059 $subtask->taskMeta()
890 1060 ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD)
891 1061 ->delete();
892 1062
893 - // REMOVE: User-specific data for security
894 - $this->removeCommentsAndReplies($subtask->id);
895 - $this->removeTimeTrackingRecords($subtask->id);
896 -
897 1063 // REMOVE: Recurring task settings
898 1064 $this->removeRecurringTaskSettings($subtask->id);
899 1065
900 1066 $subtask->save();
1067 + do_action('fluent_boards/task_moved_update_time_tracking', $subtask);
901 1068 }
902 1069 }
903 1070
904 1071 /**
@@ -944,24 +1111,26 @@
944 1111 }
945 1112 }
946 1113
947 1114 /**
948 - * Remove comments and replies for security reasons
949 - * Prevents exposing user-specific data to unauthorized users
1115 + * Move a task's complete comment history and images to another board.
950 1116 */
951 - private function removeCommentsAndReplies($taskId)
1117 + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService)
952 1118 {
953 - // Input validation
954 - 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) {
955 1124 return;
956 1125 }
957 -
958 - // Remove all comments and replies for this task (delete individually to fire model events and clean up images)
959 - $comments = Comment::where('task_id', (int) $taskId)->get();
960 - foreach ($comments as $comment) {
961 - $comment->delete();
962 - }
963 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]);
964 1133 }
965 1134
966 1135 /**
967 1136 * Remove time tracking records for security reasons
@@ -989,9 +1158,9 @@
989 1158 return;
990 1159 }
991 1160
992 1161 // Remove all attachments for this task
993 - if (class_exists('FluentBoardsPro\App\Models\TaskAttachment')) {
1162 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
994 1163 \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', (int) $taskId)
995 1164 ->where('object_type', 'task')
996 1165 ->delete();
997 1166 }
@@ -1015,20 +1184,147 @@
1015 1184 }
1016 1185
1017 1186 public function getIdeaVoteStatistics($taskId)
1018 1187 {
1019 - return IdeaReaction::where('object_id', $taskId)
1020 - ->where('object_type', 'idea')
1021 - ->where('type', 'upvote')
1022 - ->count();
1188 + $taskId = absint($taskId);
1189 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds([$taskId]);
1190 +
1191 + return $voteStatistics[$taskId] ?? 0;
1023 1192 }
1024 1193
1194 + public function loadIdeaVoteStatistics($tasks)
1195 + {
1196 + $taskIds = [];
1025 1197
1198 + foreach ($tasks as $task) {
1199 + $taskId = absint($task->id);
1200 + if ($taskId) {
1201 + $taskIds[] = $taskId;
1202 + }
1203 + }
1204 +
1205 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds($taskIds);
1206 +
1207 + foreach ($tasks as $task) {
1208 + $task->vote_statistics = $voteStatistics[(int) $task->id] ?? 0;
1209 + }
1210 +
1211 + return $tasks;
1212 + }
1213 +
1214 + private function getIdeaVoteStatisticsByTaskIds(array $taskIds)
1215 + {
1216 + global $wpdb;
1217 +
1218 + $taskIds = array_values(array_unique(array_filter(array_map('absint', $taskIds))));
1219 +
1220 + if (!$taskIds) {
1221 + return [];
1222 + }
1223 +
1224 + $placeholders = implode(', ', array_fill(0, count($taskIds), '%d'));
1225 + $ideaReactionTable = $this->getIdeaReactionTable();
1226 + $counts = [];
1227 +
1228 + if ($ideaReactionTable) {
1229 + $rows = $wpdb->get_results(
1230 + $wpdb->prepare(
1231 + "SELECT object_id, COUNT(*) as total FROM {$ideaReactionTable} WHERE object_id IN ({$placeholders}) AND object_type = %s AND type = %s GROUP BY object_id",
1232 + array_merge($taskIds, ['idea', 'upvote'])
1233 + )
1234 + );
1235 +
1236 + foreach ($rows as $row) {
1237 + $counts[(int) $row->object_id] = (int) $row->total;
1238 + }
1239 +
1240 + return $counts;
1241 + }
1242 +
1243 + $taskMetaTable = $this->getPhysicalTableName((new TaskMeta())->getTable());
1244 + $rows = $wpdb->get_results(
1245 + $wpdb->prepare(
1246 + "SELECT task_id, COALESCE(MAX(CAST(value AS UNSIGNED)), 0) as total FROM {$taskMetaTable} WHERE task_id IN ({$placeholders}) AND `key` = %s GROUP BY task_id",
1247 + array_merge($taskIds, ['upvote'])
1248 + )
1249 + );
1250 +
1251 + foreach ($rows as $row) {
1252 + $counts[(int) $row->task_id] = (int) $row->total;
1253 + }
1254 +
1255 + return $counts;
1256 + }
1257 +
1026 1258 /**
1027 - * Summary of getArchivedOrCompletedTasks
1028 - * this function will return completd tasks or archived tasks based on users input and also can search by name
1029 - * @param mixed $data
1030 - * @param mixed $taskType
1259 + * Returns the canonical SQL expression for an idea's upvote count.
1260 + *
1261 + * The roadmap reaction table is authoritative when it exists; legacy task
1262 + * metadata remains the fallback for installations without that table.
1263 + */
1264 + public function getIdeaVoteStatisticsSelect()
1265 + {
1266 + $taskTable = $this->getPhysicalTableName((new Task())->getTable());
1267 +
1268 + return $this->buildIdeaVoteStatisticsSelect($taskTable);
1269 + }
1270 +
1271 + private function buildIdeaVoteStatisticsSelect($taskTable)
1272 + {
1273 + $ideaReactionTable = $this->getIdeaReactionTable();
1274 +
1275 + if ($ideaReactionTable) {
1276 + return "(SELECT COUNT(*) FROM {$ideaReactionTable} WHERE {$ideaReactionTable}.object_id = {$taskTable}.id AND {$ideaReactionTable}.object_type = 'idea' AND {$ideaReactionTable}.type = 'upvote')";
1277 + }
1278 +
1279 + $taskMetaTable = $this->getPhysicalTableName((new TaskMeta())->getTable());
1280 +
1281 + return "(SELECT COALESCE(MAX(CAST({$taskMetaTable}.value AS UNSIGNED)), 0) FROM {$taskMetaTable} WHERE {$taskMetaTable}.task_id = {$taskTable}.id AND {$taskMetaTable}.key = 'upvote')";
1282 + }
1283 +
1284 + private function getIdeaReactionTable()
1285 + {
1286 + $table = $this->getPhysicalTableName((new IdeaReaction())->getTable(), false);
1287 +
1288 + if ($table) {
1289 + return $table;
1290 + }
1291 +
1292 + return '';
1293 + }
1294 +
1295 + private function getPhysicalTableName($table, $usePrefixedFallback = true)
1296 + {
1297 + global $wpdb;
1298 + $cacheKey = $table . '|' . (int) $usePrefixedFallback;
1299 +
1300 + if (array_key_exists($cacheKey, self::$physicalTableNameCache)) {
1301 + return self::$physicalTableNameCache[$cacheKey];
1302 + }
1303 +
1304 + $candidates = array_values(array_unique([
1305 + $wpdb->prefix . $table,
1306 + $table,
1307 + ]));
1308 +
1309 + foreach ($candidates as $candidate) {
1310 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($candidate))) === $candidate) {
1311 + self::$physicalTableNameCache[$cacheKey] = $candidate;
1312 + return $candidate;
1313 + }
1314 + }
1315 +
1316 + self::$physicalTableNameCache[$cacheKey] = $usePrefixedFallback ? $wpdb->prefix . $table : '';
1317 +
1318 + return self::$physicalTableNameCache[$cacheKey];
1319 + }
1320 +
1321 +
1322 + /**
1323 + * Get a bounded paginated list of archived board tasks with their latest archive actor.
1324 + *
1325 + * @param array $data
1326 + * @param int $boardId
1031 1327 * @return mixed
1032 1328 * @throws \Exception
1033 1329 */
1034 1330 public function getArchivedTasks($data, $boardId)
@@ -1036,10 +1332,11 @@
1036 1332 if (!$boardId) {
1037 1333 throw new \Exception(esc_html__('Board id is required', 'fluent-boards'));
1038 1334 }
1039 1335
1040 - $per_page = isset($data['per_page']) ? $data['per_page'] : 20;
1041 - $page = isset($data['page']) ? $data['page'] : 1;
1336 + // Bound the task page so the related activity and user batch queries stay predictable.
1337 + $perPage = max(1, min(50, absint($data['per_page'] ?? 20)));
1338 + $page = max(1, absint($data['page'] ?? 1));
1042 1339 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
1043 1340
1044 1341 if (!empty($data['query'])) {
1045 1342 $query = strtolower($data['query']);
@@ -1053,9 +1350,51 @@
1053 1350 $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
1054 1351 }
1055 1352 }
1056 1353
1057 - return $tasksQuery->orderBy('created_at', 'DESC')->with('assignees')->paginate($per_page, ['*'], 'page', $page);
1354 + $tasks = $tasksQuery->orderBy('created_at', 'DESC')->with('assignees')->paginate($perPage, ['*'], 'page', $page);
1355 +
1356 + $taskIds = [];
1357 + foreach ($tasks as $task) {
1358 + $taskIds[] = (int) $task->id;
1359 + $task->archived_by_id = null;
1360 + $task->archived_by = null;
1361 + }
1362 +
1363 + if (empty($taskIds)) {
1364 + return $tasks;
1365 + }
1366 +
1367 + $activityIds = Activity::whereIn('object_id', $taskIds)
1368 + ->where('object_type', Constant::ACTIVITY_TASK)
1369 + ->where('action', 'archived')
1370 + ->where('column', 'task')
1371 + ->selectRaw('MAX(id) as id')
1372 + ->groupBy('object_id')
1373 + ->pluck('id')
1374 + ->toArray();
1375 +
1376 + if (empty($activityIds)) {
1377 + return $tasks;
1378 + }
1379 +
1380 + $activities = Activity::whereIn('id', $activityIds)
1381 + ->with('user')
1382 + ->get()
1383 + ->keyBy('object_id');
1384 +
1385 + foreach ($tasks as $task) {
1386 + $activity = $activities->get($task->id);
1387 +
1388 + if (!$activity) {
1389 + continue;
1390 + }
1391 +
1392 + $task->archived_by_id = $activity->created_by ? (int) $activity->created_by : null;
1393 + $task->archived_by = $activity->user ? Helper::sanitizeUserCollections($activity->user) : null;
1394 + }
1395 +
1396 + return $tasks;
1058 1397 }
1059 1398
1060 1399 public function getTableTasks($boardId, $data = [])
1061 1400 {
@@ -1065,9 +1404,9 @@
1065 1404 $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc';
1066 1405 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1067 1406 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1068 1407 $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', []));
1069 - $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []));
1408 + $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1070 1409 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1071 1410 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1072 1411 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
1073 1412 $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
@@ -1073,8 +1412,10 @@
1073 1412 $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
1074 1413 $customFieldFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'custom_fields', []));
1075 1414 $dueDateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'due_date', []));
1076 1415 $includeArchived = !empty($data['include_archived']) || in_array('archived', $taskStatusFilters, true);
1416 + $board = Board::select('id', 'type')->find($boardId);
1417 + $isRoadmapBoard = $board && $board->type === 'roadmap';
1077 1418
1078 1419 $perPage = max(1, min(150, $perPage));
1079 1420 $page = max(1, $page);
1080 1421 $sortDirection = strtolower($sortDirection) === 'desc' ? 'desc' : 'asc';
@@ -1081,42 +1422,71 @@
1081 1422
1082 1423 $sortColumnMap = [
1083 1424 'title' => 'title',
1084 1425 'status' => 'status',
1426 + 'stage_id' => 'stage_id',
1427 + 'priority' => 'priority',
1428 + 'due_at' => 'due_at',
1085 1429 'created_at' => 'created_at',
1430 + 'updated_at' => 'updated_at',
1086 1431 'position' => 'position',
1087 1432 ];
1433 +
1434 + if ($isRoadmapBoard) {
1435 + $sortColumnMap['vote_statistics'] = 'vote_statistics';
1436 + }
1437 +
1088 1438 $sortColumn = Arr::get($sortColumnMap, $sortBy, 'position');
1439 + $taskTable = (new Task())->getTable();
1440 + $taskColumnNames = [
1441 + 'id',
1442 + 'title',
1443 + 'slug',
1444 + 'board_id',
1445 + 'parent_id',
1446 + 'type',
1447 + 'stage_id',
1448 + 'status',
1449 + 'priority',
1450 + 'archived_at',
1451 + 'remind_at',
1452 + 'reminder_type',
1453 + 'started_at',
1454 + 'due_at',
1455 + 'last_completed_at',
1456 + 'position',
1457 + 'comments_count',
1458 + 'created_by',
1459 + 'settings',
1460 + 'source',
1461 + 'source_id',
1462 + 'created_at',
1463 + 'updated_at',
1464 + ];
1465 + $taskColumns = array_map(function ($columnName) use ($taskTable) {
1466 + return "{$taskTable}.{$columnName}";
1467 + }, $taskColumnNames);
1089 1468
1090 1469 $tasksQuery = Task::query()
1091 1470 // Table rows only need row-level fields; modal open rehydrates the full task.
1092 - ->select([
1093 - 'id',
1094 - 'title',
1095 - 'slug',
1096 - 'board_id',
1097 - 'parent_id',
1098 - 'stage_id',
1099 - 'status',
1100 - 'priority',
1101 - 'archived_at',
1102 - 'remind_at',
1103 - 'reminder_type',
1104 - 'started_at',
1105 - 'due_at',
1106 - 'last_completed_at',
1107 - 'position',
1108 - 'comments_count',
1109 - 'created_by',
1110 - 'settings',
1111 - 'source',
1112 - 'source_id',
1113 - 'created_at',
1114 - ])
1115 1471 ->with(['assignees', 'labels', 'watchers'])
1116 1472 ->where('board_id', $boardId)
1117 1473 ->whereNull('parent_id');
1118 1474
1475 + if ($isRoadmapBoard) {
1476 + $taskSqlTable = $this->getPhysicalTableName($taskTable);
1477 + $taskSqlColumns = [];
1478 +
1479 + foreach ($taskColumnNames as $columnName) {
1480 + $taskSqlColumns[] = "{$taskSqlTable}.{$columnName}";
1481 + }
1482 +
1483 + $taskSqlColumns[] = $this->buildIdeaVoteStatisticsSelect($taskSqlTable) . ' as vote_statistics';
1484 + $tasksQuery->selectRaw(implode(', ', $taskSqlColumns));
1485 + } else {
1486 + $tasksQuery->select($taskColumns);
1487 + }
1488 +
1119 1489 if (!$includeArchived && !$taskStatusFilters) {
1120 1490 $tasksQuery->whereNull('archived_at');
1121 1491 }
1122 1492
@@ -1146,9 +1516,9 @@
1146 1516 {
1147 1517 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1148 1518 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1149 1519 $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', []));
1150 - $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []));
1520 + $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1151 1521 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1152 1522 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1153 1523 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
1154 1524 $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
@@ -1181,8 +1551,9 @@
1181 1551 'created_by',
1182 1552 'settings',
1183 1553 'source',
1184 1554 'source_id',
1555 + 'updated_at',
1185 1556 ])
1186 1557 ->with(['assignees', 'labels', 'watchers'])
1187 1558 ->where('board_id', $boardId)
1188 1559 ->whereNull('parent_id');
@@ -1209,18 +1580,18 @@
1209 1580 ->orderBy('position', 'asc')
1210 1581 ->get();
1211 1582 }
1212 1583
1213 - private function sanitizeTableFilterValues($values)
1584 + private function sanitizeTableFilterValues($values, $allowEmpty = false)
1214 1585 {
1215 1586 if (!is_array($values)) {
1216 - $values = ($values === null || $values === '') ? [] : [$values];
1587 + $values = ($values === null || (!$allowEmpty && $values === '')) ? [] : [$values];
1217 1588 }
1218 1589
1219 1590 return array_values(array_filter(array_map(static function ($value) {
1220 1591 return sanitize_text_field($value);
1221 - }, $values), static function ($value) {
1222 - return $value !== '';
1592 + }, $values), static function ($value) use ($allowEmpty) {
1593 + return $allowEmpty || $value !== '';
1223 1594 }));
1224 1595 }
1225 1596
1226 1597 private function applyTableTaskSearch($tasksQuery, $search)
@@ -1266,9 +1637,26 @@
1266 1637 $this->applyTableTaskStatusFilters($tasksQuery, $taskStatusFilters);
1267 1638 }
1268 1639
1269 1640 if ($priorityFilters) {
1270 - $tasksQuery->whereIn('priority', array_map('strtolower', $priorityFilters));
1641 + $priorityFilters = array_map('strtolower', $priorityFilters);
1642 + $hasNoPriorityFilter = in_array('', $priorityFilters, true);
1643 + $selectedPriorities = array_values(array_filter($priorityFilters, static function ($priority) {
1644 + return $priority !== '';
1645 + }));
1646 +
1647 + $tasksQuery->where(function ($query) use ($hasNoPriorityFilter, $selectedPriorities) {
1648 + if ($selectedPriorities) {
1649 + $query->whereIn('priority', $selectedPriorities);
1650 + }
1651 +
1652 + if ($hasNoPriorityFilter) {
1653 + $method = $selectedPriorities ? 'orWhere' : 'where';
1654 + $query->{$method}(function ($priorityQuery) {
1655 + $priorityQuery->whereNull('priority')->orWhere('priority', '');
1656 + });
1657 + }
1658 + });
1271 1659 }
1272 1660
1273 1661 if ($contactFilters) {
1274 1662 $contactIds = array_values(array_filter(array_map('intval', $contactFilters)));
@@ -1386,13 +1774,16 @@
1386 1774 return;
1387 1775 }
1388 1776
1389 1777 $nowTimestamp = current_time('timestamp');
1778 + $startOfTodayTimestamp = strtotime(gmdate('Y-m-d 00:00:00', $nowTimestamp));
1779 + $dayOfWeek = (int) gmdate('w', $nowTimestamp);
1780 + $startOfThisWeekTimestamp = strtotime('-' . $dayOfWeek . ' days', $startOfTodayTimestamp);
1390 1781 $startOfToday = gmdate('Y-m-d 00:00:00', $nowTimestamp);
1391 1782 $endOfToday = gmdate('Y-m-d 23:59:59', $nowTimestamp);
1392 - $startOfThisWeek = gmdate('Y-m-d 00:00:00', strtotime('sunday this week', $nowTimestamp));
1393 - $startOfNextWeek = gmdate('Y-m-d 00:00:00', strtotime('sunday next week', $nowTimestamp));
1394 - $startOfWeekAfterNext = gmdate('Y-m-d 00:00:00', strtotime('+1 week', strtotime($startOfNextWeek)));
1783 + $startOfThisWeek = gmdate('Y-m-d 00:00:00', $startOfThisWeekTimestamp);
1784 + $startOfNextWeek = gmdate('Y-m-d 00:00:00', strtotime('+7 days', $startOfThisWeekTimestamp));
1785 + $startOfWeekAfterNext = gmdate('Y-m-d 00:00:00', strtotime('+14 days', $startOfThisWeekTimestamp));
1395 1786 $endOfThisMonth = gmdate('Y-m-t 23:59:59', $nowTimestamp);
1396 1787 $nowMysql = current_time('mysql');
1397 1788
1398 1789 $tasksQuery->where(function ($query) use ($dueDateFilters, $startOfToday, $endOfToday, $startOfThisWeek, $startOfNextWeek, $startOfWeekAfterNext, $endOfThisMonth, $nowMysql) {
@@ -1719,14 +2110,23 @@
1719 2110 $dbInstance->beginTransaction();
1720 2111
1721 2112 try {
1722 2113 foreach ($allActiveTasks as $task) {
2114 + if ($task->parent_id && empty($taskMap[$task->parent_id])) {
2115 + continue;
2116 + }
2117 +
2118 + $stageId = !empty($task->stage_id) ? (int) $task->stage_id : 0;
2119 + if (!$task->parent_id && empty($stageMap[$stageId])) {
2120 + continue;
2121 + }
2122 +
1723 2123 $newTask = array();
1724 2124 $newTask['title'] = $task->title;
1725 2125 $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
1726 - $newTask['description'] = $task->description;
2126 + $newTask['description'] = DescriptionMarkdownConverter::normalize($task->description);
1727 2127 $newTask['board_id'] = $newBoard->id;
1728 - $newTask['stage_id'] = $stageMap[$task->stage_id];
2128 + $newTask['stage_id'] = $stageId && isset($stageMap[$stageId]) ? $stageMap[$stageId] : null;
1729 2129 $newTask['status'] = $task->status;
1730 2130 $newTask['priority'] = $task->priority;
1731 2131 $newTask['position'] = $task->position;
1732 2132 $newTask['due_at'] = $task->due_at;
@@ -1747,9 +2147,9 @@
1747 2147 $groupRelationOfTask = TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
1748 2148 ->where('task_id', $task->id)
1749 2149 ->first();
1750 2150
1751 - if ($groupRelationOfTask && $subtaskGroupMap[$groupRelationOfTask->value]) {
2151 + if ($groupRelationOfTask && !empty($subtaskGroupMap[$groupRelationOfTask->value])) {
1752 2152 TaskMeta::create([
1753 2153 'task_id' => $newTask->id,
1754 2154 'key' => Constant::SUBTASK_GROUP_CHILD,
1755 2155 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
@@ -1809,31 +2209,40 @@
1809 2209
1810 2210 /**
1811 2211 * @param $taskId
1812 2212 * @param $perPage
1813 - * @param $offset
2213 + * @param $page
1814 2214 * @param string $filter
2215 + * @param $boardId
2216 + * @param string $feedType
1815 2217 * @return array
1816 2218 */
1817 - public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null): array
2219 + public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
1818 2220 {
1819 2221 // Fetch the task
1820 2222 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2223 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
1821 2224
1822 2225 // Fetch comments and activities separately
1823 - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
1824 - $activities = $task->activities()
1825 - ->with('user')
1826 - ->where(function($query) {
1827 - $query->whereNotIn('column', [ 'comment', 'a reply'])
1828 - ->orWhere(function($subQuery) {
1829 - $subQuery->whereNotIn('action', ['added', 'updated']);
1830 - });
1831 - })
1832 - ->orderBy('created_at', 'desc')
1833 - ->get()
1834 - ->toArray();
2226 + $comments = [];
2227 + if ($feedType !== 'activities') {
2228 + $comments = $task->comments()->with(['user', 'replies.user'])->orderBy('created_at', 'desc')->get()->toArray();
2229 + }
1835 2230
2231 + $activities = [];
2232 + if ($feedType !== 'comments') {
2233 + $activities = $task->activities()
2234 + ->with('user')
2235 + ->where(function($query) {
2236 + $query->whereNotIn('column', [ 'comment', 'a reply'])
2237 + ->orWhere(function($subQuery) {
2238 + $subQuery->whereNotIn('action', ['added', 'updated']);
2239 + });
2240 + })
2241 + ->orderBy('created_at', 'desc')
2242 + ->get()
2243 + ->toArray();
2244 + }
1836 2245
1837 2246
1838 2247 // Merge comments and activities into a single array
1839 2248 $commentsAndActivities = array_merge($comments, $activities);
@@ -2058,8 +2467,12 @@
2058 2467 }
2059 2468
2060 2469 private function deleteTaskAttachments($task)
2061 2470 {
2471 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2472 + return;
2473 + }
2474 +
2062 2475 $attachments = TaskAttachment::where('object_id', $task->id)
2063 2476 ->where('object_type', Constant::TASK_ATTACHMENT)
2064 2477 ->get();
2065 2478 foreach ($attachments as $attachment) {
@@ -2065,12 +2478,38 @@
2065 2478 foreach ($attachments as $attachment) {
2066 2479 $deletedAttachment = clone $attachment;
2067 2480 $attachment->delete();
2068 2481
2069 - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2482 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2070 2483 }
2071 2484 }
2072 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 +
2073 2512 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2074 2513 {
2075 2514 global $wpdb;
2076 2515 $attachmentFileService = new AttachmentFileService();
@@ -2324,8 +2763,14 @@
2324 2763 if ($images->count() > 0) {
2325 2764 foreach ($images as $image) {
2326 2765 $clonedImage = $image->replicate();
2327 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 + );
2328 2773 $clonedImage->save();
2329 2774 }
2330 2775 }
2331 2776 }
@@ -2652,14 +3097,16 @@
2652 3097 $priority = $params['priority'] ?? null;
2653 3098
2654 3099 // Get valid priorities including custom ones added by hooks
2655 3100 $validPriorities = array_keys(apply_filters('fluent_boards/task_priorities', [
2656 - 'low' => __('Low', 'fluent-boards'),
3101 + '' => __('No priority', 'fluent-boards'),
3102 + 'urgent' => __('Urgent', 'fluent-boards'),
3103 + 'high' => __('High', 'fluent-boards'),
2657 3104 'medium' => __('Medium', 'fluent-boards'),
2658 - 'high' => __('High', 'fluent-boards')
3105 + 'low' => __('Low', 'fluent-boards')
2659 3106 ]));
2660 3107
2661 - if (!in_array($priority, $validPriorities)) {
3108 + if (!in_array($priority, $validPriorities, true)) {
2662 3109 throw new \Exception(esc_html__('Invalid priority level', 'fluent-boards'));
2663 3110 }
2664 3111
2665 3112 $successfulTasks = [];
@@ -2908,15 +3355,16 @@
2908 3355 'message' => $message
2909 3356 ];
2910 3357 }
2911 3358
2912 - /* Delete time tracking records for one or multiple tasks
2913 - * Uses try-catch for better performance - avoids table existence check overhead
3359 + /**
3360 + * Delete time tracking records for one or multiple tasks.
2914 3361 *
2915 - * @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.
2916 3364 * @return void
2917 3365 */
2918 - public function deleteTimeTrackingRecords($taskIds)
3366 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
2919 3367 {
2920 3368 // Check if FluentBoards Pro time tracking is available
2921 3369 if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
2922 3370 return;
@@ -2932,11 +3380,12 @@
2932 3380 if (is_numeric($taskIds) && $taskIds > 0) {
2933 3381 \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
2934 3382 }
2935 3383 }
2936 - } catch (\Exception $e) {
2937 - // Silently fail if table doesn't exist or any other error occurs
2938 - // This is intentional for cleanup operations
3384 + } catch (\Throwable $e) {
3385 + if (!$suppressErrors) {
3386 + throw $e;
3387 + }
2939 3388 }
2940 3389 }
2941 3390
2942 3391 }