PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.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 All 43 releases
← All changes | app/Services/TaskService.php +888 -220 1.95.2 → 2.2.0 View file →
@@ -1,11 +1,14 @@
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;
7 +use FluentBoards\App\Hooks\Handlers\FileHandler;
6 8 use FluentBoards\App\Models\Attachment;
7 9 use FluentBoards\App\Models\Comment;
10 +use FluentBoards\App\Models\Notification;
8 11 use FluentBoards\App\Models\NotificationUser;
9 12 use FluentBoards\App\Models\TaskImage;
10 13 use FluentBoards\App\Services\Constant;
11 14 use FluentBoards\App\Models\Label;
@@ -18,8 +21,10 @@
18 21 use FluentBoards\App\Models\CommentImage;
19 22 use FluentBoards\App\Models\Relation;
20 23 use FluentBoards\Framework\Support\Arr;
21 24 use FluentBoardsPro\App\Models\TaskAttachment;
25 +use FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack;
26 +use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper;
22 27 use FluentBoardsPro\App\Services\AttachmentService;
23 28 use FluentBoardsPro\App\Services\RemoteUrlParser;
24 29 use FluentRoadmap\App\Models\IdeaReaction;
25 30
@@ -24,19 +29,78 @@
24 29 use FluentRoadmap\App\Models\IdeaReaction;
25 30
26 31 class TaskService
27 32 {
33 + private static $physicalTableNameCache = [];
34 +
35 + /** Load appended task metadata in two queries without exposing internal relation rows. */
36 + public function loadTaskListMetadata($tasks)
37 + {
38 + $items = [];
39 + foreach ($tasks as $task) {
40 + $items[] = $task;
41 + }
42 +
43 + $collection = (new Task())->newCollection($items);
44 + $collection->load(['taskMeta', 'repeatTaskMeta']);
45 +
46 + foreach ($collection as $task) {
47 + // Keep the existing meta/repeat_task_meta API fields, not duplicate raw relations.
48 + $task->makeHidden(['taskMeta', 'repeatTaskMeta']);
49 + }
50 + }
51 +
28 52 /**
53 + * Preserve the original CRM template copy when Pro lacks automation-context support.
54 + * Copies parent data, assignees and labels; full related-data copying requires updated Pro.
55 + * Context is provided by the automation handler, not directly from an HTTP request.
56 + */
57 + public function createAutomationTaskFromLegacyTemplate(Task $template, array $data, array $context): Task
58 + {
59 + $stage = Stage::where('id', absint($data['stage_id']))
60 + ->where('board_id', absint($data['board_id']))->firstOrFail();
61 + $task = new Task();
62 + $task->fill($template->toArray());
63 + $task->title = sanitize_text_field($data['title']);
64 + $task->board_id = $stage->board_id;
65 + $task->stage_id = $stage->id;
66 + $task->parent_id = null;
67 + $task->created_by = absint($context['created_by']);
68 + $task->crm_contact_id = absint($context['crm_contact_id']);
69 + $task->source = sanitize_key($context['source']);
70 + $task->comments_count = 0;
71 + $settings = $task->settings ?: [];
72 + $settings['subtask_count'] = 0;
73 + $settings['subtask_completed_count'] = 0;
74 + $task->settings = $settings;
75 + $task->moveToNewPosition(1);
76 + $task->save();
77 +
78 + $assigneeIds = array_key_exists('assignees', $context)
79 + ? $context['assignees']
80 + : $template->assignees()->get()->pluck('ID')->toArray();
81 + foreach (array_unique(array_filter(array_map('absint', (array) $assigneeIds))) as $assigneeId) {
82 + $this->updateAssignee($assigneeId, $task);
83 + }
84 + foreach ($template->labels as $label) {
85 + $task->labels()->syncWithoutDetaching([$label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]]);
86 + }
87 +
88 + return $task;
89 + }
90 +
91 + /**
29 92 * Resolve a task only when it belongs to the requested board.
30 93 *
31 94 * Subtasks normally carry the same board_id as their parent, but the parent
32 95 * fallback protects older data where that relationship may be incomplete.
96 + * Missing or mismatched tasks use the router's deliberate 404 response.
33 97 *
34 98 * @param int $taskId
35 99 * @param int $boardId
36 100 * @param bool $allowParentFallback
37 101 * @return Task
38 - * @throws \Exception
102 + * @throws ModelNotFoundException
39 103 */
40 104 public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true)
41 105 {
42 106 $taskId = absint($taskId);
@@ -42,9 +106,9 @@
42 106 $taskId = absint($taskId);
43 107 $boardId = absint($boardId);
44 108
45 109 if (!$taskId || !$boardId) {
46 - throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
110 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
47 111 }
48 112
49 113 $task = Task::where('id', $taskId)
50 114 ->where('board_id', $boardId)
@@ -50,9 +114,9 @@
50 114 ->where('board_id', $boardId)
51 115 ->first();
52 116
53 117 if ($task) {
54 - return $task;
118 + return $this->normalizeTaskDescriptionForEditor($task);
55 119 }
56 120
57 121 if ($allowParentFallback) {
58 122 $task = Task::where('id', $taskId)
@@ -63,16 +127,26 @@
63 127 if ($task) {
64 128 $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
65 129
66 130 if ((int) $parentBoardId === $boardId) {
67 - return $task;
131 + return $this->normalizeTaskDescriptionForEditor($task);
68 132 }
69 133 }
70 134 }
71 135
72 - throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
136 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
73 137 }
74 138
139 + private function normalizeTaskDescriptionForEditor(Task $task)
140 + {
141 + $task->description = DescriptionMarkdownConverter::normalize($task->description);
142 +
143 + return $task;
144 + }
145 +
146 + /**
147 + * Create a board task and notify eligible selected assignees before applying stage defaults.
148 + */
75 149 public function createTask($data, $boardId)
76 150 {
77 151 $board = Board::select('id', 'type')->find($boardId);
78 152
@@ -90,8 +164,17 @@
90 164 }
91 165
92 166 $data['status'] = $stage->defaultTaskStatus();
93 167
168 + // Image covers require a persisted task, so creation only accepts a sanitized color cover.
169 + $coverColor = sanitize_hex_color(Arr::get($data, 'settings.cover.backgroundColor', ''));
170 + $taskSettings = [];
171 + if ($coverColor) {
172 + $taskSettings['cover'] = [
173 + 'backgroundColor' => $coverColor,
174 + ];
175 + }
176 +
94 177 if ($board->type == 'roadmap') {
95 178 $current_user = wp_get_current_user();
96 179 $settingData = array(
97 180 'integration_type' => 'feature',
@@ -99,10 +182,14 @@
99 182 'author' => [
100 183 'email' => $current_user->user_email // email of who posted this feature
101 184 ],
102 185 );
103 - $data['settings'] = $settingData;
186 + $data['settings'] = array_merge($taskSettings, $settingData);
104 187 $data['type'] = 'roadmap';
188 + } elseif ($taskSettings) {
189 + $data['settings'] = $taskSettings;
190 + } else {
191 + unset($data['settings']);
105 192 }
106 193
107 194 $providerPosition = Arr::get($data, 'position');
108 195
@@ -109,12 +196,25 @@
109 196 $data['position'] = $this->getLastPositionOfTasks($stage->id);
110 197
111 198 $data['board_id'] = $boardId;
112 199 $data = Helper::normalizeDates($data, ['due_at', 'started_at', 'last_completed_at', 'archived_at', 'remind_at']);
200 + if (isset($data['description'])) {
201 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
202 + }
113 203
114 204 $data = array_filter($data);
115 205 $task = (new Task())->createTask($data);
116 206
207 + // Selected assignees are already attached; toggling them via updateAssignee would remove them.
208 + $task->load('assignees');
209 + $currentUserId = get_current_user_id();
210 + $assigneeIds = array_diff($task->assignees->pluck('ID')->all(), [$currentUserId]);
211 + $notificationService = new NotificationService();
212 + $recipientIds = $notificationService->getEmailEnabledUserIds($task->board_id, $assigneeIds, Constant::BOARD_EMAIL_TASK_ASSIGN);
213 + foreach ($recipientIds as $recipientId) {
214 + $this->sendMailAfterTaskModify('add_assignee', $recipientId, $task->id);
215 + }
216 +
117 217 $this->manageDefaultAssignees($task, $stage->id);
118 218 $this->manageDefaultWatchers($task, $stage->id);
119 219
120 220 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
@@ -180,8 +280,9 @@
180 280 $taskQuery = Task::whereIn('id', $taskIds)
181 281 ->with(['assignees', 'board', 'stage'])
182 282 ->whereNull('archived_at')
183 283 ->where('parent_id', null)
284 + ->onActiveAvailableBoards()
184 285 ->orderBy('due_at', 'DESC');
185 286
186 287 switch ($category) {
187 288 case 'overdue':
@@ -208,8 +309,10 @@
208 309 ->with(['assignees', 'board', 'stage'])
209 310 ->whereIn('fbs_tasks.id', $taskIds)
210 311 ->whereNull('fbs_tasks.archived_at')
211 312 ->whereNull('fbs_tasks.parent_id')
313 + ->where('fbs_tasks.status', '!=', 'closed')
314 + ->onActiveAvailableBoards()
212 315 ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
213 316 $join->on('rel.object_id', '=', 'fbs_tasks.id')
214 317 ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
215 318 ->where('rel.foreign_id', $currentUserId);
@@ -230,8 +333,9 @@
230 333 return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
231 334 })->pluck('notification.task_id')->unique();
232 335 $validTasks = Task::whereIn('id', $taskIds)
233 336 ->with(['assignees', 'board', 'stage'])
337 + ->onActiveAvailableBoards()
234 338 ->get();
235 339
236 340 return $validTasks->toArray();
237 341 default:
@@ -251,9 +355,10 @@
251 355
252 356 $taskQuery = Task::query()
253 357 ->whereIn('id', $taskIds)
254 358 ->whereNull('archived_at')
255 - ->whereNull('parent_id');
359 + ->whereNull('parent_id')
360 + ->onActiveAvailableBoards();
256 361
257 362 switch ($category) {
258 363 case 'overdue':
259 364 $taskQuery->overdue();
@@ -276,8 +381,10 @@
276 381 ->select('fbs_tasks.id')
277 382 ->whereIn('fbs_tasks.id', $taskIds)
278 383 ->whereNull('fbs_tasks.archived_at')
279 384 ->whereNull('fbs_tasks.parent_id')
385 + ->where('fbs_tasks.status', '!=', 'closed')
386 + ->onActiveAvailableBoards()
280 387 ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
281 388 $join->on('rel.object_id', '=', 'fbs_tasks.id')
282 389 ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
283 390 ->where('rel.foreign_id', $currentUserId);
@@ -283,8 +390,25 @@
283 390 ->where('rel.foreign_id', $currentUserId);
284 391 });
285 392
286 393 return (int) $taskQuery->distinct()->count('fbs_tasks.id');
394 + case 'mentioned':
395 + $currentUserId = get_current_user_id();
396 + $taskIds = NotificationUser::where('user_id', $currentUserId)
397 + ->with(['notification' => function ($query) {
398 + $query->where('action', 'task_comment_mentioned')
399 + ->with('task');
400 + }])
401 + ->get()
402 + ->filter(function ($userNotification) {
403 + $notification = $userNotification->notification;
404 +
405 + return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
406 + })
407 + ->pluck('notification.task_id')
408 + ->unique();
409 +
410 + return Task::whereIn('id', $taskIds)->onActiveAvailableBoards()->count();
287 411 default:
288 412 return 0;
289 413 }
290 414
@@ -290,8 +414,40 @@
290 414
291 415 return (int) $taskQuery->count();
292 416 }
293 417
418 + /**
419 + * Unlink a Fluent Support ticket from a board-scoped task.
420 + *
421 + * @param int $taskId
422 + * @param int $boardId
423 + * @return Task
424 + * @throws \Exception
425 + */
426 + public function removeSupportTicketLink($taskId, $boardId)
427 + {
428 + $taskId = absint($taskId);
429 + $boardId = absint($boardId);
430 + $task = $this->findTaskOnBoard($taskId, $boardId);
431 +
432 + if ($task->source !== Constant::TASK_SOURCE_FLUENT_SUPPORT || !$task->source_id) {
433 + return $task;
434 + }
435 +
436 + $ticketId = $task->source_id;
437 + $settings = is_array($task->settings) ? $task->settings : [];
438 + unset($settings['author']);
439 +
440 + $task->source = null;
441 + $task->source_id = null;
442 + $task->settings = $settings ?: null;
443 + $task->save();
444 +
445 + do_action('fluent_boards/support_ticket_unlinked', $task, $ticketId);
446 +
447 + return $task;
448 + }
449 +
294 450 /*
295 451 * TODO: Refactor this function - For me.
296 452 */
297 453 public function updateTaskProperty($col, $value, $task)
@@ -302,11 +458,17 @@
302 458 'type',
303 459 // 'reminder_type',
304 460 'remind_at',
305 461 'log_minutes',
462 + 'source',
463 + 'source_id',
306 464 'settings'
307 465 ];
308 466
467 + if ($col === 'description') {
468 + $value = DescriptionMarkdownConverter::normalize($value);
469 + }
470 +
309 471 if (in_array($col, $validColumns) && $task->{$col} != $value) {
310 472 if ($col === 'remind_at') {
311 473 $value = Helper::normalizeDateValue($value);
312 474 }
@@ -526,10 +688,12 @@
526 688 }
527 689 $task->archived_at = $value == null ? null : current_time('mysql');
528 690 $task->save();
529 691 do_action('fluent_boards/task_archived', $task);
530 - $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
531 - $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
692 + if ($value != null) {
693 + $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
694 + $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
695 + }
532 696 }
533 697
534 698 private function updateStatus($value, $task)
535 699 {
@@ -556,9 +720,9 @@
556 720 }
557 721
558 722 private function updateDescription($col, $value, $task, $oldTask)
559 723 {
560 - $task->description = $value;
724 + $task->description = DescriptionMarkdownConverter::normalize($value);
561 725 $task->save();
562 726 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
563 727 }
564 728
@@ -699,95 +863,206 @@
699 863 $this->deleteTask($subtask);
700 864 }
701 865 }
702 866
703 - $deleted = $task->delete();
867 + $this->deleteTasksBatch([$task]);
868 + }
869 +
870 + /**
871 + * Delete the supplied tasks and their owned records without discovering children.
872 + *
873 + * @param iterable $tasks
874 + * @param bool $manageTransaction Set false only when the caller owns an active transaction.
875 + * @return void
876 + * @throws \Throwable
877 + */
878 + public function deleteTasksBatch($tasks, $manageTransaction = true)
879 + {
880 + if (!is_array($tasks) && !($tasks instanceof \Traversable)) {
881 + $tasks = [$tasks];
882 + }
883 +
884 + $deletedTasks = [];
885 + $taskBoardIds = [];
886 + foreach ($tasks as $task) {
887 + if (!$task instanceof Task) {
888 + continue;
889 + }
890 +
891 + $taskId = (int) $task->id;
892 + if ($taskId < 1) {
893 + continue;
894 + }
895 +
896 + $deletedTasks[$taskId] = clone $task;
897 + $taskBoardIds[$taskId] = (int) $task->board_id;
898 + }
899 +
900 + if (!$deletedTasks) {
901 + return;
902 + }
903 +
904 + ksort($deletedTasks, SORT_NUMERIC);
905 + $taskIds = array_keys($deletedTasks);
704 906 $dbInstance = App::getInstance('db');
705 - $dbInstance->beginTransaction();
706 907
707 - $deletedTask = clone $task;
708 - //cloning because after delete $task object will be useless
908 + if (!$manageTransaction && !$dbInstance->inTransaction()) {
909 + throw new \RuntimeException(__('An active transaction is required for caller-managed task deletion.', 'fluent-boards'));
910 + }
709 911
912 + if ($manageTransaction) {
913 + $dbInstance->beginTransaction();
914 + }
915 +
710 916 try {
711 - $deleted = $task->delete();
917 + $relationTypes = [
918 + Constant::OBJECT_TYPE_USER_TASK_WATCH,
919 + Constant::OBJECT_TYPE_TASK_ASSIGNEE,
920 + Constant::OBJECT_TYPE_TASK_LABEL,
921 + ];
712 922
713 - if ($deleted) {
714 - //task assignees watchers removed
715 - $task->watchers()->detach();
716 - $task->assignees()->detach();
923 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
924 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
925 + }
717 926
718 - //removing all task related notifications
719 - $notificationIds = $task->notifications->pluck('id');
720 - $task->notifications()->delete();
721 - NotificationUser::whereIn('notification_id', $notificationIds)->delete();
927 + Relation::whereIn('object_id', $taskIds)
928 + ->whereIn('object_type', $relationTypes)
929 + ->delete();
722 930
723 - //task labels removed
724 - $task->labels()->detach();
931 + Relation::where('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
932 + ->where(function ($query) use ($taskIds) {
933 + $query->whereIn('object_id', $taskIds)
934 + ->orWhereIn('foreign_id', $taskIds);
935 + })
936 + ->delete();
725 937
726 - //task custom field value
727 - if (defined('FLUENT_BOARDS_PRO')) {
728 - $task->customFields()->detach();
938 + $notificationIds = Notification::whereIn('task_id', $taskIds)->pluck('id')->toArray();
939 + NotificationUser::whereIn('notification_id', $notificationIds)->delete();
940 + Notification::whereIn('task_id', $taskIds)->delete();
941 +
942 + $this->deleteTaskAttachmentsBatch($taskIds, $taskBoardIds);
943 + $this->deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance);
944 + Activity::whereIn('object_id', $taskIds)
945 + ->where('object_type', Constant::ACTIVITY_TASK)
946 + ->delete();
947 + Meta::whereIn('object_id', $taskIds)
948 + ->where('object_type', Constant::REPEAT_TASK_META)
949 + ->delete();
950 + $this->deleteTimeTrackingRecords($taskIds, false);
951 + TaskMeta::whereIn('task_id', $taskIds)->delete();
952 +
953 + $deletedCount = Task::whereIn('id', $taskIds)->delete();
954 + if ($deletedCount !== count($taskIds)) {
955 + throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards'));
729 956 }
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 957
737 - // Delete time tracking records for this task
738 - $this->deleteTimeTrackingRecords($task->id);
958 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
739 959
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();
960 + if ($manageTransaction) {
961 + $dbInstance->commit();
744 962 }
963 + } catch (\Throwable $e) {
964 + if ($manageTransaction) {
965 + $dbInstance->rollBack();
966 + }
745 967
746 - $dbInstance->commit();
747 - } catch (\Exception $e) {
748 - $dbInstance->rollBack();
749 - throw $e; // Re-throw the exception after rolling back
968 + throw $e;
750 969 }
970 + }
751 971
752 - }
753 - public function deleteTaskForBulk($task)
972 + /** Remove comment rows transactionally; file cleanup must wait for commit. */
973 + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance)
754 974 {
755 - // If this is a parent task, delete all subtasks first
756 - if (!$task->parent_id) {
757 - $subtasks = Task::where('parent_id', $task->id)->get();
758 - foreach ($subtasks as $subtask) {
759 - // Recursively delete each subtask (cleans up all their relations)
760 - $this->deleteTaskForBulk($subtask);
761 - }
975 + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray();
976 + if (!$commentTasks) {
977 + return;
762 978 }
763 979
764 - $deleted = $task->delete();
980 + $commentIds = array_keys($commentTasks);
981 + $images = CommentImage::where('object_type', Constant::COMMENT_IMAGE)
982 + ->whereIn('object_id', $commentIds)->get();
983 + CommentImage::where('object_type', Constant::COMMENT_IMAGE)
984 + ->whereIn('id', $images->pluck('id')->toArray())->delete();
985 + // Bulk deletion bypasses model file hooks and counters for the deleted task.
986 + Comment::whereIn('id', $commentIds)->delete();
765 987
766 - if ($deleted) {
988 + $dbInstance->afterCommit(function () use ($images, $commentTasks, $taskBoardIds) {
989 + $fileHandler = new FileHandler();
990 + $remoteFiles = [];
991 + $localImages = $images->filter(function ($image) {
992 + return $image->attachment_type !== 'url' && $image->file_path && (!$image->driver || $image->driver === 'local');
993 + });
994 + try {
995 + $sharedReferences = $fileHandler->getSharedLocalReferences($localImages);
996 + } catch (\Throwable $e) {
997 + // Null falls back to per-file reference checks.
998 + $sharedReferences = null;
999 + }
1000 + foreach ($images as $image) {
1001 + if ($image->attachment_type === 'url' || !$image->file_path) {
1002 + continue;
1003 + }
1004 + if ($image->driver && $image->driver !== 'local') {
1005 + $remoteFiles[] = ['file_path' => $image->file_path, 'driver' => $image->driver];
1006 + continue;
1007 + }
767 1008
768 - //task assignees watchers removed
769 - $task->watchers()->detach();
770 - $task->assignees()->detach();
1009 + try {
1010 + $boardId = $taskBoardIds[$commentTasks[$image->object_id]];
1011 + $fileHandler->deleteAttachmentFile($image, $boardId, $sharedReferences);
1012 + } catch (\Throwable $e) {
1013 + error_log('FluentBoards: Failed to delete committed comment image ' . (int) $image->id . ': ' . sanitize_text_field($e->getMessage()));
1014 + }
1015 + }
771 1016
772 - //removing all task related notifications
773 - $notificationIds = $task->notifications->pluck('id');
774 - $task->notifications()->delete();
775 - NotificationUser::whereIn('notification_id', $notificationIds)->delete();
1017 + // Use the existing remote cleanup worker, which rechecks shared references.
1018 + foreach (array_chunk($remoteFiles, 50) as $batch) {
1019 + try {
1020 + if (function_exists('as_enqueue_async_action')) {
1021 + as_enqueue_async_action('fluent_boards/delete_remote_attachment_files', [$batch], 'fluent-boards');
1022 + } else {
1023 + $fileHandler->deleteRemoteAttachmentFiles($batch);
1024 + }
1025 + } catch (\Throwable $e) {
1026 + error_log('FluentBoards: Failed to queue committed comment image cleanup: ' . sanitize_text_field($e->getMessage()));
1027 + }
1028 + }
1029 + });
1030 + }
776 1031
777 - //task labels removed
778 - $task->labels()->detach();
779 -
780 - //task custom field value
781 - if (defined('FLUENT_BOARDS_PRO_VERSION')) {
782 - $task->customFields()->detach();
783 - $this->deleteTaskAttachments($task);
1032 + /**
1033 + * Dispatch task deletion hooks after the outermost transaction commits.
1034 + *
1035 + * @param mixed $dbInstance
1036 + * @param array $deletedTasks
1037 + * @return void
1038 + */
1039 + private function dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks)
1040 + {
1041 + $dbInstance->afterCommit(function () use ($deletedTasks) {
1042 + foreach ($deletedTasks as $deletedTask) {
1043 + try {
1044 + do_action('fluent_boards/task_deleted', $deletedTask);
1045 + } catch (\Throwable $e) {
1046 + error_log(sprintf(
1047 + 'FluentBoards: Failed to dispatch committed task deletion hook for task %d: %s',
1048 + (int) $deletedTask->id,
1049 + sanitize_text_field($e->getMessage())
1050 + ));
1051 + }
784 1052 }
1053 + });
1054 + }
785 1055
786 - // For bulk delete, you might want to avoid firing hooks/actions,
787 - // so 'fluent_boards/task_deleted' is not triggered here.
788 - TaskMeta::where('task_id', $task->id)->delete();
1056 + /** Keep legacy bulk callers on the normal task deletion lifecycle. */
1057 + public function deleteTaskForBulk($task)
1058 + {
1059 + // A selected subtask may already have been deleted with its parent.
1060 + if (!Task::where('id', $task->id)->exists()) {
1061 + return;
789 1062 }
1063 +
1064 + $this->deleteTask($task);
790 1065 }
791 1066 // this is invoked when task is moved to another board
792 1067
793 1068 /**
@@ -824,8 +1099,9 @@
824 1099 $dbInstance->beginTransaction();
825 1100
826 1101 try {
827 1102 $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId);
1103 + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
828 1104
829 1105 $task->board_id = (int) $targetBoardId;
830 1106 $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task';
831 1107
@@ -834,19 +1110,16 @@
834 1110 $task->assignees()->detach();
835 1111 $task->watchers()->detach();
836 1112 $this->removeCustomFieldAssociations($task);
837 1113
838 - // REMOVE: User-specific data to prevent security issues
839 - $this->removeCommentsAndReplies($task->id);
840 - $this->removeTimeTrackingRecords($task->id);
841 -
842 1114 // REMOVE: Recurring task settings for security
843 1115 $this->removeRecurringTaskSettings($task->id);
844 1116
845 1117 $task->save();
1118 + do_action('fluent_boards/task_moved_update_time_tracking', $task);
846 1119
847 1120 // MOVE: Subtasks to new board (preserves subtask groups)
848 - $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type, $attachmentFileService);
1121 + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService);
849 1122
850 1123 $dbInstance->commit();
851 1124 $attachmentFileService->commitMovedOriginalFiles();
852 1125 } catch (\Exception $e) {
@@ -862,9 +1135,9 @@
862 1135 /**
863 1136 * Move all subtasks to the new board when parent task is moved
864 1137 * Preserves subtask groups and their relationships
865 1138 */
866 - private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
1139 + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
867 1140 {
868 1141 // Get all subtasks of the parent task
869 1142 $subtasks = Task::where('parent_id', $parentTaskId)->get();
870 1143
@@ -873,10 +1146,12 @@
873 1146 }
874 1147
875 1148 foreach ($subtasks as $subtask) {
876 1149 // Update board_id and type
877 - $oldBoardId = (int) $subtask->board_id;
1150 + // Legacy subtasks may not have their own board_id; inherit the parent's source board.
1151 + $oldBoardId = absint($subtask->board_id) ?: absint($sourceBoardId);
878 1152 $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId);
1153 + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
879 1154
880 1155 $subtask->board_id = (int) $targetBoardId;
881 1156 $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task';
882 1157
@@ -889,16 +1164,13 @@
889 1164 $subtask->taskMeta()
890 1165 ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD)
891 1166 ->delete();
892 1167
893 - // REMOVE: User-specific data for security
894 - $this->removeCommentsAndReplies($subtask->id);
895 - $this->removeTimeTrackingRecords($subtask->id);
896 -
897 1168 // REMOVE: Recurring task settings
898 1169 $this->removeRecurringTaskSettings($subtask->id);
899 1170
900 1171 $subtask->save();
1172 + do_action('fluent_boards/task_moved_update_time_tracking', $subtask);
901 1173 }
902 1174 }
903 1175
904 1176 /**
@@ -944,24 +1216,26 @@
944 1216 }
945 1217 }
946 1218
947 1219 /**
948 - * Remove comments and replies for security reasons
949 - * Prevents exposing user-specific data to unauthorized users
1220 + * Move a task's complete comment history and images to another board.
950 1221 */
951 - private function removeCommentsAndReplies($taskId)
1222 + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService)
952 1223 {
953 - // Input validation
954 - if (!is_numeric($taskId) || $taskId <= 0) {
1224 + $taskId = absint($taskId);
1225 + $sourceBoardId = absint($sourceBoardId);
1226 + $targetBoardId = absint($targetBoardId);
1227 +
1228 + if (!$taskId || !$sourceBoardId || !$targetBoardId || $sourceBoardId === $targetBoardId) {
955 1229 return;
956 1230 }
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 1231
1232 + $attachmentFileService->moveCommentImagesToBoard($taskId, $sourceBoardId, $targetBoardId);
1233 +
1234 + // Bypass ORM timestamps so only board ownership changes.
1235 + Comment::where('task_id', $taskId)
1236 + ->toBase()
1237 + ->update(['board_id' => $targetBoardId]);
964 1238 }
965 1239
966 1240 /**
967 1241 * Remove time tracking records for security reasons
@@ -989,9 +1263,9 @@
989 1263 return;
990 1264 }
991 1265
992 1266 // Remove all attachments for this task
993 - if (class_exists('FluentBoardsPro\App\Models\TaskAttachment')) {
1267 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
994 1268 \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', (int) $taskId)
995 1269 ->where('object_type', 'task')
996 1270 ->delete();
997 1271 }
@@ -1015,20 +1289,147 @@
1015 1289 }
1016 1290
1017 1291 public function getIdeaVoteStatistics($taskId)
1018 1292 {
1019 - return IdeaReaction::where('object_id', $taskId)
1020 - ->where('object_type', 'idea')
1021 - ->where('type', 'upvote')
1022 - ->count();
1293 + $taskId = absint($taskId);
1294 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds([$taskId]);
1295 +
1296 + return $voteStatistics[$taskId] ?? 0;
1023 1297 }
1024 1298
1299 + public function loadIdeaVoteStatistics($tasks)
1300 + {
1301 + $taskIds = [];
1025 1302
1303 + foreach ($tasks as $task) {
1304 + $taskId = absint($task->id);
1305 + if ($taskId) {
1306 + $taskIds[] = $taskId;
1307 + }
1308 + }
1309 +
1310 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds($taskIds);
1311 +
1312 + foreach ($tasks as $task) {
1313 + $task->vote_statistics = $voteStatistics[(int) $task->id] ?? 0;
1314 + }
1315 +
1316 + return $tasks;
1317 + }
1318 +
1319 + private function getIdeaVoteStatisticsByTaskIds(array $taskIds)
1320 + {
1321 + global $wpdb;
1322 +
1323 + $taskIds = array_values(array_unique(array_filter(array_map('absint', $taskIds))));
1324 +
1325 + if (!$taskIds) {
1326 + return [];
1327 + }
1328 +
1329 + $placeholders = implode(', ', array_fill(0, count($taskIds), '%d'));
1330 + $ideaReactionTable = $this->getIdeaReactionTable();
1331 + $counts = [];
1332 +
1333 + if ($ideaReactionTable) {
1334 + $rows = $wpdb->get_results(
1335 + $wpdb->prepare(
1336 + "SELECT object_id, COUNT(*) as total FROM {$ideaReactionTable} WHERE object_id IN ({$placeholders}) AND object_type = %s AND type = %s GROUP BY object_id",
1337 + array_merge($taskIds, ['idea', 'upvote'])
1338 + )
1339 + );
1340 +
1341 + foreach ($rows as $row) {
1342 + $counts[(int) $row->object_id] = (int) $row->total;
1343 + }
1344 +
1345 + return $counts;
1346 + }
1347 +
1348 + $taskMetaTable = $this->getPhysicalTableName((new TaskMeta())->getTable());
1349 + $rows = $wpdb->get_results(
1350 + $wpdb->prepare(
1351 + "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",
1352 + array_merge($taskIds, ['upvote'])
1353 + )
1354 + );
1355 +
1356 + foreach ($rows as $row) {
1357 + $counts[(int) $row->task_id] = (int) $row->total;
1358 + }
1359 +
1360 + return $counts;
1361 + }
1362 +
1026 1363 /**
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
1364 + * Returns the canonical SQL expression for an idea's upvote count.
1365 + *
1366 + * The roadmap reaction table is authoritative when it exists; legacy task
1367 + * metadata remains the fallback for installations without that table.
1368 + */
1369 + public function getIdeaVoteStatisticsSelect()
1370 + {
1371 + $taskTable = $this->getPhysicalTableName((new Task())->getTable());
1372 +
1373 + return $this->buildIdeaVoteStatisticsSelect($taskTable);
1374 + }
1375 +
1376 + private function buildIdeaVoteStatisticsSelect($taskTable)
1377 + {
1378 + $ideaReactionTable = $this->getIdeaReactionTable();
1379 +
1380 + if ($ideaReactionTable) {
1381 + return "(SELECT COUNT(*) FROM {$ideaReactionTable} WHERE {$ideaReactionTable}.object_id = {$taskTable}.id AND {$ideaReactionTable}.object_type = 'idea' AND {$ideaReactionTable}.type = 'upvote')";
1382 + }
1383 +
1384 + $taskMetaTable = $this->getPhysicalTableName((new TaskMeta())->getTable());
1385 +
1386 + return "(SELECT COALESCE(MAX(CAST({$taskMetaTable}.value AS UNSIGNED)), 0) FROM {$taskMetaTable} WHERE {$taskMetaTable}.task_id = {$taskTable}.id AND {$taskMetaTable}.key = 'upvote')";
1387 + }
1388 +
1389 + private function getIdeaReactionTable()
1390 + {
1391 + $table = $this->getPhysicalTableName((new IdeaReaction())->getTable(), false);
1392 +
1393 + if ($table) {
1394 + return $table;
1395 + }
1396 +
1397 + return '';
1398 + }
1399 +
1400 + private function getPhysicalTableName($table, $usePrefixedFallback = true)
1401 + {
1402 + global $wpdb;
1403 + $cacheKey = $table . '|' . (int) $usePrefixedFallback;
1404 +
1405 + if (array_key_exists($cacheKey, self::$physicalTableNameCache)) {
1406 + return self::$physicalTableNameCache[$cacheKey];
1407 + }
1408 +
1409 + $candidates = array_values(array_unique([
1410 + $wpdb->prefix . $table,
1411 + $table,
1412 + ]));
1413 +
1414 + foreach ($candidates as $candidate) {
1415 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($candidate))) === $candidate) {
1416 + self::$physicalTableNameCache[$cacheKey] = $candidate;
1417 + return $candidate;
1418 + }
1419 + }
1420 +
1421 + self::$physicalTableNameCache[$cacheKey] = $usePrefixedFallback ? $wpdb->prefix . $table : '';
1422 +
1423 + return self::$physicalTableNameCache[$cacheKey];
1424 + }
1425 +
1426 +
1427 + /**
1428 + * Get a bounded paginated list of archived board tasks with their latest archive actor.
1429 + *
1430 + * @param array $data
1431 + * @param int $boardId
1031 1432 * @return mixed
1032 1433 * @throws \Exception
1033 1434 */
1034 1435 public function getArchivedTasks($data, $boardId)
@@ -1036,10 +1437,11 @@
1036 1437 if (!$boardId) {
1037 1438 throw new \Exception(esc_html__('Board id is required', 'fluent-boards'));
1038 1439 }
1039 1440
1040 - $per_page = isset($data['per_page']) ? $data['per_page'] : 20;
1041 - $page = isset($data['page']) ? $data['page'] : 1;
1441 + // Bound the task page so the related activity and user batch queries stay predictable.
1442 + $perPage = max(1, min(50, absint($data['per_page'] ?? 20)));
1443 + $page = max(1, absint($data['page'] ?? 1));
1042 1444 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
1043 1445
1044 1446 if (!empty($data['query'])) {
1045 1447 $query = strtolower($data['query']);
@@ -1053,11 +1455,54 @@
1053 1455 $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
1054 1456 }
1055 1457 }
1056 1458
1057 - return $tasksQuery->orderBy('created_at', 'DESC')->with('assignees')->paginate($per_page, ['*'], 'page', $page);
1459 + $tasks = $tasksQuery->orderBy('created_at', 'DESC')->with('assignees')->paginate($perPage, ['*'], 'page', $page);
1460 +
1461 + $taskIds = [];
1462 + foreach ($tasks as $task) {
1463 + $taskIds[] = (int) $task->id;
1464 + $task->archived_by_id = null;
1465 + $task->archived_by = null;
1466 + }
1467 +
1468 + if (empty($taskIds)) {
1469 + return $tasks;
1470 + }
1471 +
1472 + $activityIds = Activity::whereIn('object_id', $taskIds)
1473 + ->where('object_type', Constant::ACTIVITY_TASK)
1474 + ->where('action', 'archived')
1475 + ->where('column', 'task')
1476 + ->selectRaw('MAX(id) as id')
1477 + ->groupBy('object_id')
1478 + ->pluck('id')
1479 + ->toArray();
1480 +
1481 + if (empty($activityIds)) {
1482 + return $tasks;
1483 + }
1484 +
1485 + $activities = Activity::whereIn('id', $activityIds)
1486 + ->with('user')
1487 + ->get()
1488 + ->keyBy('object_id');
1489 +
1490 + foreach ($tasks as $task) {
1491 + $activity = $activities->get($task->id);
1492 +
1493 + if (!$activity) {
1494 + continue;
1495 + }
1496 +
1497 + $task->archived_by_id = $activity->created_by ? (int) $activity->created_by : null;
1498 + $task->archived_by = $activity->user ? Helper::sanitizeUserCollections($activity->user) : null;
1499 + }
1500 +
1501 + return $tasks;
1058 1502 }
1059 1503
1504 + /** Fetch a page of board tasks, applying filters before pagination. */
1060 1505 public function getTableTasks($boardId, $data = [])
1061 1506 {
1062 1507 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20;
1063 1508 $page = isset($data['page']) ? intval($data['page']) : 1;
@@ -1065,9 +1510,11 @@
1065 1510 $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc';
1066 1511 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1067 1512 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1068 1513 $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', []));
1069 - $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []));
1514 + $recurringFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'recurring', []));
1515 + $templateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'template', []));
1516 + $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1070 1517 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1071 1518 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1072 1519 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
1073 1520 $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
@@ -1073,8 +1520,10 @@
1073 1520 $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
1074 1521 $customFieldFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'custom_fields', []));
1075 1522 $dueDateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'due_date', []));
1076 1523 $includeArchived = !empty($data['include_archived']) || in_array('archived', $taskStatusFilters, true);
1524 + $board = Board::select('id', 'type')->find($boardId);
1525 + $isRoadmapBoard = $board && $board->type === 'roadmap';
1077 1526
1078 1527 $perPage = max(1, min(150, $perPage));
1079 1528 $page = max(1, $page);
1080 1529 $sortDirection = strtolower($sortDirection) === 'desc' ? 'desc' : 'asc';
@@ -1081,42 +1530,72 @@
1081 1530
1082 1531 $sortColumnMap = [
1083 1532 'title' => 'title',
1084 1533 'status' => 'status',
1534 + 'stage_id' => 'stage_id',
1535 + 'priority' => 'priority',
1536 + 'due_at' => 'due_at',
1085 1537 'created_at' => 'created_at',
1538 + 'updated_at' => 'updated_at',
1086 1539 'position' => 'position',
1087 1540 ];
1541 +
1542 + if ($isRoadmapBoard) {
1543 + $sortColumnMap['vote_statistics'] = 'vote_statistics';
1544 + }
1545 +
1088 1546 $sortColumn = Arr::get($sortColumnMap, $sortBy, 'position');
1547 + $taskTable = (new Task())->getTable();
1548 + $taskColumnNames = [
1549 + 'id',
1550 + 'title',
1551 + 'slug',
1552 + 'board_id',
1553 + 'parent_id',
1554 + 'crm_contact_id',
1555 + 'type',
1556 + 'stage_id',
1557 + 'status',
1558 + 'priority',
1559 + 'archived_at',
1560 + 'remind_at',
1561 + 'reminder_type',
1562 + 'started_at',
1563 + 'due_at',
1564 + 'last_completed_at',
1565 + 'position',
1566 + 'comments_count',
1567 + 'created_by',
1568 + 'settings',
1569 + 'source',
1570 + 'source_id',
1571 + 'created_at',
1572 + 'updated_at',
1573 + ];
1574 + $taskColumns = array_map(function ($columnName) use ($taskTable) {
1575 + return "{$taskTable}.{$columnName}";
1576 + }, $taskColumnNames);
1089 1577
1090 1578 $tasksQuery = Task::query()
1091 1579 // 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 1580 ->with(['assignees', 'labels', 'watchers'])
1116 1581 ->where('board_id', $boardId)
1117 1582 ->whereNull('parent_id');
1118 1583
1584 + if ($isRoadmapBoard) {
1585 + $taskSqlTable = $this->getPhysicalTableName($taskTable);
1586 + $taskSqlColumns = [];
1587 +
1588 + foreach ($taskColumnNames as $columnName) {
1589 + $taskSqlColumns[] = "{$taskSqlTable}.{$columnName}";
1590 + }
1591 +
1592 + $taskSqlColumns[] = $this->buildIdeaVoteStatisticsSelect($taskSqlTable) . ' as vote_statistics';
1593 + $tasksQuery->selectRaw(implode(', ', $taskSqlColumns));
1594 + } else {
1595 + $tasksQuery->select($taskColumns);
1596 + }
1597 +
1119 1598 if (!$includeArchived && !$taskStatusFilters) {
1120 1599 $tasksQuery->whereNull('archived_at');
1121 1600 }
1122 1601
@@ -1123,8 +1602,10 @@
1123 1602 $this->applyTableTaskSearch($tasksQuery, $search);
1124 1603 $this->applyTableTaskFilters($tasksQuery, [
1125 1604 'stage' => $stageFilters,
1126 1605 'task_status' => $taskStatusFilters,
1606 + 'recurring' => $recurringFilters,
1607 + 'template' => $templateFilters,
1127 1608 'priority' => $priorityFilters,
1128 1609 'assignee' => $assigneeFilters,
1129 1610 'labels' => $labelFilters,
1130 1611 'watchers' => $watcherFilters,
@@ -1141,14 +1622,17 @@
1141 1622 ->orderBy($sortColumn, $sortDirection)
1142 1623 ->paginate($perPage, ['*'], 'page', $page);
1143 1624 }
1144 1625
1626 + /** Fetch matching board cards with the same filters as table view. */
1145 1627 public function getBoardViewTasks($boardId, $data = [])
1146 1628 {
1147 1629 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1148 1630 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1149 1631 $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', []));
1150 - $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []));
1632 + $recurringFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'recurring', []));
1633 + $templateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'template', []));
1634 + $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1151 1635 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1152 1636 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1153 1637 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
1154 1638 $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
@@ -1181,8 +1665,9 @@
1181 1665 'created_by',
1182 1666 'settings',
1183 1667 'source',
1184 1668 'source_id',
1669 + 'updated_at',
1185 1670 ])
1186 1671 ->with(['assignees', 'labels', 'watchers'])
1187 1672 ->where('board_id', $boardId)
1188 1673 ->whereNull('parent_id');
@@ -1194,8 +1679,10 @@
1194 1679 $this->applyTableTaskSearch($tasksQuery, $search);
1195 1680 $this->applyTableTaskFilters($tasksQuery, [
1196 1681 'stage' => $stageFilters,
1197 1682 'task_status' => $taskStatusFilters,
1683 + 'recurring' => $recurringFilters,
1684 + 'template' => $templateFilters,
1198 1685 'priority' => $priorityFilters,
1199 1686 'assignee' => $assigneeFilters,
1200 1687 'labels' => $labelFilters,
1201 1688 'watchers' => $watcherFilters,
@@ -1209,18 +1696,18 @@
1209 1696 ->orderBy('position', 'asc')
1210 1697 ->get();
1211 1698 }
1212 1699
1213 - private function sanitizeTableFilterValues($values)
1700 + private function sanitizeTableFilterValues($values, $allowEmpty = false)
1214 1701 {
1215 1702 if (!is_array($values)) {
1216 - $values = ($values === null || $values === '') ? [] : [$values];
1703 + $values = ($values === null || (!$allowEmpty && $values === '')) ? [] : [$values];
1217 1704 }
1218 1705
1219 1706 return array_values(array_filter(array_map(static function ($value) {
1220 1707 return sanitize_text_field($value);
1221 - }, $values), static function ($value) {
1222 - return $value !== '';
1708 + }, $values), static function ($value) use ($allowEmpty) {
1709 + return $allowEmpty || $value !== '';
1223 1710 }));
1224 1711 }
1225 1712
1226 1713 private function applyTableTaskSearch($tasksQuery, $search)
@@ -1245,8 +1732,9 @@
1245 1732 $escapedSearch = $wpdb->esc_like($search);
1246 1733 $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%');
1247 1734 }
1248 1735
1736 + /** Apply board filters to table and card queries, gating Pro-only options. */
1249 1737 private function applyTableTaskFilters($tasksQuery, $filters)
1250 1738 {
1251 1739 $stageFilters = Arr::get($filters, 'stage', []);
1252 1740 $taskStatusFilters = Arr::get($filters, 'task_status', []);
@@ -1257,8 +1745,22 @@
1257 1745 $contactFilters = Arr::get($filters, 'contact', []);
1258 1746 $customFieldFilters = Arr::get($filters, 'custom_fields', []);
1259 1747 $dueDateFilters = Arr::get($filters, 'due_date', []);
1260 1748
1749 + $hasPro = defined('FLUENT_BOARDS_PRO_VERSION');
1750 +
1751 + if ($hasPro && in_array('yes', Arr::get($filters, 'template', []), true)) {
1752 + $tasksQuery->whereHas('taskMeta', function ($metaQuery) {
1753 + $metaQuery->where('key', Constant::IS_TASK_TEMPLATE)->where('value', 'yes');
1754 + });
1755 + }
1756 +
1757 + if ($hasPro && in_array('yes', Arr::get($filters, 'recurring', []), true)
1758 + && Arr::get(fluent_boards_get_pref_settings(), 'recurring_task.enabled') === 'yes') {
1759 + // Only the schedule owner repeats; generated copies may have no schedule.
1760 + $tasksQuery->whereNull('archived_at')->whereHas('repeatTaskMeta');
1761 + }
1762 +
1261 1763 if ($stageFilters) {
1262 1764 $this->applyTableStageFilters($tasksQuery, $stageFilters);
1263 1765 }
1264 1766
@@ -1266,9 +1768,26 @@
1266 1768 $this->applyTableTaskStatusFilters($tasksQuery, $taskStatusFilters);
1267 1769 }
1268 1770
1269 1771 if ($priorityFilters) {
1270 - $tasksQuery->whereIn('priority', array_map('strtolower', $priorityFilters));
1772 + $priorityFilters = array_map('strtolower', $priorityFilters);
1773 + $hasNoPriorityFilter = in_array('', $priorityFilters, true);
1774 + $selectedPriorities = array_values(array_filter($priorityFilters, static function ($priority) {
1775 + return $priority !== '';
1776 + }));
1777 +
1778 + $tasksQuery->where(function ($query) use ($hasNoPriorityFilter, $selectedPriorities) {
1779 + if ($selectedPriorities) {
1780 + $query->whereIn('priority', $selectedPriorities);
1781 + }
1782 +
1783 + if ($hasNoPriorityFilter) {
1784 + $method = $selectedPriorities ? 'orWhere' : 'where';
1785 + $query->{$method}(function ($priorityQuery) {
1786 + $priorityQuery->whereNull('priority')->orWhere('priority', '');
1787 + });
1788 + }
1789 + });
1271 1790 }
1272 1791
1273 1792 if ($contactFilters) {
1274 1793 $contactIds = array_values(array_filter(array_map('intval', $contactFilters)));
@@ -1386,13 +1905,16 @@
1386 1905 return;
1387 1906 }
1388 1907
1389 1908 $nowTimestamp = current_time('timestamp');
1909 + $startOfTodayTimestamp = strtotime(gmdate('Y-m-d 00:00:00', $nowTimestamp));
1910 + $dayOfWeek = (int) gmdate('w', $nowTimestamp);
1911 + $startOfThisWeekTimestamp = strtotime('-' . $dayOfWeek . ' days', $startOfTodayTimestamp);
1390 1912 $startOfToday = gmdate('Y-m-d 00:00:00', $nowTimestamp);
1391 1913 $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)));
1914 + $startOfThisWeek = gmdate('Y-m-d 00:00:00', $startOfThisWeekTimestamp);
1915 + $startOfNextWeek = gmdate('Y-m-d 00:00:00', strtotime('+7 days', $startOfThisWeekTimestamp));
1916 + $startOfWeekAfterNext = gmdate('Y-m-d 00:00:00', strtotime('+14 days', $startOfThisWeekTimestamp));
1395 1917 $endOfThisMonth = gmdate('Y-m-t 23:59:59', $nowTimestamp);
1396 1918 $nowMysql = current_time('mysql');
1397 1919
1398 1920 $tasksQuery->where(function ($query) use ($dueDateFilters, $startOfToday, $endOfToday, $startOfThisWeek, $startOfNextWeek, $startOfWeekAfterNext, $endOfThisMonth, $nowMysql) {
@@ -1477,8 +1999,12 @@
1477 1999 }
1478 2000
1479 2001 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
1480 2002 {
2003 + if (empty($assigneeIds)) {
2004 + return;
2005 + }
2006 +
1481 2007 $current_user_id = get_current_user_id();
1482 2008 /* this will run in background as soon as possible */
1483 2009 /* sending Model or Model Instance won't work here */
1484 2010
@@ -1542,8 +2068,9 @@
1542 2068 }
1543 2069
1544 2070 public function getActivities($taskId, $perPage, $filter = 'newest')
1545 2071 {
2072 + $perPage = Sanitize::perPage($perPage);
1546 2073 $activityQuery = Activity::where('object_id', $taskId)
1547 2074 ->where('object_type', Constant::ACTIVITY_TASK);
1548 2075 if ($filter == 'newest') {
1549 2076 $activityQuery = $activityQuery->latest();
@@ -1648,22 +2175,36 @@
1648 2175 if (!$associatedId || !$userId) {
1649 2176 return [];
1650 2177 }
1651 2178
1652 - // Load only the relationships rendered by the FluentCRM profile tab.
1653 - $tasks = Task::query()
2179 + $isAdmin = PermissionManager::isAdmin($userId);
2180 + $editableBoardIds = [];
2181 +
2182 + $tasksQuery = Task::query()
1654 2183 ->where('crm_contact_id', $associatedId)
1655 2184 ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
1656 - ->orderBy('due_at', 'ASC')
1657 - ->get();
2185 + ->orderBy('due_at', 'ASC');
1658 2186
1659 - // Batch board access once so each task avoids its own permission query.
1660 - $editableBoardIds = array_map('intval', PermissionManager::getBoardIdsForUser($userId));
2187 + if ($isAdmin) {
2188 + $tasksQuery->whereHas('board', function ($query) {
2189 + $query->whereNull('archived_at');
2190 + });
2191 + } else {
2192 + $editableBoardIds = array_map('intval', PermissionManager::getBoardIdsForUser($userId));
1661 2193
2194 + if (!$editableBoardIds) {
2195 + return [];
2196 + }
2197 +
2198 + $tasksQuery->whereIn('board_id', $editableBoardIds);
2199 + }
2200 +
2201 + $tasks = $tasksQuery->get();
2202 +
1662 2203 foreach ($tasks as $task) {
1663 2204 $task->isOverdue = $task->isOverdue();
1664 2205 $task->isUpcoming = $task->upcoming();
1665 - $task->can_edit = in_array((int)$task->board_id, $editableBoardIds, true);
2206 + $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
1666 2207
1667 2208 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1668 2209
1669 2210 foreach ($task->subtaskGroup as $group) {
@@ -1692,11 +2233,14 @@
1692 2233
1693 2234 return $subtaskGroupMap;
1694 2235 }
1695 2236
1696 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
2237 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = [])
1697 2238 {
1698 - $allActiveTasks = Task::where('board_id', $boardId)->whereNull('archived_at')->get();
2239 + $allActiveTasks = Task::where('board_id', $boardId)
2240 + ->whereNull('archived_at')
2241 + ->with(['labels', 'taskCustomFields'])
2242 + ->get();
1699 2243 $taskMap = [];
1700 2244 $subtaskGroupMap = [];
1701 2245 $parentTaskCount = 0;
1702 2246 $attachmentFileService = new AttachmentFileService();
@@ -1705,14 +2249,23 @@
1705 2249 $dbInstance->beginTransaction();
1706 2250
1707 2251 try {
1708 2252 foreach ($allActiveTasks as $task) {
2253 + if ($task->parent_id && empty($taskMap[$task->parent_id])) {
2254 + continue;
2255 + }
2256 +
2257 + $stageId = !empty($task->stage_id) ? (int) $task->stage_id : 0;
2258 + if (!$task->parent_id && empty($stageMap[$stageId])) {
2259 + continue;
2260 + }
2261 +
1709 2262 $newTask = array();
1710 2263 $newTask['title'] = $task->title;
1711 2264 $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
1712 - $newTask['description'] = $task->description;
2265 + $newTask['description'] = DescriptionMarkdownConverter::normalize($task->description);
1713 2266 $newTask['board_id'] = $newBoard->id;
1714 - $newTask['stage_id'] = $stageMap[$task->stage_id];
2267 + $newTask['stage_id'] = $stageId && isset($stageMap[$stageId]) ? $stageMap[$stageId] : null;
1715 2268 $newTask['status'] = $task->status;
1716 2269 $newTask['priority'] = $task->priority;
1717 2270 $newTask['position'] = $task->position;
1718 2271 $newTask['due_at'] = $task->due_at;
@@ -1733,9 +2286,9 @@
1733 2286 $groupRelationOfTask = TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
1734 2287 ->where('task_id', $task->id)
1735 2288 ->first();
1736 2289
1737 - if ($groupRelationOfTask && $subtaskGroupMap[$groupRelationOfTask->value]) {
2290 + if ($groupRelationOfTask && !empty($subtaskGroupMap[$groupRelationOfTask->value])) {
1738 2291 TaskMeta::create([
1739 2292 'task_id' => $newTask->id,
1740 2293 'key' => Constant::SUBTASK_GROUP_CHILD,
1741 2294 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
@@ -1754,8 +2307,9 @@
1754 2307 'value' => $isTemplate->value
1755 2308 ]);
1756 2309 }
1757 2310 }
2311 + $this->copyTaskCustomFields($task, $newTask, $customFieldMap);
1758 2312 if(!$task->parent_id){
1759 2313 ++$parentTaskCount;
1760 2314 $taskMap[$task['id']] = $newTask->id;
1761 2315 //duplicate labels to task
@@ -1784,8 +2338,31 @@
1784 2338 throw $e;
1785 2339 }
1786 2340 }
1787 2341
2342 + /**
2343 + * Copy task custom field values to the duplicated task using copied board field IDs.
2344 + */
2345 + private function copyTaskCustomFields($task, $newTask, array $customFieldMap)
2346 + {
2347 + if (!$customFieldMap || !$task->taskCustomFields) {
2348 + return;
2349 + }
2350 +
2351 + foreach ($task->taskCustomFields as $customField) {
2352 + $newCustomFieldId = $customFieldMap[(int) $customField->foreign_id] ?? null;
2353 +
2354 + if (!$newCustomFieldId) {
2355 + continue;
2356 + }
2357 +
2358 + $clonedField = $customField->replicate();
2359 + $clonedField->object_id = $newTask->id;
2360 + $clonedField->foreign_id = $newCustomFieldId;
2361 + $clonedField->save();
2362 + }
2363 + }
2364 +
1788 2365 private function subtaskCountUpdate($taskId){
1789 2366 $parentTask = Task::findOrFail($taskId);
1790 2367 $settings = $parentTask->settings;
1791 2368 $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1;
@@ -1795,80 +2372,110 @@
1795 2372
1796 2373 /**
1797 2374 * @param $taskId
1798 2375 * @param $perPage
1799 - * @param $offset
2376 + * @param $page
1800 2377 * @param string $filter
2378 + * @param $boardId
2379 + * @param string $feedType
1801 2380 * @return array
1802 2381 */
1803 - public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null): array
2382 + public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
1804 2383 {
2384 + $perPage = Sanitize::perPage($perPage);
2385 + $page = max(1, (int) $page);
1805 2386 // Fetch the task
1806 2387 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2388 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
1807 2389
1808 - // Fetch comments and activities separately
1809 - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
1810 - $activities = $task->activities()
1811 - ->with('user')
2390 + $direction = $filter == 'newest' ? 'desc' : 'asc';
2391 + $offset = ($page - 1) * $perPage;
2392 + // Each source needs at most offset + perPage rows to fill the requested page.
2393 + $limit = $offset + $perPage;
2394 +
2395 + $commentQuery = $task->comments();
2396 + $activityQuery = $task->activities()
1812 2397 ->where(function($query) {
1813 2398 $query->whereNotIn('column', [ 'comment', 'a reply'])
1814 2399 ->orWhere(function($subQuery) {
1815 2400 $subQuery->whereNotIn('action', ['added', 'updated']);
1816 2401 });
1817 - })
1818 - ->orderBy('created_at', 'desc')
1819 - ->get()
1820 - ->toArray();
2402 + });
1821 2403
2404 + $entries = [];
2405 + $total = 0;
2406 + if ($feedType !== 'activities') {
2407 + $total += (clone $commentQuery)->count();
2408 + foreach ((clone $commentQuery)->reorder('created_at', $direction)->orderBy('id', $direction)->limit($limit)->get(['id', 'created_at']) as $row) {
2409 + $entries[] = ['comment', (int) $row->id, $row->created_at];
2410 + }
2411 + }
2412 + if ($feedType !== 'comments') {
2413 + $total += (clone $activityQuery)->count();
2414 + foreach ((clone $activityQuery)->reorder('created_at', $direction)->orderBy('id', $direction)->limit($limit)->get(['id', 'created_at']) as $row) {
2415 + $entries[] = ['activity', (int) $row->id, $row->created_at];
2416 + }
2417 + }
1822 2418
2419 + // Stable sort keeps comments ahead of activities on equal timestamps, as before.
2420 + $order = $direction === 'desc' ? -1 : 1;
2421 + usort($entries, function ($a, $b) use ($order) {
2422 + return $order * (strtotime($a[2]) - strtotime($b[2]));
2423 + });
2424 + $entries = array_slice($entries, $offset, $perPage);
1823 2425
1824 - // Merge comments and activities into a single array
1825 - $commentsAndActivities = array_merge($comments, $activities);
2426 + // Hydrate relations only for the selected page.
2427 + $selectedIds = ['comment' => [], 'activity' => []];
2428 + foreach ($entries as $entry) {
2429 + $selectedIds[$entry[0]][] = $entry[1];
2430 + }
2431 + $models = ['comment' => [], 'activity' => []];
2432 + if ($selectedIds['comment']) {
2433 + $models['comment'] = $commentQuery->with(['user', 'replies.user'])->whereIn('id', $selectedIds['comment'])->get()->keyBy('id')->all();
2434 + }
2435 + if ($selectedIds['activity']) {
2436 + $models['activity'] = $activityQuery->with('user')->whereIn('id', $selectedIds['activity'])->get()->keyBy('id')->all();
2437 + }
1826 2438
1827 - // Sort the merged array by created_at date in ascending or descending order
1828 - $order = $filter == 'newest' ? -1 : 1;
1829 - usort($commentsAndActivities, function ($a, $b) use ($order) {
1830 - return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
1831 - });
2439 + $paginatedResults = [];
2440 + foreach ($entries as $entry) {
2441 + if (isset($models[$entry[0]][$entry[1]])) {
2442 + $paginatedResults[] = $models[$entry[0]][$entry[1]]->toArray();
2443 + }
2444 + }
1832 2445
1833 - // Paginate the results
1834 - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
1835 - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
1836 -
1837 - // Get the total count of comments and activities
1838 - $total = count($commentsAndActivities);
1839 2446 $lastPage = (int) ceil($total / $perPage);
1840 2447
1841 2448 // Construct pagination metadata
1842 - $path = "https://wordpress.test/wp-json/fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities";
2449 + $path = rest_url("fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities");
1843 2450 return [
1844 2451 'current_page' => (int) $page,
1845 2452 'data' => $paginatedResults,
1846 - 'first_page_url' => "{$path}?page=1",
2453 + 'first_page_url' => add_query_arg('page', 1, $path),
1847 2454 'from' => $total > 0 ? (int) ($offset + 1) : null,
1848 2455 'last_page' => (int) $lastPage,
1849 - 'last_page_url' => "{$path}?page={$lastPage}",
2456 + 'last_page_url' => add_query_arg('page', $lastPage, $path),
1850 2457 'links' => [
1851 2458 [
1852 - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2459 + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
1853 2460 'label' => 'pagination.previous',
1854 2461 'active' => false
1855 2462 ],
1856 2463 [
1857 - 'url' => "{$path}?page={$page}",
2464 + 'url' => add_query_arg('page', $page, $path),
1858 2465 'label' => (int) $page,
1859 2466 'active' => true
1860 2467 ],
1861 2468 [
1862 - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2469 + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
1863 2470 'label' => 'pagination.next',
1864 2471 'active' => false
1865 2472 ]
1866 2473 ],
1867 - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2474 + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
1868 2475 'path' => $path,
1869 2476 'per_page' => (int) $perPage,
1870 - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2477 + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
1871 2478 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
1872 2479 'total' => (int) $total
1873 2480 ];
1874 2481 }
@@ -1923,11 +2530,15 @@
1923 2530 }
1924 2531 return $title ?? $UrlMeta['title'] ?? '';
1925 2532 }
1926 2533
1927 - public function manageDefaultAssignees($task, $stageId)
2534 + /** Apply missing stage assignees and report whether any assignments were added. */
2535 + public function manageDefaultAssignees($task, $stageId, ?Stage $stage = null)
1928 2536 {
1929 - $stage = Stage::findOrFail($stageId);
2537 + if (!$stage || (int) $stage->id !== (int) $stageId) {
2538 + $stage = Stage::findOrFail($stageId);
2539 + }
2540 + $assignmentsChanged = false;
1930 2541 if ($stage && isset($stage->settings['default_task_assignees'])) {
1931 2542 $defaultAssignees = $stage->settings['default_task_assignees'];
1932 2543 foreach ($defaultAssignees as $assigneeId) {
1933 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
@@ -1933,11 +2544,13 @@
1933 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
1934 2545 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
1935 2546 if (!$IfAlreadyAssignee) {
1936 2547 $this->updateAssignee($assigneeId, $task);
2548 + $assignmentsChanged = true;
1937 2549 }
1938 2550 }
1939 2551 }
2552 + return $assignmentsChanged;
1940 2553 }
1941 2554
1942 2555 public function manageDefaultWatchers($task, $stageId)
1943 2556 {
@@ -2044,8 +2657,12 @@
2044 2657 }
2045 2658
2046 2659 private function deleteTaskAttachments($task)
2047 2660 {
2661 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2662 + return;
2663 + }
2664 +
2048 2665 $attachments = TaskAttachment::where('object_id', $task->id)
2049 2666 ->where('object_type', Constant::TASK_ATTACHMENT)
2050 2667 ->get();
2051 2668 foreach ($attachments as $attachment) {
@@ -2051,12 +2668,38 @@
2051 2668 foreach ($attachments as $attachment) {
2052 2669 $deletedAttachment = clone $attachment;
2053 2670 $attachment->delete();
2054 2671
2055 - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2672 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2056 2673 }
2057 2674 }
2058 2675
2676 + /**
2677 + * Delete task attachments one at a time so each attachment-deleted hook is preserved.
2678 + *
2679 + * @param array $taskIds
2680 + * @param array $taskBoardIds
2681 + * @return void
2682 + */
2683 + private function deleteTaskAttachmentsBatch($taskIds, $taskBoardIds)
2684 + {
2685 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2686 + return;
2687 + }
2688 +
2689 + $attachments = TaskAttachment::whereIn('object_id', $taskIds)
2690 + ->where('object_type', Constant::TASK_ATTACHMENT)
2691 + ->get();
2692 +
2693 + foreach ($attachments as $attachment) {
2694 + $deletedAttachment = clone $attachment;
2695 + $attachment->delete();
2696 + $boardId = $taskBoardIds[(int) $attachment->object_id] ?? null;
2697 +
2698 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $boardId);
2699 + }
2700 + }
2701 +
2059 2702 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2060 2703 {
2061 2704 global $wpdb;
2062 2705 $attachmentFileService = new AttachmentFileService();
@@ -2083,9 +2726,10 @@
2083 2726 }
2084 2727
2085 2728 // Create new task with cloned data
2086 2729 $clonedTask = $task->replicate();
2087 - $clonedTask->title = $taskData['title'] ?? $task->title . ' (' . \__('cloned', 'fluent-boards') . ')';
2730 + /* translators: %s is the title of the task being cloned */
2731 + $clonedTask->title = $taskData['title'] ?? sprintf(\__('%s (cloned)', 'fluent-boards'), $task->title);
2088 2732
2089 2733 $settings = $clonedTask->settings ?? [];
2090 2734
2091 2735 unset(
@@ -2162,9 +2806,10 @@
2162 2806 $attachmentFileService->rollbackCreatedFiles();
2163 2807 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2164 2808 $wpdb->query('ROLLBACK');
2165 2809 throw new \Exception(
2166 - esc_html(\__('Failed to clone task: ', 'fluent-boards') . $e->getMessage()),
2810 + /* translators: %s is the error message */
2811 + esc_html(sprintf(\__('Failed to clone task: %s', 'fluent-boards'), $e->getMessage())),
2167 2812 (int) ($e->getCode() ?: 500)
2168 2813 );
2169 2814 }
2170 2815 }
@@ -2310,8 +2955,14 @@
2310 2955 if ($images->count() > 0) {
2311 2956 foreach ($images as $image) {
2312 2957 $clonedImage = $image->replicate();
2313 2958 $clonedImage->object_id = $clonedCommentOrReply->id;
2959 + (new CommentService())->applyCommentImageScope(
2960 + $clonedImage,
2961 + $clonedCommentOrReply->board_id,
2962 + $clonedCommentOrReply->task_id,
2963 + $clonedCommentOrReply->created_by
2964 + );
2314 2965 $clonedImage->save();
2315 2966 }
2316 2967 }
2317 2968 }
@@ -2523,8 +3174,9 @@
2523 3174
2524 3175 $successfulTasks = [];
2525 3176
2526 3177 foreach ($tasks as $task) {
3178 + $oldStageId = $task->stage_id;
2527 3179 if ($isMovingToAnotherBoard) {
2528 3180 // Cross-board move - use existing method for data cleanup and security
2529 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2530 3182 $task->stage_id = $targetStageId;
@@ -2529,33 +3181,32 @@
2529 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2530 3182 $task->stage_id = $targetStageId;
2531 3183 $task = $task->moveToNewPosition(null);
2532 3184 } else {
2533 - // Same board - simple stage move
2534 - $oldStageId = $task->stage_id;
2535 3185 $task->stage_id = $targetStageId;
2536 3186 $task = $task->moveToNewPosition(1);
2537 -
2538 - // Only process stage-specific logic if stage actually changed
2539 - if ($oldStageId != $targetStageId) {
2540 - $this->manageDefaultAssignees($task, $targetStageId);
2541 -
2542 - $defaultPosition = $task->stage->defaultTaskStatus();
2543 - if ($defaultPosition == 'closed' && $task->status != 'closed') {
2544 - $task = $task->close();
2545 - }
2546 -
2547 - $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
2548 - $this->sendMailAfterTaskModify('stage_change', $usersToSendEmail, $task->id);
3187 + }
3188 +
3189 + // Board moves detach relations; listeners must see the destination state.
3190 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3191 +
3192 + if ($oldStageId != $targetStageId) {
3193 + $this->manageDefaultAssignees($task, $targetStageId);
3194 + if ($targetStage->defaultTaskStatus() == 'closed' && $task->status != 'closed') {
3195 + $task = $task->close();
2549 3196 }
3197 +
3198 + // Default assignees may also become watchers.
3199 + $task->load(['assignees', 'watchers']);
3200 + do_action('fluent_boards/task_stage_updated', $task, $oldStageId);
3201 +
3202 + $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
3203 + $this->sendMailAfterTaskModify('stage_change', $usersToSendEmail, $task->id);
2550 3204 }
2551 -
2552 - // Reload task with all relationships
2553 - $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2554 -
3205 +
2555 3206 $successfulTasks[] = $task;
2556 3207 }
2557 -
3208 +
2558 3209 $successCount = count($successfulTasks);
2559 3210
2560 3211 if ($isMovingToAnotherBoard) {
2561 3212 // translators: %d is the number of tasks successfully moved to another board
@@ -2638,14 +3289,16 @@
2638 3289 $priority = $params['priority'] ?? null;
2639 3290
2640 3291 // Get valid priorities including custom ones added by hooks
2641 3292 $validPriorities = array_keys(apply_filters('fluent_boards/task_priorities', [
2642 - 'low' => __('Low', 'fluent-boards'),
3293 + '' => __('No priority', 'fluent-boards'),
3294 + 'urgent' => __('Urgent', 'fluent-boards'),
3295 + 'high' => __('High', 'fluent-boards'),
2643 3296 'medium' => __('Medium', 'fluent-boards'),
2644 - 'high' => __('High', 'fluent-boards')
3297 + 'low' => __('Low', 'fluent-boards')
2645 3298 ]));
2646 3299
2647 - if (!in_array($priority, $validPriorities)) {
3300 + if (!in_array($priority, $validPriorities, true)) {
2648 3301 throw new \Exception(esc_html__('Invalid priority level', 'fluent-boards'));
2649 3302 }
2650 3303
2651 3304 $successfulTasks = [];
@@ -2894,35 +3547,50 @@
2894 3547 'message' => $message
2895 3548 ];
2896 3549 }
2897 3550
2898 - /* Delete time tracking records for one or multiple tasks
2899 - * Uses try-catch for better performance - avoids table existence check overhead
3551 + /**
3552 + * Delete time tracking records for one or multiple tasks when the table exists.
2900 3553 *
2901 - * @param int|array $taskIds Single task ID or array of task IDs
3554 + * @param int|array $taskIds Single task ID or array of task IDs.
3555 + * @param bool $suppressErrors Whether cleanup failures should be ignored.
2902 3556 * @return void
2903 3557 */
2904 - public function deleteTimeTrackingRecords($taskIds)
3558 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
2905 3559 {
2906 - // Check if FluentBoards Pro time tracking is available
2907 - if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
3560 + if (!class_exists(TimeTrack::class)) {
2908 3561 return;
2909 3562 }
2910 3563
2911 3564 try {
3565 + // Disabled modules still need cleanup, but never-enabled modules may have no table.
3566 + if (method_exists(TimeTrackingHelper::class, 'timeTrackingTableExists')) {
3567 + $tableExists = TimeTrackingHelper::timeTrackingTableExists();
3568 + } else {
3569 + // Older Pro versions do not provide the table-existence helper.
3570 + global $wpdb;
3571 + $table = $wpdb->prefix . (new TimeTrack())->getTable();
3572 + $tableExists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table;
3573 + }
3574 +
3575 + if (!$tableExists) {
3576 + return;
3577 + }
3578 +
2912 3579 // Handle single task ID or array of task IDs
2913 3580 if (is_array($taskIds)) {
2914 3581 if (!empty($taskIds)) {
2915 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete();
3582 + TimeTrack::whereIn('task_id', $taskIds)->delete();
2916 3583 }
2917 3584 } else {
2918 3585 if (is_numeric($taskIds) && $taskIds > 0) {
2919 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
3586 + TimeTrack::where('task_id', (int) $taskIds)->delete();
2920 3587 }
2921 3588 }
2922 - } catch (\Exception $e) {
2923 - // Silently fail if table doesn't exist or any other error occurs
2924 - // This is intentional for cleanup operations
3589 + } catch (\Throwable $e) {
3590 + if (!$suppressErrors) {
3591 + throw $e;
3592 + }
2925 3593 }
2926 3594 }
2927 3595
2928 3596 }