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.1 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 All 44 releases
← All changes | app/Services/TaskService.php +867 -213 1.95.3 → 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();
@@ -1706,11 +2233,14 @@
1706 2233
1707 2234 return $subtaskGroupMap;
1708 2235 }
1709 2236
1710 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
2237 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = [])
1711 2238 {
1712 - $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();
1713 2243 $taskMap = [];
1714 2244 $subtaskGroupMap = [];
1715 2245 $parentTaskCount = 0;
1716 2246 $attachmentFileService = new AttachmentFileService();
@@ -1719,14 +2249,23 @@
1719 2249 $dbInstance->beginTransaction();
1720 2250
1721 2251 try {
1722 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 +
1723 2262 $newTask = array();
1724 2263 $newTask['title'] = $task->title;
1725 2264 $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
1726 - $newTask['description'] = $task->description;
2265 + $newTask['description'] = DescriptionMarkdownConverter::normalize($task->description);
1727 2266 $newTask['board_id'] = $newBoard->id;
1728 - $newTask['stage_id'] = $stageMap[$task->stage_id];
2267 + $newTask['stage_id'] = $stageId && isset($stageMap[$stageId]) ? $stageMap[$stageId] : null;
1729 2268 $newTask['status'] = $task->status;
1730 2269 $newTask['priority'] = $task->priority;
1731 2270 $newTask['position'] = $task->position;
1732 2271 $newTask['due_at'] = $task->due_at;
@@ -1747,9 +2286,9 @@
1747 2286 $groupRelationOfTask = TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
1748 2287 ->where('task_id', $task->id)
1749 2288 ->first();
1750 2289
1751 - if ($groupRelationOfTask && $subtaskGroupMap[$groupRelationOfTask->value]) {
2290 + if ($groupRelationOfTask && !empty($subtaskGroupMap[$groupRelationOfTask->value])) {
1752 2291 TaskMeta::create([
1753 2292 'task_id' => $newTask->id,
1754 2293 'key' => Constant::SUBTASK_GROUP_CHILD,
1755 2294 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
@@ -1768,8 +2307,9 @@
1768 2307 'value' => $isTemplate->value
1769 2308 ]);
1770 2309 }
1771 2310 }
2311 + $this->copyTaskCustomFields($task, $newTask, $customFieldMap);
1772 2312 if(!$task->parent_id){
1773 2313 ++$parentTaskCount;
1774 2314 $taskMap[$task['id']] = $newTask->id;
1775 2315 //duplicate labels to task
@@ -1798,8 +2338,31 @@
1798 2338 throw $e;
1799 2339 }
1800 2340 }
1801 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 +
1802 2365 private function subtaskCountUpdate($taskId){
1803 2366 $parentTask = Task::findOrFail($taskId);
1804 2367 $settings = $parentTask->settings;
1805 2368 $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1;
@@ -1809,80 +2372,110 @@
1809 2372
1810 2373 /**
1811 2374 * @param $taskId
1812 2375 * @param $perPage
1813 - * @param $offset
2376 + * @param $page
1814 2377 * @param string $filter
2378 + * @param $boardId
2379 + * @param string $feedType
1815 2380 * @return array
1816 2381 */
1817 - 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
1818 2383 {
2384 + $perPage = Sanitize::perPage($perPage);
2385 + $page = max(1, (int) $page);
1819 2386 // Fetch the task
1820 2387 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2388 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
1821 2389
1822 - // Fetch comments and activities separately
1823 - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
1824 - $activities = $task->activities()
1825 - ->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()
1826 2397 ->where(function($query) {
1827 2398 $query->whereNotIn('column', [ 'comment', 'a reply'])
1828 2399 ->orWhere(function($subQuery) {
1829 2400 $subQuery->whereNotIn('action', ['added', 'updated']);
1830 2401 });
1831 - })
1832 - ->orderBy('created_at', 'desc')
1833 - ->get()
1834 - ->toArray();
2402 + });
1835 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 + }
1836 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);
1837 2425
1838 - // Merge comments and activities into a single array
1839 - $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 + }
1840 2438
1841 - // Sort the merged array by created_at date in ascending or descending order
1842 - $order = $filter == 'newest' ? -1 : 1;
1843 - usort($commentsAndActivities, function ($a, $b) use ($order) {
1844 - return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
1845 - });
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 + }
1846 2445
1847 - // Paginate the results
1848 - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
1849 - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
1850 -
1851 - // Get the total count of comments and activities
1852 - $total = count($commentsAndActivities);
1853 2446 $lastPage = (int) ceil($total / $perPage);
1854 2447
1855 2448 // Construct pagination metadata
1856 - $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");
1857 2450 return [
1858 2451 'current_page' => (int) $page,
1859 2452 'data' => $paginatedResults,
1860 - 'first_page_url' => "{$path}?page=1",
2453 + 'first_page_url' => add_query_arg('page', 1, $path),
1861 2454 'from' => $total > 0 ? (int) ($offset + 1) : null,
1862 2455 'last_page' => (int) $lastPage,
1863 - 'last_page_url' => "{$path}?page={$lastPage}",
2456 + 'last_page_url' => add_query_arg('page', $lastPage, $path),
1864 2457 'links' => [
1865 2458 [
1866 - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2459 + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
1867 2460 'label' => 'pagination.previous',
1868 2461 'active' => false
1869 2462 ],
1870 2463 [
1871 - 'url' => "{$path}?page={$page}",
2464 + 'url' => add_query_arg('page', $page, $path),
1872 2465 'label' => (int) $page,
1873 2466 'active' => true
1874 2467 ],
1875 2468 [
1876 - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2469 + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
1877 2470 'label' => 'pagination.next',
1878 2471 'active' => false
1879 2472 ]
1880 2473 ],
1881 - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2474 + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
1882 2475 'path' => $path,
1883 2476 'per_page' => (int) $perPage,
1884 - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2477 + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
1885 2478 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
1886 2479 'total' => (int) $total
1887 2480 ];
1888 2481 }
@@ -1937,11 +2530,15 @@
1937 2530 }
1938 2531 return $title ?? $UrlMeta['title'] ?? '';
1939 2532 }
1940 2533
1941 - 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)
1942 2536 {
1943 - $stage = Stage::findOrFail($stageId);
2537 + if (!$stage || (int) $stage->id !== (int) $stageId) {
2538 + $stage = Stage::findOrFail($stageId);
2539 + }
2540 + $assignmentsChanged = false;
1944 2541 if ($stage && isset($stage->settings['default_task_assignees'])) {
1945 2542 $defaultAssignees = $stage->settings['default_task_assignees'];
1946 2543 foreach ($defaultAssignees as $assigneeId) {
1947 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
@@ -1947,11 +2544,13 @@
1947 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
1948 2545 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
1949 2546 if (!$IfAlreadyAssignee) {
1950 2547 $this->updateAssignee($assigneeId, $task);
2548 + $assignmentsChanged = true;
1951 2549 }
1952 2550 }
1953 2551 }
2552 + return $assignmentsChanged;
1954 2553 }
1955 2554
1956 2555 public function manageDefaultWatchers($task, $stageId)
1957 2556 {
@@ -2058,8 +2657,12 @@
2058 2657 }
2059 2658
2060 2659 private function deleteTaskAttachments($task)
2061 2660 {
2661 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2662 + return;
2663 + }
2664 +
2062 2665 $attachments = TaskAttachment::where('object_id', $task->id)
2063 2666 ->where('object_type', Constant::TASK_ATTACHMENT)
2064 2667 ->get();
2065 2668 foreach ($attachments as $attachment) {
@@ -2065,12 +2668,38 @@
2065 2668 foreach ($attachments as $attachment) {
2066 2669 $deletedAttachment = clone $attachment;
2067 2670 $attachment->delete();
2068 2671
2069 - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2672 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2070 2673 }
2071 2674 }
2072 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 +
2073 2702 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2074 2703 {
2075 2704 global $wpdb;
2076 2705 $attachmentFileService = new AttachmentFileService();
@@ -2097,9 +2726,10 @@
2097 2726 }
2098 2727
2099 2728 // Create new task with cloned data
2100 2729 $clonedTask = $task->replicate();
2101 - $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);
2102 2732
2103 2733 $settings = $clonedTask->settings ?? [];
2104 2734
2105 2735 unset(
@@ -2176,9 +2806,10 @@
2176 2806 $attachmentFileService->rollbackCreatedFiles();
2177 2807 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2178 2808 $wpdb->query('ROLLBACK');
2179 2809 throw new \Exception(
2180 - 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())),
2181 2812 (int) ($e->getCode() ?: 500)
2182 2813 );
2183 2814 }
2184 2815 }
@@ -2324,8 +2955,14 @@
2324 2955 if ($images->count() > 0) {
2325 2956 foreach ($images as $image) {
2326 2957 $clonedImage = $image->replicate();
2327 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 + );
2328 2965 $clonedImage->save();
2329 2966 }
2330 2967 }
2331 2968 }
@@ -2537,8 +3174,9 @@
2537 3174
2538 3175 $successfulTasks = [];
2539 3176
2540 3177 foreach ($tasks as $task) {
3178 + $oldStageId = $task->stage_id;
2541 3179 if ($isMovingToAnotherBoard) {
2542 3180 // Cross-board move - use existing method for data cleanup and security
2543 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2544 3182 $task->stage_id = $targetStageId;
@@ -2543,33 +3181,32 @@
2543 3181 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2544 3182 $task->stage_id = $targetStageId;
2545 3183 $task = $task->moveToNewPosition(null);
2546 3184 } else {
2547 - // Same board - simple stage move
2548 - $oldStageId = $task->stage_id;
2549 3185 $task->stage_id = $targetStageId;
2550 3186 $task = $task->moveToNewPosition(1);
2551 -
2552 - // Only process stage-specific logic if stage actually changed
2553 - if ($oldStageId != $targetStageId) {
2554 - $this->manageDefaultAssignees($task, $targetStageId);
2555 -
2556 - $defaultPosition = $task->stage->defaultTaskStatus();
2557 - if ($defaultPosition == 'closed' && $task->status != 'closed') {
2558 - $task = $task->close();
2559 - }
2560 -
2561 - $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
2562 - $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();
2563 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);
2564 3204 }
2565 -
2566 - // Reload task with all relationships
2567 - $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2568 -
3205 +
2569 3206 $successfulTasks[] = $task;
2570 3207 }
2571 -
3208 +
2572 3209 $successCount = count($successfulTasks);
2573 3210
2574 3211 if ($isMovingToAnotherBoard) {
2575 3212 // translators: %d is the number of tasks successfully moved to another board
@@ -2652,14 +3289,16 @@
2652 3289 $priority = $params['priority'] ?? null;
2653 3290
2654 3291 // Get valid priorities including custom ones added by hooks
2655 3292 $validPriorities = array_keys(apply_filters('fluent_boards/task_priorities', [
2656 - 'low' => __('Low', 'fluent-boards'),
3293 + '' => __('No priority', 'fluent-boards'),
3294 + 'urgent' => __('Urgent', 'fluent-boards'),
3295 + 'high' => __('High', 'fluent-boards'),
2657 3296 'medium' => __('Medium', 'fluent-boards'),
2658 - 'high' => __('High', 'fluent-boards')
3297 + 'low' => __('Low', 'fluent-boards')
2659 3298 ]));
2660 3299
2661 - if (!in_array($priority, $validPriorities)) {
3300 + if (!in_array($priority, $validPriorities, true)) {
2662 3301 throw new \Exception(esc_html__('Invalid priority level', 'fluent-boards'));
2663 3302 }
2664 3303
2665 3304 $successfulTasks = [];
@@ -2908,35 +3547,50 @@
2908 3547 'message' => $message
2909 3548 ];
2910 3549 }
2911 3550
2912 - /* Delete time tracking records for one or multiple tasks
2913 - * 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.
2914 3553 *
2915 - * @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.
2916 3556 * @return void
2917 3557 */
2918 - public function deleteTimeTrackingRecords($taskIds)
3558 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
2919 3559 {
2920 - // Check if FluentBoards Pro time tracking is available
2921 - if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
3560 + if (!class_exists(TimeTrack::class)) {
2922 3561 return;
2923 3562 }
2924 3563
2925 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 +
2926 3579 // Handle single task ID or array of task IDs
2927 3580 if (is_array($taskIds)) {
2928 3581 if (!empty($taskIds)) {
2929 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete();
3582 + TimeTrack::whereIn('task_id', $taskIds)->delete();
2930 3583 }
2931 3584 } else {
2932 3585 if (is_numeric($taskIds) && $taskIds > 0) {
2933 - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
3586 + TimeTrack::where('task_id', (int) $taskIds)->delete();
2934 3587 }
2935 3588 }
2936 - } catch (\Exception $e) {
2937 - // Silently fail if table doesn't exist or any other error occurs
2938 - // This is intentional for cleanup operations
3589 + } catch (\Throwable $e) {
3590 + if (!$suppressErrors) {
3591 + throw $e;
3592 + }
2939 3593 }
2940 3594 }
2941 3595
2942 3596 }