| @@ -3,8 +3,9 @@ | ||
| 3 | 3 | namespace FluentBoards\App\Services; |
| 4 | 4 | |
| 5 | 5 | use FluentBoards\Framework\Database\Orm\ModelNotFoundException; |
| 6 | 6 | use FluentBoards\App\App; |
| 7 | +use FluentBoards\App\Hooks\Handlers\FileHandler; | |
| 7 | 8 | use FluentBoards\App\Models\Attachment; |
| 8 | 9 | use FluentBoards\App\Models\Comment; |
| 9 | 10 | use FluentBoards\App\Models\Notification; |
| 10 | 11 | use FluentBoards\App\Models\NotificationUser; |
| @@ -20,8 +21,10 @@ | ||
| 20 | 21 | use FluentBoards\App\Models\CommentImage; |
| 21 | 22 | use FluentBoards\App\Models\Relation; |
| 22 | 23 | use FluentBoards\Framework\Support\Arr; |
| 23 | 24 | use FluentBoardsPro\App\Models\TaskAttachment; |
| 25 | +use FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack; | |
| 26 | +use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper; | |
| 24 | 27 | use FluentBoardsPro\App\Services\AttachmentService; |
| 25 | 28 | use FluentBoardsPro\App\Services\RemoteUrlParser; |
| 26 | 29 | use FluentRoadmap\App\Models\IdeaReaction; |
| 27 | 30 | |
| @@ -28,9 +31,65 @@ | ||
| 28 | 31 | class TaskService |
| 29 | 32 | { |
| 30 | 33 | private static $physicalTableNameCache = []; |
| 31 | 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 | + | |
| 32 | 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 | + /** | |
| 33 | 92 | * Resolve a task only when it belongs to the requested board. |
| 34 | 93 | * |
| 35 | 94 | * Subtasks normally carry the same board_id as their parent, but the parent |
| 36 | 95 | * fallback protects older data where that relationship may be incomplete. |
| @@ -83,8 +142,11 @@ | ||
| 83 | 142 | |
| 84 | 143 | return $task; |
| 85 | 144 | } |
| 86 | 145 | |
| 146 | + /** | |
| 147 | + * Create a board task and notify eligible selected assignees before applying stage defaults. | |
| 148 | + */ | |
| 87 | 149 | public function createTask($data, $boardId) |
| 88 | 150 | { |
| 89 | 151 | $board = Board::select('id', 'type')->find($boardId); |
| 90 | 152 | |
| @@ -141,8 +203,18 @@ | ||
| 141 | 203 | |
| 142 | 204 | $data = array_filter($data); |
| 143 | 205 | $task = (new Task())->createTask($data); |
| 144 | 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 | + | |
| 145 | 217 | $this->manageDefaultAssignees($task, $stage->id); |
| 146 | 218 | $this->manageDefaultWatchers($task, $stage->id); |
| 147 | 219 | |
| 148 | 220 | if (isset($data['is_template']) && $data['is_template'] == 'yes') { |
| @@ -616,10 +688,12 @@ | ||
| 616 | 688 | } |
| 617 | 689 | $task->archived_at = $value == null ? null : current_time('mysql'); |
| 618 | 690 | $task->save(); |
| 619 | 691 | do_action('fluent_boards/task_archived', $task); |
| 620 | - $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE); | |
| 621 | - $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 | + } | |
| 622 | 696 | } |
| 623 | 697 | |
| 624 | 698 | private function updateStatus($value, $task) |
| 625 | 699 | { |
| @@ -865,8 +939,9 @@ | ||
| 865 | 939 | NotificationUser::whereIn('notification_id', $notificationIds)->delete(); |
| 866 | 940 | Notification::whereIn('task_id', $taskIds)->delete(); |
| 867 | 941 | |
| 868 | 942 | $this->deleteTaskAttachmentsBatch($taskIds, $taskBoardIds); |
| 943 | + $this->deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance); | |
| 869 | 944 | Activity::whereIn('object_id', $taskIds) |
| 870 | 945 | ->where('object_type', Constant::ACTIVITY_TASK) |
| 871 | 946 | ->delete(); |
| 872 | 947 | Meta::whereIn('object_id', $taskIds) |
| @@ -893,8 +968,68 @@ | ||
| 893 | 968 | throw $e; |
| 894 | 969 | } |
| 895 | 970 | } |
| 896 | 971 | |
| 972 | + /** Remove comment rows transactionally; file cleanup must wait for commit. */ | |
| 973 | + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance) | |
| 974 | + { | |
| 975 | + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray(); | |
| 976 | + if (!$commentTasks) { | |
| 977 | + return; | |
| 978 | + } | |
| 979 | + | |
| 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(); | |
| 987 | + | |
| 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 | + } | |
| 1008 | + | |
| 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 | + } | |
| 1016 | + | |
| 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 | + } | |
| 1031 | + | |
| 897 | 1032 | /** |
| 898 | 1033 | * Dispatch task deletion hooks after the outermost transaction commits. |
| 899 | 1034 | * |
| 900 | 1035 | * @param mixed $dbInstance |
| @@ -917,47 +1052,17 @@ | ||
| 917 | 1052 | } |
| 918 | 1053 | }); |
| 919 | 1054 | } |
| 920 | 1055 | |
| 1056 | + /** Keep legacy bulk callers on the normal task deletion lifecycle. */ | |
| 921 | 1057 | public function deleteTaskForBulk($task) |
| 922 | 1058 | { |
| 923 | - // If this is a parent task, delete all subtasks first | |
| 924 | - if (!$task->parent_id) { | |
| 925 | - $subtasks = Task::where('parent_id', $task->id)->get(); | |
| 926 | - foreach ($subtasks as $subtask) { | |
| 927 | - // Recursively delete each subtask (cleans up all their relations) | |
| 928 | - $this->deleteTaskForBulk($subtask); | |
| 929 | - } | |
| 1059 | + // A selected subtask may already have been deleted with its parent. | |
| 1060 | + if (!Task::where('id', $task->id)->exists()) { | |
| 1061 | + return; | |
| 930 | 1062 | } |
| 931 | 1063 | |
| 932 | - $this->deleteTimeTrackingRecords($task->id, false); | |
| 933 | - | |
| 934 | - $deleted = $task->delete(); | |
| 935 | - | |
| 936 | - if ($deleted) { | |
| 937 | - | |
| 938 | - //task assignees watchers removed | |
| 939 | - $task->watchers()->detach(); | |
| 940 | - $task->assignees()->detach(); | |
| 941 | - | |
| 942 | - //removing all task related notifications | |
| 943 | - $notificationIds = $task->notifications->pluck('id'); | |
| 944 | - $task->notifications()->delete(); | |
| 945 | - NotificationUser::whereIn('notification_id', $notificationIds)->delete(); | |
| 946 | - | |
| 947 | - //task labels removed | |
| 948 | - $task->labels()->detach(); | |
| 949 | - | |
| 950 | - //task custom field value | |
| 951 | - if (defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 952 | - $task->customFields()->detach(); | |
| 953 | - $this->deleteTaskAttachments($task); | |
| 954 | - } | |
| 955 | - | |
| 956 | - // For bulk delete, you might want to avoid firing hooks/actions, | |
| 957 | - // so 'fluent_boards/task_deleted' is not triggered here. | |
| 958 | - TaskMeta::where('task_id', $task->id)->delete(); | |
| 959 | - } | |
| 1064 | + $this->deleteTask($task); | |
| 960 | 1065 | } |
| 961 | 1066 | // this is invoked when task is moved to another board |
| 962 | 1067 | |
| 963 | 1068 | /** |
| @@ -1395,8 +1500,9 @@ | ||
| 1395 | 1500 | |
| 1396 | 1501 | return $tasks; |
| 1397 | 1502 | } |
| 1398 | 1503 | |
| 1504 | + /** Fetch a page of board tasks, applying filters before pagination. */ | |
| 1399 | 1505 | public function getTableTasks($boardId, $data = []) |
| 1400 | 1506 | { |
| 1401 | 1507 | $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20; |
| 1402 | 1508 | $page = isset($data['page']) ? intval($data['page']) : 1; |
| @@ -1404,8 +1510,10 @@ | ||
| 1404 | 1510 | $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc'; |
| 1405 | 1511 | $search = isset($data['search']) ? sanitize_text_field($data['search']) : ''; |
| 1406 | 1512 | $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', [])); |
| 1407 | 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', [])); | |
| 1408 | 1516 | $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true); |
| 1409 | 1517 | $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', [])); |
| 1410 | 1518 | $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', [])); |
| 1411 | 1519 | $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', [])); |
| @@ -1442,8 +1550,9 @@ | ||
| 1442 | 1550 | 'title', |
| 1443 | 1551 | 'slug', |
| 1444 | 1552 | 'board_id', |
| 1445 | 1553 | 'parent_id', |
| 1554 | + 'crm_contact_id', | |
| 1446 | 1555 | 'type', |
| 1447 | 1556 | 'stage_id', |
| 1448 | 1557 | 'status', |
| 1449 | 1558 | 'priority', |
| @@ -1493,8 +1602,10 @@ | ||
| 1493 | 1602 | $this->applyTableTaskSearch($tasksQuery, $search); |
| 1494 | 1603 | $this->applyTableTaskFilters($tasksQuery, [ |
| 1495 | 1604 | 'stage' => $stageFilters, |
| 1496 | 1605 | 'task_status' => $taskStatusFilters, |
| 1606 | + 'recurring' => $recurringFilters, | |
| 1607 | + 'template' => $templateFilters, | |
| 1497 | 1608 | 'priority' => $priorityFilters, |
| 1498 | 1609 | 'assignee' => $assigneeFilters, |
| 1499 | 1610 | 'labels' => $labelFilters, |
| 1500 | 1611 | 'watchers' => $watcherFilters, |
| @@ -1511,13 +1622,16 @@ | ||
| 1511 | 1622 | ->orderBy($sortColumn, $sortDirection) |
| 1512 | 1623 | ->paginate($perPage, ['*'], 'page', $page); |
| 1513 | 1624 | } |
| 1514 | 1625 | |
| 1626 | + /** Fetch matching board cards with the same filters as table view. */ | |
| 1515 | 1627 | public function getBoardViewTasks($boardId, $data = []) |
| 1516 | 1628 | { |
| 1517 | 1629 | $search = isset($data['search']) ? sanitize_text_field($data['search']) : ''; |
| 1518 | 1630 | $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', [])); |
| 1519 | 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', [])); | |
| 1520 | 1634 | $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true); |
| 1521 | 1635 | $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', [])); |
| 1522 | 1636 | $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', [])); |
| 1523 | 1637 | $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', [])); |
| @@ -1565,8 +1679,10 @@ | ||
| 1565 | 1679 | $this->applyTableTaskSearch($tasksQuery, $search); |
| 1566 | 1680 | $this->applyTableTaskFilters($tasksQuery, [ |
| 1567 | 1681 | 'stage' => $stageFilters, |
| 1568 | 1682 | 'task_status' => $taskStatusFilters, |
| 1683 | + 'recurring' => $recurringFilters, | |
| 1684 | + 'template' => $templateFilters, | |
| 1569 | 1685 | 'priority' => $priorityFilters, |
| 1570 | 1686 | 'assignee' => $assigneeFilters, |
| 1571 | 1687 | 'labels' => $labelFilters, |
| 1572 | 1688 | 'watchers' => $watcherFilters, |
| @@ -1616,8 +1732,9 @@ | ||
| 1616 | 1732 | $escapedSearch = $wpdb->esc_like($search); |
| 1617 | 1733 | $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%'); |
| 1618 | 1734 | } |
| 1619 | 1735 | |
| 1736 | + /** Apply board filters to table and card queries, gating Pro-only options. */ | |
| 1620 | 1737 | private function applyTableTaskFilters($tasksQuery, $filters) |
| 1621 | 1738 | { |
| 1622 | 1739 | $stageFilters = Arr::get($filters, 'stage', []); |
| 1623 | 1740 | $taskStatusFilters = Arr::get($filters, 'task_status', []); |
| @@ -1628,8 +1745,22 @@ | ||
| 1628 | 1745 | $contactFilters = Arr::get($filters, 'contact', []); |
| 1629 | 1746 | $customFieldFilters = Arr::get($filters, 'custom_fields', []); |
| 1630 | 1747 | $dueDateFilters = Arr::get($filters, 'due_date', []); |
| 1631 | 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 | + | |
| 1632 | 1763 | if ($stageFilters) { |
| 1633 | 1764 | $this->applyTableStageFilters($tasksQuery, $stageFilters); |
| 1634 | 1765 | } |
| 1635 | 1766 | |
| @@ -1868,8 +1999,12 @@ | ||
| 1868 | 1999 | } |
| 1869 | 2000 | |
| 1870 | 2001 | public function sendMailAfterTaskModify($column, $assigneeIds, $taskId) |
| 1871 | 2002 | { |
| 2003 | + if (empty($assigneeIds)) { | |
| 2004 | + return; | |
| 2005 | + } | |
| 2006 | + | |
| 1872 | 2007 | $current_user_id = get_current_user_id(); |
| 1873 | 2008 | /* this will run in background as soon as possible */ |
| 1874 | 2009 | /* sending Model or Model Instance won't work here */ |
| 1875 | 2010 | |
| @@ -1933,8 +2068,9 @@ | ||
| 1933 | 2068 | } |
| 1934 | 2069 | |
| 1935 | 2070 | public function getActivities($taskId, $perPage, $filter = 'newest') |
| 1936 | 2071 | { |
| 2072 | + $perPage = Sanitize::perPage($perPage); | |
| 1937 | 2073 | $activityQuery = Activity::where('object_id', $taskId) |
| 1938 | 2074 | ->where('object_type', Constant::ACTIVITY_TASK); |
| 1939 | 2075 | if ($filter == 'newest') { |
| 1940 | 2076 | $activityQuery = $activityQuery->latest(); |
| @@ -2097,11 +2233,14 @@ | ||
| 2097 | 2233 | |
| 2098 | 2234 | return $subtaskGroupMap; |
| 2099 | 2235 | } |
| 2100 | 2236 | |
| 2101 | - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no') | |
| 2237 | + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = []) | |
| 2102 | 2238 | { |
| 2103 | - $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(); | |
| 2104 | 2243 | $taskMap = []; |
| 2105 | 2244 | $subtaskGroupMap = []; |
| 2106 | 2245 | $parentTaskCount = 0; |
| 2107 | 2246 | $attachmentFileService = new AttachmentFileService(); |
| @@ -2168,8 +2307,9 @@ | ||
| 2168 | 2307 | 'value' => $isTemplate->value |
| 2169 | 2308 | ]); |
| 2170 | 2309 | } |
| 2171 | 2310 | } |
| 2311 | + $this->copyTaskCustomFields($task, $newTask, $customFieldMap); | |
| 2172 | 2312 | if(!$task->parent_id){ |
| 2173 | 2313 | ++$parentTaskCount; |
| 2174 | 2314 | $taskMap[$task['id']] = $newTask->id; |
| 2175 | 2315 | //duplicate labels to task |
| @@ -2198,8 +2338,31 @@ | ||
| 2198 | 2338 | throw $e; |
| 2199 | 2339 | } |
| 2200 | 2340 | } |
| 2201 | 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 | + | |
| 2202 | 2365 | private function subtaskCountUpdate($taskId){ |
| 2203 | 2366 | $parentTask = Task::findOrFail($taskId); |
| 2204 | 2367 | $settings = $parentTask->settings; |
| 2205 | 2368 | $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1; |
| @@ -2217,81 +2380,102 @@ | ||
| 2217 | 2380 | * @return array |
| 2218 | 2381 | */ |
| 2219 | 2382 | public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array |
| 2220 | 2383 | { |
| 2384 | + $perPage = Sanitize::perPage($perPage); | |
| 2385 | + $page = max(1, (int) $page); | |
| 2221 | 2386 | // Fetch the task |
| 2222 | 2387 | $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId); |
| 2223 | 2388 | $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all'; |
| 2224 | 2389 | |
| 2225 | - // Fetch comments and activities separately | |
| 2226 | - $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; | |
| 2227 | 2406 | if ($feedType !== 'activities') { |
| 2228 | - $comments = $task->comments()->with(['user', 'replies.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 | + } | |
| 2229 | 2411 | } |
| 2230 | - | |
| 2231 | - $activities = []; | |
| 2232 | 2412 | if ($feedType !== 'comments') { |
| 2233 | - $activities = $task->activities() | |
| 2234 | - ->with('user') | |
| 2235 | - ->where(function($query) { | |
| 2236 | - $query->whereNotIn('column', [ 'comment', 'a reply']) | |
| 2237 | - ->orWhere(function($subQuery) { | |
| 2238 | - $subQuery->whereNotIn('action', ['added', 'updated']); | |
| 2239 | - }); | |
| 2240 | - }) | |
| 2241 | - ->orderBy('created_at', 'desc') | |
| 2242 | - ->get() | |
| 2243 | - ->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 | + } | |
| 2244 | 2417 | } |
| 2245 | 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); | |
| 2246 | 2425 | |
| 2247 | - // Merge comments and activities into a single array | |
| 2248 | - $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 | + } | |
| 2249 | 2438 | |
| 2250 | - // Sort the merged array by created_at date in ascending or descending order | |
| 2251 | - $order = $filter == 'newest' ? -1 : 1; | |
| 2252 | - usort($commentsAndActivities, function ($a, $b) use ($order) { | |
| 2253 | - return $order * (strtotime($a['created_at']) - strtotime($b['created_at'])); | |
| 2254 | - }); | |
| 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 | + } | |
| 2255 | 2445 | |
| 2256 | - // Paginate the results | |
| 2257 | - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array | |
| 2258 | - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage); | |
| 2259 | - | |
| 2260 | - // Get the total count of comments and activities | |
| 2261 | - $total = count($commentsAndActivities); | |
| 2262 | 2446 | $lastPage = (int) ceil($total / $perPage); |
| 2263 | 2447 | |
| 2264 | 2448 | // Construct pagination metadata |
| 2265 | - $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"); | |
| 2266 | 2450 | return [ |
| 2267 | 2451 | 'current_page' => (int) $page, |
| 2268 | 2452 | 'data' => $paginatedResults, |
| 2269 | - 'first_page_url' => "{$path}?page=1", | |
| 2453 | + 'first_page_url' => add_query_arg('page', 1, $path), | |
| 2270 | 2454 | 'from' => $total > 0 ? (int) ($offset + 1) : null, |
| 2271 | 2455 | 'last_page' => (int) $lastPage, |
| 2272 | - 'last_page_url' => "{$path}?page={$lastPage}", | |
| 2456 | + 'last_page_url' => add_query_arg('page', $lastPage, $path), | |
| 2273 | 2457 | 'links' => [ |
| 2274 | 2458 | [ |
| 2275 | - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null, | |
| 2459 | + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null, | |
| 2276 | 2460 | 'label' => 'pagination.previous', |
| 2277 | 2461 | 'active' => false |
| 2278 | 2462 | ], |
| 2279 | 2463 | [ |
| 2280 | - 'url' => "{$path}?page={$page}", | |
| 2464 | + 'url' => add_query_arg('page', $page, $path), | |
| 2281 | 2465 | 'label' => (int) $page, |
| 2282 | 2466 | 'active' => true |
| 2283 | 2467 | ], |
| 2284 | 2468 | [ |
| 2285 | - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null, | |
| 2469 | + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null, | |
| 2286 | 2470 | 'label' => 'pagination.next', |
| 2287 | 2471 | 'active' => false |
| 2288 | 2472 | ] |
| 2289 | 2473 | ], |
| 2290 | - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null, | |
| 2474 | + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null, | |
| 2291 | 2475 | 'path' => $path, |
| 2292 | 2476 | 'per_page' => (int) $perPage, |
| 2293 | - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null, | |
| 2477 | + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null, | |
| 2294 | 2478 | 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null, |
| 2295 | 2479 | 'total' => (int) $total |
| 2296 | 2480 | ]; |
| 2297 | 2481 | } |
| @@ -2346,11 +2530,15 @@ | ||
| 2346 | 2530 | } |
| 2347 | 2531 | return $title ?? $UrlMeta['title'] ?? ''; |
| 2348 | 2532 | } |
| 2349 | 2533 | |
| 2350 | - 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) | |
| 2351 | 2536 | { |
| 2352 | - $stage = Stage::findOrFail($stageId); | |
| 2537 | + if (!$stage || (int) $stage->id !== (int) $stageId) { | |
| 2538 | + $stage = Stage::findOrFail($stageId); | |
| 2539 | + } | |
| 2540 | + $assignmentsChanged = false; | |
| 2353 | 2541 | if ($stage && isset($stage->settings['default_task_assignees'])) { |
| 2354 | 2542 | $defaultAssignees = $stage->settings['default_task_assignees']; |
| 2355 | 2543 | foreach ($defaultAssignees as $assigneeId) { |
| 2356 | 2544 | $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray(); |
| @@ -2356,11 +2544,13 @@ | ||
| 2356 | 2544 | $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray(); |
| 2357 | 2545 | $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds); |
| 2358 | 2546 | if (!$IfAlreadyAssignee) { |
| 2359 | 2547 | $this->updateAssignee($assigneeId, $task); |
| 2548 | + $assignmentsChanged = true; | |
| 2360 | 2549 | } |
| 2361 | 2550 | } |
| 2362 | 2551 | } |
| 2552 | + return $assignmentsChanged; | |
| 2363 | 2553 | } |
| 2364 | 2554 | |
| 2365 | 2555 | public function manageDefaultWatchers($task, $stageId) |
| 2366 | 2556 | { |
| @@ -2536,9 +2726,10 @@ | ||
| 2536 | 2726 | } |
| 2537 | 2727 | |
| 2538 | 2728 | // Create new task with cloned data |
| 2539 | 2729 | $clonedTask = $task->replicate(); |
| 2540 | - $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); | |
| 2541 | 2732 | |
| 2542 | 2733 | $settings = $clonedTask->settings ?? []; |
| 2543 | 2734 | |
| 2544 | 2735 | unset( |
| @@ -2615,9 +2806,10 @@ | ||
| 2615 | 2806 | $attachmentFileService->rollbackCreatedFiles(); |
| 2616 | 2807 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation |
| 2617 | 2808 | $wpdb->query('ROLLBACK'); |
| 2618 | 2809 | throw new \Exception( |
| 2619 | - 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())), | |
| 2620 | 2812 | (int) ($e->getCode() ?: 500) |
| 2621 | 2813 | ); |
| 2622 | 2814 | } |
| 2623 | 2815 | } |
| @@ -2982,8 +3174,9 @@ | ||
| 2982 | 3174 | |
| 2983 | 3175 | $successfulTasks = []; |
| 2984 | 3176 | |
| 2985 | 3177 | foreach ($tasks as $task) { |
| 3178 | + $oldStageId = $task->stage_id; | |
| 2986 | 3179 | if ($isMovingToAnotherBoard) { |
| 2987 | 3180 | // Cross-board move - use existing method for data cleanup and security |
| 2988 | 3181 | $task = $this->changeBoardByTask($task, $effectiveTargetBoardId); |
| 2989 | 3182 | $task->stage_id = $targetStageId; |
| @@ -2988,33 +3181,32 @@ | ||
| 2988 | 3181 | $task = $this->changeBoardByTask($task, $effectiveTargetBoardId); |
| 2989 | 3182 | $task->stage_id = $targetStageId; |
| 2990 | 3183 | $task = $task->moveToNewPosition(null); |
| 2991 | 3184 | } else { |
| 2992 | - // Same board - simple stage move | |
| 2993 | - $oldStageId = $task->stage_id; | |
| 2994 | 3185 | $task->stage_id = $targetStageId; |
| 2995 | 3186 | $task = $task->moveToNewPosition(1); |
| 2996 | - | |
| 2997 | - // Only process stage-specific logic if stage actually changed | |
| 2998 | - if ($oldStageId != $targetStageId) { | |
| 2999 | - $this->manageDefaultAssignees($task, $targetStageId); | |
| 3000 | - | |
| 3001 | - $defaultPosition = $task->stage->defaultTaskStatus(); | |
| 3002 | - if ($defaultPosition == 'closed' && $task->status != 'closed') { | |
| 3003 | - $task = $task->close(); | |
| 3004 | - } | |
| 3005 | - | |
| 3006 | - $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE); | |
| 3007 | - $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(); | |
| 3008 | 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); | |
| 3009 | 3204 | } |
| 3010 | - | |
| 3011 | - // Reload task with all relationships | |
| 3012 | - $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']); | |
| 3013 | - | |
| 3205 | + | |
| 3014 | 3206 | $successfulTasks[] = $task; |
| 3015 | 3207 | } |
| 3016 | - | |
| 3208 | + | |
| 3017 | 3209 | $successCount = count($successfulTasks); |
| 3018 | 3210 | |
| 3019 | 3211 | if ($isMovingToAnotherBoard) { |
| 3020 | 3212 | // translators: %d is the number of tasks successfully moved to another board |
| @@ -3356,9 +3548,9 @@ | ||
| 3356 | 3548 | ]; |
| 3357 | 3549 | } |
| 3358 | 3550 | |
| 3359 | 3551 | /** |
| 3360 | - * Delete time tracking records for one or multiple tasks. | |
| 3552 | + * Delete time tracking records for one or multiple tasks when the table exists. | |
| 3361 | 3553 | * |
| 3362 | 3554 | * @param int|array $taskIds Single task ID or array of task IDs. |
| 3363 | 3555 | * @param bool $suppressErrors Whether cleanup failures should be ignored. |
| 3364 | 3556 | * @return void |
| @@ -3364,22 +3556,35 @@ | ||
| 3364 | 3556 | * @return void |
| 3365 | 3557 | */ |
| 3366 | 3558 | public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true) |
| 3367 | 3559 | { |
| 3368 | - // Check if FluentBoards Pro time tracking is available | |
| 3369 | - if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) { | |
| 3560 | + if (!class_exists(TimeTrack::class)) { | |
| 3370 | 3561 | return; |
| 3371 | 3562 | } |
| 3372 | 3563 | |
| 3373 | 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 | + | |
| 3374 | 3579 | // Handle single task ID or array of task IDs |
| 3375 | 3580 | if (is_array($taskIds)) { |
| 3376 | 3581 | if (!empty($taskIds)) { |
| 3377 | - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete(); | |
| 3582 | + TimeTrack::whereIn('task_id', $taskIds)->delete(); | |
| 3378 | 3583 | } |
| 3379 | 3584 | } else { |
| 3380 | 3585 | if (is_numeric($taskIds) && $taskIds > 0) { |
| 3381 | - \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete(); | |
| 3586 | + TimeTrack::where('task_id', (int) $taskIds)->delete(); | |
| 3382 | 3587 | } |
| 3383 | 3588 | } |
| 3384 | 3589 | } catch (\Throwable $e) { |
| 3385 | 3590 | if (!$suppressErrors) { |