| @@ -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') { |
| @@ -613,10 +688,12 @@ | ||
| 613 | 688 | } |
| 614 | 689 | $task->archived_at = $value == null ? null : current_time('mysql'); |
| 615 | 690 | $task->save(); |
| 616 | 691 | do_action('fluent_boards/task_archived', $task); |
| 617 | - $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE); | |
| 618 | - $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 | + } | |
| 619 | 696 | } |
| 620 | 697 | |
| 621 | 698 | private function updateStatus($value, $task) |
| 622 | 699 | { |
| @@ -786,95 +863,206 @@ | ||
| 786 | 863 | $this->deleteTask($subtask); |
| 787 | 864 | } |
| 788 | 865 | } |
| 789 | 866 | |
| 790 | - $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); | |
| 791 | 906 | $dbInstance = App::getInstance('db'); |
| 792 | - $dbInstance->beginTransaction(); | |
| 793 | 907 | |
| 794 | - $deletedTask = clone $task; | |
| 795 | - //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 | + } | |
| 796 | 911 | |
| 912 | + if ($manageTransaction) { | |
| 913 | + $dbInstance->beginTransaction(); | |
| 914 | + } | |
| 915 | + | |
| 797 | 916 | try { |
| 798 | - $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 | + ]; | |
| 799 | 922 | |
| 800 | - if ($deleted) { | |
| 801 | - //task assignees watchers removed | |
| 802 | - $task->watchers()->detach(); | |
| 803 | - $task->assignees()->detach(); | |
| 923 | + if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 924 | + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD; | |
| 925 | + } | |
| 804 | 926 | |
| 805 | - //removing all task related notifications | |
| 806 | - $notificationIds = $task->notifications->pluck('id'); | |
| 807 | - $task->notifications()->delete(); | |
| 808 | - NotificationUser::whereIn('notification_id', $notificationIds)->delete(); | |
| 927 | + Relation::whereIn('object_id', $taskIds) | |
| 928 | + ->whereIn('object_type', $relationTypes) | |
| 929 | + ->delete(); | |
| 809 | 930 | |
| 810 | - //task labels removed | |
| 811 | - $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(); | |
| 812 | 937 | |
| 813 | - //task custom field value | |
| 814 | - if (defined('FLUENT_BOARDS_PRO')) { | |
| 815 | - $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')); | |
| 816 | 956 | } |
| 817 | - $this->deleteTaskAttachments($task); | |
| 818 | - //task custom field value | |
| 819 | - if(!!defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 820 | - $task->customFields()->detach(); | |
| 821 | - $this->deleteTaskAttachments($task); | |
| 822 | - } | |
| 823 | 957 | |
| 824 | - // Delete time tracking records for this task | |
| 825 | - $this->deleteTimeTrackingRecords($task->id); | |
| 958 | + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks); | |
| 826 | 959 | |
| 827 | - do_action('fluent_boards/task_deleted', $task); | |
| 828 | - TaskMeta::where('task_id', $task->id)->delete(); | |
| 829 | - do_action('fluent_boards/task_deleted', $deletedTask); | |
| 830 | - TaskMeta::where('task_id', $task->id)->delete(); | |
| 960 | + if ($manageTransaction) { | |
| 961 | + $dbInstance->commit(); | |
| 831 | 962 | } |
| 963 | + } catch (\Throwable $e) { | |
| 964 | + if ($manageTransaction) { | |
| 965 | + $dbInstance->rollBack(); | |
| 966 | + } | |
| 832 | 967 | |
| 833 | - $dbInstance->commit(); | |
| 834 | - } catch (\Exception $e) { | |
| 835 | - $dbInstance->rollBack(); | |
| 836 | - throw $e; // Re-throw the exception after rolling back | |
| 968 | + throw $e; | |
| 837 | 969 | } |
| 970 | + } | |
| 838 | 971 | |
| 839 | - } | |
| 840 | - public function deleteTaskForBulk($task) | |
| 972 | + /** Remove comment rows transactionally; file cleanup must wait for commit. */ | |
| 973 | + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance) | |
| 841 | 974 | { |
| 842 | - // If this is a parent task, delete all subtasks first | |
| 843 | - if (!$task->parent_id) { | |
| 844 | - $subtasks = Task::where('parent_id', $task->id)->get(); | |
| 845 | - foreach ($subtasks as $subtask) { | |
| 846 | - // Recursively delete each subtask (cleans up all their relations) | |
| 847 | - $this->deleteTaskForBulk($subtask); | |
| 848 | - } | |
| 975 | + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray(); | |
| 976 | + if (!$commentTasks) { | |
| 977 | + return; | |
| 849 | 978 | } |
| 850 | 979 | |
| 851 | - $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(); | |
| 852 | 987 | |
| 853 | - 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 | + } | |
| 854 | 1008 | |
| 855 | - //task assignees watchers removed | |
| 856 | - $task->watchers()->detach(); | |
| 857 | - $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 | + } | |
| 858 | 1016 | |
| 859 | - //removing all task related notifications | |
| 860 | - $notificationIds = $task->notifications->pluck('id'); | |
| 861 | - $task->notifications()->delete(); | |
| 862 | - 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 | + } | |
| 863 | 1031 | |
| 864 | - //task labels removed | |
| 865 | - $task->labels()->detach(); | |
| 866 | - | |
| 867 | - //task custom field value | |
| 868 | - if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 869 | - $task->customFields()->detach(); | |
| 870 | - $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 | + } | |
| 871 | 1052 | } |
| 1053 | + }); | |
| 1054 | + } | |
| 872 | 1055 | |
| 873 | - // For bulk delete, you might want to avoid firing hooks/actions, | |
| 874 | - // so 'fluent_boards/task_deleted' is not triggered here. | |
| 875 | - 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; | |
| 876 | 1062 | } |
| 1063 | + | |
| 1064 | + $this->deleteTask($task); | |
| 877 | 1065 | } |
| 878 | 1066 | // this is invoked when task is moved to another board |
| 879 | 1067 | |
| 880 | 1068 | /** |
| @@ -911,8 +1099,9 @@ | ||
| 911 | 1099 | $dbInstance->beginTransaction(); |
| 912 | 1100 | |
| 913 | 1101 | try { |
| 914 | 1102 | $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId); |
| 1103 | + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService); | |
| 915 | 1104 | |
| 916 | 1105 | $task->board_id = (int) $targetBoardId; |
| 917 | 1106 | $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task'; |
| 918 | 1107 | |
| @@ -921,19 +1110,16 @@ | ||
| 921 | 1110 | $task->assignees()->detach(); |
| 922 | 1111 | $task->watchers()->detach(); |
| 923 | 1112 | $this->removeCustomFieldAssociations($task); |
| 924 | 1113 | |
| 925 | - // REMOVE: User-specific data to prevent security issues | |
| 926 | - $this->removeCommentsAndReplies($task->id); | |
| 927 | - $this->removeTimeTrackingRecords($task->id); | |
| 928 | - | |
| 929 | 1114 | // REMOVE: Recurring task settings for security |
| 930 | 1115 | $this->removeRecurringTaskSettings($task->id); |
| 931 | 1116 | |
| 932 | 1117 | $task->save(); |
| 1118 | + do_action('fluent_boards/task_moved_update_time_tracking', $task); | |
| 933 | 1119 | |
| 934 | 1120 | // MOVE: Subtasks to new board (preserves subtask groups) |
| 935 | - $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type, $attachmentFileService); | |
| 1121 | + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService); | |
| 936 | 1122 | |
| 937 | 1123 | $dbInstance->commit(); |
| 938 | 1124 | $attachmentFileService->commitMovedOriginalFiles(); |
| 939 | 1125 | } catch (\Exception $e) { |
| @@ -949,9 +1135,9 @@ | ||
| 949 | 1135 | /** |
| 950 | 1136 | * Move all subtasks to the new board when parent task is moved |
| 951 | 1137 | * Preserves subtask groups and their relationships |
| 952 | 1138 | */ |
| 953 | - private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService) | |
| 1139 | + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService) | |
| 954 | 1140 | { |
| 955 | 1141 | // Get all subtasks of the parent task |
| 956 | 1142 | $subtasks = Task::where('parent_id', $parentTaskId)->get(); |
| 957 | 1143 | |
| @@ -960,10 +1146,12 @@ | ||
| 960 | 1146 | } |
| 961 | 1147 | |
| 962 | 1148 | foreach ($subtasks as $subtask) { |
| 963 | 1149 | // Update board_id and type |
| 964 | - $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); | |
| 965 | 1152 | $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId); |
| 1153 | + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService); | |
| 966 | 1154 | |
| 967 | 1155 | $subtask->board_id = (int) $targetBoardId; |
| 968 | 1156 | $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task'; |
| 969 | 1157 | |
| @@ -976,16 +1164,13 @@ | ||
| 976 | 1164 | $subtask->taskMeta() |
| 977 | 1165 | ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD) |
| 978 | 1166 | ->delete(); |
| 979 | 1167 | |
| 980 | - // REMOVE: User-specific data for security | |
| 981 | - $this->removeCommentsAndReplies($subtask->id); | |
| 982 | - $this->removeTimeTrackingRecords($subtask->id); | |
| 983 | - | |
| 984 | 1168 | // REMOVE: Recurring task settings |
| 985 | 1169 | $this->removeRecurringTaskSettings($subtask->id); |
| 986 | 1170 | |
| 987 | 1171 | $subtask->save(); |
| 1172 | + do_action('fluent_boards/task_moved_update_time_tracking', $subtask); | |
| 988 | 1173 | } |
| 989 | 1174 | } |
| 990 | 1175 | |
| 991 | 1176 | /** |
| @@ -1031,24 +1216,26 @@ | ||
| 1031 | 1216 | } |
| 1032 | 1217 | } |
| 1033 | 1218 | |
| 1034 | 1219 | /** |
| 1035 | - * Remove comments and replies for security reasons | |
| 1036 | - * Prevents exposing user-specific data to unauthorized users | |
| 1220 | + * Move a task's complete comment history and images to another board. | |
| 1037 | 1221 | */ |
| 1038 | - private function removeCommentsAndReplies($taskId) | |
| 1222 | + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService) | |
| 1039 | 1223 | { |
| 1040 | - // Input validation | |
| 1041 | - 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) { | |
| 1042 | 1229 | return; |
| 1043 | 1230 | } |
| 1044 | - | |
| 1045 | - // Remove all comments and replies for this task (delete individually to fire model events and clean up images) | |
| 1046 | - $comments = Comment::where('task_id', (int) $taskId)->get(); | |
| 1047 | - foreach ($comments as $comment) { | |
| 1048 | - $comment->delete(); | |
| 1049 | - } | |
| 1050 | 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]); | |
| 1051 | 1238 | } |
| 1052 | 1239 | |
| 1053 | 1240 | /** |
| 1054 | 1241 | * Remove time tracking records for security reasons |
| @@ -1313,8 +1500,9 @@ | ||
| 1313 | 1500 | |
| 1314 | 1501 | return $tasks; |
| 1315 | 1502 | } |
| 1316 | 1503 | |
| 1504 | + /** Fetch a page of board tasks, applying filters before pagination. */ | |
| 1317 | 1505 | public function getTableTasks($boardId, $data = []) |
| 1318 | 1506 | { |
| 1319 | 1507 | $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20; |
| 1320 | 1508 | $page = isset($data['page']) ? intval($data['page']) : 1; |
| @@ -1322,8 +1510,10 @@ | ||
| 1322 | 1510 | $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc'; |
| 1323 | 1511 | $search = isset($data['search']) ? sanitize_text_field($data['search']) : ''; |
| 1324 | 1512 | $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', [])); |
| 1325 | 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', [])); | |
| 1326 | 1516 | $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true); |
| 1327 | 1517 | $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', [])); |
| 1328 | 1518 | $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', [])); |
| 1329 | 1519 | $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', [])); |
| @@ -1360,8 +1550,9 @@ | ||
| 1360 | 1550 | 'title', |
| 1361 | 1551 | 'slug', |
| 1362 | 1552 | 'board_id', |
| 1363 | 1553 | 'parent_id', |
| 1554 | + 'crm_contact_id', | |
| 1364 | 1555 | 'type', |
| 1365 | 1556 | 'stage_id', |
| 1366 | 1557 | 'status', |
| 1367 | 1558 | 'priority', |
| @@ -1411,8 +1602,10 @@ | ||
| 1411 | 1602 | $this->applyTableTaskSearch($tasksQuery, $search); |
| 1412 | 1603 | $this->applyTableTaskFilters($tasksQuery, [ |
| 1413 | 1604 | 'stage' => $stageFilters, |
| 1414 | 1605 | 'task_status' => $taskStatusFilters, |
| 1606 | + 'recurring' => $recurringFilters, | |
| 1607 | + 'template' => $templateFilters, | |
| 1415 | 1608 | 'priority' => $priorityFilters, |
| 1416 | 1609 | 'assignee' => $assigneeFilters, |
| 1417 | 1610 | 'labels' => $labelFilters, |
| 1418 | 1611 | 'watchers' => $watcherFilters, |
| @@ -1429,13 +1622,16 @@ | ||
| 1429 | 1622 | ->orderBy($sortColumn, $sortDirection) |
| 1430 | 1623 | ->paginate($perPage, ['*'], 'page', $page); |
| 1431 | 1624 | } |
| 1432 | 1625 | |
| 1626 | + /** Fetch matching board cards with the same filters as table view. */ | |
| 1433 | 1627 | public function getBoardViewTasks($boardId, $data = []) |
| 1434 | 1628 | { |
| 1435 | 1629 | $search = isset($data['search']) ? sanitize_text_field($data['search']) : ''; |
| 1436 | 1630 | $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', [])); |
| 1437 | 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', [])); | |
| 1438 | 1634 | $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true); |
| 1439 | 1635 | $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', [])); |
| 1440 | 1636 | $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', [])); |
| 1441 | 1637 | $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', [])); |
| @@ -1483,8 +1679,10 @@ | ||
| 1483 | 1679 | $this->applyTableTaskSearch($tasksQuery, $search); |
| 1484 | 1680 | $this->applyTableTaskFilters($tasksQuery, [ |
| 1485 | 1681 | 'stage' => $stageFilters, |
| 1486 | 1682 | 'task_status' => $taskStatusFilters, |
| 1683 | + 'recurring' => $recurringFilters, | |
| 1684 | + 'template' => $templateFilters, | |
| 1487 | 1685 | 'priority' => $priorityFilters, |
| 1488 | 1686 | 'assignee' => $assigneeFilters, |
| 1489 | 1687 | 'labels' => $labelFilters, |
| 1490 | 1688 | 'watchers' => $watcherFilters, |
| @@ -1534,8 +1732,9 @@ | ||
| 1534 | 1732 | $escapedSearch = $wpdb->esc_like($search); |
| 1535 | 1733 | $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%'); |
| 1536 | 1734 | } |
| 1537 | 1735 | |
| 1736 | + /** Apply board filters to table and card queries, gating Pro-only options. */ | |
| 1538 | 1737 | private function applyTableTaskFilters($tasksQuery, $filters) |
| 1539 | 1738 | { |
| 1540 | 1739 | $stageFilters = Arr::get($filters, 'stage', []); |
| 1541 | 1740 | $taskStatusFilters = Arr::get($filters, 'task_status', []); |
| @@ -1546,8 +1745,22 @@ | ||
| 1546 | 1745 | $contactFilters = Arr::get($filters, 'contact', []); |
| 1547 | 1746 | $customFieldFilters = Arr::get($filters, 'custom_fields', []); |
| 1548 | 1747 | $dueDateFilters = Arr::get($filters, 'due_date', []); |
| 1549 | 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 | + | |
| 1550 | 1763 | if ($stageFilters) { |
| 1551 | 1764 | $this->applyTableStageFilters($tasksQuery, $stageFilters); |
| 1552 | 1765 | } |
| 1553 | 1766 | |
| @@ -1786,8 +1999,12 @@ | ||
| 1786 | 1999 | } |
| 1787 | 2000 | |
| 1788 | 2001 | public function sendMailAfterTaskModify($column, $assigneeIds, $taskId) |
| 1789 | 2002 | { |
| 2003 | + if (empty($assigneeIds)) { | |
| 2004 | + return; | |
| 2005 | + } | |
| 2006 | + | |
| 1790 | 2007 | $current_user_id = get_current_user_id(); |
| 1791 | 2008 | /* this will run in background as soon as possible */ |
| 1792 | 2009 | /* sending Model or Model Instance won't work here */ |
| 1793 | 2010 | |
| @@ -1851,8 +2068,9 @@ | ||
| 1851 | 2068 | } |
| 1852 | 2069 | |
| 1853 | 2070 | public function getActivities($taskId, $perPage, $filter = 'newest') |
| 1854 | 2071 | { |
| 2072 | + $perPage = Sanitize::perPage($perPage); | |
| 1855 | 2073 | $activityQuery = Activity::where('object_id', $taskId) |
| 1856 | 2074 | ->where('object_type', Constant::ACTIVITY_TASK); |
| 1857 | 2075 | if ($filter == 'newest') { |
| 1858 | 2076 | $activityQuery = $activityQuery->latest(); |
| @@ -2015,11 +2233,14 @@ | ||
| 2015 | 2233 | |
| 2016 | 2234 | return $subtaskGroupMap; |
| 2017 | 2235 | } |
| 2018 | 2236 | |
| 2019 | - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no') | |
| 2237 | + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = []) | |
| 2020 | 2238 | { |
| 2021 | - $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(); | |
| 2022 | 2243 | $taskMap = []; |
| 2023 | 2244 | $subtaskGroupMap = []; |
| 2024 | 2245 | $parentTaskCount = 0; |
| 2025 | 2246 | $attachmentFileService = new AttachmentFileService(); |
| @@ -2086,8 +2307,9 @@ | ||
| 2086 | 2307 | 'value' => $isTemplate->value |
| 2087 | 2308 | ]); |
| 2088 | 2309 | } |
| 2089 | 2310 | } |
| 2311 | + $this->copyTaskCustomFields($task, $newTask, $customFieldMap); | |
| 2090 | 2312 | if(!$task->parent_id){ |
| 2091 | 2313 | ++$parentTaskCount; |
| 2092 | 2314 | $taskMap[$task['id']] = $newTask->id; |
| 2093 | 2315 | //duplicate labels to task |
| @@ -2116,8 +2338,31 @@ | ||
| 2116 | 2338 | throw $e; |
| 2117 | 2339 | } |
| 2118 | 2340 | } |
| 2119 | 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 | + | |
| 2120 | 2365 | private function subtaskCountUpdate($taskId){ |
| 2121 | 2366 | $parentTask = Task::findOrFail($taskId); |
| 2122 | 2367 | $settings = $parentTask->settings; |
| 2123 | 2368 | $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1; |
| @@ -2135,81 +2380,102 @@ | ||
| 2135 | 2380 | * @return array |
| 2136 | 2381 | */ |
| 2137 | 2382 | public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array |
| 2138 | 2383 | { |
| 2384 | + $perPage = Sanitize::perPage($perPage); | |
| 2385 | + $page = max(1, (int) $page); | |
| 2139 | 2386 | // Fetch the task |
| 2140 | 2387 | $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId); |
| 2141 | 2388 | $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all'; |
| 2142 | 2389 | |
| 2143 | - // Fetch comments and activities separately | |
| 2144 | - $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; | |
| 2145 | 2406 | if ($feedType !== 'activities') { |
| 2146 | - $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 | + } | |
| 2147 | 2411 | } |
| 2148 | - | |
| 2149 | - $activities = []; | |
| 2150 | 2412 | if ($feedType !== 'comments') { |
| 2151 | - $activities = $task->activities() | |
| 2152 | - ->with('user') | |
| 2153 | - ->where(function($query) { | |
| 2154 | - $query->whereNotIn('column', [ 'comment', 'a reply']) | |
| 2155 | - ->orWhere(function($subQuery) { | |
| 2156 | - $subQuery->whereNotIn('action', ['added', 'updated']); | |
| 2157 | - }); | |
| 2158 | - }) | |
| 2159 | - ->orderBy('created_at', 'desc') | |
| 2160 | - ->get() | |
| 2161 | - ->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 | + } | |
| 2162 | 2417 | } |
| 2163 | 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); | |
| 2164 | 2425 | |
| 2165 | - // Merge comments and activities into a single array | |
| 2166 | - $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 | + } | |
| 2167 | 2438 | |
| 2168 | - // Sort the merged array by created_at date in ascending or descending order | |
| 2169 | - $order = $filter == 'newest' ? -1 : 1; | |
| 2170 | - usort($commentsAndActivities, function ($a, $b) use ($order) { | |
| 2171 | - return $order * (strtotime($a['created_at']) - strtotime($b['created_at'])); | |
| 2172 | - }); | |
| 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 | + } | |
| 2173 | 2445 | |
| 2174 | - // Paginate the results | |
| 2175 | - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array | |
| 2176 | - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage); | |
| 2177 | - | |
| 2178 | - // Get the total count of comments and activities | |
| 2179 | - $total = count($commentsAndActivities); | |
| 2180 | 2446 | $lastPage = (int) ceil($total / $perPage); |
| 2181 | 2447 | |
| 2182 | 2448 | // Construct pagination metadata |
| 2183 | - $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"); | |
| 2184 | 2450 | return [ |
| 2185 | 2451 | 'current_page' => (int) $page, |
| 2186 | 2452 | 'data' => $paginatedResults, |
| 2187 | - 'first_page_url' => "{$path}?page=1", | |
| 2453 | + 'first_page_url' => add_query_arg('page', 1, $path), | |
| 2188 | 2454 | 'from' => $total > 0 ? (int) ($offset + 1) : null, |
| 2189 | 2455 | 'last_page' => (int) $lastPage, |
| 2190 | - 'last_page_url' => "{$path}?page={$lastPage}", | |
| 2456 | + 'last_page_url' => add_query_arg('page', $lastPage, $path), | |
| 2191 | 2457 | 'links' => [ |
| 2192 | 2458 | [ |
| 2193 | - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null, | |
| 2459 | + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null, | |
| 2194 | 2460 | 'label' => 'pagination.previous', |
| 2195 | 2461 | 'active' => false |
| 2196 | 2462 | ], |
| 2197 | 2463 | [ |
| 2198 | - 'url' => "{$path}?page={$page}", | |
| 2464 | + 'url' => add_query_arg('page', $page, $path), | |
| 2199 | 2465 | 'label' => (int) $page, |
| 2200 | 2466 | 'active' => true |
| 2201 | 2467 | ], |
| 2202 | 2468 | [ |
| 2203 | - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null, | |
| 2469 | + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null, | |
| 2204 | 2470 | 'label' => 'pagination.next', |
| 2205 | 2471 | 'active' => false |
| 2206 | 2472 | ] |
| 2207 | 2473 | ], |
| 2208 | - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null, | |
| 2474 | + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null, | |
| 2209 | 2475 | 'path' => $path, |
| 2210 | 2476 | 'per_page' => (int) $perPage, |
| 2211 | - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null, | |
| 2477 | + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null, | |
| 2212 | 2478 | 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null, |
| 2213 | 2479 | 'total' => (int) $total |
| 2214 | 2480 | ]; |
| 2215 | 2481 | } |
| @@ -2264,11 +2530,15 @@ | ||
| 2264 | 2530 | } |
| 2265 | 2531 | return $title ?? $UrlMeta['title'] ?? ''; |
| 2266 | 2532 | } |
| 2267 | 2533 | |
| 2268 | - 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) | |
| 2269 | 2536 | { |
| 2270 | - $stage = Stage::findOrFail($stageId); | |
| 2537 | + if (!$stage || (int) $stage->id !== (int) $stageId) { | |
| 2538 | + $stage = Stage::findOrFail($stageId); | |
| 2539 | + } | |
| 2540 | + $assignmentsChanged = false; | |
| 2271 | 2541 | if ($stage && isset($stage->settings['default_task_assignees'])) { |
| 2272 | 2542 | $defaultAssignees = $stage->settings['default_task_assignees']; |
| 2273 | 2543 | foreach ($defaultAssignees as $assigneeId) { |
| 2274 | 2544 | $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray(); |
| @@ -2274,11 +2544,13 @@ | ||
| 2274 | 2544 | $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray(); |
| 2275 | 2545 | $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds); |
| 2276 | 2546 | if (!$IfAlreadyAssignee) { |
| 2277 | 2547 | $this->updateAssignee($assigneeId, $task); |
| 2548 | + $assignmentsChanged = true; | |
| 2278 | 2549 | } |
| 2279 | 2550 | } |
| 2280 | 2551 | } |
| 2552 | + return $assignmentsChanged; | |
| 2281 | 2553 | } |
| 2282 | 2554 | |
| 2283 | 2555 | public function manageDefaultWatchers($task, $stageId) |
| 2284 | 2556 | { |
| @@ -2396,12 +2668,38 @@ | ||
| 2396 | 2668 | foreach ($attachments as $attachment) { |
| 2397 | 2669 | $deletedAttachment = clone $attachment; |
| 2398 | 2670 | $attachment->delete(); |
| 2399 | 2671 | |
| 2400 | - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment); | |
| 2672 | + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id); | |
| 2401 | 2673 | } |
| 2402 | 2674 | } |
| 2403 | 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 | + | |
| 2404 | 2702 | public function cloneTask(int $taskId, $taskData, $boardId = null): Task |
| 2405 | 2703 | { |
| 2406 | 2704 | global $wpdb; |
| 2407 | 2705 | $attachmentFileService = new AttachmentFileService(); |
| @@ -2428,9 +2726,10 @@ | ||
| 2428 | 2726 | } |
| 2429 | 2727 | |
| 2430 | 2728 | // Create new task with cloned data |
| 2431 | 2729 | $clonedTask = $task->replicate(); |
| 2432 | - $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); | |
| 2433 | 2732 | |
| 2434 | 2733 | $settings = $clonedTask->settings ?? []; |
| 2435 | 2734 | |
| 2436 | 2735 | unset( |
| @@ -2507,9 +2806,10 @@ | ||
| 2507 | 2806 | $attachmentFileService->rollbackCreatedFiles(); |
| 2508 | 2807 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation |
| 2509 | 2808 | $wpdb->query('ROLLBACK'); |
| 2510 | 2809 | throw new \Exception( |
| 2511 | - 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())), | |
| 2512 | 2812 | (int) ($e->getCode() ?: 500) |
| 2513 | 2813 | ); |
| 2514 | 2814 | } |
| 2515 | 2815 | } |
| @@ -2655,8 +2955,14 @@ | ||
| 2655 | 2955 | if ($images->count() > 0) { |
| 2656 | 2956 | foreach ($images as $image) { |
| 2657 | 2957 | $clonedImage = $image->replicate(); |
| 2658 | 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 | + ); | |
| 2659 | 2965 | $clonedImage->save(); |
| 2660 | 2966 | } |
| 2661 | 2967 | } |
| 2662 | 2968 | } |
| @@ -2868,8 +3174,9 @@ | ||
| 2868 | 3174 | |
| 2869 | 3175 | $successfulTasks = []; |
| 2870 | 3176 | |
| 2871 | 3177 | foreach ($tasks as $task) { |
| 3178 | + $oldStageId = $task->stage_id; | |
| 2872 | 3179 | if ($isMovingToAnotherBoard) { |
| 2873 | 3180 | // Cross-board move - use existing method for data cleanup and security |
| 2874 | 3181 | $task = $this->changeBoardByTask($task, $effectiveTargetBoardId); |
| 2875 | 3182 | $task->stage_id = $targetStageId; |
| @@ -2874,33 +3181,32 @@ | ||
| 2874 | 3181 | $task = $this->changeBoardByTask($task, $effectiveTargetBoardId); |
| 2875 | 3182 | $task->stage_id = $targetStageId; |
| 2876 | 3183 | $task = $task->moveToNewPosition(null); |
| 2877 | 3184 | } else { |
| 2878 | - // Same board - simple stage move | |
| 2879 | - $oldStageId = $task->stage_id; | |
| 2880 | 3185 | $task->stage_id = $targetStageId; |
| 2881 | 3186 | $task = $task->moveToNewPosition(1); |
| 2882 | - | |
| 2883 | - // Only process stage-specific logic if stage actually changed | |
| 2884 | - if ($oldStageId != $targetStageId) { | |
| 2885 | - $this->manageDefaultAssignees($task, $targetStageId); | |
| 2886 | - | |
| 2887 | - $defaultPosition = $task->stage->defaultTaskStatus(); | |
| 2888 | - if ($defaultPosition == 'closed' && $task->status != 'closed') { | |
| 2889 | - $task = $task->close(); | |
| 2890 | - } | |
| 2891 | - | |
| 2892 | - $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE); | |
| 2893 | - $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(); | |
| 2894 | 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); | |
| 2895 | 3204 | } |
| 2896 | - | |
| 2897 | - // Reload task with all relationships | |
| 2898 | - $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']); | |
| 2899 | - | |
| 3205 | + | |
| 2900 | 3206 | $successfulTasks[] = $task; |
| 2901 | 3207 | } |
| 2902 | - | |
| 3208 | + | |
| 2903 | 3209 | $successCount = count($successfulTasks); |
| 2904 | 3210 | |
| 2905 | 3211 | if ($isMovingToAnotherBoard) { |
| 2906 | 3212 | // translators: %d is the number of tasks successfully moved to another board |
| @@ -3241,35 +3547,50 @@ | ||
| 3241 | 3547 | 'message' => $message |
| 3242 | 3548 | ]; |
| 3243 | 3549 | } |
| 3244 | 3550 | |
| 3245 | - /* Delete time tracking records for one or multiple tasks | |
| 3246 | - * 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. | |
| 3247 | 3553 | * |
| 3248 | - * @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. | |
| 3249 | 3556 | * @return void |
| 3250 | 3557 | */ |
| 3251 | - public function deleteTimeTrackingRecords($taskIds) | |
| 3558 | + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true) | |
| 3252 | 3559 | { |
| 3253 | - // Check if FluentBoards Pro time tracking is available | |
| 3254 | - if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) { | |
| 3560 | + if (!class_exists(TimeTrack::class)) { | |
| 3255 | 3561 | return; |
| 3256 | 3562 | } |
| 3257 | 3563 | |
| 3258 | 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 | + | |
| 3259 | 3579 | // Handle single task ID or array of task IDs |
| 3260 | 3580 | if (is_array($taskIds)) { |
| 3261 | 3581 | if (!empty($taskIds)) { |
| 3262 | - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete(); | |
| 3582 | + TimeTrack::whereIn('task_id', $taskIds)->delete(); | |
| 3263 | 3583 | } |
| 3264 | 3584 | } else { |
| 3265 | 3585 | if (is_numeric($taskIds) && $taskIds > 0) { |
| 3266 | - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete(); | |
| 3586 | + TimeTrack::where('task_id', (int) $taskIds)->delete(); | |
| 3267 | 3587 | } |
| 3268 | 3588 | } |
| 3269 | - } catch (\Exception $e) { | |
| 3270 | - // Silently fail if table doesn't exist or any other error occurs | |
| 3271 | - // This is intentional for cleanup operations | |
| 3589 | + } catch (\Throwable $e) { | |
| 3590 | + if (!$suppressErrors) { | |
| 3591 | + throw $e; | |
| 3592 | + } | |
| 3272 | 3593 | } |
| 3273 | 3594 | } |
| 3274 | 3595 | |
| 3275 | 3596 | } |