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