| @@ -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 | |
| @@ -26,19 +31,76 @@ | ||
| 26 | 31 | class TaskService |
| 27 | 32 | { |
| 28 | 33 | private static $physicalTableNameCache = []; |
| 29 | 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 | + | |
| 30 | 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 | + /** | |
| 31 | 92 | * Resolve a task only when it belongs to the requested board. |
| 32 | 93 | * |
| 33 | 94 | * Subtasks normally carry the same board_id as their parent, but the parent |
| 34 | 95 | * fallback protects older data where that relationship may be incomplete. |
| 96 | + * Missing or mismatched tasks use the router's deliberate 404 response. | |
| 35 | 97 | * |
| 36 | 98 | * @param int $taskId |
| 37 | 99 | * @param int $boardId |
| 38 | 100 | * @param bool $allowParentFallback |
| 39 | 101 | * @return Task |
| 40 | - * @throws \Exception | |
| 102 | + * @throws ModelNotFoundException | |
| 41 | 103 | */ |
| 42 | 104 | public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true) |
| 43 | 105 | { |
| 44 | 106 | $taskId = absint($taskId); |
| @@ -44,9 +106,9 @@ | ||
| 44 | 106 | $taskId = absint($taskId); |
| 45 | 107 | $boardId = absint($boardId); |
| 46 | 108 | |
| 47 | 109 | if (!$taskId || !$boardId) { |
| 48 | - throw new \Exception(esc_html__('Task not found', 'fluent-boards')); | |
| 110 | + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards')); | |
| 49 | 111 | } |
| 50 | 112 | |
| 51 | 113 | $task = Task::where('id', $taskId) |
| 52 | 114 | ->where('board_id', $boardId) |
| @@ -70,9 +132,9 @@ | ||
| 70 | 132 | } |
| 71 | 133 | } |
| 72 | 134 | } |
| 73 | 135 | |
| 74 | - throw new \Exception(esc_html__('Task not found', 'fluent-boards')); | |
| 136 | + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards')); | |
| 75 | 137 | } |
| 76 | 138 | |
| 77 | 139 | private function normalizeTaskDescriptionForEditor(Task $task) |
| 78 | 140 | { |
| @@ -80,8 +142,11 @@ | ||
| 80 | 142 | |
| 81 | 143 | return $task; |
| 82 | 144 | } |
| 83 | 145 | |
| 146 | + /** | |
| 147 | + * Create a board task and notify eligible selected assignees before applying stage defaults. | |
| 148 | + */ | |
| 84 | 149 | public function createTask($data, $boardId) |
| 85 | 150 | { |
| 86 | 151 | $board = Board::select('id', 'type')->find($boardId); |
| 87 | 152 | |
| @@ -138,8 +203,18 @@ | ||
| 138 | 203 | |
| 139 | 204 | $data = array_filter($data); |
| 140 | 205 | $task = (new Task())->createTask($data); |
| 141 | 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 | + | |
| 142 | 217 | $this->manageDefaultAssignees($task, $stage->id); |
| 143 | 218 | $this->manageDefaultWatchers($task, $stage->id); |
| 144 | 219 | |
| 145 | 220 | if (isset($data['is_template']) && $data['is_template'] == 'yes') { |
| @@ -205,9 +280,9 @@ | ||
| 205 | 280 | $taskQuery = Task::whereIn('id', $taskIds) |
| 206 | 281 | ->with(['assignees', 'board', 'stage']) |
| 207 | 282 | ->whereNull('archived_at') |
| 208 | 283 | ->where('parent_id', null) |
| 209 | - ->excludeTemplateBoards() | |
| 284 | + ->onActiveAvailableBoards() | |
| 210 | 285 | ->orderBy('due_at', 'DESC'); |
| 211 | 286 | |
| 212 | 287 | switch ($category) { |
| 213 | 288 | case 'overdue': |
| @@ -234,9 +309,10 @@ | ||
| 234 | 309 | ->with(['assignees', 'board', 'stage']) |
| 235 | 310 | ->whereIn('fbs_tasks.id', $taskIds) |
| 236 | 311 | ->whereNull('fbs_tasks.archived_at') |
| 237 | 312 | ->whereNull('fbs_tasks.parent_id') |
| 238 | - ->excludeTemplateBoards() | |
| 313 | + ->where('fbs_tasks.status', '!=', 'closed') | |
| 314 | + ->onActiveAvailableBoards() | |
| 239 | 315 | ->join('fbs_relations as rel', function ($join) use ($currentUserId) { |
| 240 | 316 | $join->on('rel.object_id', '=', 'fbs_tasks.id') |
| 241 | 317 | ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE) |
| 242 | 318 | ->where('rel.foreign_id', $currentUserId); |
| @@ -257,9 +333,9 @@ | ||
| 257 | 333 | return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id); |
| 258 | 334 | })->pluck('notification.task_id')->unique(); |
| 259 | 335 | $validTasks = Task::whereIn('id', $taskIds) |
| 260 | 336 | ->with(['assignees', 'board', 'stage']) |
| 261 | - ->excludeTemplateBoards() | |
| 337 | + ->onActiveAvailableBoards() | |
| 262 | 338 | ->get(); |
| 263 | 339 | |
| 264 | 340 | return $validTasks->toArray(); |
| 265 | 341 | default: |
| @@ -280,9 +356,9 @@ | ||
| 280 | 356 | $taskQuery = Task::query() |
| 281 | 357 | ->whereIn('id', $taskIds) |
| 282 | 358 | ->whereNull('archived_at') |
| 283 | 359 | ->whereNull('parent_id') |
| 284 | - ->excludeTemplateBoards(); | |
| 360 | + ->onActiveAvailableBoards(); | |
| 285 | 361 | |
| 286 | 362 | switch ($category) { |
| 287 | 363 | case 'overdue': |
| 288 | 364 | $taskQuery->overdue(); |
| @@ -305,9 +381,10 @@ | ||
| 305 | 381 | ->select('fbs_tasks.id') |
| 306 | 382 | ->whereIn('fbs_tasks.id', $taskIds) |
| 307 | 383 | ->whereNull('fbs_tasks.archived_at') |
| 308 | 384 | ->whereNull('fbs_tasks.parent_id') |
| 309 | - ->excludeTemplateBoards() | |
| 385 | + ->where('fbs_tasks.status', '!=', 'closed') | |
| 386 | + ->onActiveAvailableBoards() | |
| 310 | 387 | ->join('fbs_relations as rel', function ($join) use ($currentUserId) { |
| 311 | 388 | $join->on('rel.object_id', '=', 'fbs_tasks.id') |
| 312 | 389 | ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE) |
| 313 | 390 | ->where('rel.foreign_id', $currentUserId); |
| @@ -329,9 +406,9 @@ | ||
| 329 | 406 | }) |
| 330 | 407 | ->pluck('notification.task_id') |
| 331 | 408 | ->unique(); |
| 332 | 409 | |
| 333 | - return Task::whereIn('id', $taskIds)->excludeTemplateBoards()->count(); | |
| 410 | + return Task::whereIn('id', $taskIds)->onActiveAvailableBoards()->count(); | |
| 334 | 411 | default: |
| 335 | 412 | return 0; |
| 336 | 413 | } |
| 337 | 414 | |
| @@ -611,10 +688,12 @@ | ||
| 611 | 688 | } |
| 612 | 689 | $task->archived_at = $value == null ? null : current_time('mysql'); |
| 613 | 690 | $task->save(); |
| 614 | 691 | do_action('fluent_boards/task_archived', $task); |
| 615 | - $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE); | |
| 616 | - $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 | + } | |
| 617 | 696 | } |
| 618 | 697 | |
| 619 | 698 | private function updateStatus($value, $task) |
| 620 | 699 | { |
| @@ -784,95 +863,206 @@ | ||
| 784 | 863 | $this->deleteTask($subtask); |
| 785 | 864 | } |
| 786 | 865 | } |
| 787 | 866 | |
| 788 | - $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); | |
| 789 | 906 | $dbInstance = App::getInstance('db'); |
| 790 | - $dbInstance->beginTransaction(); | |
| 791 | 907 | |
| 792 | - $deletedTask = clone $task; | |
| 793 | - //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 | + } | |
| 794 | 911 | |
| 912 | + if ($manageTransaction) { | |
| 913 | + $dbInstance->beginTransaction(); | |
| 914 | + } | |
| 915 | + | |
| 795 | 916 | try { |
| 796 | - $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 | + ]; | |
| 797 | 922 | |
| 798 | - if ($deleted) { | |
| 799 | - //task assignees watchers removed | |
| 800 | - $task->watchers()->detach(); | |
| 801 | - $task->assignees()->detach(); | |
| 923 | + if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 924 | + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD; | |
| 925 | + } | |
| 802 | 926 | |
| 803 | - //removing all task related notifications | |
| 804 | - $notificationIds = $task->notifications->pluck('id'); | |
| 805 | - $task->notifications()->delete(); | |
| 806 | - NotificationUser::whereIn('notification_id', $notificationIds)->delete(); | |
| 927 | + Relation::whereIn('object_id', $taskIds) | |
| 928 | + ->whereIn('object_type', $relationTypes) | |
| 929 | + ->delete(); | |
| 807 | 930 | |
| 808 | - //task labels removed | |
| 809 | - $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(); | |
| 810 | 937 | |
| 811 | - //task custom field value | |
| 812 | - if (defined('FLUENT_BOARDS_PRO')) { | |
| 813 | - $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')); | |
| 814 | 956 | } |
| 815 | - $this->deleteTaskAttachments($task); | |
| 816 | - //task custom field value | |
| 817 | - if(!!defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 818 | - $task->customFields()->detach(); | |
| 819 | - $this->deleteTaskAttachments($task); | |
| 820 | - } | |
| 821 | 957 | |
| 822 | - // Delete time tracking records for this task | |
| 823 | - $this->deleteTimeTrackingRecords($task->id); | |
| 958 | + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks); | |
| 824 | 959 | |
| 825 | - do_action('fluent_boards/task_deleted', $task); | |
| 826 | - TaskMeta::where('task_id', $task->id)->delete(); | |
| 827 | - do_action('fluent_boards/task_deleted', $deletedTask); | |
| 828 | - TaskMeta::where('task_id', $task->id)->delete(); | |
| 960 | + if ($manageTransaction) { | |
| 961 | + $dbInstance->commit(); | |
| 829 | 962 | } |
| 963 | + } catch (\Throwable $e) { | |
| 964 | + if ($manageTransaction) { | |
| 965 | + $dbInstance->rollBack(); | |
| 966 | + } | |
| 830 | 967 | |
| 831 | - $dbInstance->commit(); | |
| 832 | - } catch (\Exception $e) { | |
| 833 | - $dbInstance->rollBack(); | |
| 834 | - throw $e; // Re-throw the exception after rolling back | |
| 968 | + throw $e; | |
| 835 | 969 | } |
| 970 | + } | |
| 836 | 971 | |
| 837 | - } | |
| 838 | - public function deleteTaskForBulk($task) | |
| 972 | + /** Remove comment rows transactionally; file cleanup must wait for commit. */ | |
| 973 | + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance) | |
| 839 | 974 | { |
| 840 | - // If this is a parent task, delete all subtasks first | |
| 841 | - if (!$task->parent_id) { | |
| 842 | - $subtasks = Task::where('parent_id', $task->id)->get(); | |
| 843 | - foreach ($subtasks as $subtask) { | |
| 844 | - // Recursively delete each subtask (cleans up all their relations) | |
| 845 | - $this->deleteTaskForBulk($subtask); | |
| 846 | - } | |
| 975 | + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray(); | |
| 976 | + if (!$commentTasks) { | |
| 977 | + return; | |
| 847 | 978 | } |
| 848 | 979 | |
| 849 | - $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(); | |
| 850 | 987 | |
| 851 | - 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 | + } | |
| 852 | 1008 | |
| 853 | - //task assignees watchers removed | |
| 854 | - $task->watchers()->detach(); | |
| 855 | - $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 | + } | |
| 856 | 1016 | |
| 857 | - //removing all task related notifications | |
| 858 | - $notificationIds = $task->notifications->pluck('id'); | |
| 859 | - $task->notifications()->delete(); | |
| 860 | - 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 | + } | |
| 861 | 1031 | |
| 862 | - //task labels removed | |
| 863 | - $task->labels()->detach(); | |
| 864 | - | |
| 865 | - //task custom field value | |
| 866 | - if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 867 | - $task->customFields()->detach(); | |
| 868 | - $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 | + } | |
| 869 | 1052 | } |
| 1053 | + }); | |
| 1054 | + } | |
| 870 | 1055 | |
| 871 | - // For bulk delete, you might want to avoid firing hooks/actions, | |
| 872 | - // so 'fluent_boards/task_deleted' is not triggered here. | |
| 873 | - 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; | |
| 874 | 1062 | } |
| 1063 | + | |
| 1064 | + $this->deleteTask($task); | |
| 875 | 1065 | } |
| 876 | 1066 | // this is invoked when task is moved to another board |
| 877 | 1067 | |
| 878 | 1068 | /** |
| @@ -909,8 +1099,9 @@ | ||
| 909 | 1099 | $dbInstance->beginTransaction(); |
| 910 | 1100 | |
| 911 | 1101 | try { |
| 912 | 1102 | $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId); |
| 1103 | + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService); | |
| 913 | 1104 | |
| 914 | 1105 | $task->board_id = (int) $targetBoardId; |
| 915 | 1106 | $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task'; |
| 916 | 1107 | |
| @@ -919,19 +1110,16 @@ | ||
| 919 | 1110 | $task->assignees()->detach(); |
| 920 | 1111 | $task->watchers()->detach(); |
| 921 | 1112 | $this->removeCustomFieldAssociations($task); |
| 922 | 1113 | |
| 923 | - // REMOVE: User-specific data to prevent security issues | |
| 924 | - $this->removeCommentsAndReplies($task->id); | |
| 925 | - $this->removeTimeTrackingRecords($task->id); | |
| 926 | - | |
| 927 | 1114 | // REMOVE: Recurring task settings for security |
| 928 | 1115 | $this->removeRecurringTaskSettings($task->id); |
| 929 | 1116 | |
| 930 | 1117 | $task->save(); |
| 1118 | + do_action('fluent_boards/task_moved_update_time_tracking', $task); | |
| 931 | 1119 | |
| 932 | 1120 | // MOVE: Subtasks to new board (preserves subtask groups) |
| 933 | - $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type, $attachmentFileService); | |
| 1121 | + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService); | |
| 934 | 1122 | |
| 935 | 1123 | $dbInstance->commit(); |
| 936 | 1124 | $attachmentFileService->commitMovedOriginalFiles(); |
| 937 | 1125 | } catch (\Exception $e) { |
| @@ -947,9 +1135,9 @@ | ||
| 947 | 1135 | /** |
| 948 | 1136 | * Move all subtasks to the new board when parent task is moved |
| 949 | 1137 | * Preserves subtask groups and their relationships |
| 950 | 1138 | */ |
| 951 | - private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService) | |
| 1139 | + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService) | |
| 952 | 1140 | { |
| 953 | 1141 | // Get all subtasks of the parent task |
| 954 | 1142 | $subtasks = Task::where('parent_id', $parentTaskId)->get(); |
| 955 | 1143 | |
| @@ -958,10 +1146,12 @@ | ||
| 958 | 1146 | } |
| 959 | 1147 | |
| 960 | 1148 | foreach ($subtasks as $subtask) { |
| 961 | 1149 | // Update board_id and type |
| 962 | - $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); | |
| 963 | 1152 | $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId); |
| 1153 | + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService); | |
| 964 | 1154 | |
| 965 | 1155 | $subtask->board_id = (int) $targetBoardId; |
| 966 | 1156 | $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task'; |
| 967 | 1157 | |
| @@ -974,16 +1164,13 @@ | ||
| 974 | 1164 | $subtask->taskMeta() |
| 975 | 1165 | ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD) |
| 976 | 1166 | ->delete(); |
| 977 | 1167 | |
| 978 | - // REMOVE: User-specific data for security | |
| 979 | - $this->removeCommentsAndReplies($subtask->id); | |
| 980 | - $this->removeTimeTrackingRecords($subtask->id); | |
| 981 | - | |
| 982 | 1168 | // REMOVE: Recurring task settings |
| 983 | 1169 | $this->removeRecurringTaskSettings($subtask->id); |
| 984 | 1170 | |
| 985 | 1171 | $subtask->save(); |
| 1172 | + do_action('fluent_boards/task_moved_update_time_tracking', $subtask); | |
| 986 | 1173 | } |
| 987 | 1174 | } |
| 988 | 1175 | |
| 989 | 1176 | /** |
| @@ -1029,24 +1216,26 @@ | ||
| 1029 | 1216 | } |
| 1030 | 1217 | } |
| 1031 | 1218 | |
| 1032 | 1219 | /** |
| 1033 | - * Remove comments and replies for security reasons | |
| 1034 | - * Prevents exposing user-specific data to unauthorized users | |
| 1220 | + * Move a task's complete comment history and images to another board. | |
| 1035 | 1221 | */ |
| 1036 | - private function removeCommentsAndReplies($taskId) | |
| 1222 | + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService) | |
| 1037 | 1223 | { |
| 1038 | - // Input validation | |
| 1039 | - 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) { | |
| 1040 | 1229 | return; |
| 1041 | 1230 | } |
| 1042 | - | |
| 1043 | - // Remove all comments and replies for this task (delete individually to fire model events and clean up images) | |
| 1044 | - $comments = Comment::where('task_id', (int) $taskId)->get(); | |
| 1045 | - foreach ($comments as $comment) { | |
| 1046 | - $comment->delete(); | |
| 1047 | - } | |
| 1048 | 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]); | |
| 1049 | 1238 | } |
| 1050 | 1239 | |
| 1051 | 1240 | /** |
| 1052 | 1241 | * Remove time tracking records for security reasons |
| @@ -1074,9 +1263,9 @@ | ||
| 1074 | 1263 | return; |
| 1075 | 1264 | } |
| 1076 | 1265 | |
| 1077 | 1266 | // Remove all attachments for this task |
| 1078 | - if (class_exists('FluentBoardsPro\App\Models\TaskAttachment')) { | |
| 1267 | + if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 1079 | 1268 | \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', (int) $taskId) |
| 1080 | 1269 | ->where('object_type', 'task') |
| 1081 | 1270 | ->delete(); |
| 1082 | 1271 | } |
| @@ -1311,8 +1500,9 @@ | ||
| 1311 | 1500 | |
| 1312 | 1501 | return $tasks; |
| 1313 | 1502 | } |
| 1314 | 1503 | |
| 1504 | + /** Fetch a page of board tasks, applying filters before pagination. */ | |
| 1315 | 1505 | public function getTableTasks($boardId, $data = []) |
| 1316 | 1506 | { |
| 1317 | 1507 | $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20; |
| 1318 | 1508 | $page = isset($data['page']) ? intval($data['page']) : 1; |
| @@ -1320,8 +1510,10 @@ | ||
| 1320 | 1510 | $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc'; |
| 1321 | 1511 | $search = isset($data['search']) ? sanitize_text_field($data['search']) : ''; |
| 1322 | 1512 | $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', [])); |
| 1323 | 1513 | $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', [])); |
| 1514 | + $recurringFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'recurring', [])); | |
| 1515 | + $templateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'template', [])); | |
| 1324 | 1516 | $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true); |
| 1325 | 1517 | $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', [])); |
| 1326 | 1518 | $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', [])); |
| 1327 | 1519 | $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', [])); |
| @@ -1358,8 +1550,9 @@ | ||
| 1358 | 1550 | 'title', |
| 1359 | 1551 | 'slug', |
| 1360 | 1552 | 'board_id', |
| 1361 | 1553 | 'parent_id', |
| 1554 | + 'crm_contact_id', | |
| 1362 | 1555 | 'type', |
| 1363 | 1556 | 'stage_id', |
| 1364 | 1557 | 'status', |
| 1365 | 1558 | 'priority', |
| @@ -1409,8 +1602,10 @@ | ||
| 1409 | 1602 | $this->applyTableTaskSearch($tasksQuery, $search); |
| 1410 | 1603 | $this->applyTableTaskFilters($tasksQuery, [ |
| 1411 | 1604 | 'stage' => $stageFilters, |
| 1412 | 1605 | 'task_status' => $taskStatusFilters, |
| 1606 | + 'recurring' => $recurringFilters, | |
| 1607 | + 'template' => $templateFilters, | |
| 1413 | 1608 | 'priority' => $priorityFilters, |
| 1414 | 1609 | 'assignee' => $assigneeFilters, |
| 1415 | 1610 | 'labels' => $labelFilters, |
| 1416 | 1611 | 'watchers' => $watcherFilters, |
| @@ -1427,13 +1622,16 @@ | ||
| 1427 | 1622 | ->orderBy($sortColumn, $sortDirection) |
| 1428 | 1623 | ->paginate($perPage, ['*'], 'page', $page); |
| 1429 | 1624 | } |
| 1430 | 1625 | |
| 1626 | + /** Fetch matching board cards with the same filters as table view. */ | |
| 1431 | 1627 | public function getBoardViewTasks($boardId, $data = []) |
| 1432 | 1628 | { |
| 1433 | 1629 | $search = isset($data['search']) ? sanitize_text_field($data['search']) : ''; |
| 1434 | 1630 | $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', [])); |
| 1435 | 1631 | $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', [])); |
| 1632 | + $recurringFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'recurring', [])); | |
| 1633 | + $templateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'template', [])); | |
| 1436 | 1634 | $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true); |
| 1437 | 1635 | $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', [])); |
| 1438 | 1636 | $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', [])); |
| 1439 | 1637 | $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', [])); |
| @@ -1481,8 +1679,10 @@ | ||
| 1481 | 1679 | $this->applyTableTaskSearch($tasksQuery, $search); |
| 1482 | 1680 | $this->applyTableTaskFilters($tasksQuery, [ |
| 1483 | 1681 | 'stage' => $stageFilters, |
| 1484 | 1682 | 'task_status' => $taskStatusFilters, |
| 1683 | + 'recurring' => $recurringFilters, | |
| 1684 | + 'template' => $templateFilters, | |
| 1485 | 1685 | 'priority' => $priorityFilters, |
| 1486 | 1686 | 'assignee' => $assigneeFilters, |
| 1487 | 1687 | 'labels' => $labelFilters, |
| 1488 | 1688 | 'watchers' => $watcherFilters, |
| @@ -1532,8 +1732,9 @@ | ||
| 1532 | 1732 | $escapedSearch = $wpdb->esc_like($search); |
| 1533 | 1733 | $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%'); |
| 1534 | 1734 | } |
| 1535 | 1735 | |
| 1736 | + /** Apply board filters to table and card queries, gating Pro-only options. */ | |
| 1536 | 1737 | private function applyTableTaskFilters($tasksQuery, $filters) |
| 1537 | 1738 | { |
| 1538 | 1739 | $stageFilters = Arr::get($filters, 'stage', []); |
| 1539 | 1740 | $taskStatusFilters = Arr::get($filters, 'task_status', []); |
| @@ -1544,8 +1745,22 @@ | ||
| 1544 | 1745 | $contactFilters = Arr::get($filters, 'contact', []); |
| 1545 | 1746 | $customFieldFilters = Arr::get($filters, 'custom_fields', []); |
| 1546 | 1747 | $dueDateFilters = Arr::get($filters, 'due_date', []); |
| 1547 | 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 | + | |
| 1548 | 1763 | if ($stageFilters) { |
| 1549 | 1764 | $this->applyTableStageFilters($tasksQuery, $stageFilters); |
| 1550 | 1765 | } |
| 1551 | 1766 | |
| @@ -1784,8 +1999,12 @@ | ||
| 1784 | 1999 | } |
| 1785 | 2000 | |
| 1786 | 2001 | public function sendMailAfterTaskModify($column, $assigneeIds, $taskId) |
| 1787 | 2002 | { |
| 2003 | + if (empty($assigneeIds)) { | |
| 2004 | + return; | |
| 2005 | + } | |
| 2006 | + | |
| 1788 | 2007 | $current_user_id = get_current_user_id(); |
| 1789 | 2008 | /* this will run in background as soon as possible */ |
| 1790 | 2009 | /* sending Model or Model Instance won't work here */ |
| 1791 | 2010 | |
| @@ -1849,8 +2068,9 @@ | ||
| 1849 | 2068 | } |
| 1850 | 2069 | |
| 1851 | 2070 | public function getActivities($taskId, $perPage, $filter = 'newest') |
| 1852 | 2071 | { |
| 2072 | + $perPage = Sanitize::perPage($perPage); | |
| 1853 | 2073 | $activityQuery = Activity::where('object_id', $taskId) |
| 1854 | 2074 | ->where('object_type', Constant::ACTIVITY_TASK); |
| 1855 | 2075 | if ($filter == 'newest') { |
| 1856 | 2076 | $activityQuery = $activityQuery->latest(); |
| @@ -2013,11 +2233,14 @@ | ||
| 2013 | 2233 | |
| 2014 | 2234 | return $subtaskGroupMap; |
| 2015 | 2235 | } |
| 2016 | 2236 | |
| 2017 | - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no') | |
| 2237 | + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = []) | |
| 2018 | 2238 | { |
| 2019 | - $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(); | |
| 2020 | 2243 | $taskMap = []; |
| 2021 | 2244 | $subtaskGroupMap = []; |
| 2022 | 2245 | $parentTaskCount = 0; |
| 2023 | 2246 | $attachmentFileService = new AttachmentFileService(); |
| @@ -2084,8 +2307,9 @@ | ||
| 2084 | 2307 | 'value' => $isTemplate->value |
| 2085 | 2308 | ]); |
| 2086 | 2309 | } |
| 2087 | 2310 | } |
| 2311 | + $this->copyTaskCustomFields($task, $newTask, $customFieldMap); | |
| 2088 | 2312 | if(!$task->parent_id){ |
| 2089 | 2313 | ++$parentTaskCount; |
| 2090 | 2314 | $taskMap[$task['id']] = $newTask->id; |
| 2091 | 2315 | //duplicate labels to task |
| @@ -2114,8 +2338,31 @@ | ||
| 2114 | 2338 | throw $e; |
| 2115 | 2339 | } |
| 2116 | 2340 | } |
| 2117 | 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 | + | |
| 2118 | 2365 | private function subtaskCountUpdate($taskId){ |
| 2119 | 2366 | $parentTask = Task::findOrFail($taskId); |
| 2120 | 2367 | $settings = $parentTask->settings; |
| 2121 | 2368 | $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1; |
| @@ -2133,81 +2380,102 @@ | ||
| 2133 | 2380 | * @return array |
| 2134 | 2381 | */ |
| 2135 | 2382 | public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array |
| 2136 | 2383 | { |
| 2384 | + $perPage = Sanitize::perPage($perPage); | |
| 2385 | + $page = max(1, (int) $page); | |
| 2137 | 2386 | // Fetch the task |
| 2138 | 2387 | $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId); |
| 2139 | 2388 | $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all'; |
| 2140 | 2389 | |
| 2141 | - // Fetch comments and activities separately | |
| 2142 | - $comments = []; | |
| 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() | |
| 2397 | + ->where(function($query) { | |
| 2398 | + $query->whereNotIn('column', [ 'comment', 'a reply']) | |
| 2399 | + ->orWhere(function($subQuery) { | |
| 2400 | + $subQuery->whereNotIn('action', ['added', 'updated']); | |
| 2401 | + }); | |
| 2402 | + }); | |
| 2403 | + | |
| 2404 | + $entries = []; | |
| 2405 | + $total = 0; | |
| 2143 | 2406 | if ($feedType !== 'activities') { |
| 2144 | - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray(); | |
| 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 | + } | |
| 2145 | 2411 | } |
| 2146 | - | |
| 2147 | - $activities = []; | |
| 2148 | 2412 | if ($feedType !== 'comments') { |
| 2149 | - $activities = $task->activities() | |
| 2150 | - ->with('user') | |
| 2151 | - ->where(function($query) { | |
| 2152 | - $query->whereNotIn('column', [ 'comment', 'a reply']) | |
| 2153 | - ->orWhere(function($subQuery) { | |
| 2154 | - $subQuery->whereNotIn('action', ['added', 'updated']); | |
| 2155 | - }); | |
| 2156 | - }) | |
| 2157 | - ->orderBy('created_at', 'desc') | |
| 2158 | - ->get() | |
| 2159 | - ->toArray(); | |
| 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 | + } | |
| 2160 | 2417 | } |
| 2161 | 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); | |
| 2162 | 2425 | |
| 2163 | - // Merge comments and activities into a single array | |
| 2164 | - $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 | + } | |
| 2165 | 2438 | |
| 2166 | - // Sort the merged array by created_at date in ascending or descending order | |
| 2167 | - $order = $filter == 'newest' ? -1 : 1; | |
| 2168 | - usort($commentsAndActivities, function ($a, $b) use ($order) { | |
| 2169 | - return $order * (strtotime($a['created_at']) - strtotime($b['created_at'])); | |
| 2170 | - }); | |
| 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 | + } | |
| 2171 | 2445 | |
| 2172 | - // Paginate the results | |
| 2173 | - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array | |
| 2174 | - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage); | |
| 2175 | - | |
| 2176 | - // Get the total count of comments and activities | |
| 2177 | - $total = count($commentsAndActivities); | |
| 2178 | 2446 | $lastPage = (int) ceil($total / $perPage); |
| 2179 | 2447 | |
| 2180 | 2448 | // Construct pagination metadata |
| 2181 | - $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"); | |
| 2182 | 2450 | return [ |
| 2183 | 2451 | 'current_page' => (int) $page, |
| 2184 | 2452 | 'data' => $paginatedResults, |
| 2185 | - 'first_page_url' => "{$path}?page=1", | |
| 2453 | + 'first_page_url' => add_query_arg('page', 1, $path), | |
| 2186 | 2454 | 'from' => $total > 0 ? (int) ($offset + 1) : null, |
| 2187 | 2455 | 'last_page' => (int) $lastPage, |
| 2188 | - 'last_page_url' => "{$path}?page={$lastPage}", | |
| 2456 | + 'last_page_url' => add_query_arg('page', $lastPage, $path), | |
| 2189 | 2457 | 'links' => [ |
| 2190 | 2458 | [ |
| 2191 | - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null, | |
| 2459 | + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null, | |
| 2192 | 2460 | 'label' => 'pagination.previous', |
| 2193 | 2461 | 'active' => false |
| 2194 | 2462 | ], |
| 2195 | 2463 | [ |
| 2196 | - 'url' => "{$path}?page={$page}", | |
| 2464 | + 'url' => add_query_arg('page', $page, $path), | |
| 2197 | 2465 | 'label' => (int) $page, |
| 2198 | 2466 | 'active' => true |
| 2199 | 2467 | ], |
| 2200 | 2468 | [ |
| 2201 | - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null, | |
| 2469 | + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null, | |
| 2202 | 2470 | 'label' => 'pagination.next', |
| 2203 | 2471 | 'active' => false |
| 2204 | 2472 | ] |
| 2205 | 2473 | ], |
| 2206 | - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null, | |
| 2474 | + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null, | |
| 2207 | 2475 | 'path' => $path, |
| 2208 | 2476 | 'per_page' => (int) $perPage, |
| 2209 | - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null, | |
| 2477 | + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null, | |
| 2210 | 2478 | 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null, |
| 2211 | 2479 | 'total' => (int) $total |
| 2212 | 2480 | ]; |
| 2213 | 2481 | } |
| @@ -2262,11 +2530,15 @@ | ||
| 2262 | 2530 | } |
| 2263 | 2531 | return $title ?? $UrlMeta['title'] ?? ''; |
| 2264 | 2532 | } |
| 2265 | 2533 | |
| 2266 | - 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) | |
| 2267 | 2536 | { |
| 2268 | - $stage = Stage::findOrFail($stageId); | |
| 2537 | + if (!$stage || (int) $stage->id !== (int) $stageId) { | |
| 2538 | + $stage = Stage::findOrFail($stageId); | |
| 2539 | + } | |
| 2540 | + $assignmentsChanged = false; | |
| 2269 | 2541 | if ($stage && isset($stage->settings['default_task_assignees'])) { |
| 2270 | 2542 | $defaultAssignees = $stage->settings['default_task_assignees']; |
| 2271 | 2543 | foreach ($defaultAssignees as $assigneeId) { |
| 2272 | 2544 | $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray(); |
| @@ -2272,11 +2544,13 @@ | ||
| 2272 | 2544 | $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray(); |
| 2273 | 2545 | $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds); |
| 2274 | 2546 | if (!$IfAlreadyAssignee) { |
| 2275 | 2547 | $this->updateAssignee($assigneeId, $task); |
| 2548 | + $assignmentsChanged = true; | |
| 2276 | 2549 | } |
| 2277 | 2550 | } |
| 2278 | 2551 | } |
| 2552 | + return $assignmentsChanged; | |
| 2279 | 2553 | } |
| 2280 | 2554 | |
| 2281 | 2555 | public function manageDefaultWatchers($task, $stageId) |
| 2282 | 2556 | { |
| @@ -2383,8 +2657,12 @@ | ||
| 2383 | 2657 | } |
| 2384 | 2658 | |
| 2385 | 2659 | private function deleteTaskAttachments($task) |
| 2386 | 2660 | { |
| 2661 | + if (!defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 2662 | + return; | |
| 2663 | + } | |
| 2664 | + | |
| 2387 | 2665 | $attachments = TaskAttachment::where('object_id', $task->id) |
| 2388 | 2666 | ->where('object_type', Constant::TASK_ATTACHMENT) |
| 2389 | 2667 | ->get(); |
| 2390 | 2668 | foreach ($attachments as $attachment) { |
| @@ -2390,12 +2668,38 @@ | ||
| 2390 | 2668 | foreach ($attachments as $attachment) { |
| 2391 | 2669 | $deletedAttachment = clone $attachment; |
| 2392 | 2670 | $attachment->delete(); |
| 2393 | 2671 | |
| 2394 | - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment); | |
| 2672 | + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id); | |
| 2395 | 2673 | } |
| 2396 | 2674 | } |
| 2397 | 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 | + | |
| 2398 | 2702 | public function cloneTask(int $taskId, $taskData, $boardId = null): Task |
| 2399 | 2703 | { |
| 2400 | 2704 | global $wpdb; |
| 2401 | 2705 | $attachmentFileService = new AttachmentFileService(); |
| @@ -2422,9 +2726,10 @@ | ||
| 2422 | 2726 | } |
| 2423 | 2727 | |
| 2424 | 2728 | // Create new task with cloned data |
| 2425 | 2729 | $clonedTask = $task->replicate(); |
| 2426 | - $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); | |
| 2427 | 2732 | |
| 2428 | 2733 | $settings = $clonedTask->settings ?? []; |
| 2429 | 2734 | |
| 2430 | 2735 | unset( |
| @@ -2501,9 +2806,10 @@ | ||
| 2501 | 2806 | $attachmentFileService->rollbackCreatedFiles(); |
| 2502 | 2807 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation |
| 2503 | 2808 | $wpdb->query('ROLLBACK'); |
| 2504 | 2809 | throw new \Exception( |
| 2505 | - 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())), | |
| 2506 | 2812 | (int) ($e->getCode() ?: 500) |
| 2507 | 2813 | ); |
| 2508 | 2814 | } |
| 2509 | 2815 | } |
| @@ -2649,8 +2955,14 @@ | ||
| 2649 | 2955 | if ($images->count() > 0) { |
| 2650 | 2956 | foreach ($images as $image) { |
| 2651 | 2957 | $clonedImage = $image->replicate(); |
| 2652 | 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 | + ); | |
| 2653 | 2965 | $clonedImage->save(); |
| 2654 | 2966 | } |
| 2655 | 2967 | } |
| 2656 | 2968 | } |
| @@ -2862,8 +3174,9 @@ | ||
| 2862 | 3174 | |
| 2863 | 3175 | $successfulTasks = []; |
| 2864 | 3176 | |
| 2865 | 3177 | foreach ($tasks as $task) { |
| 3178 | + $oldStageId = $task->stage_id; | |
| 2866 | 3179 | if ($isMovingToAnotherBoard) { |
| 2867 | 3180 | // Cross-board move - use existing method for data cleanup and security |
| 2868 | 3181 | $task = $this->changeBoardByTask($task, $effectiveTargetBoardId); |
| 2869 | 3182 | $task->stage_id = $targetStageId; |
| @@ -2868,33 +3181,32 @@ | ||
| 2868 | 3181 | $task = $this->changeBoardByTask($task, $effectiveTargetBoardId); |
| 2869 | 3182 | $task->stage_id = $targetStageId; |
| 2870 | 3183 | $task = $task->moveToNewPosition(null); |
| 2871 | 3184 | } else { |
| 2872 | - // Same board - simple stage move | |
| 2873 | - $oldStageId = $task->stage_id; | |
| 2874 | 3185 | $task->stage_id = $targetStageId; |
| 2875 | 3186 | $task = $task->moveToNewPosition(1); |
| 2876 | - | |
| 2877 | - // Only process stage-specific logic if stage actually changed | |
| 2878 | - if ($oldStageId != $targetStageId) { | |
| 2879 | - $this->manageDefaultAssignees($task, $targetStageId); | |
| 2880 | - | |
| 2881 | - $defaultPosition = $task->stage->defaultTaskStatus(); | |
| 2882 | - if ($defaultPosition == 'closed' && $task->status != 'closed') { | |
| 2883 | - $task = $task->close(); | |
| 2884 | - } | |
| 2885 | - | |
| 2886 | - $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE); | |
| 2887 | - $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(); | |
| 2888 | 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); | |
| 2889 | 3204 | } |
| 2890 | - | |
| 2891 | - // Reload task with all relationships | |
| 2892 | - $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']); | |
| 2893 | - | |
| 3205 | + | |
| 2894 | 3206 | $successfulTasks[] = $task; |
| 2895 | 3207 | } |
| 2896 | - | |
| 3208 | + | |
| 2897 | 3209 | $successCount = count($successfulTasks); |
| 2898 | 3210 | |
| 2899 | 3211 | if ($isMovingToAnotherBoard) { |
| 2900 | 3212 | // translators: %d is the number of tasks successfully moved to another board |
| @@ -3235,35 +3547,50 @@ | ||
| 3235 | 3547 | 'message' => $message |
| 3236 | 3548 | ]; |
| 3237 | 3549 | } |
| 3238 | 3550 | |
| 3239 | - /* Delete time tracking records for one or multiple tasks | |
| 3240 | - * 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. | |
| 3241 | 3553 | * |
| 3242 | - * @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. | |
| 3243 | 3556 | * @return void |
| 3244 | 3557 | */ |
| 3245 | - public function deleteTimeTrackingRecords($taskIds) | |
| 3558 | + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true) | |
| 3246 | 3559 | { |
| 3247 | - // Check if FluentBoards Pro time tracking is available | |
| 3248 | - if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) { | |
| 3560 | + if (!class_exists(TimeTrack::class)) { | |
| 3249 | 3561 | return; |
| 3250 | 3562 | } |
| 3251 | 3563 | |
| 3252 | 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 | + | |
| 3253 | 3579 | // Handle single task ID or array of task IDs |
| 3254 | 3580 | if (is_array($taskIds)) { |
| 3255 | 3581 | if (!empty($taskIds)) { |
| 3256 | - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete(); | |
| 3582 | + TimeTrack::whereIn('task_id', $taskIds)->delete(); | |
| 3257 | 3583 | } |
| 3258 | 3584 | } else { |
| 3259 | 3585 | if (is_numeric($taskIds) && $taskIds > 0) { |
| 3260 | - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete(); | |
| 3586 | + TimeTrack::where('task_id', (int) $taskIds)->delete(); | |
| 3261 | 3587 | } |
| 3262 | 3588 | } |
| 3263 | - } catch (\Exception $e) { | |
| 3264 | - // Silently fail if table doesn't exist or any other error occurs | |
| 3265 | - // This is intentional for cleanup operations | |
| 3589 | + } catch (\Throwable $e) { | |
| 3590 | + if (!$suppressErrors) { | |
| 3591 | + throw $e; | |
| 3592 | + } | |
| 3266 | 3593 | } |
| 3267 | 3594 | } |
| 3268 | 3595 | |
| 3269 | 3596 | } |