| @@ -1,11 +1,13 @@ | ||
| 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; |
| 6 | 7 | use FluentBoards\App\Models\Attachment; |
| 7 | 8 | use FluentBoards\App\Models\Comment; |
| 9 | +use FluentBoards\App\Models\Notification; | |
| 8 | 10 | use FluentBoards\App\Models\NotificationUser; |
| 9 | 11 | use FluentBoards\App\Models\TaskImage; |
| 10 | 12 | use FluentBoards\App\Services\Constant; |
| 11 | 13 | use FluentBoards\App\Models\Label; |
| @@ -31,14 +33,15 @@ | ||
| 31 | 33 | * Resolve a task only when it belongs to the requested board. |
| 32 | 34 | * |
| 33 | 35 | * Subtasks normally carry the same board_id as their parent, but the parent |
| 34 | 36 | * fallback protects older data where that relationship may be incomplete. |
| 37 | + * Missing or mismatched tasks use the router's deliberate 404 response. | |
| 35 | 38 | * |
| 36 | 39 | * @param int $taskId |
| 37 | 40 | * @param int $boardId |
| 38 | 41 | * @param bool $allowParentFallback |
| 39 | 42 | * @return Task |
| 40 | - * @throws \Exception | |
| 43 | + * @throws ModelNotFoundException | |
| 41 | 44 | */ |
| 42 | 45 | public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true) |
| 43 | 46 | { |
| 44 | 47 | $taskId = absint($taskId); |
| @@ -44,9 +47,9 @@ | ||
| 44 | 47 | $taskId = absint($taskId); |
| 45 | 48 | $boardId = absint($boardId); |
| 46 | 49 | |
| 47 | 50 | if (!$taskId || !$boardId) { |
| 48 | - throw new \Exception(esc_html__('Task not found', 'fluent-boards')); | |
| 51 | + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards')); | |
| 49 | 52 | } |
| 50 | 53 | |
| 51 | 54 | $task = Task::where('id', $taskId) |
| 52 | 55 | ->where('board_id', $boardId) |
| @@ -70,9 +73,9 @@ | ||
| 70 | 73 | } |
| 71 | 74 | } |
| 72 | 75 | } |
| 73 | 76 | |
| 74 | - throw new \Exception(esc_html__('Task not found', 'fluent-boards')); | |
| 77 | + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards')); | |
| 75 | 78 | } |
| 76 | 79 | |
| 77 | 80 | private function normalizeTaskDescriptionForEditor(Task $task) |
| 78 | 81 | { |
| @@ -786,42 +789,136 @@ | ||
| 786 | 789 | $this->deleteTask($subtask); |
| 787 | 790 | } |
| 788 | 791 | } |
| 789 | 792 | |
| 793 | + $this->deleteTasksBatch([$task]); | |
| 794 | + } | |
| 795 | + | |
| 796 | + /** | |
| 797 | + * Delete the supplied tasks and their owned records without discovering children. | |
| 798 | + * | |
| 799 | + * @param iterable $tasks | |
| 800 | + * @param bool $manageTransaction Set false only when the caller owns an active transaction. | |
| 801 | + * @return void | |
| 802 | + * @throws \Throwable | |
| 803 | + */ | |
| 804 | + public function deleteTasksBatch($tasks, $manageTransaction = true) | |
| 805 | + { | |
| 806 | + if (!is_array($tasks) && !($tasks instanceof \Traversable)) { | |
| 807 | + $tasks = [$tasks]; | |
| 808 | + } | |
| 809 | + | |
| 810 | + $deletedTasks = []; | |
| 811 | + $taskBoardIds = []; | |
| 812 | + foreach ($tasks as $task) { | |
| 813 | + if (!$task instanceof Task) { | |
| 814 | + continue; | |
| 815 | + } | |
| 816 | + | |
| 817 | + $taskId = (int) $task->id; | |
| 818 | + if ($taskId < 1) { | |
| 819 | + continue; | |
| 820 | + } | |
| 821 | + | |
| 822 | + $deletedTasks[$taskId] = clone $task; | |
| 823 | + $taskBoardIds[$taskId] = (int) $task->board_id; | |
| 824 | + } | |
| 825 | + | |
| 826 | + if (!$deletedTasks) { | |
| 827 | + return; | |
| 828 | + } | |
| 829 | + | |
| 830 | + ksort($deletedTasks, SORT_NUMERIC); | |
| 831 | + $taskIds = array_keys($deletedTasks); | |
| 790 | 832 | $dbInstance = App::getInstance('db'); |
| 791 | - $dbInstance->beginTransaction(); | |
| 792 | 833 | |
| 793 | - try { | |
| 794 | - $deletedTask = clone $task; | |
| 795 | - $task->watchers()->detach(); | |
| 796 | - $task->assignees()->detach(); | |
| 834 | + if (!$manageTransaction && !$dbInstance->inTransaction()) { | |
| 835 | + throw new \RuntimeException(__('An active transaction is required for caller-managed task deletion.', 'fluent-boards')); | |
| 836 | + } | |
| 797 | 837 | |
| 798 | - $notificationIds = $task->notifications->pluck('id'); | |
| 799 | - $task->notifications()->delete(); | |
| 800 | - NotificationUser::whereIn('notification_id', $notificationIds)->delete(); | |
| 838 | + if ($manageTransaction) { | |
| 839 | + $dbInstance->beginTransaction(); | |
| 840 | + } | |
| 801 | 841 | |
| 802 | - $task->labels()->detach(); | |
| 842 | + try { | |
| 843 | + $relationTypes = [ | |
| 844 | + Constant::OBJECT_TYPE_USER_TASK_WATCH, | |
| 845 | + Constant::OBJECT_TYPE_TASK_ASSIGNEE, | |
| 846 | + Constant::OBJECT_TYPE_TASK_LABEL, | |
| 847 | + ]; | |
| 803 | 848 | |
| 804 | 849 | if (defined('FLUENT_BOARDS_PRO_VERSION')) { |
| 805 | - $task->customFields()->detach(); | |
| 806 | - $this->deleteTaskAttachments($task); | |
| 850 | + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD; | |
| 807 | 851 | } |
| 808 | 852 | |
| 809 | - $this->deleteTimeTrackingRecords($task->id, false); | |
| 810 | - TaskMeta::where('task_id', $task->id)->delete(); | |
| 853 | + Relation::whereIn('object_id', $taskIds) | |
| 854 | + ->whereIn('object_type', $relationTypes) | |
| 855 | + ->delete(); | |
| 811 | 856 | |
| 812 | - if (!$task->delete()) { | |
| 857 | + Relation::where('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY) | |
| 858 | + ->where(function ($query) use ($taskIds) { | |
| 859 | + $query->whereIn('object_id', $taskIds) | |
| 860 | + ->orWhereIn('foreign_id', $taskIds); | |
| 861 | + }) | |
| 862 | + ->delete(); | |
| 863 | + | |
| 864 | + $notificationIds = Notification::whereIn('task_id', $taskIds)->pluck('id')->toArray(); | |
| 865 | + NotificationUser::whereIn('notification_id', $notificationIds)->delete(); | |
| 866 | + Notification::whereIn('task_id', $taskIds)->delete(); | |
| 867 | + | |
| 868 | + $this->deleteTaskAttachmentsBatch($taskIds, $taskBoardIds); | |
| 869 | + Activity::whereIn('object_id', $taskIds) | |
| 870 | + ->where('object_type', Constant::ACTIVITY_TASK) | |
| 871 | + ->delete(); | |
| 872 | + Meta::whereIn('object_id', $taskIds) | |
| 873 | + ->where('object_type', Constant::REPEAT_TASK_META) | |
| 874 | + ->delete(); | |
| 875 | + $this->deleteTimeTrackingRecords($taskIds, false); | |
| 876 | + TaskMeta::whereIn('task_id', $taskIds)->delete(); | |
| 877 | + | |
| 878 | + $deletedCount = Task::whereIn('id', $taskIds)->delete(); | |
| 879 | + if ($deletedCount !== count($taskIds)) { | |
| 813 | 880 | throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards')); |
| 814 | 881 | } |
| 815 | 882 | |
| 816 | - do_action('fluent_boards/task_deleted', $deletedTask); | |
| 817 | - $dbInstance->commit(); | |
| 883 | + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks); | |
| 884 | + | |
| 885 | + if ($manageTransaction) { | |
| 886 | + $dbInstance->commit(); | |
| 887 | + } | |
| 818 | 888 | } catch (\Throwable $e) { |
| 819 | - $dbInstance->rollBack(); | |
| 820 | - throw $e; // Re-throw the exception after rolling back | |
| 889 | + if ($manageTransaction) { | |
| 890 | + $dbInstance->rollBack(); | |
| 891 | + } | |
| 892 | + | |
| 893 | + throw $e; | |
| 821 | 894 | } |
| 895 | + } | |
| 822 | 896 | |
| 897 | + /** | |
| 898 | + * Dispatch task deletion hooks after the outermost transaction commits. | |
| 899 | + * | |
| 900 | + * @param mixed $dbInstance | |
| 901 | + * @param array $deletedTasks | |
| 902 | + * @return void | |
| 903 | + */ | |
| 904 | + private function dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks) | |
| 905 | + { | |
| 906 | + $dbInstance->afterCommit(function () use ($deletedTasks) { | |
| 907 | + foreach ($deletedTasks as $deletedTask) { | |
| 908 | + try { | |
| 909 | + do_action('fluent_boards/task_deleted', $deletedTask); | |
| 910 | + } catch (\Throwable $e) { | |
| 911 | + error_log(sprintf( | |
| 912 | + 'FluentBoards: Failed to dispatch committed task deletion hook for task %d: %s', | |
| 913 | + (int) $deletedTask->id, | |
| 914 | + sanitize_text_field($e->getMessage()) | |
| 915 | + )); | |
| 916 | + } | |
| 917 | + } | |
| 918 | + }); | |
| 823 | 919 | } |
| 920 | + | |
| 824 | 921 | public function deleteTaskForBulk($task) |
| 825 | 922 | { |
| 826 | 923 | // If this is a parent task, delete all subtasks first |
| 827 | 924 | if (!$task->parent_id) { |
| @@ -831,8 +928,10 @@ | ||
| 831 | 928 | $this->deleteTaskForBulk($subtask); |
| 832 | 929 | } |
| 833 | 930 | } |
| 834 | 931 | |
| 932 | + $this->deleteTimeTrackingRecords($task->id, false); | |
| 933 | + | |
| 835 | 934 | $deleted = $task->delete(); |
| 836 | 935 | |
| 837 | 936 | if ($deleted) { |
| 838 | 937 | |
| @@ -848,9 +947,9 @@ | ||
| 848 | 947 | //task labels removed |
| 849 | 948 | $task->labels()->detach(); |
| 850 | 949 | |
| 851 | 950 | //task custom field value |
| 852 | - if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 951 | + if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 853 | 952 | $task->customFields()->detach(); |
| 854 | 953 | $this->deleteTaskAttachments($task); |
| 855 | 954 | } |
| 856 | 955 | |
| @@ -2125,9 +2224,9 @@ | ||
| 2125 | 2224 | |
| 2126 | 2225 | // Fetch comments and activities separately |
| 2127 | 2226 | $comments = []; |
| 2128 | 2227 | if ($feedType !== 'activities') { |
| 2129 | - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray(); | |
| 2228 | + $comments = $task->comments()->with(['user', 'replies.user'])->orderBy('created_at', 'desc')->get()->toArray(); | |
| 2130 | 2229 | } |
| 2131 | 2230 | |
| 2132 | 2231 | $activities = []; |
| 2133 | 2232 | if ($feedType !== 'comments') { |
| @@ -2379,12 +2478,38 @@ | ||
| 2379 | 2478 | foreach ($attachments as $attachment) { |
| 2380 | 2479 | $deletedAttachment = clone $attachment; |
| 2381 | 2480 | $attachment->delete(); |
| 2382 | 2481 | |
| 2383 | - do_action('fluent_boards/task_attachment_deleted', $deletedAttachment); | |
| 2482 | + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id); | |
| 2384 | 2483 | } |
| 2385 | 2484 | } |
| 2386 | 2485 | |
| 2486 | + /** | |
| 2487 | + * Delete task attachments one at a time so each attachment-deleted hook is preserved. | |
| 2488 | + * | |
| 2489 | + * @param array $taskIds | |
| 2490 | + * @param array $taskBoardIds | |
| 2491 | + * @return void | |
| 2492 | + */ | |
| 2493 | + private function deleteTaskAttachmentsBatch($taskIds, $taskBoardIds) | |
| 2494 | + { | |
| 2495 | + if (!defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 2496 | + return; | |
| 2497 | + } | |
| 2498 | + | |
| 2499 | + $attachments = TaskAttachment::whereIn('object_id', $taskIds) | |
| 2500 | + ->where('object_type', Constant::TASK_ATTACHMENT) | |
| 2501 | + ->get(); | |
| 2502 | + | |
| 2503 | + foreach ($attachments as $attachment) { | |
| 2504 | + $deletedAttachment = clone $attachment; | |
| 2505 | + $attachment->delete(); | |
| 2506 | + $boardId = $taskBoardIds[(int) $attachment->object_id] ?? null; | |
| 2507 | + | |
| 2508 | + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $boardId); | |
| 2509 | + } | |
| 2510 | + } | |
| 2511 | + | |
| 2387 | 2512 | public function cloneTask(int $taskId, $taskData, $boardId = null): Task |
| 2388 | 2513 | { |
| 2389 | 2514 | global $wpdb; |
| 2390 | 2515 | $attachmentFileService = new AttachmentFileService(); |
| @@ -2638,8 +2763,14 @@ | ||
| 2638 | 2763 | if ($images->count() > 0) { |
| 2639 | 2764 | foreach ($images as $image) { |
| 2640 | 2765 | $clonedImage = $image->replicate(); |
| 2641 | 2766 | $clonedImage->object_id = $clonedCommentOrReply->id; |
| 2767 | + (new CommentService())->applyCommentImageScope( | |
| 2768 | + $clonedImage, | |
| 2769 | + $clonedCommentOrReply->board_id, | |
| 2770 | + $clonedCommentOrReply->task_id, | |
| 2771 | + $clonedCommentOrReply->created_by | |
| 2772 | + ); | |
| 2642 | 2773 | $clonedImage->save(); |
| 2643 | 2774 | } |
| 2644 | 2775 | } |
| 2645 | 2776 | } |