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 +613 -150 1.952.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) {
@@ -1445,11 +1836,11 @@
1445 1836 // Shared watcher/assignee filters are treated as an OR group, matching
1446 1837 // the existing client-side filter semantics.
1447 1838 $tasksQuery->where(function ($query) use ($commonIds) {
1448 1839 $query->whereHas('assignees', function ($assigneeQuery) use ($commonIds) {
1449 - $assigneeQuery->whereIn('ID', $commonIds);
1840 + $assigneeQuery->whereIn('users.ID', $commonIds);
1450 1841 })->orWhereHas('watchers', function ($watcherQuery) use ($commonIds) {
1451 - $watcherQuery->whereIn('ID', $commonIds);
1842 + $watcherQuery->whereIn('users.ID', $commonIds);
1452 1843 });
1453 1844 });
1454 1845 }
1455 1846
@@ -1460,9 +1851,9 @@
1460 1851 }
1461 1852
1462 1853 if ($assigneeOnlyIds) {
1463 1854 $query->orWhereHas('assignees', function ($assigneeQuery) use ($assigneeOnlyIds) {
1464 - $assigneeQuery->whereIn('ID', $assigneeOnlyIds);
1855 + $assigneeQuery->whereIn('users.ID', $assigneeOnlyIds);
1465 1856 });
1466 1857 }
1467 1858 });
1468 1859 }
@@ -1468,11 +1859,11 @@
1468 1859 }
1469 1860
1470 1861 if ($watcherOnlyIds) {
1471 1862 $tasksQuery->whereHas('watchers', function ($watcherQuery) use ($watcherOnlyIds) {
1472 - $watcherQuery->whereIn('ID', $watcherOnlyIds);
1863 + $watcherQuery->whereIn('users.ID', $watcherOnlyIds);
1473 1864 })->whereDoesntHave('assignees', function ($assigneeQuery) use ($watcherOnlyIds) {
1474 - $assigneeQuery->whereIn('ID', $watcherOnlyIds);
1865 + $assigneeQuery->whereIn('users.ID', $watcherOnlyIds);
1475 1866 });
1476 1867 }
1477 1868 }
1478 1869
@@ -1648,22 +2039,36 @@
1648 2039 if (!$associatedId || !$userId) {
1649 2040 return [];
1650 2041 }
1651 2042
1652 - // Load only the relationships rendered by the FluentCRM profile tab.
1653 - $tasks = Task::query()
2043 + $isAdmin = PermissionManager::isAdmin($userId);
2044 + $editableBoardIds = [];
2045 +
2046 + $tasksQuery = Task::query()
1654 2047 ->where('crm_contact_id', $associatedId)
1655 2048 ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
1656 - ->orderBy('due_at', 'ASC')
1657 - ->get();
2049 + ->orderBy('due_at', 'ASC');
1658 2050
1659 - // Batch board access once so each task avoids its own permission query.
1660 - $editableBoardIds = array_map('intval', PermissionManager::getBoardIdsForUser($userId));
2051 + if ($isAdmin) {
2052 + $tasksQuery->whereHas('board', function ($query) {
2053 + $query->whereNull('archived_at');
2054 + });
2055 + } else {
2056 + $editableBoardIds = array_map('intval', PermissionManager::getBoardIdsForUser($userId));
1661 2057
2058 + if (!$editableBoardIds) {
2059 + return [];
2060 + }
2061 +
2062 + $tasksQuery->whereIn('board_id', $editableBoardIds);
2063 + }
2064 +
2065 + $tasks = $tasksQuery->get();
2066 +
1662 2067 foreach ($tasks as $task) {
1663 2068 $task->isOverdue = $task->isOverdue();
1664 2069 $task->isUpcoming = $task->upcoming();
1665 - $task->can_edit = in_array((int)$task->board_id, $editableBoardIds, true);
2070 + $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
1666 2071
1667 2072 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1668 2073
1669 2074 foreach ($task->subtaskGroup as $group) {
@@ -1705,14 +2110,23 @@
1705 2110 $dbInstance->beginTransaction();
1706 2111
1707 2112 try {
1708 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 +
1709 2123 $newTask = array();
1710 2124 $newTask['title'] = $task->title;
1711 2125 $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
1712 - $newTask['description'] = $task->description;
2126 + $newTask['description'] = DescriptionMarkdownConverter::normalize($task->description);
1713 2127 $newTask['board_id'] = $newBoard->id;
1714 - $newTask['stage_id'] = $stageMap[$task->stage_id];
2128 + $newTask['stage_id'] = $stageId && isset($stageMap[$stageId]) ? $stageMap[$stageId] : null;
1715 2129 $newTask['status'] = $task->status;
1716 2130 $newTask['priority'] = $task->priority;
1717 2131 $newTask['position'] = $task->position;
1718 2132 $newTask['due_at'] = $task->due_at;
@@ -1733,9 +2147,9 @@
1733 2147 $groupRelationOfTask = TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
1734 2148 ->where('task_id', $task->id)
1735 2149 ->first();
1736 2150
1737 - if ($groupRelationOfTask && $subtaskGroupMap[$groupRelationOfTask->value]) {
2151 + if ($groupRelationOfTask && !empty($subtaskGroupMap[$groupRelationOfTask->value])) {
1738 2152 TaskMeta::create([
1739 2153 'task_id' => $newTask->id,
1740 2154 'key' => Constant::SUBTASK_GROUP_CHILD,
1741 2155 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
@@ -1795,31 +2209,40 @@
1795 2209
1796 2210 /**
1797 2211 * @param $taskId
1798 2212 * @param $perPage
1799 - * @param $offset
2213 + * @param $page
1800 2214 * @param string $filter
2215 + * @param $boardId
2216 + * @param string $feedType
1801 2217 * @return array
1802 2218 */
1803 - 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
1804 2220 {
1805 2221 // Fetch the task
1806 2222 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2223 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
1807 2224
1808 2225 // Fetch comments and activities separately
1809 - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
1810 - $activities = $task->activities()
1811 - ->with('user')
1812 - ->where(function($query) {
1813 - $query->whereNotIn('column', [ 'comment', 'a reply'])
1814 - ->orWhere(function($subQuery) {
1815 - $subQuery->whereNotIn('action', ['added', 'updated']);
1816 - });
1817 - })
1818 - ->orderBy('created_at', 'desc')
1819 - ->get()
1820 - ->toArray();
2226 + $comments = [];
2227 + if ($feedType !== 'activities') {
2228 + $comments = $task->comments()->with(['user', 'replies.user'])->orderBy('created_at', 'desc')->get()->toArray();
2229 + }
1821 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 + }
1822 2245
1823 2246
1824 2247 // Merge comments and activities into a single array
1825 2248 $commentsAndActivities = array_merge($comments, $activities);
@@ -2044,8 +2467,12 @@
2044 2467 }
2045 2468
2046 2469 private function deleteTaskAttachments($task)
2047 2470 {
2471 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2472 + return;
2473 + }
2474 +
2048 2475 $attachments = TaskAttachment::where('object_id', $task->id)
2049 2476 ->where('object_type', Constant::TASK_ATTACHMENT)
2050 2477 ->get();
2051 2478 foreach ($attachments as $attachment) {
@@ -2051,12 +2478,38 @@
2051 2478 foreach ($attachments as $attachment) {
2052 2479 $deletedAttachment = clone $attachment;
2053 2480 $attachment->delete();
2054 2481
2055 - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2482 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2056 2483 }
2057 2484 }
2058 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 +
2059 2512 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2060 2513 {
2061 2514 global $wpdb;
2062 2515 $attachmentFileService = new AttachmentFileService();
@@ -2310,8 +2763,14 @@
2310 2763 if ($images->count() > 0) {
2311 2764 foreach ($images as $image) {
2312 2765 $clonedImage = $image->replicate();
2313 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 + );
2314 2773 $clonedImage->save();
2315 2774 }
2316 2775 }
2317 2776 }
@@ -2638,14 +3097,16 @@
2638 3097 $priority = $params['priority'] ?? null;
2639 3098
2640 3099 // Get valid priorities including custom ones added by hooks
2641 3100 $validPriorities = array_keys(apply_filters('fluent_boards/task_priorities', [
2642 - 'low' => __('Low', 'fluent-boards'),
3101 + '' => __('No priority', 'fluent-boards'),
3102 + 'urgent' => __('Urgent', 'fluent-boards'),
3103 + 'high' => __('High', 'fluent-boards'),
2643 3104 'medium' => __('Medium', 'fluent-boards'),
2644 - 'high' => __('High', 'fluent-boards')
3105 + 'low' => __('Low', 'fluent-boards')
2645 3106 ]));
2646 3107
2647 - if (!in_array($priority, $validPriorities)) {
3108 + if (!in_array($priority, $validPriorities, true)) {
2648 3109 throw new \Exception(esc_html__('Invalid priority level', 'fluent-boards'));
2649 3110 }
2650 3111
2651 3112 $successfulTasks = [];
@@ -2894,15 +3355,16 @@
2894 3355 'message' => $message
2895 3356 ];
2896 3357 }
2897 3358
2898 - /* Delete time tracking records for one or multiple tasks
2899 - * Uses try-catch for better performance - avoids table existence check overhead
3359 + /**
3360 + * Delete time tracking records for one or multiple tasks.
2900 3361 *
2901 - * @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.
2902 3364 * @return void
2903 3365 */
2904 - public function deleteTimeTrackingRecords($taskIds)
3366 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
2905 3367 {
2906 3368 // Check if FluentBoards Pro time tracking is available
2907 3369 if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
2908 3370 return;
@@ -2918,11 +3380,12 @@
2918 3380 if (is_numeric($taskIds) && $taskIds > 0) {
2919 3381 \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
2920 3382 }
2921 3383 }
2922 - } catch (\Exception $e) {
2923 - // Silently fail if table doesn't exist or any other error occurs
2924 - // This is intentional for cleanup operations
3384 + } catch (\Throwable $e) {
3385 + if (!$suppressErrors) {
3386 + throw $e;
3387 + }
2925 3388 }
2926 3389 }
2927 3390
2928 3391 }