PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 All 43 releases
← All changes | app/Services/TaskService.php +2976 -252 1.22 → 2.2.0 View file →
@@ -1,40 +1,180 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\Framework\Database\Orm\ModelNotFoundException;
6 +use FluentBoards\App\App;
7 +use FluentBoards\App\Hooks\Handlers\FileHandler;
5 8 use FluentBoards\App\Models\Attachment;
6 9 use FluentBoards\App\Models\Comment;
10 +use FluentBoards\App\Models\Notification;
7 11 use FluentBoards\App\Models\NotificationUser;
8 12 use FluentBoards\App\Models\TaskImage;
9 13 use FluentBoards\App\Services\Constant;
14 +use FluentBoards\App\Models\Label;
10 15 use FluentBoards\App\Models\Stage;
11 16 use FluentBoards\App\Models\Task;
12 17 use FluentBoards\App\Models\Board;
13 18 use FluentBoards\App\Models\TaskMeta;
19 +use FluentBoards\App\Models\Meta;
14 20 use FluentBoards\App\Models\Activity;
15 -use FluentBoards\App\Models\BoardTerm;
21 +use FluentBoards\App\Models\CommentImage;
22 +use FluentBoards\App\Models\Relation;
16 23 use FluentBoards\Framework\Support\Arr;
24 +use FluentBoardsPro\App\Models\TaskAttachment;
25 +use FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack;
26 +use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper;
27 +use FluentBoardsPro\App\Services\AttachmentService;
17 28 use FluentBoardsPro\App\Services\RemoteUrlParser;
18 29 use FluentRoadmap\App\Models\IdeaReaction;
19 30
20 31 class TaskService
21 32 {
33 + private static $physicalTableNameCache = [];
34 +
35 + /** Load appended task metadata in two queries without exposing internal relation rows. */
36 + public function loadTaskListMetadata($tasks)
37 + {
38 + $items = [];
39 + foreach ($tasks as $task) {
40 + $items[] = $task;
41 + }
42 +
43 + $collection = (new Task())->newCollection($items);
44 + $collection->load(['taskMeta', 'repeatTaskMeta']);
45 +
46 + foreach ($collection as $task) {
47 + // Keep the existing meta/repeat_task_meta API fields, not duplicate raw relations.
48 + $task->makeHidden(['taskMeta', 'repeatTaskMeta']);
49 + }
50 + }
51 +
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 + /**
92 + * Resolve a task only when it belongs to the requested board.
93 + *
94 + * Subtasks normally carry the same board_id as their parent, but the parent
95 + * fallback protects older data where that relationship may be incomplete.
96 + * Missing or mismatched tasks use the router's deliberate 404 response.
97 + *
98 + * @param int $taskId
99 + * @param int $boardId
100 + * @param bool $allowParentFallback
101 + * @return Task
102 + * @throws ModelNotFoundException
103 + */
104 + public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true)
105 + {
106 + $taskId = absint($taskId);
107 + $boardId = absint($boardId);
108 +
109 + if (!$taskId || !$boardId) {
110 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
111 + }
112 +
113 + $task = Task::where('id', $taskId)
114 + ->where('board_id', $boardId)
115 + ->first();
116 +
117 + if ($task) {
118 + return $this->normalizeTaskDescriptionForEditor($task);
119 + }
120 +
121 + if ($allowParentFallback) {
122 + $task = Task::where('id', $taskId)
123 + ->whereNull('board_id')
124 + ->whereNotNull('parent_id')
125 + ->first();
126 +
127 + if ($task) {
128 + $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
129 +
130 + if ((int) $parentBoardId === $boardId) {
131 + return $this->normalizeTaskDescriptionForEditor($task);
132 + }
133 + }
134 + }
135 +
136 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
137 + }
138 +
139 + private function normalizeTaskDescriptionForEditor(Task $task)
140 + {
141 + $task->description = DescriptionMarkdownConverter::normalize($task->description);
142 +
143 + return $task;
144 + }
145 +
146 + /**
147 + * Create a board task and notify eligible selected assignees before applying stage defaults.
148 + */
22 149 public function createTask($data, $boardId)
23 150 {
24 151 $board = Board::select('id', 'type')->find($boardId);
25 152
26 153 if (!$board) {
27 - throw new \Exception(__("Board doesn't exists", 'fluent-boards'));
154 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
28 155 }
29 156
30 157 $stage = Stage::find($data['stage_id']);
31 158 if (!$stage) {
32 - throw new \Exception(__("Stage doesn't exists", 'fluent-boards'));
159 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
33 160 }
34 161
162 + if ((int) $stage->board_id !== (int) $boardId) {
163 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
164 + }
165 +
35 166 $data['status'] = $stage->defaultTaskStatus();
36 167
168 + // Image covers require a persisted task, so creation only accepts a sanitized color cover.
169 + $coverColor = sanitize_hex_color(Arr::get($data, 'settings.cover.backgroundColor', ''));
170 + $taskSettings = [];
171 + if ($coverColor) {
172 + $taskSettings['cover'] = [
173 + 'backgroundColor' => $coverColor,
174 + ];
175 + }
176 +
37 177 if ($board->type == 'roadmap') {
38 178 $current_user = wp_get_current_user();
39 179 $settingData = array(
40 180 'integration_type' => 'feature',
@@ -42,10 +182,14 @@
42 182 'author' => [
43 183 'email' => $current_user->user_email // email of who posted this feature
44 184 ],
45 185 );
46 - $data['settings'] = $settingData;
186 + $data['settings'] = array_merge($taskSettings, $settingData);
47 187 $data['type'] = 'roadmap';
188 + } elseif ($taskSettings) {
189 + $data['settings'] = $taskSettings;
190 + } else {
191 + unset($data['settings']);
48 192 }
49 193
50 194 $providerPosition = Arr::get($data, 'position');
51 195
@@ -51,11 +195,29 @@
51 195
52 196 $data['position'] = $this->getLastPositionOfTasks($stage->id);
53 197
54 198 $data['board_id'] = $boardId;
199 + $data = Helper::normalizeDates($data, ['due_at', 'started_at', 'last_completed_at', 'archived_at', 'remind_at']);
200 + if (isset($data['description'])) {
201 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
202 + }
55 203
56 204 $data = array_filter($data);
57 205 $task = (new Task())->createTask($data);
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 +
217 + $this->manageDefaultAssignees($task, $stage->id);
218 + $this->manageDefaultWatchers($task, $stage->id);
219 +
58 220 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
59 221 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $data['is_template']);
60 222 }
61 223
@@ -81,20 +243,38 @@
81 243 }
82 244
83 245 public function getTasksForBoards($filters = ['overdue', 'upcoming'], $limit = 5, $task_ids = [])
84 246 {
247 + $assigned = $this->getTasksForBoardsByCategory('assigned', $limit, $task_ids);
85 248 $overDue = $this->getTasksForBoardsByCategory('overdue', $limit, $task_ids);
249 + $dueToday = $this->getTasksForBoardsByCategory('due_today', $limit, $task_ids);
86 250 $completed = $this->getTasksForBoardsByCategory('completed', $limit, $task_ids);
251 + $mentioned = $this->getTasksForBoardsByCategory('mentioned', $limit, $task_ids);
87 252 $upcoming = $this->getTasksForBoardsByCategory('upcoming', $limit, $task_ids);
253 + $others = $this->getTasksForBoardsByCategory('others', $limit, $task_ids);
88 254
89 -
90 255 return [
256 + 'assigned' => $assigned ?? [],
91 257 'overdue' => $overDue ?? [],
258 + 'due_today' => $dueToday ?? [],
92 259 'upcoming' => $upcoming ?? [],
93 - 'completed' => $completed ?? []
260 + 'mentioned' => $mentioned ?? [],
261 + 'completed' => $completed ?? [],
262 + 'others' => $others ?? []
94 263 ];
95 264 }
96 265
266 + public function getTaskCountsForBoards($categories = ['assigned', 'overdue', 'upcoming', 'completed', 'others'], $taskIds = [])
267 + {
268 + $counts = [];
269 +
270 + foreach ($categories as $category) {
271 + $counts[$category] = $this->getTaskCountForBoardsByCategory($category, $taskIds);
272 + }
273 +
274 + return $counts;
275 + }
276 +
97 277 public function getTasksForBoardsByCategory($category, $limit, $taskIds)
98 278 {
99 279 unset($taskQuery);
100 280 $taskQuery = Task::whereIn('id', $taskIds)
@@ -100,20 +280,67 @@
100 280 $taskQuery = Task::whereIn('id', $taskIds)
101 281 ->with(['assignees', 'board', 'stage'])
102 282 ->whereNull('archived_at')
103 283 ->where('parent_id', null)
104 - ->orderBy('due_at', 'ASC');
284 + ->onActiveAvailableBoards()
285 + ->orderBy('due_at', 'DESC');
105 286
106 - if ('overdue' == $category) {
107 - $taskQuery->overdue();
108 - } elseif ('upcoming' == $category) {
109 - $taskQuery->upcoming();
110 - } elseif ('upcoming_no_duedate' == $category) {
111 - $taskQuery->whereNull('due_at');
112 - } elseif ('completed' == $category) {
113 - $taskQuery->where('status', 'closed');
114 - } else {
115 - return [];
287 + switch ($category) {
288 + case 'overdue':
289 + $taskQuery->overdue();
290 + break;
291 + case 'upcoming':
292 + $taskQuery->upcoming();
293 + break;
294 + case 'due_today':
295 + $taskQuery->dueToday();
296 + break;
297 + case 'others':
298 + $taskQuery->whereNull('due_at');
299 + break;
300 + case 'completed':
301 + $taskQuery->where('status', 'closed');
302 + break;
303 + case 'assigned':
304 + // Rebuild query to order by latest assignment (pivot created_at) so the most recently assigned tasks come first.
305 + $currentUserId = get_current_user_id();
306 + $taskQuery = Task::query()
307 + ->select('fbs_tasks.*')
308 + ->distinct()
309 + ->with(['assignees', 'board', 'stage'])
310 + ->whereIn('fbs_tasks.id', $taskIds)
311 + ->whereNull('fbs_tasks.archived_at')
312 + ->whereNull('fbs_tasks.parent_id')
313 + ->where('fbs_tasks.status', '!=', 'closed')
314 + ->onActiveAvailableBoards()
315 + ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
316 + $join->on('rel.object_id', '=', 'fbs_tasks.id')
317 + ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
318 + ->where('rel.foreign_id', $currentUserId);
319 + })
320 + ->orderBy('rel.created_at', 'DESC')
321 + ->orderBy('fbs_tasks.updated_at', 'DESC');
322 + break;
323 + case 'mentioned':
324 + $currentUserId = get_current_user_id();
325 + $userNotifications = NotificationUser::where('user_id', $currentUserId)
326 + ->with(['notification' => function ($query) {
327 + $query->where('action', 'task_comment_mentioned');
328 + }])
329 + ->orderBy('created_at', 'desc')
330 + ->get();
331 + $taskIds = $userNotifications->filter(function ($userNotification) {
332 + $notification = $userNotification->notification;
333 + return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
334 + })->pluck('notification.task_id')->unique();
335 + $validTasks = Task::whereIn('id', $taskIds)
336 + ->with(['assignees', 'board', 'stage'])
337 + ->onActiveAvailableBoards()
338 + ->get();
339 +
340 + return $validTasks->toArray();
341 + default:
342 + return [];
116 343 }
117 344
118 345 $tasks = $taskQuery->take($limit)->get();
119 346
@@ -119,8 +346,108 @@
119 346
120 347 return $tasks->toArray();
121 348 }
122 349
350 + public function getTaskCountForBoardsByCategory($category, $taskIds)
351 + {
352 + if (empty($taskIds)) {
353 + return 0;
354 + }
355 +
356 + $taskQuery = Task::query()
357 + ->whereIn('id', $taskIds)
358 + ->whereNull('archived_at')
359 + ->whereNull('parent_id')
360 + ->onActiveAvailableBoards();
361 +
362 + switch ($category) {
363 + case 'overdue':
364 + $taskQuery->overdue();
365 + break;
366 + case 'upcoming':
367 + $taskQuery->upcoming();
368 + break;
369 + case 'due_today':
370 + $taskQuery->dueToday();
371 + break;
372 + case 'completed':
373 + $taskQuery->where('status', 'closed');
374 + break;
375 + case 'others':
376 + $taskQuery->whereNull('due_at');
377 + break;
378 + case 'assigned':
379 + $currentUserId = get_current_user_id();
380 + $taskQuery = Task::query()
381 + ->select('fbs_tasks.id')
382 + ->whereIn('fbs_tasks.id', $taskIds)
383 + ->whereNull('fbs_tasks.archived_at')
384 + ->whereNull('fbs_tasks.parent_id')
385 + ->where('fbs_tasks.status', '!=', 'closed')
386 + ->onActiveAvailableBoards()
387 + ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
388 + $join->on('rel.object_id', '=', 'fbs_tasks.id')
389 + ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
390 + ->where('rel.foreign_id', $currentUserId);
391 + });
392 +
393 + return (int) $taskQuery->distinct()->count('fbs_tasks.id');
394 + case 'mentioned':
395 + $currentUserId = get_current_user_id();
396 + $taskIds = NotificationUser::where('user_id', $currentUserId)
397 + ->with(['notification' => function ($query) {
398 + $query->where('action', 'task_comment_mentioned')
399 + ->with('task');
400 + }])
401 + ->get()
402 + ->filter(function ($userNotification) {
403 + $notification = $userNotification->notification;
404 +
405 + return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
406 + })
407 + ->pluck('notification.task_id')
408 + ->unique();
409 +
410 + return Task::whereIn('id', $taskIds)->onActiveAvailableBoards()->count();
411 + default:
412 + return 0;
413 + }
414 +
415 + return (int) $taskQuery->count();
416 + }
417 +
418 + /**
419 + * Unlink a Fluent Support ticket from a board-scoped task.
420 + *
421 + * @param int $taskId
422 + * @param int $boardId
423 + * @return Task
424 + * @throws \Exception
425 + */
426 + public function removeSupportTicketLink($taskId, $boardId)
427 + {
428 + $taskId = absint($taskId);
429 + $boardId = absint($boardId);
430 + $task = $this->findTaskOnBoard($taskId, $boardId);
431 +
432 + if ($task->source !== Constant::TASK_SOURCE_FLUENT_SUPPORT || !$task->source_id) {
433 + return $task;
434 + }
435 +
436 + $ticketId = $task->source_id;
437 + $settings = is_array($task->settings) ? $task->settings : [];
438 + unset($settings['author']);
439 +
440 + $task->source = null;
441 + $task->source_id = null;
442 + $task->settings = $settings ?: null;
443 + $task->save();
444 +
445 + do_action('fluent_boards/support_ticket_unlinked', $task, $ticketId);
446 +
447 + return $task;
448 + }
449 +
123 450 /*
124 451 * TODO: Refactor this function - For me.
125 452 */
126 453 public function updateTaskProperty($col, $value, $task)
@@ -127,70 +454,135 @@
127 454 {
128 455 $oldTask = clone $task; // normal assigning won't work here. because objects are passed by reference in php
129 456 $validColumns = [
130 457 'board_id',
131 - 'task_type',
132 - 'reminder_type',
458 + 'type',
459 +// 'reminder_type',
133 460 'remind_at',
134 461 'log_minutes',
462 + 'source',
463 + 'source_id',
135 464 'settings'
136 465 ];
137 466
467 + if ($col === 'description') {
468 + $value = DescriptionMarkdownConverter::normalize($value);
469 + }
470 +
138 471 if (in_array($col, $validColumns) && $task->{$col} != $value) {
139 - $task->{$col} = $value;
140 - $task->save();
141 - // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
142 - } elseif ('assignees' == $col) {
143 - if (is_array($value)) {
144 - foreach ($value as $id) {
145 - $this->updateAssignee($id, $task);
146 - }
147 - } else {
148 - $this->updateAssignee($value, $task);
472 + if ($col === 'remind_at') {
473 + $value = Helper::normalizeDateValue($value);
149 474 }
150 475
151 - } elseif ('crm_contact_id' == $col) {
152 - $this->updateAssociate($value, $task);
153 - } elseif ('archived_at' == $col) {
154 - $this->updateArchive($value, $task);
155 - } elseif ('status' == $col) {
156 - $this->updateStatus($value, $task);
157 - } elseif ('parent_id' == $col) {
158 - $this->updateParent($value, $task);
159 - } elseif ('title' == $col) {
160 - $this->updateTitle($col, $value, $task, $oldTask);
161 - } elseif ('description' == $col) {
162 - $this->updateDescription($col, $value, $task, $oldTask);
163 - } elseif ($col == 'due_at') {
164 - $this->updateDueDate($value, $task);
165 - } elseif ($col == 'started_at') {
166 - $this->updateStartedDate($value, $task);
167 - } elseif ($col == 'priority') {
168 - $this->updatePriority($value, $task);
169 - } elseif ($col == 'is_watching') {
170 - $this->updateObservationOfCurrentUser($value, $task);
171 - } elseif ($col == 'last_completed_at') {
172 - $isClosed = $value == 'true' || $value === true;
173 - if ($isClosed) {
174 - $task = $task->close();
175 - } else {
176 - $task = $task->reopen();
476 + if ($col == 'settings' && isset($value['cover']['backgroundColor']) && $value['cover']['backgroundColor']) {
477 + $settings = $task->settings;
478 + $this->deleteTaskCoverImage($settings);
479 + unset($value['cover']['imageId']);
480 + unset($value['cover']['backgroundImage']);
177 481 }
482 + $task->{$col} = $value ?: null;
178 483 $task->save();
179 - } elseif ($col == 'attachment_count') {
180 - $settings = $task->settings;
181 - $settings['attachment_count'] = $task->attachments()->count();
182 - $task->settings = $settings;
183 - $task->save();
184 - } elseif ($col == 'subtask_count') {
185 - $settings = $task->settings;
186 - $subtasksCount = Task::where('parent_id', $task->id)->count();
187 - $settings['subtask_count'] = $subtasksCount;
188 - $task->settings = $settings;
189 - $task->save();
190 - } elseif ($col == 'is_template') {
191 - if (defined('FLUENT_BOARDS_PRO')) {
192 - $task->updateMeta(Constant::IS_TASK_TEMPLATE, $value);
484 + // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
485 + } else {
486 + switch ($col) {
487 + case 'assignees':
488 + if (is_array($value)) {
489 + foreach ($value as $id) {
490 + $this->updateAssignee($id, $task);
491 + }
492 + } else {
493 + $this->updateAssignee($value, $task);
494 + }
495 + break;
496 +
497 + case 'crm_contact_id':
498 + $this->updateAssociate($value, $task);
499 + break;
500 +
501 + case 'archived_at':
502 + $this->updateArchive($value, $task);
503 + break;
504 +
505 + case 'status':
506 + $this->updateStatus($value, $task);
507 + break;
508 +
509 + case 'parent_id':
510 + $this->updateParent($value, $task);
511 + break;
512 +
513 + case 'title':
514 + $this->updateTitle($col, $value, $task, $oldTask);
515 + break;
516 +
517 + case 'description':
518 + $this->updateDescription($col, $value, $task, $oldTask);
519 + break;
520 +
521 + case 'due_at':
522 + $this->updateDueDate($value, $task);
523 + break;
524 +
525 + case 'started_at':
526 + $this->updateStartedDate($value, $task);
527 + break;
528 +
529 + case 'priority':
530 + $this->updatePriority($value, $task);
531 + break;
532 +
533 + case 'is_watching':
534 + $this->updateObservationOfUser($value, $task);
535 + break;
536 +
537 + case 'last_completed_at':
538 + $isClosed = $value == 'true' || $value === true;
539 + if ($isClosed) {
540 + $task = $task->close();
541 + } else {
542 + $task = $task->reopen();
543 + }
544 + $task->save();
545 + break;
546 +
547 + case 'attachment_count':
548 + $settings = $task->settings;
549 + $settings['attachment_count'] = $task->attachments()->count();
550 + $task->settings = $settings;
551 + $task->save();
552 + break;
553 +
554 + case 'subtask_count':
555 + $settings = $task->settings;
556 + $subtasksCount = Task::where('parent_id', $task->id)->count();
557 + $settings['subtask_count'] = $subtasksCount;
558 + $task->settings = $settings;
559 + $task->save();
560 + break;
561 +
562 + case 'is_template':
563 + if (defined('FLUENT_BOARDS_PRO')) {
564 + $task->updateMeta(Constant::IS_TASK_TEMPLATE, $value);
565 + }
566 + break;
567 +
568 + case 'reminder_type':
569 + if (defined('FLUENT_BOARDS_PRO')) {
570 + $allowedTypes = Helper::taskReminderTypes();
571 +
572 + // check in keys of allowed types
573 + if (array_key_exists($value, $allowedTypes)) {
574 +
575 + $value = $value;
576 + } else {
577 + $value = null;
578 + }
579 +
580 + $task->reminder_type = $value;
581 + $task->save();
582 + do_action('fluent_boards/task_reminder_type_changed', $task, $value);
583 + }
584 + break;
193 585 }
194 586 }
195 587
196 588 return $task;
@@ -204,22 +596,22 @@
204 596
205 597 $task->save();
206 598
207 599 if ($operation == 'added') {
208 - if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id)) {
600 + if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id) && $payloadAssigneeId != get_current_user_id()) {
209 601 $this->sendMailAfterTaskModify('add_assignee', $payloadAssigneeId, $task->id);
210 602 }
211 603 // $assigneeIdsToSendEmail = $this->filterAssigneeToSendEmail($task, $idArray, Constant::BOARD_EMAIL_TASK_ASSIGN);
212 604 // $this->sendMailAfterAddAssignees($assigneeIdsToSendEmail, $task->id);
213 - do_action('fluent_boards/task_assignee_changed', $task, $payloadAssigneeId, $operation);
605 + do_action('fluent_boards/task_assignee_added', $task, $payloadAssigneeId);
214 606 if($payloadAssigneeId != get_current_user_id()){
215 607 do_action('fluent_boards/assign_another_user', $task, $payloadAssigneeId);
216 608 }
217 609 } else {
218 - if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_REMOVE_FROM_TASK, $task->board_id)) {
610 + if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_REMOVE_FROM_TASK, $task->board_id) && $payloadAssigneeId != get_current_user_id()) {
219 611 $this->sendMailAfterTaskModify('remove_assignee', $payloadAssigneeId, $task->id);
220 612 }
221 - do_action('fluent_boards/task_assignee_changed', $task, $payloadAssigneeId, $operation);
613 + do_action('fluent_boards/task_assignee_removed', $task, $payloadAssigneeId);
222 614 }
223 615
224 616 }
225 617
@@ -272,17 +664,36 @@
272 664
273 665 private function updateArchive($value, $task)
274 666 {
275 667 if ($value != null) {
668 + // Archiving task
276 669 $task->position = 0;
277 670 } else {
671 + // Restoring task - check if stage is archived
672 + $stage = Stage::find($task->stage_id);
673 + if ($stage && $stage->archived_at !== null) {
674 + throw new \Exception(
675 + sprintf(
676 + // translators: %s is the archived stage title.
677 + esc_html__('This task cannot be restored because its stage "%s" is archived. Please restore the stage first.', 'fluent-boards'),
678 + esc_html($stage->title)
679 + ),
680 + 400
681 + );
682 + }
683 +
278 684 $task->moveToNewPosition(1);
685 +
686 + // Clean up archived_by_stage meta when task is manually restored
687 + $this->cleanupArchivedByStageMetaIfExists($task->id);
279 688 }
280 689 $task->archived_at = $value == null ? null : current_time('mysql');
281 690 $task->save();
282 - do_action('fluent_boards/board_task_archived', $task);
283 - $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
284 - $this->sendMailAfterTaskModify('task_archived', $wathersToSendEmail, $task->id);
691 + do_action('fluent_boards/task_archived', $task);
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 + }
285 696 }
286 697
287 698 private function updateStatus($value, $task)
288 699 {
@@ -309,9 +720,9 @@
309 720 }
310 721
311 722 private function updateDescription($col, $value, $task, $oldTask)
312 723 {
313 - $task->description = $value;
724 + $task->description = DescriptionMarkdownConverter::normalize($value);
314 725 $task->save();
315 726 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
316 727 }
317 728
@@ -317,15 +728,19 @@
317 728
318 729 private function updateDueDate($value, $task)
319 730 {
320 731 $oldValue = $task->due_at;
321 - $value = $this->filterNullDate($value);
732 + $value = Helper::normalizeDateValue($value);
322 733 $task->due_at = $value;
323 734 $task->save();
324 735
325 736 $task = $task->reopen();
326 737
327 - do_action('fluent_boards/task_date_changed', $task, $oldValue, 'Due Date');
738 + if($value){
739 + do_action('fluent_boards/task_due_date_changed', $task, $oldValue);
740 + } else {
741 + do_action('fluent_boards/task_due_date_removed', $task);
742 + }
328 743
329 744 $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_DUE_DATE_CHANGE);
330 745 $this->sendMailAfterTaskModify('due_date_update', $wathersToSendEmail, $task->id);
331 746 }
@@ -332,14 +747,15 @@
332 747
333 748 private function updateStartedDate($value, $task)
334 749 {
335 750 $oldValue = $task->started_at;
336 - $value = $this->filterNullDate($value);
751 + $value = Helper::normalizeDateValue($value);
337 752 $task->started_at = $value;
338 753 $task->save();
339 754
340 - do_action('fluent_boards/task_date_changed', $task, $oldValue, 'Start Date');
341 -
755 + if($value){
756 + do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
757 + }
342 758 }
343 759
344 760 private function updatePriority($value, $task)
345 761 {
@@ -348,46 +764,53 @@
348 764 $task->save();
349 765 do_action('fluent_boards/task_priority_changed', $task, $oldPriority);
350 766 }
351 767
352 - public function updateObservationOfCurrentUser($value, $task)
353 - {
354 - $currentUserId = get_current_user_id();
768 + public function updateObservationOfUser($value, $task)
769 +{
770 + if (is_array($value) && isset($value['userId'])) {
771 + $userId = intval($value['userId']);
772 + $action = isset($value['action']) ? $value['action'] : 'start';
773 + } else {
774 + $userId = get_current_user_id();
775 + $action = is_string($value) ? $value : 'start';
776 + }
355 777
356 - if ($value == 'stop') {
357 - $task->watchers()->detach($currentUserId);
358 - } else {
359 - $task->watchers()->syncWithoutDetaching([$currentUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
360 - }
361 - $task->updated_at = current_time('mysql');
362 - $task->save();
778 + if (!$userId || !in_array($action, ['stop', 'start'])) {
779 + return;
780 + }
363 781
364 - if ($value == 'stop') {
365 - $task->is_watching = false;
366 - } else {
367 - $task->is_watching = true;
368 - }
782 + if ($action == 'stop') {
783 + $task->watchers()->detach($userId);
784 + } else {
785 + $task->watchers()->syncWithoutDetaching([$userId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
369 786 }
787 + $task->updated_at = current_time('mysql');
788 + $task->save();
789 +}
370 790
371 - public function taskCoverPhotoUpdate($taskId, $imagePath)
791 + public function taskCoverPhotoUpdate($taskId, $imagePath, $boardId = null)
372 792 {
373 - $task = Task::find($taskId);
793 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
374 794 if (!$task) {
375 795 return null;
376 796 }
377 797
378 - $settings = unserialize($task->settings);
798 + $settings = $task->settings;
799 + if (!is_array($settings)) {
800 + $settings = [];
801 + }
379 802
380 803 $settings['logo'] = $imagePath;
381 - $task->settings = serialize($settings);
804 + $task->settings = $settings;
382 805 $task->save();
383 806
384 807 return $task;
385 808 }
386 809
387 - public function taskStatusUpdate($taskId, $integrationType)
810 + public function taskStatusUpdate($taskId, $integrationType, $boardId = null)
388 811 {
389 - $task = Task::find($taskId);
812 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
390 813 if (!$task) {
391 814 return null;
392 815 }
393 816
@@ -392,9 +815,9 @@
392 815 }
393 816
394 817 $settings = $task->settings;
395 818 $settings['integration_type'] = $integrationType;
396 - $task->settings = serialize($settings);
819 + $task->settings = $settings;
397 820 $task->save();
398 821
399 822 return $task;
400 823 }
@@ -400,9 +823,9 @@
400 823 }
401 824
402 825 public function assignYourselfInTask($boardId, $taskId)
403 826 {
404 - $task = Task::find($taskId);
827 + $task = $this->findTaskOnBoard($taskId, $boardId);
405 828 $authUserId = get_current_user_id();
406 829
407 830 $boardService = new BoardService();
408 831 if (!$boardService->isAlreadyMember($boardId, $authUserId)) {
@@ -413,8 +836,9 @@
413 836 // when user assign himself then he will be watching that task
414 837 $task->watchers()->syncWithoutDetaching([$authUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
415 838
416 839 $task->load('assignees');
840 + do_action('fluent_boards/task_assignee_added', $task, $authUserId);
417 841
418 842 return $task;
419 843 }
420 844
@@ -419,11 +843,13 @@
419 843 }
420 844
421 845 public function detachYourselfFromTask($boardId, $taskId)
422 846 {
423 - $task = Task::find($taskId);
424 - $task->addOrRemoveAssignee(get_current_user_id());
847 + $task = $this->findTaskOnBoard($taskId, $boardId);
848 + $currentUserId = get_current_user_id();
849 + $task->addOrRemoveAssignee($currentUserId);
425 850 $task->load('assignees');
851 + do_action('fluent_boards/task_assignee_removed', $task, $currentUserId);
426 852
427 853 return $task;
428 854 }
429 855
@@ -428,40 +854,216 @@
428 854 }
429 855
430 856 public function deleteTask($task)
431 857 {
432 - $deleted = $task->delete();
858 + // If this is a parent task, delete all subtasks first
859 + if (!$task->parent_id) {
860 + $subtasks = Task::where('parent_id', $task->id)->get();
861 + foreach ($subtasks as $subtask) {
862 + // Recursively delete each subtask (cleans up all their relations)
863 + $this->deleteTask($subtask);
864 + }
865 + }
433 866
434 - if ($deleted) {
867 + $this->deleteTasksBatch([$task]);
868 + }
435 869
436 - //task assignees watchers removed
437 - $task->watchers()->detach();
438 - $task->assignees()->detach();
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 + }
439 883
440 - //removing all task related notifications
441 - $notificationIds = $task->notifications->pluck('id');
442 - $task->notifications()->delete();
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);
906 + $dbInstance = App::getInstance('db');
907 +
908 + if (!$manageTransaction && !$dbInstance->inTransaction()) {
909 + throw new \RuntimeException(__('An active transaction is required for caller-managed task deletion.', 'fluent-boards'));
910 + }
911 +
912 + if ($manageTransaction) {
913 + $dbInstance->beginTransaction();
914 + }
915 +
916 + try {
917 + $relationTypes = [
918 + Constant::OBJECT_TYPE_USER_TASK_WATCH,
919 + Constant::OBJECT_TYPE_TASK_ASSIGNEE,
920 + Constant::OBJECT_TYPE_TASK_LABEL,
921 + ];
922 +
923 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
924 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
925 + }
926 +
927 + Relation::whereIn('object_id', $taskIds)
928 + ->whereIn('object_type', $relationTypes)
929 + ->delete();
930 +
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();
937 +
938 + $notificationIds = Notification::whereIn('task_id', $taskIds)->pluck('id')->toArray();
443 939 NotificationUser::whereIn('notification_id', $notificationIds)->delete();
940 + Notification::whereIn('task_id', $taskIds)->delete();
444 941
445 - //task labels removed
446 - $task->labels()->detach();
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();
447 952
448 - //task custom field value
449 - $task->customFields()->detach();
953 + $deletedCount = Task::whereIn('id', $taskIds)->delete();
954 + if ($deletedCount !== count($taskIds)) {
955 + throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards'));
956 + }
450 957
451 - do_action('fluent_boards/task_deleted', $task);
452 - TaskMeta::where('task_id', $task->id)->delete();
958 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
959 +
960 + if ($manageTransaction) {
961 + $dbInstance->commit();
962 + }
963 + } catch (\Throwable $e) {
964 + if ($manageTransaction) {
965 + $dbInstance->rollBack();
966 + }
967 +
968 + throw $e;
453 969 }
454 970 }
455 971
456 - public function filterNullDate($date)
972 + /** Remove comment rows transactionally; file cleanup must wait for commit. */
973 + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance)
457 974 {
458 - if ('0000-00-00 00:00:00' == $date || false === strtotime($date)) {
459 - return null;
975 + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray();
976 + if (!$commentTasks) {
977 + return;
460 978 }
461 - return $date;
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 + });
462 1030 }
463 1031
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 + }
1052 + }
1053 + });
1054 + }
1055 +
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;
1062 + }
1063 +
1064 + $this->deleteTask($task);
1065 + }
464 1066 // this is invoked when task is moved to another board
465 1067
466 1068 /**
467 1069 * @throws \Exception
@@ -467,77 +1069,383 @@
467 1069 * @throws \Exception
468 1070 */
469 1071 public function changeBoardByTask($task, $targetBoardId)
470 1072 {
1073 + // Input validation - must be positive integer
1074 + if (!is_numeric($targetBoardId) || $targetBoardId <= 0 || !is_int($targetBoardId + 0) || $targetBoardId != (int)$targetBoardId) {
1075 + throw new \Exception(esc_html__('Invalid board id - must be a positive integer', 'fluent-boards'), 400);
1076 + }
1077 +
1078 +
471 1079 if ($task->board_id == $targetBoardId) {
472 1080 return $task;
473 1081 }
474 1082
475 1083 $oldBoard = Board::find($task->board_id);
1084 + $newBoard = Board::find($targetBoardId);
476 1085
477 - $newBoard = Board::find($targetBoardId);
1086 + if (!$oldBoard) {
1087 + throw new \Exception(esc_html__('Source board not found', 'fluent-boards'), 404);
1088 + }
1089 +
478 1090 if (!$newBoard) {
479 - throw new \Exception('Invalid board id', 400);
1091 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'), 404);
480 1092 }
481 - $task->board_id = $targetBoardId;
482 - $task->save();
483 - //delete labels of that task because labels have board dependencies
484 - $task->labels()->detach();
485 1093
1094 +
1095 + $dbInstance = App::getInstance('db');
1096 + $attachmentFileService = new AttachmentFileService();
1097 + $oldBoardId = (int) $task->board_id;
1098 +
1099 + $dbInstance->beginTransaction();
1100 +
1101 + try {
1102 + $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId);
1103 + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
1104 +
1105 + $task->board_id = (int) $targetBoardId;
1106 + $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task';
1107 +
1108 + // REMOVE: Board-dependent data
1109 + $task->labels()->detach();
1110 + $task->assignees()->detach();
1111 + $task->watchers()->detach();
1112 + $this->removeCustomFieldAssociations($task);
1113 +
1114 + // REMOVE: Recurring task settings for security
1115 + $this->removeRecurringTaskSettings($task->id);
1116 +
1117 + $task->save();
1118 + do_action('fluent_boards/task_moved_update_time_tracking', $task);
1119 +
1120 + // MOVE: Subtasks to new board (preserves subtask groups)
1121 + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService);
1122 +
1123 + $dbInstance->commit();
1124 + $attachmentFileService->commitMovedOriginalFiles();
1125 + } catch (\Exception $e) {
1126 + $dbInstance->rollBack();
1127 + $attachmentFileService->rollbackCreatedFiles();
1128 + throw $e;
1129 + }
1130 +
486 1131 do_action('fluent_boards/task_moved_from_board', $task, $oldBoard, $newBoard);
1132 + return $task;
1133 + }
487 1134
488 - return $task;
1135 + /**
1136 + * Move all subtasks to the new board when parent task is moved
1137 + * Preserves subtask groups and their relationships
1138 + */
1139 + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
1140 + {
1141 + // Get all subtasks of the parent task
1142 + $subtasks = Task::where('parent_id', $parentTaskId)->get();
1143 +
1144 + if ($subtasks->isEmpty()) {
1145 + return;
1146 + }
1147 +
1148 + foreach ($subtasks as $subtask) {
1149 + // Update board_id and type
1150 + // Legacy subtasks may not have their own board_id; inherit the parent's source board.
1151 + $oldBoardId = absint($subtask->board_id) ?: absint($sourceBoardId);
1152 + $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId);
1153 + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
1154 +
1155 + $subtask->board_id = (int) $targetBoardId;
1156 + $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task';
1157 +
1158 + // REMOVE: Board-dependent data for subtasks
1159 + $subtask->labels()->detach();
1160 + $subtask->assignees()->detach();
1161 + $subtask->watchers()->detach();
1162 +
1163 + // Remove custom fields but preserve subtask group relationships
1164 + $subtask->taskMeta()
1165 + ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD)
1166 + ->delete();
1167 +
1168 + // REMOVE: Recurring task settings
1169 + $this->removeRecurringTaskSettings($subtask->id);
1170 +
1171 + $subtask->save();
1172 + do_action('fluent_boards/task_moved_update_time_tracking', $subtask);
1173 + }
489 1174 }
490 1175
1176 + /**
1177 + * Remove task cover image for security reasons
1178 + * Keeps background colors but removes image references
1179 + */
1180 + private function removeTaskCoverImage($task)
1181 + {
1182 + $settings = $task->settings;
1183 + if (empty($settings) || !is_array($settings)) {
1184 + return;
1185 + }
491 1186
1187 + if (isset($settings['cover']) && is_array($settings['cover'])) {
1188 + $cover = $settings['cover'];
1189 +
1190 + // Remove image references
1191 + unset($cover['imageId']);
1192 + unset($cover['backgroundImage']);
1193 +
1194 + // Keep only background color if it exists
1195 + if (isset($cover['backgroundColor'])) {
1196 + $settings['cover'] = array('backgroundColor' => $cover['backgroundColor']);
1197 + } else {
1198 + unset($settings['cover']);
1199 + }
1200 +
1201 + $task->settings = $settings;
1202 + }
1203 + }
1204 +
1205 + /**
1206 + * Remove custom field associations for board move
1207 + * Custom field values are stored in fbs_relations table, not fbs_task_meta
1208 + * This method removes task-to-customfield associations from fbs_relations
1209 + */
1210 + private function removeCustomFieldAssociations($task)
1211 + {
1212 + // Remove custom field values from fbs_relations table
1213 + // Custom fields are board-specific, so they must be removed when task moves to different board
1214 + if (defined('FLUENT_BOARDS_PRO')) {
1215 + $task->customFields()->detach();
1216 + }
1217 + }
1218 +
1219 + /**
1220 + * Move a task's complete comment history and images to another board.
1221 + */
1222 + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService)
1223 + {
1224 + $taskId = absint($taskId);
1225 + $sourceBoardId = absint($sourceBoardId);
1226 + $targetBoardId = absint($targetBoardId);
1227 +
1228 + if (!$taskId || !$sourceBoardId || !$targetBoardId || $sourceBoardId === $targetBoardId) {
1229 + return;
1230 + }
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]);
1238 + }
1239 +
1240 + /**
1241 + * Remove time tracking records for security reasons
1242 + * Prevents exposing user-specific time data to unauthorized users
1243 + */
1244 + private function removeTimeTrackingRecords($taskId)
1245 + {
1246 + // Input validation
1247 + if (!is_numeric($taskId) || $taskId <= 0) {
1248 + return;
1249 + }
1250 +
1251 + // Remove all time tracking records for this task
1252 + $this->deleteTimeTrackingRecords((int) $taskId);
1253 + }
1254 +
1255 + /**
1256 + * Remove attachments for security reasons
1257 + * Prevents file access issues across boards
1258 + */
1259 + private function removeAttachments($taskId)
1260 + {
1261 + // Input validation
1262 + if (!is_numeric($taskId) || $taskId <= 0) {
1263 + return;
1264 + }
1265 +
1266 + // Remove all attachments for this task
1267 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
1268 + \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', (int) $taskId)
1269 + ->where('object_type', 'task')
1270 + ->delete();
1271 + }
1272 + }
1273 +
1274 + /**
1275 + * Remove recurring task settings for security reasons
1276 + * Prevents recurring task settings from being moved between boards
1277 + */
1278 + private function removeRecurringTaskSettings($taskId)
1279 + {
1280 + // Input validation
1281 + if (!is_numeric($taskId) || $taskId <= 0) {
1282 + return;
1283 + }
1284 +
1285 + // Remove recurring task settings for this task from fbs_metas table
1286 + Meta::where('object_id', (int) $taskId)
1287 + ->where('object_type', Constant::REPEAT_TASK_META)
1288 + ->delete();
1289 + }
1290 +
492 1291 public function getIdeaVoteStatistics($taskId)
493 1292 {
494 - $reactionTypes = [
495 - [
496 - 'label' => 'Upvote',
497 - 'type' => 'upvote'
498 - ],
499 - [
500 - 'label' => 'Downvote',
501 - 'type' => 'downvote'
502 - ]
503 - ];
1293 + $taskId = absint($taskId);
1294 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds([$taskId]);
504 1295
505 - $reactionCounts = [];
1296 + return $voteStatistics[$taskId] ?? 0;
1297 + }
506 1298
507 - foreach ($reactionTypes as $reactionType) {
508 - $count = IdeaReaction::where('object_id', $taskId)
509 - ->where('object_type', 'idea')
510 - ->where('type', $reactionType['type'])
511 - ->count();
1299 + public function loadIdeaVoteStatistics($tasks)
1300 + {
1301 + $taskIds = [];
512 1302
513 - $reactionCounts[] = [
514 - 'label' => $reactionType['label'],
515 - 'type' => $reactionType['type'],
516 - 'count' => $count
517 - ];
1303 + foreach ($tasks as $task) {
1304 + $taskId = absint($task->id);
1305 + if ($taskId) {
1306 + $taskIds[] = $taskId;
1307 + }
518 1308 }
519 1309
520 - return $reactionCounts;
1310 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds($taskIds);
1311 +
1312 + foreach ($tasks as $task) {
1313 + $task->vote_statistics = $voteStatistics[(int) $task->id] ?? 0;
1314 + }
1315 +
1316 + return $tasks;
521 1317 }
522 1318
1319 + private function getIdeaVoteStatisticsByTaskIds(array $taskIds)
1320 + {
1321 + global $wpdb;
523 1322
1323 + $taskIds = array_values(array_unique(array_filter(array_map('absint', $taskIds))));
1324 +
1325 + if (!$taskIds) {
1326 + return [];
1327 + }
1328 +
1329 + $placeholders = implode(', ', array_fill(0, count($taskIds), '%d'));
1330 + $ideaReactionTable = $this->getIdeaReactionTable();
1331 + $counts = [];
1332 +
1333 + if ($ideaReactionTable) {
1334 + $rows = $wpdb->get_results(
1335 + $wpdb->prepare(
1336 + "SELECT object_id, COUNT(*) as total FROM {$ideaReactionTable} WHERE object_id IN ({$placeholders}) AND object_type = %s AND type = %s GROUP BY object_id",
1337 + array_merge($taskIds, ['idea', 'upvote'])
1338 + )
1339 + );
1340 +
1341 + foreach ($rows as $row) {
1342 + $counts[(int) $row->object_id] = (int) $row->total;
1343 + }
1344 +
1345 + return $counts;
1346 + }
1347 +
1348 + $taskMetaTable = $this->getPhysicalTableName((new TaskMeta())->getTable());
1349 + $rows = $wpdb->get_results(
1350 + $wpdb->prepare(
1351 + "SELECT task_id, COALESCE(MAX(CAST(value AS UNSIGNED)), 0) as total FROM {$taskMetaTable} WHERE task_id IN ({$placeholders}) AND `key` = %s GROUP BY task_id",
1352 + array_merge($taskIds, ['upvote'])
1353 + )
1354 + );
1355 +
1356 + foreach ($rows as $row) {
1357 + $counts[(int) $row->task_id] = (int) $row->total;
1358 + }
1359 +
1360 + return $counts;
1361 + }
1362 +
524 1363 /**
525 - * Summary of getArchivedOrCompletedTasks
526 - * this function will return completd tasks or archived tasks based on users input and also can search by name
527 - * @param mixed $data
528 - * @param mixed $taskType
1364 + * Returns the canonical SQL expression for an idea's upvote count.
1365 + *
1366 + * The roadmap reaction table is authoritative when it exists; legacy task
1367 + * metadata remains the fallback for installations without that table.
1368 + */
1369 + public function getIdeaVoteStatisticsSelect()
1370 + {
1371 + $taskTable = $this->getPhysicalTableName((new Task())->getTable());
1372 +
1373 + return $this->buildIdeaVoteStatisticsSelect($taskTable);
1374 + }
1375 +
1376 + private function buildIdeaVoteStatisticsSelect($taskTable)
1377 + {
1378 + $ideaReactionTable = $this->getIdeaReactionTable();
1379 +
1380 + if ($ideaReactionTable) {
1381 + return "(SELECT COUNT(*) FROM {$ideaReactionTable} WHERE {$ideaReactionTable}.object_id = {$taskTable}.id AND {$ideaReactionTable}.object_type = 'idea' AND {$ideaReactionTable}.type = 'upvote')";
1382 + }
1383 +
1384 + $taskMetaTable = $this->getPhysicalTableName((new TaskMeta())->getTable());
1385 +
1386 + return "(SELECT COALESCE(MAX(CAST({$taskMetaTable}.value AS UNSIGNED)), 0) FROM {$taskMetaTable} WHERE {$taskMetaTable}.task_id = {$taskTable}.id AND {$taskMetaTable}.key = 'upvote')";
1387 + }
1388 +
1389 + private function getIdeaReactionTable()
1390 + {
1391 + $table = $this->getPhysicalTableName((new IdeaReaction())->getTable(), false);
1392 +
1393 + if ($table) {
1394 + return $table;
1395 + }
1396 +
1397 + return '';
1398 + }
1399 +
1400 + private function getPhysicalTableName($table, $usePrefixedFallback = true)
1401 + {
1402 + global $wpdb;
1403 + $cacheKey = $table . '|' . (int) $usePrefixedFallback;
1404 +
1405 + if (array_key_exists($cacheKey, self::$physicalTableNameCache)) {
1406 + return self::$physicalTableNameCache[$cacheKey];
1407 + }
1408 +
1409 + $candidates = array_values(array_unique([
1410 + $wpdb->prefix . $table,
1411 + $table,
1412 + ]));
1413 +
1414 + foreach ($candidates as $candidate) {
1415 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($candidate))) === $candidate) {
1416 + self::$physicalTableNameCache[$cacheKey] = $candidate;
1417 + return $candidate;
1418 + }
1419 + }
1420 +
1421 + self::$physicalTableNameCache[$cacheKey] = $usePrefixedFallback ? $wpdb->prefix . $table : '';
1422 +
1423 + return self::$physicalTableNameCache[$cacheKey];
1424 + }
1425 +
1426 +
1427 + /**
1428 + * Get a bounded paginated list of archived board tasks with their latest archive actor.
1429 + *
1430 + * @param array $data
1431 + * @param int $boardId
529 1432 * @return mixed
530 1433 * @throws \Exception
531 1434 */
532 1435 public function getArchivedTasks($data, $boardId)
533 1436 {
534 - $per_page = isset($data['per_page']) ? $data['per_page'] : 25;
535 - $page = isset($data['page']) ? $data['page'] : 1;
1437 + if (!$boardId) {
1438 + throw new \Exception(esc_html__('Board id is required', 'fluent-boards'));
1439 + }
1440 +
1441 + // Bound the task page so the related activity and user batch queries stay predictable.
1442 + $perPage = max(1, min(50, absint($data['per_page'] ?? 20)));
1443 + $page = max(1, absint($data['page'] ?? 1));
536 1444 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
537 1445
538 - if (isset($data['searchInput'])) {
539 - $query = strtolower($data['searchInput']);
1446 + if (!empty($data['query'])) {
1447 + $query = strtolower($data['query']);
540 1448 $firstThreeChars = substr($query, 0, 3);
541 1449
542 1450 if($firstThreeChars == 'id:') {
543 1451 $idPart = substr($query, 3);
@@ -543,22 +1451,560 @@
543 1451 $idPart = substr($query, 3);
544 1452 $idPart = preg_replace('/[^a-zA-Z0-9]/', '', $idPart);
545 1453 $tasksQuery = $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
546 1454 } else {
547 - $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['searchInput'] . '%');
1455 + $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
548 1456 }
549 1457 }
550 1458
551 - // if board_id is not passed then throw an exception
552 - if (!$boardId) {
553 - throw new \Exception('Board id is required', 'fluent-boards');
1459 + $tasks = $tasksQuery->orderBy('created_at', 'DESC')->with('assignees')->paginate($perPage, ['*'], 'page', $page);
1460 +
1461 + $taskIds = [];
1462 + foreach ($tasks as $task) {
1463 + $taskIds[] = (int) $task->id;
1464 + $task->archived_by_id = null;
1465 + $task->archived_by = null;
554 1466 }
555 1467
556 - return $tasksQuery->orderBy('created_at', 'DESC')->with('assignees')->paginate($per_page, ['*'], 'page', $page);
1468 + if (empty($taskIds)) {
1469 + return $tasks;
1470 + }
1471 +
1472 + $activityIds = Activity::whereIn('object_id', $taskIds)
1473 + ->where('object_type', Constant::ACTIVITY_TASK)
1474 + ->where('action', 'archived')
1475 + ->where('column', 'task')
1476 + ->selectRaw('MAX(id) as id')
1477 + ->groupBy('object_id')
1478 + ->pluck('id')
1479 + ->toArray();
1480 +
1481 + if (empty($activityIds)) {
1482 + return $tasks;
1483 + }
1484 +
1485 + $activities = Activity::whereIn('id', $activityIds)
1486 + ->with('user')
1487 + ->get()
1488 + ->keyBy('object_id');
1489 +
1490 + foreach ($tasks as $task) {
1491 + $activity = $activities->get($task->id);
1492 +
1493 + if (!$activity) {
1494 + continue;
1495 + }
1496 +
1497 + $task->archived_by_id = $activity->created_by ? (int) $activity->created_by : null;
1498 + $task->archived_by = $activity->user ? Helper::sanitizeUserCollections($activity->user) : null;
1499 + }
1500 +
1501 + return $tasks;
557 1502 }
558 1503
1504 + /** Fetch a page of board tasks, applying filters before pagination. */
1505 + public function getTableTasks($boardId, $data = [])
1506 + {
1507 + $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20;
1508 + $page = isset($data['page']) ? intval($data['page']) : 1;
1509 + $sortBy = isset($data['sort_by']) ? sanitize_text_field($data['sort_by']) : 'position';
1510 + $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc';
1511 + $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1512 + $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
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', []));
1516 + $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1517 + $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1518 + $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1519 + $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
1520 + $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
1521 + $customFieldFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'custom_fields', []));
1522 + $dueDateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'due_date', []));
1523 + $includeArchived = !empty($data['include_archived']) || in_array('archived', $taskStatusFilters, true);
1524 + $board = Board::select('id', 'type')->find($boardId);
1525 + $isRoadmapBoard = $board && $board->type === 'roadmap';
1526 +
1527 + $perPage = max(1, min(150, $perPage));
1528 + $page = max(1, $page);
1529 + $sortDirection = strtolower($sortDirection) === 'desc' ? 'desc' : 'asc';
1530 +
1531 + $sortColumnMap = [
1532 + 'title' => 'title',
1533 + 'status' => 'status',
1534 + 'stage_id' => 'stage_id',
1535 + 'priority' => 'priority',
1536 + 'due_at' => 'due_at',
1537 + 'created_at' => 'created_at',
1538 + 'updated_at' => 'updated_at',
1539 + 'position' => 'position',
1540 + ];
1541 +
1542 + if ($isRoadmapBoard) {
1543 + $sortColumnMap['vote_statistics'] = 'vote_statistics';
1544 + }
1545 +
1546 + $sortColumn = Arr::get($sortColumnMap, $sortBy, 'position');
1547 + $taskTable = (new Task())->getTable();
1548 + $taskColumnNames = [
1549 + 'id',
1550 + 'title',
1551 + 'slug',
1552 + 'board_id',
1553 + 'parent_id',
1554 + 'crm_contact_id',
1555 + 'type',
1556 + 'stage_id',
1557 + 'status',
1558 + 'priority',
1559 + 'archived_at',
1560 + 'remind_at',
1561 + 'reminder_type',
1562 + 'started_at',
1563 + 'due_at',
1564 + 'last_completed_at',
1565 + 'position',
1566 + 'comments_count',
1567 + 'created_by',
1568 + 'settings',
1569 + 'source',
1570 + 'source_id',
1571 + 'created_at',
1572 + 'updated_at',
1573 + ];
1574 + $taskColumns = array_map(function ($columnName) use ($taskTable) {
1575 + return "{$taskTable}.{$columnName}";
1576 + }, $taskColumnNames);
1577 +
1578 + $tasksQuery = Task::query()
1579 + // Table rows only need row-level fields; modal open rehydrates the full task.
1580 + ->with(['assignees', 'labels', 'watchers'])
1581 + ->where('board_id', $boardId)
1582 + ->whereNull('parent_id');
1583 +
1584 + if ($isRoadmapBoard) {
1585 + $taskSqlTable = $this->getPhysicalTableName($taskTable);
1586 + $taskSqlColumns = [];
1587 +
1588 + foreach ($taskColumnNames as $columnName) {
1589 + $taskSqlColumns[] = "{$taskSqlTable}.{$columnName}";
1590 + }
1591 +
1592 + $taskSqlColumns[] = $this->buildIdeaVoteStatisticsSelect($taskSqlTable) . ' as vote_statistics';
1593 + $tasksQuery->selectRaw(implode(', ', $taskSqlColumns));
1594 + } else {
1595 + $tasksQuery->select($taskColumns);
1596 + }
1597 +
1598 + if (!$includeArchived && !$taskStatusFilters) {
1599 + $tasksQuery->whereNull('archived_at');
1600 + }
1601 +
1602 + $this->applyTableTaskSearch($tasksQuery, $search);
1603 + $this->applyTableTaskFilters($tasksQuery, [
1604 + 'stage' => $stageFilters,
1605 + 'task_status' => $taskStatusFilters,
1606 + 'recurring' => $recurringFilters,
1607 + 'template' => $templateFilters,
1608 + 'priority' => $priorityFilters,
1609 + 'assignee' => $assigneeFilters,
1610 + 'labels' => $labelFilters,
1611 + 'watchers' => $watcherFilters,
1612 + 'contact' => $contactFilters,
1613 + 'custom_fields' => $customFieldFilters,
1614 + 'due_date' => $dueDateFilters,
1615 + ]);
1616 +
1617 + if ($sortColumn === 'position') {
1618 + $tasksQuery->orderBy('stage_id', 'asc');
1619 + }
1620 +
1621 + return $tasksQuery
1622 + ->orderBy($sortColumn, $sortDirection)
1623 + ->paginate($perPage, ['*'], 'page', $page);
1624 + }
1625 +
1626 + /** Fetch matching board cards with the same filters as table view. */
1627 + public function getBoardViewTasks($boardId, $data = [])
1628 + {
1629 + $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1630 + $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
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', []));
1634 + $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1635 + $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1636 + $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1637 + $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
1638 + $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
1639 + $customFieldFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'custom_fields', []));
1640 + $dueDateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'due_date', []));
1641 + $includeArchived = !empty($data['include_archived']) || in_array('archived', $taskStatusFilters, true);
1642 +
1643 + $tasksQuery = Task::query()
1644 + // Kanban/List filtering only need board-card fields because opening a
1645 + // task already rehydrates the full payload through the detail endpoint.
1646 + ->select([
1647 + 'id',
1648 + 'title',
1649 + 'slug',
1650 + 'board_id',
1651 + 'parent_id',
1652 + 'crm_contact_id',
1653 + 'type',
1654 + 'stage_id',
1655 + 'status',
1656 + 'reminder_type',
1657 + 'priority',
1658 + 'archived_at',
1659 + 'remind_at',
1660 + 'started_at',
1661 + 'due_at',
1662 + 'last_completed_at',
1663 + 'position',
1664 + 'comments_count',
1665 + 'created_by',
1666 + 'settings',
1667 + 'source',
1668 + 'source_id',
1669 + 'updated_at',
1670 + ])
1671 + ->with(['assignees', 'labels', 'watchers'])
1672 + ->where('board_id', $boardId)
1673 + ->whereNull('parent_id');
1674 +
1675 + if (!$includeArchived && !$taskStatusFilters) {
1676 + $tasksQuery->whereNull('archived_at');
1677 + }
1678 +
1679 + $this->applyTableTaskSearch($tasksQuery, $search);
1680 + $this->applyTableTaskFilters($tasksQuery, [
1681 + 'stage' => $stageFilters,
1682 + 'task_status' => $taskStatusFilters,
1683 + 'recurring' => $recurringFilters,
1684 + 'template' => $templateFilters,
1685 + 'priority' => $priorityFilters,
1686 + 'assignee' => $assigneeFilters,
1687 + 'labels' => $labelFilters,
1688 + 'watchers' => $watcherFilters,
1689 + 'contact' => $contactFilters,
1690 + 'custom_fields' => $customFieldFilters,
1691 + 'due_date' => $dueDateFilters,
1692 + ]);
1693 +
1694 + return $tasksQuery
1695 + ->orderBy('stage_id', 'asc')
1696 + ->orderBy('position', 'asc')
1697 + ->get();
1698 + }
1699 +
1700 + private function sanitizeTableFilterValues($values, $allowEmpty = false)
1701 + {
1702 + if (!is_array($values)) {
1703 + $values = ($values === null || (!$allowEmpty && $values === '')) ? [] : [$values];
1704 + }
1705 +
1706 + return array_values(array_filter(array_map(static function ($value) {
1707 + return sanitize_text_field($value);
1708 + }, $values), static function ($value) use ($allowEmpty) {
1709 + return $allowEmpty || $value !== '';
1710 + }));
1711 + }
1712 +
1713 + private function applyTableTaskSearch($tasksQuery, $search)
1714 + {
1715 + if (!$search) {
1716 + return;
1717 + }
1718 +
1719 + global $wpdb;
1720 +
1721 + $query = strtolower($search);
1722 + $firstThreeChars = substr($query, 0, 3);
1723 +
1724 + if ($firstThreeChars === 'id:') {
1725 + $idPart = preg_replace('/[^a-zA-Z0-9]/', '', substr($query, 3));
1726 + if ($idPart !== '') {
1727 + $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
1728 + }
1729 + return;
1730 + }
1731 +
1732 + $escapedSearch = $wpdb->esc_like($search);
1733 + $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%');
1734 + }
1735 +
1736 + /** Apply board filters to table and card queries, gating Pro-only options. */
1737 + private function applyTableTaskFilters($tasksQuery, $filters)
1738 + {
1739 + $stageFilters = Arr::get($filters, 'stage', []);
1740 + $taskStatusFilters = Arr::get($filters, 'task_status', []);
1741 + $priorityFilters = Arr::get($filters, 'priority', []);
1742 + $assigneeFilters = Arr::get($filters, 'assignee', []);
1743 + $labelFilters = Arr::get($filters, 'labels', []);
1744 + $watcherFilters = Arr::get($filters, 'watchers', []);
1745 + $contactFilters = Arr::get($filters, 'contact', []);
1746 + $customFieldFilters = Arr::get($filters, 'custom_fields', []);
1747 + $dueDateFilters = Arr::get($filters, 'due_date', []);
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 +
1763 + if ($stageFilters) {
1764 + $this->applyTableStageFilters($tasksQuery, $stageFilters);
1765 + }
1766 +
1767 + if ($taskStatusFilters) {
1768 + $this->applyTableTaskStatusFilters($tasksQuery, $taskStatusFilters);
1769 + }
1770 +
1771 + if ($priorityFilters) {
1772 + $priorityFilters = array_map('strtolower', $priorityFilters);
1773 + $hasNoPriorityFilter = in_array('', $priorityFilters, true);
1774 + $selectedPriorities = array_values(array_filter($priorityFilters, static function ($priority) {
1775 + return $priority !== '';
1776 + }));
1777 +
1778 + $tasksQuery->where(function ($query) use ($hasNoPriorityFilter, $selectedPriorities) {
1779 + if ($selectedPriorities) {
1780 + $query->whereIn('priority', $selectedPriorities);
1781 + }
1782 +
1783 + if ($hasNoPriorityFilter) {
1784 + $method = $selectedPriorities ? 'orWhere' : 'where';
1785 + $query->{$method}(function ($priorityQuery) {
1786 + $priorityQuery->whereNull('priority')->orWhere('priority', '');
1787 + });
1788 + }
1789 + });
1790 + }
1791 +
1792 + if ($contactFilters) {
1793 + $contactIds = array_values(array_filter(array_map('intval', $contactFilters)));
1794 + if ($contactIds) {
1795 + $tasksQuery->whereIn('crm_contact_id', $contactIds);
1796 + }
1797 + }
1798 +
1799 + if ($labelFilters) {
1800 + $labelIds = array_values(array_filter(array_map('intval', array_diff($labelFilters, ['no-label']))));
1801 + $includeNoLabel = in_array('no-label', $labelFilters, true);
1802 + $labelTable = (new Label())->getTable();
1803 +
1804 + if ($labelIds || $includeNoLabel) {
1805 + $tasksQuery->where(function ($query) use ($labelIds, $includeNoLabel, $labelTable) {
1806 + if ($includeNoLabel) {
1807 + $query->orWhereDoesntHave('labels');
1808 + }
1809 +
1810 + if ($labelIds) {
1811 + $query->orWhereHas('labels', function ($labelQuery) use ($labelIds, $labelTable) {
1812 + $labelQuery->whereIn($labelTable . '.id', $labelIds);
1813 + });
1814 + }
1815 + });
1816 + }
1817 + }
1818 +
1819 + if ($customFieldFilters) {
1820 + $customFieldIds = array_values(array_filter(array_map('intval', array_diff($customFieldFilters, ['no-custom-field']))));
1821 + $includeNoCustomField = in_array('no-custom-field', $customFieldFilters, true);
1822 +
1823 + if ($customFieldIds || $includeNoCustomField) {
1824 + $tasksQuery->where(function ($query) use ($customFieldIds, $includeNoCustomField) {
1825 + if ($includeNoCustomField) {
1826 + $query->orWhereDoesntHave('taskCustomFields');
1827 + }
1828 +
1829 + if ($customFieldIds) {
1830 + $query->orWhereHas('taskCustomFields', function ($customFieldQuery) use ($customFieldIds) {
1831 + $customFieldQuery->whereIn('foreign_id', $customFieldIds);
1832 + });
1833 + }
1834 + });
1835 + }
1836 + }
1837 +
1838 + if ($dueDateFilters) {
1839 + $this->applyTableDueDateFilters($tasksQuery, $dueDateFilters);
1840 + }
1841 +
1842 + if ($assigneeFilters || $watcherFilters) {
1843 + $this->applyTableAssignmentFilters($tasksQuery, $assigneeFilters, $watcherFilters);
1844 + }
1845 + }
1846 +
1847 + private function applyTableStageFilters($tasksQuery, $stageFilters)
1848 + {
1849 + $stageIds = array_values(array_filter(array_map('intval', array_diff($stageFilters, ['archived']))));
1850 + $includeArchivedStages = in_array('archived', $stageFilters, true);
1851 +
1852 + if (!$stageIds && !$includeArchivedStages) {
1853 + return;
1854 + }
1855 +
1856 + $tasksQuery->where(function ($query) use ($stageIds, $includeArchivedStages) {
1857 + if ($stageIds) {
1858 + $query->orWhereIn('stage_id', $stageIds);
1859 + }
1860 +
1861 + if ($includeArchivedStages) {
1862 + $query->orWhereHas('stage', function ($stageQuery) {
1863 + $stageQuery->whereNotNull('archived_at');
1864 + });
1865 + }
1866 + });
1867 + }
1868 +
1869 + private function applyTableTaskStatusFilters($tasksQuery, $taskStatusFilters)
1870 + {
1871 + $statuses = array_values(array_diff($taskStatusFilters, ['archived']));
1872 + $includeArchived = in_array('archived', $taskStatusFilters, true);
1873 +
1874 + if (!$statuses && !$includeArchived) {
1875 + return;
1876 + }
1877 +
1878 + $tasksQuery->where(function ($query) use ($statuses, $includeArchived) {
1879 + if ($statuses) {
1880 + $query->orWhere(function ($statusQuery) use ($statuses) {
1881 + $statusQuery->whereNull('archived_at')
1882 + ->whereIn('status', $statuses);
1883 + });
1884 + }
1885 +
1886 + if ($includeArchived) {
1887 + $query->orWhereNotNull('archived_at');
1888 + }
1889 + });
1890 + }
1891 +
1892 + private function applyTableDueDateFilters($tasksQuery, $dueDateFilters)
1893 + {
1894 + $dueDateFilters = array_values(array_intersect($dueDateFilters, [
1895 + 'overdue',
1896 + 'no-dates',
1897 + 'today',
1898 + 'this-week',
1899 + 'next-week',
1900 + 'this-month',
1901 + 'upcoming',
1902 + ]));
1903 +
1904 + if (!$dueDateFilters) {
1905 + return;
1906 + }
1907 +
1908 + $nowTimestamp = current_time('timestamp');
1909 + $startOfTodayTimestamp = strtotime(gmdate('Y-m-d 00:00:00', $nowTimestamp));
1910 + $dayOfWeek = (int) gmdate('w', $nowTimestamp);
1911 + $startOfThisWeekTimestamp = strtotime('-' . $dayOfWeek . ' days', $startOfTodayTimestamp);
1912 + $startOfToday = gmdate('Y-m-d 00:00:00', $nowTimestamp);
1913 + $endOfToday = gmdate('Y-m-d 23:59:59', $nowTimestamp);
1914 + $startOfThisWeek = gmdate('Y-m-d 00:00:00', $startOfThisWeekTimestamp);
1915 + $startOfNextWeek = gmdate('Y-m-d 00:00:00', strtotime('+7 days', $startOfThisWeekTimestamp));
1916 + $startOfWeekAfterNext = gmdate('Y-m-d 00:00:00', strtotime('+14 days', $startOfThisWeekTimestamp));
1917 + $endOfThisMonth = gmdate('Y-m-t 23:59:59', $nowTimestamp);
1918 + $nowMysql = current_time('mysql');
1919 +
1920 + $tasksQuery->where(function ($query) use ($dueDateFilters, $startOfToday, $endOfToday, $startOfThisWeek, $startOfNextWeek, $startOfWeekAfterNext, $endOfThisMonth, $nowMysql) {
1921 + foreach ($dueDateFilters as $filter) {
1922 + switch ($filter) {
1923 + case 'overdue':
1924 + $query->orWhere(function ($dueQuery) use ($nowMysql) {
1925 + $dueQuery->whereNull('last_completed_at')
1926 + ->whereNotNull('due_at')
1927 + ->where('due_at', '<=', $nowMysql);
1928 + });
1929 + break;
1930 + case 'no-dates':
1931 + $query->orWhereNull('due_at');
1932 + break;
1933 + case 'today':
1934 + $query->orWhereBetween('due_at', [$startOfToday, $endOfToday]);
1935 + break;
1936 + case 'this-week':
1937 + $query->orWhereBetween('due_at', [$startOfThisWeek, $startOfNextWeek]);
1938 + break;
1939 + case 'next-week':
1940 + $query->orWhereBetween('due_at', [$startOfNextWeek, $startOfWeekAfterNext]);
1941 + break;
1942 + case 'this-month':
1943 + $query->orWhereBetween('due_at', [$nowMysql, $endOfThisMonth]);
1944 + break;
1945 + case 'upcoming':
1946 + $query->orWhere(function ($upcomingQuery) use ($nowMysql) {
1947 + $upcomingQuery->whereNull('last_completed_at')
1948 + ->whereNotNull('due_at')
1949 + ->where('due_at', '>=', $nowMysql);
1950 + });
1951 + break;
1952 + }
1953 + }
1954 + });
1955 + }
1956 +
1957 + private function applyTableAssignmentFilters($tasksQuery, $assigneeFilters, $watcherFilters)
1958 + {
1959 + $assigneeIds = array_values(array_filter(array_map('intval', array_diff($assigneeFilters, ['no-assignee']))));
1960 + $watcherIds = array_values(array_filter(array_map('intval', $watcherFilters)));
1961 + $includeNoAssignee = in_array('no-assignee', $assigneeFilters, true);
1962 + $commonIds = array_values(array_intersect($assigneeIds, $watcherIds));
1963 + $assigneeOnlyIds = array_values(array_diff($assigneeIds, $commonIds));
1964 + $watcherOnlyIds = array_values(array_diff($watcherIds, $commonIds));
1965 +
1966 + if ($commonIds) {
1967 + // Shared watcher/assignee filters are treated as an OR group, matching
1968 + // the existing client-side filter semantics.
1969 + $tasksQuery->where(function ($query) use ($commonIds) {
1970 + $query->whereHas('assignees', function ($assigneeQuery) use ($commonIds) {
1971 + $assigneeQuery->whereIn('users.ID', $commonIds);
1972 + })->orWhereHas('watchers', function ($watcherQuery) use ($commonIds) {
1973 + $watcherQuery->whereIn('users.ID', $commonIds);
1974 + });
1975 + });
1976 + }
1977 +
1978 + if ($includeNoAssignee || $assigneeOnlyIds) {
1979 + $tasksQuery->where(function ($query) use ($includeNoAssignee, $assigneeOnlyIds) {
1980 + if ($includeNoAssignee) {
1981 + $query->orWhereDoesntHave('assignees');
1982 + }
1983 +
1984 + if ($assigneeOnlyIds) {
1985 + $query->orWhereHas('assignees', function ($assigneeQuery) use ($assigneeOnlyIds) {
1986 + $assigneeQuery->whereIn('users.ID', $assigneeOnlyIds);
1987 + });
1988 + }
1989 + });
1990 + }
1991 +
1992 + if ($watcherOnlyIds) {
1993 + $tasksQuery->whereHas('watchers', function ($watcherQuery) use ($watcherOnlyIds) {
1994 + $watcherQuery->whereIn('users.ID', $watcherOnlyIds);
1995 + })->whereDoesntHave('assignees', function ($assigneeQuery) use ($watcherOnlyIds) {
1996 + $assigneeQuery->whereIn('users.ID', $watcherOnlyIds);
1997 + });
1998 + }
1999 + }
2000 +
559 2001 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
560 2002 {
2003 + if (empty($assigneeIds)) {
2004 + return;
2005 + }
2006 +
561 2007 $current_user_id = get_current_user_id();
562 2008 /* this will run in background as soon as possible */
563 2009 /* sending Model or Model Instance won't work here */
564 2010
@@ -567,14 +2013,17 @@
567 2013
568 2014 public function getStageByTask($task_id)
569 2015 {
570 2016 $task = Task::find($task_id);
2017 + if (!$task || !PermissionManager::userHasPermission($task->board_id)) {
2018 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
2019 + }
571 2020 return $task->stage;
572 2021 }
573 2022
574 - public function moveTaskToNextStage($task_id)
2023 + public function moveTaskToNextStage($task_id, $boardId = null)
575 2024 {
576 - $task = Task::findOrFail($task_id);
2025 + $task = $boardId ? $this->findTaskOnBoard($task_id, $boardId) : Task::findOrFail($task_id);
577 2026
578 2027 $oldStage = $task->stage;
579 2028
580 2029 $nextStage = Stage::where('board_id', $task->board_id)
@@ -592,8 +2041,11 @@
592 2041 $task->last_completed_at = current_time('mysql');
593 2042 }
594 2043 }
595 2044
2045 + // Clean up archived_by_stage meta when moving to different stage
2046 + $this->cleanupArchivedByStageMetaIfExists($task->id);
2047 +
596 2048 $task->stage_id = $nextStage->id;
597 2049 $task->save();
598 2050
599 2051 $task->load(['board', 'stage', 'attachments']);
@@ -616,8 +2068,9 @@
616 2068 }
617 2069
618 2070 public function getActivities($taskId, $perPage, $filter = 'newest')
619 2071 {
2072 + $perPage = Sanitize::perPage($perPage);
620 2073 $activityQuery = Activity::where('object_id', $taskId)
621 2074 ->where('object_type', Constant::ACTIVITY_TASK);
622 2075 if ($filter == 'newest') {
623 2076 $activityQuery = $activityQuery->latest();
@@ -623,27 +2076,36 @@
623 2076 $activityQuery = $activityQuery->latest();
624 2077 } else if ($filter == 'oldest') {
625 2078 $activityQuery = $activityQuery->oldest();
626 2079 }
627 - return $activityQuery->with('user')->paginate($perPage);
2080 + $activities = $activityQuery->with('user')->paginate($perPage);
2081 +
2082 + Helper::translateActivities($activities);
2083 +
2084 + return $activities;
628 2085 }
629 2086
630 - public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null)
2087 + public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null, $includeArchived = true)
631 2088 {
632 2089 if (!$lastUpdated) {
633 - $lastUpdated = gmdate('Y-m-d H:i:s', current_time('timestamp') - 60);
2090 + $lastUpdated = date_i18n('Y-m-d H:i:s', current_time('timestamp') - 60); // 1 minute ago
634 2091 }
635 2092
636 - $tasks = Task::query()
2093 + $tasksQuery = Task::query()
637 2094 ->where([
638 2095 'board_id' => $boardId,
639 2096 'parent_id' => null,
640 2097 ])
641 - ->where('updated_at', '>', $lastUpdated)
642 - ->with(['assignees', 'labels', 'watchers'])
643 - ->orderBy('due_at', 'ASC')
644 - ->get();
2098 + ->where('updated_at', '>=', $lastUpdated) // updated since the sync cursor
2099 + ->with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
2100 + ->orderBy('due_at', 'ASC');
645 2101
2102 + if (!$includeArchived) {
2103 + $tasksQuery->whereNull('archived_at');
2104 + }
2105 +
2106 + $tasks = $tasksQuery->get();
2107 +
646 2108 foreach ($tasks as $task) {
647 2109 $task->isOverdue = $task->isOverdue();
648 2110 $task->isUpcoming = $task->upcoming();
649 2111 $task->is_watching = $task->isWatching();
@@ -666,80 +2128,239 @@
666 2128
667 2129 return $lastPosition + 1;
668 2130 }
669 2131
670 - public function getAssociatedTasks($associatedId)
2132 + /**
2133 + * Pin a task: set is_pinned in task meta only. No position change.
2134 + * Only parent tasks can be pinned.
2135 + *
2136 + * @param \FluentBoards\App\Models\Task $task
2137 + * @return \FluentBoards\App\Models\Task
2138 + */
2139 + public function pinTask($task)
671 2140 {
672 - $tasks = Task::query()
2141 + if ($task->parent_id !== null) {
2142 + return $task;
2143 + }
2144 +
2145 + $task->updateMeta(Constant::IS_TASK_PINNED, 1);
2146 +
2147 + return $task;
2148 + }
2149 +
2150 + /**
2151 + * Unpin a task: set is_pinned in task meta only. No position change.
2152 + *
2153 + * @param \FluentBoards\App\Models\Task $task
2154 + * @return \FluentBoards\App\Models\Task
2155 + */
2156 + public function unpinTask($task)
2157 + {
2158 + $task->updateMeta(Constant::IS_TASK_PINNED, 0);
2159 +
2160 + return $task;
2161 + }
2162 +
2163 + /**
2164 + * Get CRM-associated tasks and mark whether the current user may edit each task's board.
2165 + *
2166 + * @param int $associatedId CRM contact/subscriber id associated with tasks.
2167 + * @param int|null $userId User id for board permission checks.
2168 + * @return \FluentBoards\Framework\Database\Orm\Collection|array
2169 + */
2170 + public function getAssociatedTasks($associatedId, $userId = null)
2171 + {
2172 + $associatedId = absint($associatedId);
2173 + $userId = $userId ?: get_current_user_id();
2174 +
2175 + if (!$associatedId || !$userId) {
2176 + return [];
2177 + }
2178 +
2179 + $isAdmin = PermissionManager::isAdmin($userId);
2180 + $editableBoardIds = [];
2181 +
2182 + $tasksQuery = Task::query()
673 2183 ->where('crm_contact_id', $associatedId)
674 - ->with(['board', 'stage', 'assignees', 'labels', 'watchers',])
675 - ->orderBy('due_at', 'ASC')
676 - ->get();
2184 + ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
2185 + ->orderBy('due_at', 'ASC');
677 2186
2187 + if ($isAdmin) {
2188 + $tasksQuery->whereHas('board', function ($query) {
2189 + $query->whereNull('archived_at');
2190 + });
2191 + } else {
2192 + $editableBoardIds = array_map('intval', PermissionManager::getBoardIdsForUser($userId));
2193 +
2194 + if (!$editableBoardIds) {
2195 + return [];
2196 + }
2197 +
2198 + $tasksQuery->whereIn('board_id', $editableBoardIds);
2199 + }
2200 +
2201 + $tasks = $tasksQuery->get();
2202 +
678 2203 foreach ($tasks as $task) {
679 2204 $task->isOverdue = $task->isOverdue();
680 2205 $task->isUpcoming = $task->upcoming();
681 - $task->contact = Task::lead_contact($task->crm_contact_id);
682 - $task->is_watching = $task->isWatching();
2206 + $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
683 2207
684 2208 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
685 - $task->watchers = Helper::sanitizeUserCollections($task->watchers);
686 2209
687 - $subTasks = Task::query()
688 - ->where('parent_id', $task->id)
689 - ->with(['assignees'])
690 - ->whereNull('archived_at')
691 - ->orderBy('position', 'ASC')
692 - ->get();
2210 + foreach ($task->subtaskGroup as $group) {
2211 + foreach ($group->subtasks as $subtask) {
2212 + $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
2213 + }
2214 + }
2215 + $task->subtask_group = $task->subtaskGroup;
2216 + }
693 2217
694 - foreach ($subTasks as $subTask) {
695 - $subTask->assignees = Helper::sanitizeUserCollections($subTask->assignees);
696 - }
2218 + return $tasks;
2219 + }
697 2220
698 - $task->subtasks = $subTasks;
2221 + public function copySubtaskGroup($task, $newTask, $subtaskGroupMap)
2222 + {
2223 + $subtaskGroups = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_NAME)->get();
2224 + foreach ($subtaskGroups as $group) {
2225 + $newGroup = TaskMeta::create([
2226 + 'task_id' => $newTask->id,
2227 + 'key' => Constant::SUBTASK_GROUP_NAME,
2228 + 'value' => $group->value
2229 + ]);
2230 +
2231 + $subtaskGroupMap[$group->id] = $newGroup->id;
699 2232 }
700 2233
701 - return $tasks;
2234 + return $subtaskGroupMap;
702 2235 }
703 2236
704 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [])
2237 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = [])
705 2238 {
706 - $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();
707 2243 $taskMap = [];
2244 + $subtaskGroupMap = [];
708 2245 $parentTaskCount = 0;
709 - foreach ($allActiveTasks as $task) {
710 - $newTask = array();
711 - $newTask['title'] = $task->title;
712 - $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
713 - $newTask['description'] = $task->description;
714 - $newTask['board_id'] = $newBoard->id;
715 - $newTask['stage_id'] = $stageMap[$task->stage_id];
716 - $newTask['status'] = $task->status;
717 - $newTask['priority'] = $task->priority;
718 - $newTask['position'] = $task->position;
719 - $newTask['due_at'] = $task->due_at;
720 - $newTask = Task::create($newTask);
721 - if(!$task->parent_id){
722 - ++$parentTaskCount;
723 - $taskMap[$task['id']] = $newTask->id;
724 - //duplicate labels to task
725 - $labelIds = $task->labels->pluck('id')->toArray();
726 - if($labelIds){
727 - $flipLabelIds = array_flip($labelIds);
728 - $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
2246 + $attachmentFileService = new AttachmentFileService();
2247 + $dbInstance = App::getInstance('db');
729 2248
730 - $newTask->labels()->attach($labelsToAttach, [
731 - 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
732 - ]);
2249 + $dbInstance->beginTransaction();
2250 +
2251 + try {
2252 + foreach ($allActiveTasks as $task) {
2253 + if ($task->parent_id && empty($taskMap[$task->parent_id])) {
2254 + continue;
733 2255 }
2256 +
2257 + $stageId = !empty($task->stage_id) ? (int) $task->stage_id : 0;
2258 + if (!$task->parent_id && empty($stageMap[$stageId])) {
2259 + continue;
2260 + }
2261 +
2262 + $newTask = array();
2263 + $newTask['title'] = $task->title;
2264 + $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
2265 + $newTask['description'] = DescriptionMarkdownConverter::normalize($task->description);
2266 + $newTask['board_id'] = $newBoard->id;
2267 + $newTask['stage_id'] = $stageId && isset($stageMap[$stageId]) ? $stageMap[$stageId] : null;
2268 + $newTask['status'] = $task->status;
2269 + $newTask['priority'] = $task->priority;
2270 + $newTask['position'] = $task->position;
2271 + $newTask['due_at'] = $task->due_at;
2272 + $backgroundColor = $task->settings['cover']['backgroundColor'] ?? '';
2273 + $newTask['settings'] = [
2274 + 'cover' => [
2275 + 'backgroundColor' => $backgroundColor,
2276 + ]
2277 + ];
2278 +
2279 + $newTask = Task::create($newTask);
2280 + $attachmentFileService->cloneTaskFilesToBoard($task, $newTask, $newBoard->id);
2281 +
2282 + if (!$task->parent_id) {
2283 + //group mapping
2284 + $subtaskGroupMap = $this->copySubtaskGroup($task, $newTask, $subtaskGroupMap);
2285 + } else {
2286 + $groupRelationOfTask = TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
2287 + ->where('task_id', $task->id)
2288 + ->first();
2289 +
2290 + if ($groupRelationOfTask && !empty($subtaskGroupMap[$groupRelationOfTask->value])) {
2291 + TaskMeta::create([
2292 + 'task_id' => $newTask->id,
2293 + 'key' => Constant::SUBTASK_GROUP_CHILD,
2294 + 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
2295 + ]);
2296 + }
2297 + }
2298 +
2299 + if($isWithTemplates == 'yes') {
2300 + $isTemplate = TaskMeta::where('task_id', $task->id)
2301 + ->where('key', 'is_template')
2302 + ->first();
2303 + if($isTemplate) {
2304 + TaskMeta::create([
2305 + 'task_id' => $newTask->id,
2306 + 'key' => 'is_template',
2307 + 'value' => $isTemplate->value
2308 + ]);
2309 + }
2310 + }
2311 + $this->copyTaskCustomFields($task, $newTask, $customFieldMap);
2312 + if(!$task->parent_id){
2313 + ++$parentTaskCount;
2314 + $taskMap[$task['id']] = $newTask->id;
2315 + //duplicate labels to task
2316 + $labelIds = $task->labels->pluck('id')->toArray();
2317 + if($labelIds){
2318 + $flipLabelIds = array_flip($labelIds);
2319 + $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
2320 +
2321 + $newTask->labels()->attach($labelsToAttach, [
2322 + 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
2323 + ]);
2324 + }
2325 + }
734 2326 }
2327 +
2328 + $board = Board::findOrFail($newBoard->id);
2329 + $settings = [];
2330 + $settings['tasks_count'] = $parentTaskCount;
2331 + $board->settings = $settings;
2332 + $board->save();
2333 +
2334 + $dbInstance->commit();
2335 + } catch (\Exception $e) {
2336 + $dbInstance->rollBack();
2337 + $attachmentFileService->rollbackCreatedFiles();
2338 + throw $e;
735 2339 }
2340 + }
736 2341
737 - $board = Board::findOrFail($newBoard->id);
738 - $settings = [];
739 - $settings['tasks_count'] = $parentTaskCount;
740 - $board->settings = $settings;
741 - $board->save();
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 + }
742 2363 }
743 2364
744 2365 private function subtaskCountUpdate($taskId){
745 2366 $parentTask = Task::findOrFail($taskId);
@@ -751,68 +2372,110 @@
751 2372
752 2373 /**
753 2374 * @param $taskId
754 2375 * @param $perPage
755 - * @param $offset
2376 + * @param $page
756 2377 * @param string $filter
2378 + * @param $boardId
2379 + * @param string $feedType
757 2380 * @return array
758 2381 */
759 - public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest'): array
2382 + public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
760 2383 {
2384 + $perPage = Sanitize::perPage($perPage);
2385 + $page = max(1, (int) $page);
761 2386 // Fetch the task
762 - $task = Task::findOrFail($taskId);
2387 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2388 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
763 2389
764 - // Fetch comments and activities separately
765 - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
766 - $activities = $task->activities()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
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;
767 2394
768 - // Merge comments and activities into a single array
769 - $commentsAndActivities = array_merge($comments, $activities);
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 + });
770 2403
771 - // Sort the merged array by created_at date in ascending or descending order
772 - $order = $filter == 'newest' ? -1 : 1;
773 - usort($commentsAndActivities, function ($a, $b) use ($order) {
774 - return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
2404 + $entries = [];
2405 + $total = 0;
2406 + if ($feedType !== 'activities') {
2407 + $total += (clone $commentQuery)->count();
2408 + foreach ((clone $commentQuery)->reorder('created_at', $direction)->orderBy('id', $direction)->limit($limit)->get(['id', 'created_at']) as $row) {
2409 + $entries[] = ['comment', (int) $row->id, $row->created_at];
2410 + }
2411 + }
2412 + if ($feedType !== 'comments') {
2413 + $total += (clone $activityQuery)->count();
2414 + foreach ((clone $activityQuery)->reorder('created_at', $direction)->orderBy('id', $direction)->limit($limit)->get(['id', 'created_at']) as $row) {
2415 + $entries[] = ['activity', (int) $row->id, $row->created_at];
2416 + }
2417 + }
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]));
775 2423 });
2424 + $entries = array_slice($entries, $offset, $perPage);
776 2425
777 - // Paginate the results
778 - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
779 - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
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 + }
780 2438
781 - // Get the total count of comments and activities
782 - $total = count($commentsAndActivities);
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 + }
2445 +
783 2446 $lastPage = (int) ceil($total / $perPage);
784 2447
785 2448 // Construct pagination metadata
786 - $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");
787 2450 return [
788 2451 'current_page' => (int) $page,
789 2452 'data' => $paginatedResults,
790 - 'first_page_url' => "{$path}?page=1",
2453 + 'first_page_url' => add_query_arg('page', 1, $path),
791 2454 'from' => $total > 0 ? (int) ($offset + 1) : null,
792 2455 'last_page' => (int) $lastPage,
793 - 'last_page_url' => "{$path}?page={$lastPage}",
2456 + 'last_page_url' => add_query_arg('page', $lastPage, $path),
794 2457 'links' => [
795 2458 [
796 - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2459 + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
797 2460 'label' => 'pagination.previous',
798 2461 'active' => false
799 2462 ],
800 2463 [
801 - 'url' => "{$path}?page={$page}",
2464 + 'url' => add_query_arg('page', $page, $path),
802 2465 'label' => (int) $page,
803 2466 'active' => true
804 2467 ],
805 2468 [
806 - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2469 + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
807 2470 'label' => 'pagination.next',
808 2471 'active' => false
809 2472 ]
810 2473 ],
811 - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2474 + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
812 2475 'path' => $path,
813 2476 'per_page' => (int) $perPage,
814 - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2477 + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
815 2478 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
816 2479 'total' => (int) $total
817 2480 ];
818 2481 }
@@ -859,9 +2522,9 @@
859 2522 * @param $title
860 2523 * @param $UrlMeta
861 2524 * @return mixed|string
862 2525 */
863 - private function setTitle($type, $title, $UrlMeta)
2526 + public function setTitle($type, $title, $UrlMeta)
864 2527 {
865 2528 if($type != 'url') {
866 2529 return sanitize_file_name($title);
867 2530 }
@@ -867,6 +2530,1067 @@
867 2530 }
868 2531 return $title ?? $UrlMeta['title'] ?? '';
869 2532 }
870 2533
2534 + /** Apply missing stage assignees and report whether any assignments were added. */
2535 + public function manageDefaultAssignees($task, $stageId, ?Stage $stage = null)
2536 + {
2537 + if (!$stage || (int) $stage->id !== (int) $stageId) {
2538 + $stage = Stage::findOrFail($stageId);
2539 + }
2540 + $assignmentsChanged = false;
2541 + if ($stage && isset($stage->settings['default_task_assignees'])) {
2542 + $defaultAssignees = $stage->settings['default_task_assignees'];
2543 + foreach ($defaultAssignees as $assigneeId) {
2544 + $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
2545 + $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
2546 + if (!$IfAlreadyAssignee) {
2547 + $this->updateAssignee($assigneeId, $task);
2548 + $assignmentsChanged = true;
2549 + }
2550 + }
2551 + }
2552 + return $assignmentsChanged;
2553 + }
2554 +
2555 + public function manageDefaultWatchers($task, $stageId)
2556 + {
2557 + $stage = Stage::findOrFail($stageId);
2558 + if ($stage) {
2559 + $settings = $stage->settings;
2560 + $defaultWatchers = [];
2561 + if (isset($settings['default_task_watchers']) && is_array($settings['default_task_watchers'])) {
2562 + $defaultWatchers = $settings['default_task_watchers'];
2563 + }
2564 + if (isset($settings['default_task_assignees']) && is_array($settings['default_task_assignees'])) {
2565 + $defaultWatchers = array_unique(array_merge($defaultWatchers, $settings['default_task_assignees']));
2566 + }
2567 + foreach ($defaultWatchers as $watcherId) {
2568 + $alreadyWatcherIds = $task->watchers->pluck('ID')->toArray();
2569 + $isAlreadyWatcher = in_array($watcherId, $alreadyWatcherIds);
2570 + if (!$isAlreadyWatcher) {
2571 + $task->watchers()->syncWithoutDetaching([
2572 + $watcherId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]
2573 + ]);
2574 + }
2575 + }
2576 + }
2577 + }
2578 +
2579 + public function setDefaultAssigneesToEveryTasks($stage)
2580 + {
2581 + $tasks = $stage->tasks->whereNull('archived_at');
2582 + foreach ($tasks as $task) {
2583 + $this->manageDefaultAssignees($task, $stage->id);
2584 + }
2585 + }
2586 +
2587 + public function createTaskFromImage($board_id, $stage_id, $uploadInfo, $file)
2588 + {
2589 +
2590 + $board = Board::find($board_id);
2591 + $stage = Stage::where('id', absint($stage_id))
2592 + ->where('board_id', absint($board_id))
2593 + ->first();
2594 +
2595 + if (!$board || !$stage) {
2596 + throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
2597 + }
2598 +
2599 + $task = new Task();
2600 + $taskType = $board->type === 'to-do' ? 'task' : 'roadmap' ;
2601 + $taskData = [
2602 + 'title' => $uploadInfo[0]['name'],
2603 + 'board_id' => $board_id,
2604 + 'stage_id' => $stage_id,
2605 + 'type' => $taskType,
2606 + ];
2607 + $task->fill($taskData);
2608 + $task->save();
2609 +
2610 + $fileData = $uploadInfo[0];
2611 + $fileUploadedData = $this->uploadMediaFileFromWpEditor($task->id, $fileData, Constant::TASK_DESCRIPTION);
2612 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
2613 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
2614 + $fileUploadedData['driver'] = $mediaData['driver'];
2615 + $fileUploadedData['file_path'] = $mediaData['file_path'];
2616 + $fileUploadedData['full_url'] = $mediaData['full_url'];
2617 + $fileUploadedData->save();
2618 + }
2619 +
2620 + $settings = $task->settings;
2621 + $settings['cover'] = [
2622 + 'imageId' => $fileUploadedData['id'],
2623 + 'backgroundImage' => (new CommentService())->createPublicUrl($fileUploadedData, $board_id),
2624 + ];
2625 + $task->settings = $settings;
2626 + $task = $task->moveToNewPosition(1);
2627 + $task->save();
2628 + $task->load(['board', 'stage', 'labels', 'assignees']);
2629 +
2630 + $task->assignees = Helper::sanitizeUserCollections($task->assignees);
2631 +
2632 + $task->isOverdue = $task->isOverdue();
2633 + $task->contact = Task::lead_contact($task->crm_contact_id);
2634 + $task->board->stages = (new StageService())->stagesByBoardId($board_id);
2635 + $task->is_watching = (new NotificationService())->isCurrentUserObservingTask($task);
2636 +
2637 + $task = $this->loadNextStage($task);
2638 +
2639 + if ($task->type == 'roadmap') {
2640 + $task->vote_statistics = $this->getIdeaVoteStatistics($task->id);
2641 + }
2642 +
2643 + return $task;
2644 + }
2645 + public function deleteTaskCoverImage($settings)
2646 + {
2647 + if (isset($settings['cover']['imageId']) && $settings['cover']['imageId']) {
2648 + $image = TaskImage::find($settings['cover']['imageId']);
2649 + if ($image) {
2650 + $deletedImage = clone $image;
2651 + $deletedImage->delete();
2652 +
2653 + do_action('fluent_boards/task_attachment_deleted', $deletedImage);
2654 + }
2655 + }
2656 +
2657 + }
2658 +
2659 + private function deleteTaskAttachments($task)
2660 + {
2661 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2662 + return;
2663 + }
2664 +
2665 + $attachments = TaskAttachment::where('object_id', $task->id)
2666 + ->where('object_type', Constant::TASK_ATTACHMENT)
2667 + ->get();
2668 + foreach ($attachments as $attachment) {
2669 + $deletedAttachment = clone $attachment;
2670 + $attachment->delete();
2671 +
2672 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2673 + }
2674 + }
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 +
2702 + public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2703 + {
2704 + global $wpdb;
2705 + $attachmentFileService = new AttachmentFileService();
2706 +
2707 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2708 + $wpdb->query('START TRANSACTION');
2709 +
2710 + try {
2711 + // Load task with all necessary relationships
2712 + $taskQuery = Task::with([
2713 + 'assignees',
2714 + 'labels',
2715 + 'watchers',
2716 + ])->where('id', $taskId);
2717 +
2718 + if ($boardId) {
2719 + $taskQuery->where('board_id', absint($boardId));
2720 + }
2721 +
2722 + $task = $taskQuery->first();
2723 +
2724 + if (!$task) {
2725 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
2726 + }
2727 +
2728 + // Create new task with cloned data
2729 + $clonedTask = $task->replicate();
2730 + /* translators: %s is the title of the task being cloned */
2731 + $clonedTask->title = $taskData['title'] ?? sprintf(\__('%s (cloned)', 'fluent-boards'), $task->title);
2732 +
2733 + $settings = $clonedTask->settings ?? [];
2734 +
2735 + unset(
2736 + $settings['attachment_count'],
2737 + $settings['subtask_completed_count'],
2738 + $settings['subtask_count']
2739 + );
2740 + $clonedTask->settings = $settings;
2741 + $clonedTask->stage_id = $taskData['stage_id'] ?? $task->stage_id;
2742 +
2743 + // Validate that target stage belongs to the same board
2744 + $targetStage = Stage::where('id', $clonedTask->stage_id)
2745 + ->where('board_id', $task->board_id)
2746 + ->first();
2747 +
2748 + if (!$targetStage) {
2749 + throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
2750 + }
2751 +
2752 + $clonedTask->board_id = $targetStage->board_id;
2753 +
2754 + $clonedTask->comments_count = 0; // Reset comments count for cloned task
2755 + $clonedTask->save();
2756 +
2757 + $positionIndex = 1; // Default position index for new task
2758 + if($task->stage_id === $clonedTask->stage_id) {
2759 + // Calculate position for the cloned task next to original task
2760 + $positionIndex = $this->calculateClonedTaskPosition($task);
2761 + }
2762 + // Move cloned task to the new position
2763 + $clonedTask->moveToNewPosition($positionIndex);
2764 +
2765 + $this->cloneTaskMeta($task, $clonedTask);
2766 +
2767 + $this->cloneTaskCustomFields($task, $clonedTask);
2768 +
2769 + // Apply stage default assignees if any are set
2770 + $this->manageDefaultAssignees($clonedTask, $clonedTask->stage_id);
2771 +
2772 + if($taskData['assignee']) {
2773 + $this->cloneAssignees($task, $clonedTask);
2774 + }
2775 + if($taskData['label']) {
2776 + $this->cloneTaskLabels($task, $clonedTask);
2777 + }
2778 + $this->cloneTaskWatchers($task, $clonedTask);
2779 +
2780 + $attachmentFileService->cloneTaskFilesToBoard($task, $clonedTask, $clonedTask->board_id, [
2781 + 'description_images' => true,
2782 + 'cover' => true,
2783 + 'task_attachments' => (bool) $taskData['attachment'],
2784 + ]);
2785 +
2786 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
2787 + // Clone time tracking data if Pro version is active
2788 + if ($taskData['subtask']) {
2789 + $this->cloneSubtasks($task, $clonedTask, (bool) $taskData['attachment'], $attachmentFileService);
2790 + }
2791 + }
2792 +
2793 + if($taskData['comment']) {
2794 + $this->cloneCommentsAndReplies($task, $clonedTask);
2795 + }
2796 +
2797 + // Load and prepare the cloned task for response
2798 + $clonedTask = $this->prepareClonedTaskForResponse($clonedTask);
2799 + do_action('fluent_boards/task_cloned', $task, $clonedTask);
2800 +
2801 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2802 + $wpdb->query('COMMIT');
2803 + return $clonedTask;
2804 +
2805 + } catch (\Exception $e) {
2806 + $attachmentFileService->rollbackCreatedFiles();
2807 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2808 + $wpdb->query('ROLLBACK');
2809 + throw new \Exception(
2810 + /* translators: %s is the error message */
2811 + esc_html(sprintf(\__('Failed to clone task: %s', 'fluent-boards'), $e->getMessage())),
2812 + (int) ($e->getCode() ?: 500)
2813 + );
2814 + }
2815 + }
2816 +
2817 + private function calculateClonedTaskPosition(Task $originalTask): int
2818 + {
2819 + $tasks = Task::where('stage_id', $originalTask->stage_id)
2820 + ->whereNull('archived_at')
2821 + ->orderBy('position', 'asc')
2822 + ->get();
2823 +
2824 + $index = $tasks->search(function($task) use ($originalTask) {
2825 + return $task->id === $originalTask->id;
2826 + });
2827 +
2828 + return $index !== false ? $index + 2 : 1; // Return 1-based index
2829 + }
2830 +
2831 + private function cloneTaskMeta(Task $originalTask, Task $clonedTask): void
2832 + {
2833 + $taskMetas = TaskMeta::where('task_id', $originalTask->id)
2834 + ->where('key', '!=', Constant::SUBTASK_GROUP_NAME)
2835 + ->get();
2836 + foreach ($taskMetas as $meta) {
2837 + TaskMeta::create([
2838 + 'task_id' => $clonedTask->id,
2839 + 'key' => $meta->key,
2840 + 'value' => $meta->value
2841 + ]);
2842 + }
2843 + }
2844 +
2845 + private function cloneAssignees($originalTask, $clonedTask)
2846 + {
2847 + // Clone assignees
2848 + if ($originalTask->assignees) {
2849 + foreach ($originalTask->assignees as $assignee) {
2850 + $clonedTask->assignees()->syncWithoutDetaching([$assignee->ID => ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]]);
2851 + }
2852 + }
2853 + }
2854 +
2855 + private function cloneTaskLabels(Task $originalTask, Task $clonedTask): void
2856 + {
2857 + // Clone labels
2858 + if ($originalTask->labels) {
2859 + foreach ($originalTask->labels as $label) {
2860 + $clonedTask->labels()->syncWithoutDetaching([$label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]]);
2861 + }
2862 + }
2863 + }
2864 +
2865 + private function cloneTaskWatchers(Task $originalTask, Task $clonedTask): void
2866 + {
2867 + /// Clone watchers
2868 + if ($originalTask->watchers) {
2869 + foreach ($originalTask->watchers as $watcher) {
2870 + $clonedTask->watchers()->syncWithoutDetaching([$watcher->ID => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
2871 + }
2872 + }
2873 + }
2874 + private function cloneTaskCustomFields(Task $originalTask, Task $clonedTask): void
2875 + {
2876 +
2877 + // Clone custom fields
2878 + if ($originalTask->taskCustomFields) {
2879 + foreach ($originalTask->taskCustomFields as $customField) {
2880 + $clonedField = $customField->replicate();
2881 + $clonedField->object_id = $clonedTask->id;
2882 + $clonedField->save();
2883 + }
2884 + }
2885 + }
2886 + private function cloneAttachments(Task $originalTask, Task $clonedTask): void
2887 + {
2888 + $attachments = $originalTask->attachments;
2889 + foreach ($attachments as $attachment) {
2890 + $clonedAttachment = $attachment->replicate();
2891 + $clonedAttachment->object_id = $clonedTask->id;
2892 + $clonedAttachment->save();
2893 +
2894 + // If this is a cover image, update task settings
2895 + if ($attachment->type === 'cover_image') {
2896 + $settings = $clonedTask->settings;
2897 + if (isset($settings['cover_image'])) {
2898 + $settings['cover_image'] = $clonedAttachment->id;
2899 + $clonedTask->settings = $settings;
2900 + $clonedTask->save();
2901 + }
2902 + }
2903 + }
2904 + $settings = $clonedTask->settings;
2905 + $settings['attachment_count'] = $clonedTask->attachments()->count();
2906 + $clonedTask['settings'] = $settings;
2907 + $clonedTask->save();
2908 + }
2909 + private function cloneCommentsAndReplies(Task $originalTask, Task $clonedTask)
2910 + {
2911 + // Get comments ordered by created_at
2912 + $comments = Comment::where('task_id', $originalTask->id)
2913 + ->where('type', 'comment')
2914 + ->whereNull('parent_id')
2915 + ->orderBy('created_at', 'asc')
2916 + ->get();
2917 +
2918 + if ($comments->isEmpty()) {
2919 + return;
2920 + }
2921 +
2922 + foreach ($comments as $comment) {
2923 + $clonedComment = $comment->replicate();
2924 + $clonedComment->task_id = $clonedTask->id;
2925 + $clonedComment->save();
2926 +
2927 + // Get replies ordered by created_at
2928 + $replies = Comment::where('parent_id', $comment->id)
2929 + ->where('type', 'reply')
2930 + ->orderBy('created_at', 'asc')
2931 + ->get();
2932 +
2933 + foreach ($replies as $reply) {
2934 + $clonedReply = $reply->replicate();
2935 + $clonedReply->task_id = $clonedTask->id;
2936 + $clonedReply->parent_id = $clonedComment->id;
2937 + $clonedReply->save();
2938 +
2939 + // Clone reply image if any
2940 + $this->cloneCommentOrReplyImage($reply, $clonedReply);
2941 + }
2942 +
2943 + // Clone comment image if any
2944 + $this->cloneCommentOrReplyImage($comment, $clonedComment);
2945 + }
2946 + return;
2947 + }
2948 + private function cloneCommentOrReplyImage($oldCommentOrReply, $clonedCommentOrReply)
2949 + {
2950 + $images = CommentImage::where('object_id', $oldCommentOrReply->id)
2951 + ->where('object_type', Constant::COMMENT_IMAGE)
2952 + ->orderBy('created_at', 'asc')
2953 + ->get();
2954 +
2955 + if ($images->count() > 0) {
2956 + foreach ($images as $image) {
2957 + $clonedImage = $image->replicate();
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 + );
2965 + $clonedImage->save();
2966 + }
2967 + }
2968 + }
2969 + private function cloneSubtasks(Task $originalTask, Task $clonedTask, bool $cloneAttachments = false, ?AttachmentFileService $attachmentFileService = null): void
2970 + {
2971 + // First clone subtask groups
2972 + $subtaskGroupMap = $this->cloneSubtaskGroups($originalTask, $clonedTask);
2973 + $completedSubtasksCount = 0;
2974 +
2975 + if ($originalTask->subtasks) {
2976 + foreach ($originalTask->subtasks as $subtask) {
2977 + $clonedSubtask = $subtask->replicate();
2978 + $clonedSubtask->parent_id = $clonedTask->id;
2979 + $clonedSubtask->board_id = $clonedTask->board_id; // Ensure subtask has same board_id as parent
2980 + $clonedSubtask->save();
2981 + $attachmentFileService = $attachmentFileService ?: new AttachmentFileService();
2982 + $attachmentFileService->cloneTaskFilesToBoard($subtask, $clonedSubtask, $clonedTask->board_id, [
2983 + 'description_images' => true,
2984 + 'cover' => true,
2985 + 'task_attachments' => $cloneAttachments,
2986 + ]);
2987 + if($clonedSubtask->status == 'closed') {
2988 + $completedSubtasksCount++;
2989 + }
2990 +
2991 + // Update subtask group relationship if exists
2992 + $groupRelation = TaskMeta::where('task_id', $subtask->id)
2993 + ->where('key', Constant::SUBTASK_GROUP_CHILD)
2994 + ->first();
2995 +
2996 + if ($groupRelation && isset($subtaskGroupMap[$groupRelation->value])) {
2997 + TaskMeta::create([
2998 + 'task_id' => $clonedSubtask->id,
2999 + 'key' => Constant::SUBTASK_GROUP_CHILD,
3000 + 'value' => $subtaskGroupMap[$groupRelation->value]
3001 + ]);
3002 + }
3003 + }
3004 + }
3005 + $settings = $clonedTask->settings;
3006 + $settings['subtask_count'] = $clonedTask->subtasks()->count();
3007 + $clonedTask['settings'] = $settings;
3008 + $clonedTask->settings['subtask_completed_count'] = $completedSubtasksCount;
3009 + $clonedTask->save();
3010 + }
3011 + private function cloneSubtaskGroups(Task $originalTask, Task $clonedTask): array
3012 + {
3013 + $subtaskGroupMap = [];
3014 +
3015 + if ($originalTask->subtaskGroup) {
3016 + foreach ($originalTask->subtaskGroup as $group) {
3017 + $clonedGroup = TaskMeta::create([
3018 + 'task_id' => $clonedTask->id,
3019 + 'key' => Constant::SUBTASK_GROUP_NAME,
3020 + 'value' => $group->value
3021 + ]);
3022 +
3023 + $subtaskGroupMap[$group->id] = $clonedGroup->id;
3024 + }
3025 + }
3026 +
3027 + return $subtaskGroupMap;
3028 + }
3029 + private function prepareClonedTaskForResponse(Task $clonedTask): Task
3030 + {
3031 + // Load relationships
3032 + $clonedTask->load(['board', 'stage', 'labels', 'assignees', 'subtasks']);
3033 +
3034 + // Sanitize assignees
3035 + $clonedTask->assignees = Helper::sanitizeUserCollections($clonedTask->assignees);
3036 +
3037 + // Set additional properties
3038 + $clonedTask->isOverdue = $clonedTask->isOverdue();
3039 + $clonedTask->contact = Task::lead_contact($clonedTask->crm_contact_id);
3040 + $clonedTask->board->stages = (new StageService())->stagesByBoardId($clonedTask->board_id);
3041 + $clonedTask->is_watching = (new NotificationService())->isCurrentUserObservingTask($clonedTask);
3042 +
3043 + // Load next stage if applicable
3044 + return $this->loadNextStage($clonedTask);
3045 + }
3046 +
3047 + /**
3048 + * Clean up archived_by_stage metadata if it exists for a task
3049 + *
3050 + * @param int $taskId
3051 + * @return void
3052 + */
3053 + private function cleanupArchivedByStageMetaIfExists($taskId)
3054 + {
3055 + TaskMeta::where('task_id', $taskId)
3056 + ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
3057 + ->delete();
3058 + }
3059 +
3060 + /**
3061 + * Handle bulk actions for multiple tasks
3062 + *
3063 + * @param array $taskIds
3064 + * @param string $action
3065 + * @param array $params
3066 + * @param int $boardId
3067 + * @return array
3068 + * @throws \Exception
3069 + */
3070 + public function bulkActions($taskIds, $action, $params, $boardId)
3071 + {
3072 + if (empty($taskIds) || !is_array($taskIds)) {
3073 + throw new \Exception(esc_html__('No tasks selected', 'fluent-boards'));
3074 + }
3075 +
3076 + if (count($taskIds) > 150) {
3077 + throw new \Exception(esc_html__('Cannot process more than 150 tasks at once. Please select fewer tasks.', 'fluent-boards'));
3078 + }
3079 +
3080 + if (empty($action)) {
3081 + throw new \Exception(esc_html__('No action specified', 'fluent-boards'));
3082 + }
3083 +
3084 + $tasks = Task::whereIn('id', $taskIds)
3085 + ->where('board_id', $boardId)
3086 + ->get();
3087 +
3088 + if ($tasks->isEmpty()) {
3089 + throw new \Exception(esc_html__('No valid tasks found', 'fluent-boards'));
3090 + }
3091 +
3092 + $result = [
3093 + 'successful_tasks' => [],
3094 + 'failed_tasks' => [],
3095 + 'message' => ''
3096 + ];
3097 +
3098 + switch ($action) {
3099 + case 'move_tasks':
3100 + $result = $this->bulkMoveTasks($tasks, $params, $boardId);
3101 + break;
3102 +
3103 + case 'move_to_stage':
3104 + // Backward compatibility - redirect to move_tasks
3105 + $result = $this->bulkMoveTasks($tasks, $params, $boardId);
3106 + break;
3107 +
3108 + case 'archive_tasks':
3109 + $result = $this->bulkArchiveTasks($tasks);
3110 + break;
3111 +
3112 + case 'change_priority':
3113 + $result = $this->bulkChangePriority($tasks, $params);
3114 + break;
3115 +
3116 + case 'assign_members':
3117 + $result = $this->bulkAssignMembers($tasks, $params, $boardId);
3118 + break;
3119 +
3120 + case 'add_labels':
3121 + $result = $this->bulkAddLabels($tasks, $params, $boardId);
3122 + break;
3123 +
3124 + default:
3125 + throw new \Exception(esc_html__('Invalid action specified', 'fluent-boards'));
3126 + }
3127 +
3128 + // Dispatch WordPress action for other plugins to hook into
3129 + do_action('fluent_boards/bulk_action_completed', $action, $tasks, $boardId);
3130 +
3131 + return $result;
3132 + }
3133 +
3134 + /**
3135 + * Bulk move tasks to a stage (same board) or to another board
3136 + * Unified method that handles both same-board stage moves and cross-board moves
3137 + */
3138 + private function bulkMoveTasks($tasks, $params, $sourceBoardId)
3139 + {
3140 + $targetStageId = $params['target_stage_id'] ?? null;
3141 + if (!$targetStageId) {
3142 + throw new \Exception(esc_html__('Target stage ID is required', 'fluent-boards'));
3143 + }
3144 +
3145 + $targetBoardId = $params['target_board_id'] ?? null;
3146 + $isMovingToAnotherBoard = $targetBoardId && $targetBoardId != $sourceBoardId;
3147 +
3148 + // Determine effective target board ID
3149 + $effectiveTargetBoardId = $isMovingToAnotherBoard ? $targetBoardId : $sourceBoardId;
3150 +
3151 + // Validate target board exists and is not archived
3152 + $targetBoard = Board::find($effectiveTargetBoardId);
3153 + if (!$targetBoard) {
3154 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'));
3155 + }
3156 +
3157 + if ($targetBoard->archived_at) {
3158 + throw new \Exception(esc_html__('Cannot move tasks to an archived board', 'fluent-boards'));
3159 + }
3160 +
3161 + // Verify user has write access to target board if moving to different board
3162 + if ($isMovingToAnotherBoard && !PermissionManager::userHasBoardPermission($effectiveTargetBoardId, 'POST')) {
3163 + throw new \Exception(esc_html__('You do not have permission to add tasks to this board', 'fluent-boards'));
3164 + }
3165 +
3166 + // Validate target stage exists and belongs to target board
3167 + $targetStage = Stage::where('id', $targetStageId)
3168 + ->where('board_id', $effectiveTargetBoardId)
3169 + ->first();
3170 +
3171 + if (!$targetStage) {
3172 + throw new \Exception(esc_html__('Target stage not found in the selected board', 'fluent-boards'));
3173 + }
3174 +
3175 + $successfulTasks = [];
3176 +
3177 + foreach ($tasks as $task) {
3178 + $oldStageId = $task->stage_id;
3179 + if ($isMovingToAnotherBoard) {
3180 + // Cross-board move - use existing method for data cleanup and security
3181 + $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
3182 + $task->stage_id = $targetStageId;
3183 + $task = $task->moveToNewPosition(null);
3184 + } else {
3185 + $task->stage_id = $targetStageId;
3186 + $task = $task->moveToNewPosition(1);
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();
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);
3204 + }
3205 +
3206 + $successfulTasks[] = $task;
3207 + }
3208 +
3209 + $successCount = count($successfulTasks);
3210 +
3211 + if ($isMovingToAnotherBoard) {
3212 + // translators: %d is the number of tasks successfully moved to another board
3213 + $message = sprintf(__('%d tasks moved to new board successfully', 'fluent-boards'), $successCount);
3214 + } else {
3215 + // translators: %d is the number of tasks successfully moved to the stage
3216 + $message = sprintf(__('%d tasks moved to stage successfully', 'fluent-boards'), $successCount);
3217 + }
3218 +
3219 + return [
3220 + 'successful_tasks' => $successfulTasks,
3221 + 'failed_tasks' => [],
3222 + 'message' => $message,
3223 + 'moved_to_another_board' => $isMovingToAnotherBoard
3224 + ];
3225 + }
3226 +
3227 + /**
3228 + * Legacy method - kept for backward compatibility
3229 + * @deprecated Use bulkMoveTasks instead
3230 + */
3231 + private function bulkMoveToStage($tasks, $params, $boardId)
3232 + {
3233 + return $this->bulkMoveTasks($tasks, $params, $boardId);
3234 + }
3235 +
3236 + /**
3237 + * Bulk archive tasks
3238 + */
3239 + private function bulkArchiveTasks($tasks)
3240 + {
3241 + $successfulTasks = [];
3242 + $failedTasks = [];
3243 +
3244 + foreach ($tasks as $task) {
3245 + try {
3246 + // Use the same logic as single task archiving
3247 + $this->updateTaskProperty('archived_at', current_time('mysql'), $task);
3248 +
3249 + // Reload task with all relationships
3250 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3251 +
3252 + $successfulTasks[] = $task;
3253 + } catch (\Exception $e) {
3254 + $failedTasks[] = [
3255 + 'id' => $task->id,
3256 + 'title' => $task->title,
3257 + 'error' => $e->getMessage()
3258 + ];
3259 + }
3260 + }
3261 +
3262 + $successCount = count($successfulTasks);
3263 + $failureCount = count($failedTasks);
3264 +
3265 + $message = '';
3266 + if ($failureCount === 0) {
3267 + // translators: %d is the number of tasks archived successfully
3268 + $message = sprintf(__('%d tasks archived successfully', 'fluent-boards'), $successCount);
3269 + } elseif ($successCount === 0) {
3270 + // translators: %d is the number of tasks that failed to archive
3271 + $message = sprintf(__('Failed to archive %d tasks', 'fluent-boards'), $failureCount);
3272 + } else {
3273 + // translators: 1: number of tasks archived successfully; 2: number of tasks failed to archive
3274 + $message = sprintf(__('%1$d tasks archived successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3275 + }
3276 +
3277 + return [
3278 + 'successful_tasks' => $successfulTasks,
3279 + 'failed_tasks' => $failedTasks,
3280 + 'message' => $message
3281 + ];
3282 + }
3283 +
3284 + /**
3285 + * Bulk change task priority
3286 + */
3287 + private function bulkChangePriority($tasks, $params)
3288 + {
3289 + $priority = $params['priority'] ?? null;
3290 +
3291 + // Get valid priorities including custom ones added by hooks
3292 + $validPriorities = array_keys(apply_filters('fluent_boards/task_priorities', [
3293 + '' => __('No priority', 'fluent-boards'),
3294 + 'urgent' => __('Urgent', 'fluent-boards'),
3295 + 'high' => __('High', 'fluent-boards'),
3296 + 'medium' => __('Medium', 'fluent-boards'),
3297 + 'low' => __('Low', 'fluent-boards')
3298 + ]));
3299 +
3300 + if (!in_array($priority, $validPriorities, true)) {
3301 + throw new \Exception(esc_html__('Invalid priority level', 'fluent-boards'));
3302 + }
3303 +
3304 + $successfulTasks = [];
3305 + $failedTasks = [];
3306 +
3307 + foreach ($tasks as $task) {
3308 + try {
3309 + // Use the same logic as single task priority update
3310 + $this->updateTaskProperty('priority', $priority, $task);
3311 +
3312 + // Reload task with all relationships
3313 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3314 +
3315 + $successfulTasks[] = $task;
3316 + } catch (\Exception $e) {
3317 + $failedTasks[] = [
3318 + 'id' => $task->id,
3319 + 'title' => $task->title,
3320 + 'error' => $e->getMessage()
3321 + ];
3322 + }
3323 + }
3324 +
3325 + $successCount = count($successfulTasks);
3326 + $failureCount = count($failedTasks);
3327 +
3328 + $message = '';
3329 + if ($failureCount === 0) {
3330 + // translators: %d is the number of tasks whose priorities were updated successfully
3331 + $message = sprintf(__('%d task priorities updated successfully', 'fluent-boards'), $successCount);
3332 + } elseif ($successCount === 0) {
3333 + // translators: %d is the number of tasks whose priorities failed to update
3334 + $message = sprintf(__('Failed to update %d task priorities', 'fluent-boards'), $failureCount);
3335 + } else {
3336 + // translators: 1: number of tasks priorities updated; 2: number of tasks priorities failed to update
3337 + $message = sprintf(__('%1$d task priorities updated successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3338 + }
3339 +
3340 + return [
3341 + 'successful_tasks' => $successfulTasks,
3342 + 'failed_tasks' => $failedTasks,
3343 + 'message' => $message
3344 + ];
3345 + }
3346 +
3347 + /**
3348 + * Bulk assign members to tasks
3349 + */
3350 + private function bulkAssignMembers($tasks, $params, $boardId)
3351 + {
3352 + $userIds = $params['user_ids'] ?? [];
3353 + if (!is_array($userIds)) {
3354 + throw new \Exception(esc_html__('User IDs must be an array', 'fluent-boards'));
3355 + }
3356 +
3357 + // Validate that all users are valid WordPress users
3358 + $validUsers = get_users(['include' => $userIds]);
3359 + $validUserIds = array_map(function($user) {
3360 + return $user->ID;
3361 + }, $validUsers);
3362 +
3363 + if (count($validUserIds) !== count($userIds)) {
3364 + throw new \Exception(esc_html__('Some user IDs are invalid', 'fluent-boards'));
3365 + }
3366 +
3367 + // Filter only users who are already board members (skip non-members)
3368 + $boardService = new \FluentBoards\App\Services\BoardService();
3369 + $boardMemberIds = [];
3370 + foreach ($validUserIds as $userId) {
3371 + if ($boardService->isAlreadyMember($boardId, $userId)) {
3372 + $boardMemberIds[] = $userId;
3373 + }
3374 + }
3375 +
3376 + // If no valid board members, skip assignment silently
3377 + if (empty($boardMemberIds)) {
3378 + return [
3379 + 'successful_tasks' => [],
3380 + 'failed_tasks' => [],
3381 + 'message' => __('No valid board members selected for assignment', 'fluent-boards')
3382 + ];
3383 + }
3384 +
3385 + // Use only board members for assignment
3386 + $validUserIds = $boardMemberIds;
3387 +
3388 + $successfulTasks = [];
3389 + $failedTasks = [];
3390 +
3391 + foreach ($tasks as $task) {
3392 + try {
3393 + // Use pure "add-only" logic for bulk assignment - never remove existing assignees
3394 + $currentAssigneeIds = $task->assignees->pluck('ID')->toArray();
3395 + $newAssignees = [];
3396 +
3397 + foreach ($validUserIds as $userId) {
3398 + // Only add if not already assigned
3399 + if (!in_array($userId, $currentAssigneeIds)) {
3400 + $newAssignees[] = $userId;
3401 + }
3402 + }
3403 +
3404 + // Add all new assignees at once
3405 + if (!empty($newAssignees)) {
3406 + $assigneeData = [];
3407 + foreach ($newAssignees as $userId) {
3408 + $assigneeData[$userId] = ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE];
3409 + }
3410 + $task->assignees()->syncWithoutDetaching($assigneeData);
3411 +
3412 + // Add as watchers
3413 + $watcherData = [];
3414 + foreach ($newAssignees as $userId) {
3415 + $watcherData[$userId] = ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH];
3416 + }
3417 + $task->watchers()->syncWithoutDetaching($watcherData);
3418 +
3419 + // Send notifications and actions only for new assignees
3420 + foreach ($newAssignees as $userId) {
3421 + // Send email notification if enabled and not current user
3422 + if ((new \FluentBoards\App\Services\NotificationService())->checkIfEmailEnable($userId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id) && $userId != get_current_user_id()) {
3423 + $this->sendMailAfterTaskModify('add_assignee', $userId, $task->id);
3424 + }
3425 +
3426 + // Dispatch WordPress actions
3427 + //currently commented, need to check in future for bulk action
3428 +// do_action('fluent_boards/task_assignee_added', $task, $userId);
3429 +// if ($userId != get_current_user_id()) {
3430 +// do_action('fluent_boards/assign_another_user', $task, $userId);
3431 +// }
3432 + }
3433 + }
3434 +
3435 + // Update task timestamp and reload all relationships
3436 + $task->updated_at = current_time('mysql');
3437 + $task->save();
3438 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3439 +
3440 + $successfulTasks[] = $task;
3441 + } catch (\Exception $e) {
3442 + $failedTasks[] = [
3443 + 'id' => $task->id,
3444 + 'title' => $task->title,
3445 + 'error' => $e->getMessage()
3446 + ];
3447 + }
3448 + }
3449 +
3450 + $successCount = count($successfulTasks);
3451 + $failureCount = count($failedTasks);
3452 +
3453 + $message = '';
3454 + if ($failureCount === 0) {
3455 + // translators: %d is the number of tasks where members were assigned successfully
3456 + $message = sprintf(__('%d tasks assigned members successfully', 'fluent-boards'), $successCount);
3457 + } elseif ($successCount === 0) {
3458 + // translators: %d is the number of tasks where assigning members failed
3459 + $message = sprintf(__('Failed to assign members to %d tasks', 'fluent-boards'), $failureCount);
3460 + } else {
3461 + // translators: 1: number of tasks with members assigned successfully; 2: number of tasks where assigning members failed
3462 + $message = sprintf(__('%1$d tasks assigned members successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3463 + }
3464 +
3465 + return [
3466 + 'successful_tasks' => $successfulTasks,
3467 + 'failed_tasks' => $failedTasks,
3468 + 'message' => $message
3469 + ];
3470 + }
3471 +
3472 + /**
3473 + * Bulk add labels to tasks
3474 + */
3475 + private function bulkAddLabels($tasks, $params, $boardId)
3476 + {
3477 + $labelIds = $params['label_ids'] ?? [];
3478 + if (!is_array($labelIds)) {
3479 + throw new \Exception(esc_html__('Label IDs must be an array', 'fluent-boards'));
3480 + }
3481 +
3482 + // Validate that all labels exist and belong to the board
3483 + $validLabels = \FluentBoards\App\Models\Label::whereIn('id', $labelIds)
3484 + ->where('board_id', $boardId)
3485 + ->whereNull('archived_at')
3486 + ->get();
3487 +
3488 + if (count($validLabels) !== count($labelIds)) {
3489 + throw new \Exception(esc_html__('Some label IDs are invalid or do not belong to this board', 'fluent-boards'));
3490 + }
3491 +
3492 + $successfulTasks = [];
3493 + $failedTasks = [];
3494 +
3495 + foreach ($tasks as $task) {
3496 + try {
3497 + // Load existing labels first to avoid query issues
3498 + $task->load('labels');
3499 + $existingLabelIds = $task->labels->pluck('id')->toArray();
3500 +
3501 + // Use the same logic as single task label adding
3502 + foreach ($validLabels as $label) {
3503 + // Check if label is already attached
3504 + if (!in_array($label->id, $existingLabelIds)) {
3505 + // Add the label using syncWithoutDetaching to avoid duplicates
3506 + $task->labels()->syncWithoutDetaching([
3507 + $label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]
3508 + ]);
3509 +
3510 + // Dispatch WordPress action for label addition
3511 + //currently commented, need to check in future for bulk action
3512 +// do_action('fluent_boards/task_label', $task, $label, 'added');
3513 + }
3514 + }
3515 +
3516 + // Reload the task with all relationships
3517 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3518 +
3519 + $successfulTasks[] = $task;
3520 + } catch (\Exception $e) {
3521 + $failedTasks[] = [
3522 + 'id' => $task->id,
3523 + 'title' => $task->title,
3524 + 'error' => $e->getMessage()
3525 + ];
3526 + }
3527 + }
3528 +
3529 + $successCount = count($successfulTasks);
3530 + $failureCount = count($failedTasks);
3531 +
3532 + $message = '';
3533 + if ($failureCount === 0) {
3534 + // translators: %d is the number of tasks labeled successfully
3535 + $message = sprintf(__('%d tasks labeled successfully', 'fluent-boards'), $successCount);
3536 + } elseif ($successCount === 0) {
3537 + // translators: %d is the number of tasks that failed to label
3538 + $message = sprintf(__('Failed to label %d tasks', 'fluent-boards'), $failureCount);
3539 + } else {
3540 + // translators: 1: number of tasks labeled successfully; 2: number of tasks failed to label
3541 + $message = sprintf(__('%1$d tasks labeled successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3542 + }
3543 +
3544 + return [
3545 + 'successful_tasks' => $successfulTasks,
3546 + 'failed_tasks' => $failedTasks,
3547 + 'message' => $message
3548 + ];
3549 + }
3550 +
3551 + /**
3552 + * Delete time tracking records for one or multiple tasks when the table exists.
3553 + *
3554 + * @param int|array $taskIds Single task ID or array of task IDs.
3555 + * @param bool $suppressErrors Whether cleanup failures should be ignored.
3556 + * @return void
3557 + */
3558 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
3559 + {
3560 + if (!class_exists(TimeTrack::class)) {
3561 + return;
3562 + }
3563 +
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 +
3579 + // Handle single task ID or array of task IDs
3580 + if (is_array($taskIds)) {
3581 + if (!empty($taskIds)) {
3582 + TimeTrack::whereIn('task_id', $taskIds)->delete();
3583 + }
3584 + } else {
3585 + if (is_numeric($taskIds) && $taskIds > 0) {
3586 + TimeTrack::where('task_id', (int) $taskIds)->delete();
3587 + }
3588 + }
3589 + } catch (\Throwable $e) {
3590 + if (!$suppressErrors) {
3591 + throw $e;
3592 + }
3593 + }
3594 + }
871 3595
872 3596 }