PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 1.91.6
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v1.91.6
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 1.41 All 42 releases
fluent-boards / app / Services / TaskService.php

TaskService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 1.91.6, at app/Services/TaskService.php

2,226 lines 81.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 use FluentBoards\App\App;
6 use FluentBoards\App\Models\Attachment;
7 use FluentBoards\App\Models\Comment;
8 use FluentBoards\App\Models\NotificationUser;
9 use FluentBoards\App\Models\TaskImage;
10 use FluentBoards\App\Services\Constant;
11 use FluentBoards\App\Models\Stage;
12 use FluentBoards\App\Models\Task;
13 use FluentBoards\App\Models\Board;
14 use FluentBoards\App\Models\TaskMeta;
15 use FluentBoards\App\Models\Meta;
16 use FluentBoards\App\Models\Activity;
17 use FluentBoards\App\Models\CommentImage;
18 use FluentBoards\Framework\Support\Arr;
19 use FluentBoardsPro\App\Models\TaskAttachment;
20 use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper;
21 use FluentBoardsPro\App\Services\AttachmentService;
22 use FluentBoardsPro\App\Services\ProTaskService;
23 use FluentBoardsPro\App\Services\RemoteUrlParser;
24 use FluentRoadmap\App\Models\IdeaReaction;
25
26 class TaskService
27 {
28 public function createTask($data, $boardId)
29 {
30 $board = Board::select('id', 'type')->find($boardId);
31
32 if (!$board) {
33 throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
34 }
35
36 $stage = Stage::find($data['stage_id']);
37 if (!$stage) {
38 throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
39 }
40
41 $data['status'] = $stage->defaultTaskStatus();
42
43 if ($board->type == 'roadmap') {
44 $current_user = wp_get_current_user();
45 $settingData = array(
46 'integration_type' => 'feature',
47 'logo' => '',
48 'author' => [
49 'email' => $current_user->user_email // email of who posted this feature
50 ],
51 );
52 $data['settings'] = $settingData;
53 $data['type'] = 'roadmap';
54 }
55
56 $providerPosition = Arr::get($data, 'position');
57
58 $data['position'] = $this->getLastPositionOfTasks($stage->id);
59
60 $data['board_id'] = $boardId;
61
62 $data = array_filter($data);
63 $task = (new Task())->createTask($data);
64
65 $this->manageDefaultAssignees($task, $stage->id);
66
67 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
68 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $data['is_template']);
69 }
70
71 if ($providerPosition) {
72 $task->moveToNewPosition($providerPosition);
73 }
74
75 // $this->taskCreatedAction($task);
76 $this->loadWithRelations($task, ['assignees', 'labels', 'board']);
77
78 return $task;
79 }
80
81 public function loadWithRelations($task, $relations)
82 {
83 if (!is_array($relations)) {
84 return $task;
85 }
86 $task->load($relations); // $relations = ['assignees', 'board'] in this case
87 $task->isOverdue = $task->isOverdue();
88
89 return $task;
90 }
91
92 public function getTasksForBoards($filters = ['overdue', 'upcoming'], $limit = 5, $task_ids = [])
93 {
94 $assigned = $this->getTasksForBoardsByCategory('assigned', $limit, $task_ids);
95 $overDue = $this->getTasksForBoardsByCategory('overdue', $limit, $task_ids);
96 $completed = $this->getTasksForBoardsByCategory('completed', $limit, $task_ids);
97 $mentioned = $this->getTasksForBoardsByCategory('mentioned', $limit, $task_ids);
98 $upcoming = $this->getTasksForBoardsByCategory('upcoming', $limit, $task_ids);
99 $others = $this->getTasksForBoardsByCategory('others', $limit, $task_ids);
100
101 return [
102 'assigned' => $assigned ?? [],
103 'overdue' => $overDue ?? [],
104 'upcoming' => $upcoming ?? [],
105 'mentioned' => $mentioned ?? [],
106 'completed' => $completed ?? [],
107 'others' => $others ?? []
108 ];
109 }
110
111 public function getTasksForBoardsByCategory($category, $limit, $taskIds)
112 {
113 unset($taskQuery);
114 $taskQuery = Task::whereIn('id', $taskIds)
115 ->with(['assignees', 'board', 'stage'])
116 ->whereNull('archived_at')
117 ->where('parent_id', null)
118 ->orderBy('due_at', 'DESC');
119
120 switch ($category) {
121 case 'overdue':
122 $taskQuery->overdue();
123 break;
124 case 'upcoming':
125 $taskQuery->upcoming();
126 break;
127 case 'others':
128 $taskQuery->whereNull('due_at');
129 break;
130 case 'completed':
131 $taskQuery->where('status', 'closed');
132 break;
133 case 'assigned':
134 // Rebuild query to order by latest assignment (pivot created_at) so the most recently assigned tasks come first.
135 $currentUserId = get_current_user_id();
136 $taskQuery = Task::query()
137 ->select('fbs_tasks.*')
138 ->distinct()
139 ->with(['assignees', 'board', 'stage'])
140 ->whereIn('fbs_tasks.id', $taskIds)
141 ->whereNull('fbs_tasks.archived_at')
142 ->whereNull('fbs_tasks.parent_id')
143 ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
144 $join->on('rel.object_id', '=', 'fbs_tasks.id')
145 ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
146 ->where('rel.foreign_id', $currentUserId);
147 })
148 ->orderBy('rel.created_at', 'DESC')
149 ->orderBy('fbs_tasks.updated_at', 'DESC');
150 break;
151 case 'mentioned':
152 $currentUserId = get_current_user_id();
153 $userNotifications = NotificationUser::where('user_id', $currentUserId)
154 ->with(['notification' => function ($query) {
155 $query->where('action', 'task_comment_mentioned');
156 }])
157 ->orderBy('created_at', 'desc')
158 ->get();
159 $taskIds = $userNotifications->filter(function ($userNotification) {
160 $notification = $userNotification->notification;
161 return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
162 })->pluck('notification.task_id')->unique();
163 $validTasks = Task::whereIn('id', $taskIds)
164 ->with(['assignees', 'board', 'stage'])
165 ->get();
166
167 return $validTasks->toArray();
168 default:
169 return [];
170 }
171
172 $tasks = $taskQuery->take($limit)->get();
173
174 return $tasks->toArray();
175 }
176
177 /*
178 * TODO: Refactor this function - For me.
179 */
180 public function updateTaskProperty($col, $value, $task)
181 {
182 $oldTask = clone $task; // normal assigning won't work here. because objects are passed by reference in php
183 $validColumns = [
184 'board_id',
185 'type',
186 // 'reminder_type',
187 'remind_at',
188 'log_minutes',
189 'settings'
190 ];
191
192 if (in_array($col, $validColumns) && $task->{$col} != $value) {
193 if ($col == 'settings' && isset($value['cover']['backgroundColor']) && $value['cover']['backgroundColor']) {
194 $settings = $task->settings;
195 $this->deleteTaskCoverImage($settings);
196 unset($value['cover']['imageId']);
197 unset($value['cover']['backgroundImage']);
198 }
199 $task->{$col} = $value ?: null;
200 $task->save();
201 // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
202 } else {
203 switch ($col) {
204 case 'assignees':
205 if (is_array($value)) {
206 foreach ($value as $id) {
207 $this->updateAssignee($id, $task);
208 }
209 } else {
210 $this->updateAssignee($value, $task);
211 }
212 break;
213
214 case 'crm_contact_id':
215 $this->updateAssociate($value, $task);
216 break;
217
218 case 'archived_at':
219 $this->updateArchive($value, $task);
220 break;
221
222 case 'status':
223 $this->updateStatus($value, $task);
224 break;
225
226 case 'parent_id':
227 $this->updateParent($value, $task);
228 break;
229
230 case 'title':
231 $this->updateTitle($col, $value, $task, $oldTask);
232 break;
233
234 case 'description':
235 $this->updateDescription($col, $value, $task, $oldTask);
236 break;
237
238 case 'due_at':
239 $this->updateDueDate($value, $task);
240 break;
241
242 case 'started_at':
243 $this->updateStartedDate($value, $task);
244 break;
245
246 case 'priority':
247 $this->updatePriority($value, $task);
248 break;
249
250 case 'is_watching':
251 $this->updateObservationOfUser($value, $task);
252 break;
253
254 case 'last_completed_at':
255 $isClosed = $value == 'true' || $value === true;
256 if ($isClosed) {
257 $task = $task->close();
258 } else {
259 $task = $task->reopen();
260 }
261 $task->save();
262 break;
263
264 case 'attachment_count':
265 $settings = $task->settings;
266 $settings['attachment_count'] = $task->attachments()->count();
267 $task->settings = $settings;
268 $task->save();
269 break;
270
271 case 'subtask_count':
272 $settings = $task->settings;
273 $subtasksCount = Task::where('parent_id', $task->id)->count();
274 $settings['subtask_count'] = $subtasksCount;
275 $task->settings = $settings;
276 $task->save();
277 break;
278
279 case 'is_template':
280 if (defined('FLUENT_BOARDS_PRO')) {
281 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $value);
282 }
283 break;
284
285 case 'reminder_type':
286 if (defined('FLUENT_BOARDS_PRO')) {
287 $allowedTypes = Helper::taskReminderTypes();
288
289 // check in keys of allowed types
290 if (array_key_exists($value, $allowedTypes)) {
291 $value = $value;
292 } else {
293 $value = null;
294 }
295
296 $task->reminder_type = $value;
297 $task->save();
298 do_action('fluent_boards/task_reminder_type_changed', $task, $value);
299 }
300 break;
301 }
302 }
303
304 return $task;
305 }
306
307 public function updateAssignee($payloadAssigneeId, $task)
308 {
309 $operation = $task->addOrRemoveAssignee($payloadAssigneeId);
310 $task->load('assignees');
311 $task->updated_at = current_time('mysql');
312
313 $task->save();
314
315 if ($operation == 'added') {
316 if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id) && $payloadAssigneeId != get_current_user_id()) {
317 $this->sendMailAfterTaskModify('add_assignee', $payloadAssigneeId, $task->id);
318 }
319 // $assigneeIdsToSendEmail = $this->filterAssigneeToSendEmail($task, $idArray, Constant::BOARD_EMAIL_TASK_ASSIGN);
320 // $this->sendMailAfterAddAssignees($assigneeIdsToSendEmail, $task->id);
321 do_action('fluent_boards/task_assignee_added', $task, $payloadAssigneeId);
322 if($payloadAssigneeId != get_current_user_id()){
323 do_action('fluent_boards/assign_another_user', $task, $payloadAssigneeId);
324 }
325 } else {
326 if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_REMOVE_FROM_TASK, $task->board_id) && $payloadAssigneeId != get_current_user_id()) {
327 $this->sendMailAfterTaskModify('remove_assignee', $payloadAssigneeId, $task->id);
328 }
329 do_action('fluent_boards/task_assignee_removed', $task, $payloadAssigneeId);
330 }
331
332 }
333
334 // public function filterAssigneeToSendEmail($task, $newAssigneeIds, $purpose)
335 // {
336 // $toSendEmail = array();
337 // foreach ($newAssigneeIds as $assigneeId) {
338 // if ((new NotificationService())->checkIfEmailEnabled($task->board_id, $assigneeId, $purpose)) {
339 // $toSendEmail[] = $assigneeId;
340 // }
341 // }
342 // return $toSendEmail;
343 // }
344
345 // public function defaultWatchingTaskByNewUsers($task, $newIds)
346 // {
347 // foreach ($newIds as $newId) {
348 // if (!$task->watchers->contains($newId)) {
349 // $task->watchers()->attach(
350 // $newId,
351 // [
352 // 'object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH,
353 // ]
354 // );
355 // }
356 // }
357 // }
358
359 // public function checkIfAnybodyRemovedFromTask($newAssigneeIds, $oldAssigneeIds, $task)
360 // {
361 // $removedAssignees = array_diff($oldAssigneeIds, $newAssigneeIds);
362 // $this->sendMailAfterTaskModify('removed_from_task', $removedAssignees, $task->id);
363 // dd($removedAssignees);
364 // }
365
366 private function updateAssociate($value, $task)
367 {
368 // if task has no crm contact and got value null then return current task
369 if (($task->crm_contact_id == null || $task->crm_contact_id == 0) && $value == null) {
370 return $task;
371 }
372
373 $oldAssociateId = $task->crm_contact_id;
374 $task->crm_contact_id = $value;
375 $task->save();
376 $task->contact = Task::lead_contact($task->crm_contact_id);
377 do_action('fluent_boards/contact_added_to_task', $task);
378 do_action('fluent_boards/associate_user_add_change_remove_activity', $oldAssociateId, $task->crm_contact_id, $task->id);
379 }
380
381 private function updateArchive($value, $task)
382 {
383 if ($value != null) {
384 $task->position = 0;
385 } else {
386 $task->moveToNewPosition(1);
387 }
388 $task->archived_at = $value == null ? null : current_time('mysql');
389 $task->save();
390 do_action('fluent_boards/task_archived', $task);
391 $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
392 $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
393 }
394
395 private function updateStatus($value, $task)
396 {
397 if ($value == 'closed') {
398 $task = $task->close();
399 } else {
400 $task = $task->reopen();
401 }
402
403 do_action('fluent_boards/task_completed_activity', $task, $value);
404 }
405
406 private function updateParent($value, $task)
407 {
408 $task->parent_id = $value;
409 $task->save();
410 }
411
412 private function updateTitle($col, $value, $task, $oldTask)
413 {
414 $task->title = $value;
415 $task->save();
416 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
417 }
418
419 private function updateDescription($col, $value, $task, $oldTask)
420 {
421 $task->description = $value;
422 $task->save();
423 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
424 }
425
426 private function updateDueDate($value, $task)
427 {
428 $oldValue = $task->due_at;
429 $value = $this->filterNullDate($value);
430 $task->due_at = $value;
431 $task->save();
432
433 $task = $task->reopen();
434
435 if($value){
436 do_action('fluent_boards/task_due_date_changed', $task, $oldValue);
437 } else {
438 do_action('fluent_boards/task_due_date_removed', $task);
439 }
440
441 $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_DUE_DATE_CHANGE);
442 $this->sendMailAfterTaskModify('due_date_update', $wathersToSendEmail, $task->id);
443 }
444
445 private function updateStartedDate($value, $task)
446 {
447 $oldValue = $task->started_at;
448 $value = $this->filterNullDate($value);
449 $task->started_at = $value;
450 $task->save();
451
452 if($value){
453 do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
454 }
455 }
456
457 private function updatePriority($value, $task)
458 {
459 $oldPriority = $task->priority;
460 $task->priority = $value;
461 $task->save();
462 do_action('fluent_boards/task_priority_changed', $task, $oldPriority);
463 }
464
465 public function updateObservationOfUser($value, $task)
466 {
467 if (is_array($value) && isset($value['userId'])) {
468 $userId = intval($value['userId']);
469 $action = isset($value['action']) ? $value['action'] : 'start';
470 } else {
471 $userId = get_current_user_id();
472 $action = is_string($value) ? $value : 'start';
473 }
474
475 if (!$userId || !in_array($action, ['stop', 'start'])) {
476 return;
477 }
478
479 if ($action == 'stop') {
480 $task->watchers()->detach($userId);
481 } else {
482 $task->watchers()->syncWithoutDetaching([$userId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
483 }
484 $task->updated_at = current_time('mysql');
485 $task->save();
486 }
487
488 public function taskCoverPhotoUpdate($taskId, $imagePath)
489 {
490 $task = Task::find($taskId);
491 if (!$task) {
492 return null;
493 }
494
495 $settings = $task->settings;
496 if (!is_array($settings)) {
497 $settings = [];
498 }
499
500 $settings['logo'] = $imagePath;
501 $task->settings = $settings;
502 $task->save();
503
504 return $task;
505 }
506
507 public function taskStatusUpdate($taskId, $integrationType)
508 {
509 $task = Task::find($taskId);
510 if (!$task) {
511 return null;
512 }
513
514 $settings = $task->settings;
515 $settings['integration_type'] = $integrationType;
516 $task->settings = $settings;
517 $task->save();
518
519 return $task;
520 }
521
522 public function assignYourselfInTask($boardId, $taskId)
523 {
524 $task = Task::find($taskId);
525 $authUserId = get_current_user_id();
526
527 $boardService = new BoardService();
528 if (!$boardService->isAlreadyMember($boardId, $authUserId)) {
529 $boardService->addMembersInBoard($boardId, $authUserId);
530 }
531
532 $task->addOrRemoveAssignee($authUserId);
533 // when user assign himself then he will be watching that task
534 $task->watchers()->syncWithoutDetaching([$authUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
535
536 $task->load('assignees');
537 do_action('fluent_boards/task_assignee_added', $task, $authUserId);
538
539 return $task;
540 }
541
542 public function detachYourselfFromTask($boardId, $taskId)
543 {
544 $task = Task::find($taskId);
545 $currentUserId = get_current_user_id();
546 $task->addOrRemoveAssignee($currentUserId);
547 $task->load('assignees');
548 do_action('fluent_boards/task_assignee_removed', $task, $currentUserId);
549
550 return $task;
551 }
552
553 public function deleteTask($task)
554 {
555 // If this is a parent task, delete all subtasks first
556 if (!$task->parent_id) {
557 $subtasks = Task::where('parent_id', $task->id)->get();
558 foreach ($subtasks as $subtask) {
559 // Recursively delete each subtask (cleans up all their relations)
560 $this->deleteTask($subtask);
561 }
562 }
563
564 $deleted = $task->delete();
565 $dbInstance = App::getInstance('db');
566 $dbInstance->beginTransaction();
567
568 $deletedTask = clone $task;
569 //cloning because after delete $task object will be useless
570
571 try {
572 $deleted = $task->delete();
573
574 if ($deleted) {
575 //task assignees watchers removed
576 $task->watchers()->detach();
577 $task->assignees()->detach();
578
579 //removing all task related notifications
580 $notificationIds = $task->notifications->pluck('id');
581 $task->notifications()->delete();
582 NotificationUser::whereIn('notification_id', $notificationIds)->delete();
583
584 //task labels removed
585 $task->labels()->detach();
586
587 //task custom field value
588 if (defined('FLUENT_BOARDS_PRO')) {
589 $task->customFields()->detach();
590 }
591 $this->deleteTaskAttachments($task);
592 //task custom field value
593 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
594 $task->customFields()->detach();
595 $this->deleteTaskAttachments($task);
596 }
597
598 // Delete time tracking records for this task
599 $this->deleteTimeTrackingRecords($task->id);
600
601 do_action('fluent_boards/task_deleted', $task);
602 TaskMeta::where('task_id', $task->id)->delete();
603 do_action('fluent_boards/task_deleted', $deletedTask);
604 TaskMeta::where('task_id', $task->id)->delete();
605 }
606
607 $dbInstance->commit();
608 } catch (\Exception $e) {
609 $dbInstance->rollBack();
610 throw $e; // Re-throw the exception after rolling back
611 }
612
613 }
614 public function deleteTaskForBulk($task)
615 {
616 // If this is a parent task, delete all subtasks first
617 if (!$task->parent_id) {
618 $subtasks = Task::where('parent_id', $task->id)->get();
619 foreach ($subtasks as $subtask) {
620 // Recursively delete each subtask (cleans up all their relations)
621 $this->deleteTaskForBulk($subtask);
622 }
623 }
624
625 $deleted = $task->delete();
626
627 if ($deleted) {
628
629 //task assignees watchers removed
630 $task->watchers()->detach();
631 $task->assignees()->detach();
632
633 //removing all task related notifications
634 $notificationIds = $task->notifications->pluck('id');
635 $task->notifications()->delete();
636 NotificationUser::whereIn('notification_id', $notificationIds)->delete();
637
638 //task labels removed
639 $task->labels()->detach();
640
641 //task custom field value
642 if (defined('FLUENT_BOARDS_PRO_VERSION')) {
643 $task->customFields()->detach();
644 $this->deleteTaskAttachments($task);
645 }
646
647 // For bulk delete, you might want to avoid firing hooks/actions,
648 // so 'fluent_boards/task_deleted' is not triggered here.
649 TaskMeta::where('task_id', $task->id)->delete();
650 }
651 }
652 public function filterNullDate($date)
653 {
654 if ('0000-00-00 00:00:00' == $date || false === strtotime($date)) {
655 return null;
656 }
657 return $date;
658 }
659
660 // this is invoked when task is moved to another board
661
662 /**
663 * @throws \Exception
664 */
665 public function changeBoardByTask($task, $targetBoardId)
666 {
667 // Input validation - must be positive integer
668 if (!is_numeric($targetBoardId) || $targetBoardId <= 0 || !is_int($targetBoardId + 0) || $targetBoardId != (int)$targetBoardId) {
669 throw new \Exception(esc_html__('Invalid board id - must be a positive integer', 'fluent-boards'), 400);
670 }
671
672
673 if ($task->board_id == $targetBoardId) {
674 return $task;
675 }
676
677 $oldBoard = Board::find($task->board_id);
678 $newBoard = Board::find($targetBoardId);
679
680 if (!$oldBoard) {
681 throw new \Exception(esc_html__('Source board not found', 'fluent-boards'), 404);
682 }
683
684 if (!$newBoard) {
685 throw new \Exception(esc_html__('Target board not found', 'fluent-boards'), 404);
686 }
687
688
689 $task->board_id = (int) $targetBoardId;
690 $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task';
691
692 // Remove task cover if it contains image URL for security
693 $this->removeTaskCoverImage($task);
694
695 // REMOVE: Board-dependent data
696 $task->labels()->detach();
697 $task->assignees()->detach();
698 $task->watchers()->detach();
699 $this->removeCustomFieldAssociations($task);
700
701 // REMOVE: User-specific data to prevent security issues
702 $this->removeCommentsAndReplies($task->id);
703 $this->removeTimeTrackingRecords($task->id);
704
705 // REMOVE: File attachments to prevent access issues
706 $this->removeAttachments($task->id);
707
708 // REMOVE: Recurring task settings for security
709 $this->removeRecurringTaskSettings($task->id);
710
711 $task->save();
712
713 // MOVE: Subtasks to new board (preserves subtask groups)
714 $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type);
715
716 do_action('fluent_boards/task_moved_from_board', $task, $oldBoard, $newBoard);
717 return $task;
718 }
719
720 /**
721 * Move all subtasks to the new board when parent task is moved
722 * Preserves subtask groups and their relationships
723 */
724 private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType)
725 {
726 // Get all subtasks of the parent task
727 $subtasks = Task::where('parent_id', $parentTaskId)->get();
728
729 if ($subtasks->isEmpty()) {
730 return;
731 }
732
733 foreach ($subtasks as $subtask) {
734 // Update board_id and type
735 $subtask->board_id = (int) $targetBoardId;
736 $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task';
737
738 // Remove task cover image for security
739 $this->removeTaskCoverImage($subtask);
740
741 // REMOVE: Board-dependent data for subtasks
742 $subtask->labels()->detach();
743 $subtask->assignees()->detach();
744 $subtask->watchers()->detach();
745
746 // Remove custom fields but preserve subtask group relationships
747 $subtask->taskMeta()
748 ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD)
749 ->delete();
750
751 // REMOVE: User-specific data for security
752 $this->removeCommentsAndReplies($subtask->id);
753 $this->removeTimeTrackingRecords($subtask->id);
754
755 // REMOVE: File attachments
756 $this->removeAttachments($subtask->id);
757
758 // REMOVE: Recurring task settings
759 $this->removeRecurringTaskSettings($subtask->id);
760
761 $subtask->save();
762 }
763 }
764
765 /**
766 * Remove task cover image for security reasons
767 * Keeps background colors but removes image references
768 */
769 private function removeTaskCoverImage($task)
770 {
771 $settings = $task->settings;
772 if (empty($settings) || !is_array($settings)) {
773 return;
774 }
775
776 if (isset($settings['cover']) && is_array($settings['cover'])) {
777 $cover = $settings['cover'];
778
779 // Remove image references
780 unset($cover['imageId']);
781 unset($cover['backgroundImage']);
782
783 // Keep only background color if it exists
784 if (isset($cover['backgroundColor'])) {
785 $settings['cover'] = array('backgroundColor' => $cover['backgroundColor']);
786 } else {
787 unset($settings['cover']);
788 }
789
790 $task->settings = $settings;
791 }
792 }
793
794 /**
795 * Remove custom field associations for board move
796 * Custom field values are stored in fbs_relations table, not fbs_task_meta
797 * This method removes task-to-customfield associations from fbs_relations
798 */
799 private function removeCustomFieldAssociations($task)
800 {
801 // Remove custom field values from fbs_relations table
802 // Custom fields are board-specific, so they must be removed when task moves to different board
803 if (defined('FLUENT_BOARDS_PRO')) {
804 $task->customFields()->detach();
805 }
806 }
807
808 /**
809 * Remove comments and replies for security reasons
810 * Prevents exposing user-specific data to unauthorized users
811 */
812 private function removeCommentsAndReplies($taskId)
813 {
814 // Input validation
815 if (!is_numeric($taskId) || $taskId <= 0) {
816 return;
817 }
818
819 // Remove all comments and replies for this task (delete individually to fire model events and clean up images)
820 $comments = Comment::where('task_id', (int) $taskId)->get();
821 foreach ($comments as $comment) {
822 $comment->delete();
823 }
824
825 }
826
827 /**
828 * Remove time tracking records for security reasons
829 * Prevents exposing user-specific time data to unauthorized users
830 */
831 private function removeTimeTrackingRecords($taskId)
832 {
833 // Input validation
834 if (!is_numeric($taskId) || $taskId <= 0) {
835 return;
836 }
837
838 // Remove all time tracking records for this task
839 $this->deleteTimeTrackingRecords((int) $taskId);
840 }
841
842 /**
843 * Remove attachments for security reasons
844 * Prevents file access issues across boards
845 */
846 private function removeAttachments($taskId)
847 {
848 // Input validation
849 if (!is_numeric($taskId) || $taskId <= 0) {
850 return;
851 }
852
853 // Remove all attachments for this task
854 if (class_exists('FluentBoardsPro\App\Models\TaskAttachment')) {
855 \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', (int) $taskId)
856 ->where('object_type', 'task')
857 ->delete();
858 }
859 }
860
861 /**
862 * Remove recurring task settings for security reasons
863 * Prevents recurring task settings from being moved between boards
864 */
865 private function removeRecurringTaskSettings($taskId)
866 {
867 // Input validation
868 if (!is_numeric($taskId) || $taskId <= 0) {
869 return;
870 }
871
872 // Remove recurring task settings for this task from fbs_metas table
873 Meta::where('object_id', (int) $taskId)
874 ->where('object_type', Constant::REPEAT_TASK_META)
875 ->delete();
876 }
877
878 public function getIdeaVoteStatistics($taskId)
879 {
880 return IdeaReaction::where('object_id', $taskId)
881 ->where('object_type', 'idea')
882 ->where('type', 'upvote')
883 ->count();
884 }
885
886
887 /**
888 * Summary of getArchivedOrCompletedTasks
889 * this function will return completd tasks or archived tasks based on users input and also can search by name
890 * @param mixed $data
891 * @param mixed $taskType
892 * @return mixed
893 * @throws \Exception
894 */
895 public function getArchivedTasks($data, $boardId)
896 {
897 $per_page = isset($data['per_page']) ? $data['per_page'] : 25;
898 $page = isset($data['page']) ? $data['page'] : 1;
899 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
900
901 if (isset($data['searchInput'])) {
902 $query = strtolower($data['searchInput']);
903 $firstThreeChars = substr($query, 0, 3);
904
905 if($firstThreeChars == 'id:') {
906 $idPart = substr($query, 3);
907 $idPart = preg_replace('/[^a-zA-Z0-9]/', '', $idPart);
908 $tasksQuery = $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
909 } else {
910 $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['searchInput'] . '%');
911 }
912 }
913
914 // if board_id is not passed then throw an exception
915 if (!$boardId) {
916 throw new \Exception(esc_html__('Board id is required', 'fluent-boards'));
917 }
918
919 return $tasksQuery->orderBy('created_at', 'DESC')->with('assignees')->paginate($per_page, ['*'], 'page', $page);
920 }
921
922 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
923 {
924 $current_user_id = get_current_user_id();
925 /* this will run in background as soon as possible */
926 /* sending Model or Model Instance won't work here */
927
928 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_'.$column, [$taskId, $assigneeIds, $current_user_id], 'fluent-boards');
929 }
930
931 public function getStageByTask($task_id)
932 {
933 $task = Task::find($task_id);
934 return $task->stage;
935 }
936
937 public function moveTaskToNextStage($task_id)
938 {
939 $task = Task::findOrFail($task_id);
940
941 $oldStage = $task->stage;
942
943 $nextStage = Stage::where('board_id', $task->board_id)
944 ->where('position', '>', $oldStage->position)
945 ->orderBy('position', 'ASC')
946 ->first();
947
948 if (!$nextStage) {
949 return $task;
950 }
951
952 if ($nextStage->defaultTaskStatus() == 'closed' && $task->status != 'closed') {
953 $task->status = 'closed';
954 if (!$task->last_completed_at) {
955 $task->last_completed_at = current_time('mysql');
956 }
957 }
958
959 $task->stage_id = $nextStage->id;
960 $task->save();
961
962 $task->load(['board', 'stage', 'attachments']);
963
964 $task = $this->loadNextStage($task);
965
966 return $task;
967 }
968
969 public function loadNextStage($task)
970 {
971 $stage = $task->stage;
972 $nextStage = Stage::where('board_id', $task->board_id)
973 ->where('position', '>', $stage->position)
974 ->orderBy('position', 'ASC')
975 ->first();
976
977 $task->nextStage = $nextStage ? $nextStage->title : null;
978 return $task;
979 }
980
981 public function getActivities($taskId, $perPage, $filter = 'newest')
982 {
983 $activityQuery = Activity::where('object_id', $taskId)
984 ->where('object_type', Constant::ACTIVITY_TASK);
985 if ($filter == 'newest') {
986 $activityQuery = $activityQuery->latest();
987 } else if ($filter == 'oldest') {
988 $activityQuery = $activityQuery->oldest();
989 }
990 return $activityQuery->with('user')->paginate($perPage);
991 }
992
993 public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null)
994 {
995 if (!$lastUpdated) {
996 $lastUpdated = gmdate('Y-m-d H:i:s', current_time('timestamp') - 60); // 1 minute ago
997 }
998
999 $tasks = Task::query()
1000 ->where([
1001 'board_id' => $boardId,
1002 'parent_id' => null,
1003 ])
1004 ->where('updated_at', '>', $lastUpdated) // updated in the last minute
1005 ->with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
1006 ->orderBy('due_at', 'ASC')
1007 ->get();
1008
1009 foreach ($tasks as $task) {
1010 $task->isOverdue = $task->isOverdue();
1011 $task->isUpcoming = $task->upcoming();
1012 $task->is_watching = $task->isWatching();
1013 $task->contact = Task::lead_contact($task->crm_contact_id);
1014 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1015 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
1016 }
1017 return $tasks;
1018 }
1019
1020 public function getLastPositionOfTasks($stage_id)
1021 {
1022 $lastPosition = Task::query()
1023 ->where('stage_id', $stage_id)
1024 ->where('parent_id', null)
1025 ->whereNull('archived_at')
1026 ->orderBy('position', 'desc')
1027 ->pluck('position')
1028 ->first();
1029
1030 return $lastPosition + 1;
1031 }
1032
1033 public function getAssociatedTasks($associatedId)
1034 {
1035 $tasks = Task::query()
1036 ->where('crm_contact_id', $associatedId)
1037 ->with(['board', 'stage', 'assignees', 'labels', 'watchers', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
1038 ->orderBy('due_at', 'ASC')
1039 ->get();
1040
1041 foreach ($tasks as $task) {
1042 $task->isOverdue = $task->isOverdue();
1043 $task->isUpcoming = $task->upcoming();
1044 $task->contact = Task::lead_contact($task->crm_contact_id);
1045 $task->is_watching = $task->isWatching();
1046
1047 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1048 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
1049
1050 if(defined('FLUENT_BOARDS_PRO')) {
1051 $modules = fluent_boards_get_pref_settings();
1052 if($modules['timeTracking']['enabled'] == 'yes') {
1053 $task->time_tracks= [
1054 'tracks' => (new ProTaskService())->getTaskTimeTrack($task->board_id, $task->id),
1055 'estimated_minutes' => TimeTrackingHelper::getTaskEstimation($task->id)
1056 ];
1057 }
1058 }
1059
1060
1061 // $subTasks = Task::query()
1062 // ->where('parent_id', $task->id)
1063 // ->with(['assignees'])
1064 // ->whereNull('archived_at')
1065 // ->orderBy('position', 'ASC')
1066 // ->get();
1067 //
1068 // foreach ($subTasks as $subTask) {
1069 // $subTask->assignees = Helper::sanitizeUserCollections($subTask->assignees);
1070 // }
1071 //
1072 // $task->subtasks = $subTasks;
1073
1074
1075 foreach ($task->subtaskGroup as $group) {
1076 foreach ($group->subtasks as $subtask) {
1077 $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
1078 }
1079 }
1080 $task->subtask_group = $task->subtaskGroup;
1081 }
1082
1083 return $tasks;
1084 }
1085
1086 public function copySubtaskGroup($task, $newTask, $subtaskGroupMap)
1087 {
1088 $subtaskGroups = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_NAME)->get();
1089 foreach ($subtaskGroups as $group) {
1090 $newGroup = TaskMeta::create([
1091 'task_id' => $newTask->id,
1092 'key' => Constant::SUBTASK_GROUP_NAME,
1093 'value' => $group->value
1094 ]);
1095
1096 $subtaskGroupMap[$group->id] = $newGroup->id;
1097 }
1098
1099 return $subtaskGroupMap;
1100 }
1101
1102 public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
1103 {
1104 $allActiveTasks = Task::where('board_id', $boardId)->whereNull('archived_at')->get();
1105 $taskMap = [];
1106 $subtaskGroupMap = [];
1107 $parentTaskCount = 0;
1108 foreach ($allActiveTasks as $task) {
1109 $newTask = array();
1110 $newTask['title'] = $task->title;
1111 $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
1112 $newTask['description'] = $task->description;
1113 $newTask['board_id'] = $newBoard->id;
1114 $newTask['stage_id'] = $stageMap[$task->stage_id];
1115 $newTask['status'] = $task->status;
1116 $newTask['priority'] = $task->priority;
1117 $newTask['position'] = $task->position;
1118 $newTask['due_at'] = $task->due_at;
1119 $backgroundColor = '';
1120 $backgroundColor = $task->settings['cover']['backgroundColor'];
1121 $newTask['settings'] = [
1122 'cover' => [
1123 'backgroundColor' => $backgroundColor,
1124 ]
1125 ];
1126
1127 $newTask = Task::create($newTask);
1128
1129 if (!$task->parent_id) {
1130 //group mapping
1131 $subtaskGroupMap = $this->copySubtaskGroup($task, $newTask, $subtaskGroupMap);
1132 } else {
1133 $groupRelationOfTask = TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
1134 ->where('task_id', $task->id)
1135 ->first();
1136
1137 if ($groupRelationOfTask && $subtaskGroupMap[$groupRelationOfTask->value]) {
1138 TaskMeta::create([
1139 'task_id' => $newTask->id,
1140 'key' => Constant::SUBTASK_GROUP_CHILD,
1141 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
1142 ]);
1143 }
1144 }
1145
1146 if($isWithTemplates == 'yes') {
1147 $isTemplate = TaskMeta::where('task_id', $task->id)
1148 ->where('key', 'is_template')
1149 ->first();
1150 if($isTemplate) {
1151 TaskMeta::create([
1152 'task_id' => $newTask->id,
1153 'key' => 'is_template',
1154 'value' => $isTemplate->value
1155 ]);
1156 }
1157 }
1158 if(!$task->parent_id){
1159 ++$parentTaskCount;
1160 $taskMap[$task['id']] = $newTask->id;
1161 //duplicate labels to task
1162 $labelIds = $task->labels->pluck('id')->toArray();
1163 if($labelIds){
1164 $flipLabelIds = array_flip($labelIds);
1165 $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
1166
1167 $newTask->labels()->attach($labelsToAttach, [
1168 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
1169 ]);
1170 }
1171 }
1172 }
1173
1174 $board = Board::findOrFail($newBoard->id);
1175 $settings = [];
1176 $settings['tasks_count'] = $parentTaskCount;
1177 $board->settings = $settings;
1178 $board->save();
1179 }
1180
1181 private function subtaskCountUpdate($taskId){
1182 $parentTask = Task::findOrFail($taskId);
1183 $settings = $parentTask->settings;
1184 $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1;
1185 $parentTask->settings = $settings;
1186 $parentTask->save();
1187 }
1188
1189 /**
1190 * @param $taskId
1191 * @param $perPage
1192 * @param $offset
1193 * @param string $filter
1194 * @return array
1195 */
1196 public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest'): array
1197 {
1198 // Fetch the task
1199 $task = Task::findOrFail($taskId);
1200
1201 // Fetch comments and activities separately
1202 $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
1203 $activities = $task->activities()
1204 ->with('user')
1205 ->where(function($query) {
1206 $query->whereNotIn('column', [ 'comment', 'a reply'])
1207 ->orWhere(function($subQuery) {
1208 $subQuery->whereNotIn('action', ['added', 'updated']);
1209 });
1210 })
1211 ->orderBy('created_at', 'desc')
1212 ->get()
1213 ->toArray();
1214
1215
1216
1217 // Merge comments and activities into a single array
1218 $commentsAndActivities = array_merge($comments, $activities);
1219
1220 // Sort the merged array by created_at date in ascending or descending order
1221 $order = $filter == 'newest' ? -1 : 1;
1222 usort($commentsAndActivities, function ($a, $b) use ($order) {
1223 return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
1224 });
1225
1226 // Paginate the results
1227 $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
1228 $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
1229
1230 // Get the total count of comments and activities
1231 $total = count($commentsAndActivities);
1232 $lastPage = (int) ceil($total / $perPage);
1233
1234 // Construct pagination metadata
1235 $path = "https://wordpress.test/wp-json/fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities";
1236 return [
1237 'current_page' => (int) $page,
1238 'data' => $paginatedResults,
1239 'first_page_url' => "{$path}?page=1",
1240 'from' => $total > 0 ? (int) ($offset + 1) : null,
1241 'last_page' => (int) $lastPage,
1242 'last_page_url' => "{$path}?page={$lastPage}",
1243 'links' => [
1244 [
1245 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
1246 'label' => 'pagination.previous',
1247 'active' => false
1248 ],
1249 [
1250 'url' => "{$path}?page={$page}",
1251 'label' => (int) $page,
1252 'active' => true
1253 ],
1254 [
1255 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
1256 'label' => 'pagination.next',
1257 'active' => false
1258 ]
1259 ],
1260 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
1261 'path' => $path,
1262 'per_page' => (int) $perPage,
1263 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
1264 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
1265 'total' => (int) $total
1266 ];
1267 }
1268
1269 /**
1270 * @param $task_id
1271 * @param $fileData
1272 * @param $type
1273 * @return Attachment
1274 */
1275 public function uploadMediaFileFromWpEditor($task_id, $fileData, $type)
1276 {
1277 $initialDataData = [
1278 'type' => 'url',
1279 'url' => '',
1280 'name' => '',
1281 'size' => 0,
1282 ];
1283
1284 $attachData = array_merge($initialDataData, $fileData);
1285 $UrlMeta = [];
1286 if($attachData['type'] == 'url') {
1287 $UrlMeta = RemoteUrlParser::parse($attachData['url']);
1288 }
1289 $attachment = new TaskImage();
1290 $attachment->object_id = $task_id;
1291 $attachment->object_type = $type;
1292 $attachment->attachment_type = $attachData['type'];
1293 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
1294 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
1295 $attachment->full_url = esc_url($attachData['url']);
1296 $attachment->file_size = $attachData['size'];
1297 $attachment->settings = $attachData['type'] == 'url' ? [
1298 'meta' => $UrlMeta
1299 ] : '';
1300 $attachment->driver = 'local';
1301 $attachment->save();
1302 return $attachment;
1303 }
1304
1305
1306 /**
1307 * @param $type
1308 * @param $title
1309 * @param $UrlMeta
1310 * @return mixed|string
1311 */
1312 public function setTitle($type, $title, $UrlMeta)
1313 {
1314 if($type != 'url') {
1315 return sanitize_file_name($title);
1316 }
1317 return $title ?? $UrlMeta['title'] ?? '';
1318 }
1319
1320 public function manageDefaultAssignees($task, $stageId)
1321 {
1322 $stage = Stage::findOrFail($stageId);
1323 if ($stage && isset($stage->settings['default_task_assignees'])) {
1324 $defaultAssignees = $stage->settings['default_task_assignees'];
1325 foreach ($defaultAssignees as $assigneeId) {
1326 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
1327 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
1328 if (!$IfAlreadyAssignee) {
1329 $this->updateAssignee($assigneeId, $task);
1330 }
1331 }
1332 }
1333 }
1334
1335 public function setDefaultAssigneesToEveryTasks($stage)
1336 {
1337 $tasks = $stage->tasks->whereNull('archived_at');
1338 foreach ($tasks as $task) {
1339 $this->manageDefaultAssignees($task, $stage->id);
1340 }
1341 }
1342
1343 public function createTaskFromImage($board_id, $stage_id, $uploadInfo, $file)
1344 {
1345
1346 $board = Board::find($board_id);
1347 $task = new Task();
1348 $taskType = $board->type === 'to-do' ? 'task' : 'roadmap' ;
1349 $taskData = [
1350 'title' => $uploadInfo[0]['name'],
1351 'board_id' => $board_id,
1352 'stage_id' => $stage_id,
1353 'type' => $taskType,
1354 ];
1355 $task->fill($taskData);
1356 $task->save();
1357
1358 $fileData = $uploadInfo[0];
1359 $fileUploadedData = $this->uploadMediaFileFromWpEditor($task->id, $fileData, Constant::TASK_DESCRIPTION);
1360 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1361 $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1362 $fileUploadedData['driver'] = $mediaData['driver'];
1363 $fileUploadedData['file_path'] = $mediaData['file_path'];
1364 $fileUploadedData['full_url'] = $mediaData['full_url'];
1365 $fileUploadedData->save();
1366 }
1367
1368 $settings = $task->settings;
1369 $settings['cover'] = [
1370 'imageId' => $fileUploadedData['id'],
1371 'backgroundImage' => (new CommentService())->createPublicUrl($fileUploadedData, $board_id),
1372 ];
1373 $task->settings = $settings;
1374 $task = $task->moveToNewPosition(1);
1375 $task->save();
1376 $task->load(['board', 'stage', 'labels', 'assignees']);
1377
1378 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1379
1380 $task->isOverdue = $task->isOverdue();
1381 $task->contact = Task::lead_contact($task->crm_contact_id);
1382 $task->board->stages = (new StageService())->stagesByBoardId($board_id);
1383 $task->is_watching = (new NotificationService())->isCurrentUserObservingTask($task);
1384
1385 $task = $this->loadNextStage($task);
1386
1387 if ($task->type == 'roadmap') {
1388 $task->vote_statistics = $this->getIdeaVoteStatistics($task->id);
1389 }
1390
1391 return $task;
1392 }
1393 public function deleteTaskCoverImage($settings)
1394 {
1395 if (isset($settings['cover']['imageId']) && $settings['cover']['imageId']) {
1396 $image = TaskImage::find($settings['cover']['imageId']);
1397 if ($image) {
1398 $deletedImage = clone $image;
1399 $deletedImage->delete();
1400
1401 do_action('fluent_boards/task_attachment_deleted', $deletedImage);
1402 }
1403 }
1404
1405 }
1406
1407 private function deleteTaskAttachments($task)
1408 {
1409 $attachments = TaskAttachment::where('object_id', $task->id)
1410 ->where('object_type', Constant::TASK_ATTACHMENT)
1411 ->get();
1412 foreach ($attachments as $attachment) {
1413 $deletedAttachment = clone $attachment;
1414 $attachment->delete();
1415
1416 do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
1417 }
1418 }
1419
1420 public function cloneTask(int $taskId, $taskData): Task
1421 {
1422 global $wpdb;
1423
1424 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
1425 $wpdb->query('START TRANSACTION');
1426
1427 try {
1428 // Load task with all necessary relationships
1429 $task = Task::with([
1430 'assignees',
1431 'labels',
1432 'watchers',
1433 ])->findOrFail($taskId);
1434
1435 // Create new task with cloned data
1436 $clonedTask = $task->replicate();
1437 $clonedTask->title = $taskData['title'] ?? $task->title . ' (' . \__('cloned', 'fluent-boards') . ')';
1438
1439 $settings = $clonedTask->settings ?? [];
1440
1441 unset(
1442 $settings['attachment_count'],
1443 $settings['subtask_completed_count'],
1444 $settings['subtask_count']
1445 );
1446 $clonedTask->settings = $settings;
1447 $clonedTask->stage_id = $taskData['stage_id'] ?? $task->stage_id;
1448
1449 // Validate that target stage belongs to the same board
1450 $targetStage = Stage::findOrFail($clonedTask->stage_id);
1451 if ($task->board_id != $targetStage->board_id) {
1452 throw new \Exception(esc_html__('Cannot clone task to a different board. Task and target stage must be on the same board.', 'fluent-boards'));
1453 }
1454
1455 $clonedTask->board_id = $targetStage->board_id;
1456
1457 $clonedTask->comments_count = 0; // Reset comments count for cloned task
1458 $clonedTask->save();
1459
1460 $positionIndex = 1; // Default position index for new task
1461 if($task->stage_id === $clonedTask->stage_id) {
1462 // Calculate position for the cloned task next to original task
1463 $positionIndex = $this->calculateClonedTaskPosition($task);
1464 }
1465 // Move cloned task to the new position
1466 $clonedTask->moveToNewPosition($positionIndex);
1467
1468 $this->cloneTaskMeta($task, $clonedTask);
1469
1470 $this->cloneTaskCustomFields($task, $clonedTask);
1471
1472 // Apply stage default assignees if any are set
1473 $this->manageDefaultAssignees($clonedTask, $clonedTask->stage_id);
1474
1475 if($taskData['assignee']) {
1476 $this->cloneAssignees($task, $clonedTask);
1477 }
1478 if($taskData['label']) {
1479 $this->cloneTaskLabels($task, $clonedTask);
1480 }
1481 $this->cloneTaskWatchers($task, $clonedTask);
1482
1483 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1484 // Clone time tracking data if Pro version is active
1485 if ($taskData['attachment']) {
1486 $this->cloneAttachments($task, $clonedTask);
1487 }
1488 if ($taskData['subtask']) {
1489 $this->cloneSubtasks($task, $clonedTask);
1490 }
1491 }
1492
1493 if($taskData['comment']) {
1494 $this->cloneCommentsAndReplies($task, $clonedTask);
1495 }
1496
1497 // Load and prepare the cloned task for response
1498 $clonedTask = $this->prepareClonedTaskForResponse($clonedTask);
1499 do_action('fluent_boards/task_cloned', $task, $clonedTask);
1500
1501 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
1502 $wpdb->query('COMMIT');
1503 return $clonedTask;
1504
1505 } catch (\Exception $e) {
1506 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
1507 $wpdb->query('ROLLBACK');
1508 throw new \Exception(
1509 esc_html(\__('Failed to clone task: ', 'fluent-boards') . $e->getMessage()),
1510 (int) ($e->getCode() ?: 500)
1511 );
1512 }
1513 }
1514
1515 private function calculateClonedTaskPosition(Task $originalTask): int
1516 {
1517 $tasks = Task::where('stage_id', $originalTask->stage_id)
1518 ->whereNull('archived_at')
1519 ->orderBy('position', 'asc')
1520 ->get();
1521
1522 $index = $tasks->search(function($task) use ($originalTask) {
1523 return $task->id === $originalTask->id;
1524 });
1525
1526 return $index !== false ? $index + 2 : 1; // Return 1-based index
1527 }
1528
1529 private function cloneTaskMeta(Task $originalTask, Task $clonedTask): void
1530 {
1531 $taskMetas = TaskMeta::where('task_id', $originalTask->id)
1532 ->where('key', '!=', Constant::SUBTASK_GROUP_NAME)
1533 ->get();
1534 foreach ($taskMetas as $meta) {
1535 TaskMeta::create([
1536 'task_id' => $clonedTask->id,
1537 'key' => $meta->key,
1538 'value' => $meta->value
1539 ]);
1540 }
1541 }
1542
1543 private function cloneAssignees($originalTask, $clonedTask)
1544 {
1545 // Clone assignees
1546 if ($originalTask->assignees) {
1547 foreach ($originalTask->assignees as $assignee) {
1548 $clonedTask->assignees()->syncWithoutDetaching([$assignee->ID => ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]]);
1549 }
1550 }
1551 }
1552
1553 private function cloneTaskLabels(Task $originalTask, Task $clonedTask): void
1554 {
1555 // Clone labels
1556 if ($originalTask->labels) {
1557 foreach ($originalTask->labels as $label) {
1558 $clonedTask->labels()->syncWithoutDetaching([$label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]]);
1559 }
1560 }
1561 }
1562
1563 private function cloneTaskWatchers(Task $originalTask, Task $clonedTask): void
1564 {
1565 /// Clone watchers
1566 if ($originalTask->watchers) {
1567 foreach ($originalTask->watchers as $watcher) {
1568 $clonedTask->watchers()->syncWithoutDetaching([$watcher->ID => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
1569 }
1570 }
1571 }
1572 private function cloneTaskCustomFields(Task $originalTask, Task $clonedTask): void
1573 {
1574
1575 // Clone custom fields
1576 if ($originalTask->taskCustomFields) {
1577 foreach ($originalTask->taskCustomFields as $customField) {
1578 $clonedField = $customField->replicate();
1579 $clonedField->object_id = $clonedTask->id;
1580 $clonedField->save();
1581 }
1582 }
1583 }
1584 private function cloneAttachments(Task $originalTask, Task $clonedTask): void
1585 {
1586 $attachments = $originalTask->attachments;
1587 foreach ($attachments as $attachment) {
1588 $clonedAttachment = $attachment->replicate();
1589 $clonedAttachment->object_id = $clonedTask->id;
1590 $clonedAttachment->save();
1591
1592 // If this is a cover image, update task settings
1593 if ($attachment->type === 'cover_image') {
1594 $settings = $clonedTask->settings;
1595 if (isset($settings['cover_image'])) {
1596 $settings['cover_image'] = $clonedAttachment->id;
1597 $clonedTask->settings = $settings;
1598 $clonedTask->save();
1599 }
1600 }
1601 }
1602 $settings = $clonedTask->settings;
1603 $settings['attachment_count'] = $clonedTask->attachments()->count();
1604 $clonedTask['settings'] = $settings;
1605 $clonedTask->save();
1606 }
1607 private function cloneCommentsAndReplies(Task $originalTask, Task $clonedTask)
1608 {
1609 // Get comments ordered by created_at
1610 $comments = Comment::where('task_id', $originalTask->id)
1611 ->where('type', 'comment')
1612 ->whereNull('parent_id')
1613 ->orderBy('created_at', 'asc')
1614 ->get();
1615
1616 if ($comments->isEmpty()) {
1617 return;
1618 }
1619
1620 foreach ($comments as $comment) {
1621 $clonedComment = $comment->replicate();
1622 $clonedComment->task_id = $clonedTask->id;
1623 $clonedComment->save();
1624
1625 // Get replies ordered by created_at
1626 $replies = Comment::where('parent_id', $comment->id)
1627 ->where('type', 'reply')
1628 ->orderBy('created_at', 'asc')
1629 ->get();
1630
1631 foreach ($replies as $reply) {
1632 $clonedReply = $reply->replicate();
1633 $clonedReply->task_id = $clonedTask->id;
1634 $clonedReply->parent_id = $clonedComment->id;
1635 $clonedReply->save();
1636
1637 // Clone reply image if any
1638 $this->cloneCommentOrReplyImage($reply, $clonedReply);
1639 }
1640
1641 // Clone comment image if any
1642 $this->cloneCommentOrReplyImage($comment, $clonedComment);
1643 }
1644 return;
1645 }
1646 private function cloneCommentOrReplyImage($oldCommentOrReply, $clonedCommentOrReply)
1647 {
1648 $images = CommentImage::where('object_id', $oldCommentOrReply->id)
1649 ->where('object_type', Constant::COMMENT_IMAGE)
1650 ->orderBy('created_at', 'asc')
1651 ->get();
1652
1653 if ($images->count() > 0) {
1654 foreach ($images as $image) {
1655 $clonedImage = $image->replicate();
1656 $clonedImage->object_id = $clonedCommentOrReply->id;
1657 $clonedImage->save();
1658 }
1659 }
1660 }
1661 private function cloneSubtasks(Task $originalTask, Task $clonedTask): void
1662 {
1663 // First clone subtask groups
1664 $subtaskGroupMap = $this->cloneSubtaskGroups($originalTask, $clonedTask);
1665 $completedSubtasksCount = 0;
1666
1667 if ($originalTask->subtasks) {
1668 foreach ($originalTask->subtasks as $subtask) {
1669 $clonedSubtask = $subtask->replicate();
1670 $clonedSubtask->parent_id = $clonedTask->id;
1671 $clonedSubtask->board_id = $clonedTask->board_id; // Ensure subtask has same board_id as parent
1672 $clonedSubtask->save();
1673 if($clonedSubtask->status == 'closed') {
1674 $completedSubtasksCount++;
1675 }
1676
1677 // Update subtask group relationship if exists
1678 $groupRelation = TaskMeta::where('task_id', $subtask->id)
1679 ->where('key', Constant::SUBTASK_GROUP_CHILD)
1680 ->first();
1681
1682 if ($groupRelation && isset($subtaskGroupMap[$groupRelation->value])) {
1683 TaskMeta::create([
1684 'task_id' => $clonedSubtask->id,
1685 'key' => Constant::SUBTASK_GROUP_CHILD,
1686 'value' => $subtaskGroupMap[$groupRelation->value]
1687 ]);
1688 }
1689 }
1690 }
1691 $settings = $clonedTask->settings;
1692 $settings['subtask_count'] = $clonedTask->subtasks()->count();
1693 $clonedTask['settings'] = $settings;
1694 $clonedTask->settings['subtask_completed_count'] = $completedSubtasksCount;
1695 $clonedTask->save();
1696 }
1697 private function cloneSubtaskGroups(Task $originalTask, Task $clonedTask): array
1698 {
1699 $subtaskGroupMap = [];
1700
1701 if ($originalTask->subtaskGroup) {
1702 foreach ($originalTask->subtaskGroup as $group) {
1703 $clonedGroup = TaskMeta::create([
1704 'task_id' => $clonedTask->id,
1705 'key' => Constant::SUBTASK_GROUP_NAME,
1706 'value' => $group->value
1707 ]);
1708
1709 $subtaskGroupMap[$group->id] = $clonedGroup->id;
1710 }
1711 }
1712
1713 return $subtaskGroupMap;
1714 }
1715 private function prepareClonedTaskForResponse(Task $clonedTask): Task
1716 {
1717 // Load relationships
1718 $clonedTask->load(['board', 'stage', 'labels', 'assignees', 'subtasks']);
1719
1720 // Sanitize assignees
1721 $clonedTask->assignees = Helper::sanitizeUserCollections($clonedTask->assignees);
1722
1723 // Set additional properties
1724 $clonedTask->isOverdue = $clonedTask->isOverdue();
1725 $clonedTask->contact = Task::lead_contact($clonedTask->crm_contact_id);
1726 $clonedTask->board->stages = (new StageService())->stagesByBoardId($clonedTask->board_id);
1727 $clonedTask->is_watching = (new NotificationService())->isCurrentUserObservingTask($clonedTask);
1728
1729 // Load next stage if applicable
1730 return $this->loadNextStage($clonedTask);
1731 }
1732
1733 /**
1734 * Handle bulk actions for multiple tasks
1735 *
1736 * @param array $taskIds
1737 * @param string $action
1738 * @param array $params
1739 * @param int $boardId
1740 * @return array
1741 * @throws \Exception
1742 */
1743 public function bulkActions($taskIds, $action, $params, $boardId)
1744 {
1745 if (empty($taskIds) || !is_array($taskIds)) {
1746 throw new \Exception(esc_html__('No tasks selected', 'fluent-boards'));
1747 }
1748
1749 if (count($taskIds) > 150) {
1750 throw new \Exception(esc_html__('Cannot process more than 150 tasks at once. Please select fewer tasks.', 'fluent-boards'));
1751 }
1752
1753 if (empty($action)) {
1754 throw new \Exception(esc_html__('No action specified', 'fluent-boards'));
1755 }
1756
1757 $tasks = Task::whereIn('id', $taskIds)
1758 ->where('board_id', $boardId)
1759 ->get();
1760
1761 if ($tasks->isEmpty()) {
1762 throw new \Exception(esc_html__('No valid tasks found', 'fluent-boards'));
1763 }
1764
1765 $result = [
1766 'successful_tasks' => [],
1767 'failed_tasks' => [],
1768 'message' => ''
1769 ];
1770
1771 switch ($action) {
1772 case 'move_to_stage':
1773 $result = $this->bulkMoveToStage($tasks, $params, $boardId);
1774 break;
1775
1776 case 'archive_tasks':
1777 $result = $this->bulkArchiveTasks($tasks);
1778 break;
1779
1780 case 'change_priority':
1781 $result = $this->bulkChangePriority($tasks, $params);
1782 break;
1783
1784 case 'assign_members':
1785 $result = $this->bulkAssignMembers($tasks, $params, $boardId);
1786 break;
1787
1788 case 'add_labels':
1789 $result = $this->bulkAddLabels($tasks, $params, $boardId);
1790 break;
1791
1792 default:
1793 throw new \Exception(esc_html__('Invalid action specified', 'fluent-boards'));
1794 }
1795
1796 // Dispatch WordPress action for other plugins to hook into
1797 do_action('fluent_boards/bulk_action_completed', $action, $tasks, $boardId);
1798
1799 return $result;
1800 }
1801
1802 /**
1803 * Bulk move tasks to a stage
1804 */
1805 private function bulkMoveToStage($tasks, $params, $boardId)
1806 {
1807 $stageId = $params['stage_id'] ?? null;
1808 if (!$stageId) {
1809 throw new \Exception(esc_html__('Stage ID is required', 'fluent-boards'));
1810 }
1811
1812 $successfulTasks = [];
1813 $failedTasks = [];
1814
1815 foreach ($tasks as $task) {
1816 try {
1817 $oldStageId = $task->stage_id;
1818
1819 // Set new stage
1820 $task->stage_id = $stageId;
1821 $task = $task->moveToNewPosition(1);
1822
1823 // Only process stage-specific logic if stage actually changed
1824 if ($oldStageId != $stageId) {
1825 // Manage default assignees for the new stage
1826 $this->manageDefaultAssignees($task, $stageId);
1827
1828 // Check if new stage has default closed status
1829 $defaultPosition = $task->stage->defaultTaskStatus();
1830 if ($defaultPosition == 'closed' && $task->status != 'closed') {
1831 $task = $task->close();
1832 }
1833
1834 // Dispatch WordPress action for stage change
1835 //currently commented, need to check in future for bulk action
1836 // do_action('fluent_boards/task_stage_updated', $task, $oldStageId);
1837
1838 // Send email notifications for stage change
1839 $usersToSendEmail = (new \FluentBoards\App\Services\NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
1840 $this->sendMailAfterTaskModify('stage_change', $usersToSendEmail, $task->id);
1841 }
1842
1843 // Dispatch general task update action
1844 //currently commented, need to check in future for bulk action
1845 // do_action('fluent_boards/task_updated', $task, 'position');
1846
1847 // Reload task with all relationships
1848 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
1849
1850 $successfulTasks[] = $task;
1851 } catch (\Exception $e) {
1852 $failedTasks[] = [
1853 'id' => $task->id,
1854 'title' => $task->title,
1855 'error' => $e->getMessage()
1856 ];
1857 }
1858 }
1859
1860 $successCount = count($successfulTasks);
1861 $failureCount = count($failedTasks);
1862
1863 $message = '';
1864 if ($failureCount === 0) {
1865 // translators: %d is the number of tasks successfully moved to the stage
1866 $message = sprintf(__('%d tasks moved to stage successfully', 'fluent-boards'), $successCount);
1867 } elseif ($successCount === 0) {
1868 // translators: %d is the number of tasks that failed to move to the stage
1869 $message = sprintf(__('Failed to move %d tasks to stage', 'fluent-boards'), $failureCount);
1870 } else {
1871 // translators: 1: number of tasks successfully moved; 2: number of tasks failed to move
1872 $message = sprintf(__('%1$d tasks moved successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
1873 }
1874
1875 return [
1876 'successful_tasks' => $successfulTasks,
1877 'failed_tasks' => $failedTasks,
1878 'message' => $message
1879 ];
1880 }
1881
1882 /**
1883 * Bulk archive tasks
1884 */
1885 private function bulkArchiveTasks($tasks)
1886 {
1887 $successfulTasks = [];
1888 $failedTasks = [];
1889
1890 foreach ($tasks as $task) {
1891 try {
1892 // Use the same logic as single task archiving
1893 $this->updateTaskProperty('archived_at', current_time('mysql'), $task);
1894
1895 // Reload task with all relationships
1896 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
1897
1898 $successfulTasks[] = $task;
1899 } catch (\Exception $e) {
1900 $failedTasks[] = [
1901 'id' => $task->id,
1902 'title' => $task->title,
1903 'error' => $e->getMessage()
1904 ];
1905 }
1906 }
1907
1908 $successCount = count($successfulTasks);
1909 $failureCount = count($failedTasks);
1910
1911 $message = '';
1912 if ($failureCount === 0) {
1913 // translators: %d is the number of tasks archived successfully
1914 $message = sprintf(__('%d tasks archived successfully', 'fluent-boards'), $successCount);
1915 } elseif ($successCount === 0) {
1916 // translators: %d is the number of tasks that failed to archive
1917 $message = sprintf(__('Failed to archive %d tasks', 'fluent-boards'), $failureCount);
1918 } else {
1919 // translators: 1: number of tasks archived successfully; 2: number of tasks failed to archive
1920 $message = sprintf(__('%1$d tasks archived successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
1921 }
1922
1923 return [
1924 'successful_tasks' => $successfulTasks,
1925 'failed_tasks' => $failedTasks,
1926 'message' => $message
1927 ];
1928 }
1929
1930 /**
1931 * Bulk change task priority
1932 */
1933 private function bulkChangePriority($tasks, $params)
1934 {
1935 $priority = $params['priority'] ?? null;
1936
1937 // Get valid priorities including custom ones added by hooks
1938 $validPriorities = array_keys(apply_filters('fluent_boards/task_priorities', [
1939 'low' => __('Low', 'fluent-boards'),
1940 'medium' => __('Medium', 'fluent-boards'),
1941 'high' => __('High', 'fluent-boards')
1942 ]));
1943
1944 if (!in_array($priority, $validPriorities)) {
1945 throw new \Exception(esc_html__('Invalid priority level', 'fluent-boards'));
1946 }
1947
1948 $successfulTasks = [];
1949 $failedTasks = [];
1950
1951 foreach ($tasks as $task) {
1952 try {
1953 // Use the same logic as single task priority update
1954 $this->updateTaskProperty('priority', $priority, $task);
1955
1956 // Reload task with all relationships
1957 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
1958
1959 $successfulTasks[] = $task;
1960 } catch (\Exception $e) {
1961 $failedTasks[] = [
1962 'id' => $task->id,
1963 'title' => $task->title,
1964 'error' => $e->getMessage()
1965 ];
1966 }
1967 }
1968
1969 $successCount = count($successfulTasks);
1970 $failureCount = count($failedTasks);
1971
1972 $message = '';
1973 if ($failureCount === 0) {
1974 // translators: %d is the number of tasks whose priorities were updated successfully
1975 $message = sprintf(__('%d task priorities updated successfully', 'fluent-boards'), $successCount);
1976 } elseif ($successCount === 0) {
1977 // translators: %d is the number of tasks whose priorities failed to update
1978 $message = sprintf(__('Failed to update %d task priorities', 'fluent-boards'), $failureCount);
1979 } else {
1980 // translators: 1: number of tasks priorities updated; 2: number of tasks priorities failed to update
1981 $message = sprintf(__('%1$d task priorities updated successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
1982 }
1983
1984 return [
1985 'successful_tasks' => $successfulTasks,
1986 'failed_tasks' => $failedTasks,
1987 'message' => $message
1988 ];
1989 }
1990
1991 /**
1992 * Bulk assign members to tasks
1993 */
1994 private function bulkAssignMembers($tasks, $params, $boardId)
1995 {
1996 $userIds = $params['user_ids'] ?? [];
1997 if (!is_array($userIds)) {
1998 throw new \Exception(esc_html__('User IDs must be an array', 'fluent-boards'));
1999 }
2000
2001 // Validate that all users are valid WordPress users
2002 $validUsers = get_users(['include' => $userIds]);
2003 $validUserIds = array_map(function($user) {
2004 return $user->ID;
2005 }, $validUsers);
2006
2007 if (count($validUserIds) !== count($userIds)) {
2008 throw new \Exception(esc_html__('Some user IDs are invalid', 'fluent-boards'));
2009 }
2010
2011 // Filter only users who are already board members (skip non-members)
2012 $boardService = new \FluentBoards\App\Services\BoardService();
2013 $boardMemberIds = [];
2014 foreach ($validUserIds as $userId) {
2015 if ($boardService->isAlreadyMember($boardId, $userId)) {
2016 $boardMemberIds[] = $userId;
2017 }
2018 }
2019
2020 // If no valid board members, skip assignment silently
2021 if (empty($boardMemberIds)) {
2022 return [
2023 'successful_tasks' => [],
2024 'failed_tasks' => [],
2025 'message' => __('No valid board members selected for assignment', 'fluent-boards')
2026 ];
2027 }
2028
2029 // Use only board members for assignment
2030 $validUserIds = $boardMemberIds;
2031
2032 $successfulTasks = [];
2033 $failedTasks = [];
2034
2035 foreach ($tasks as $task) {
2036 try {
2037 // Use pure "add-only" logic for bulk assignment - never remove existing assignees
2038 $currentAssigneeIds = $task->assignees->pluck('ID')->toArray();
2039 $newAssignees = [];
2040
2041 foreach ($validUserIds as $userId) {
2042 // Only add if not already assigned
2043 if (!in_array($userId, $currentAssigneeIds)) {
2044 $newAssignees[] = $userId;
2045 }
2046 }
2047
2048 // Add all new assignees at once
2049 if (!empty($newAssignees)) {
2050 $assigneeData = [];
2051 foreach ($newAssignees as $userId) {
2052 $assigneeData[$userId] = ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE];
2053 }
2054 $task->assignees()->syncWithoutDetaching($assigneeData);
2055
2056 // Add as watchers
2057 $watcherData = [];
2058 foreach ($newAssignees as $userId) {
2059 $watcherData[$userId] = ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH];
2060 }
2061 $task->watchers()->syncWithoutDetaching($watcherData);
2062
2063 // Send notifications and actions only for new assignees
2064 foreach ($newAssignees as $userId) {
2065 // Send email notification if enabled and not current user
2066 if ((new \FluentBoards\App\Services\NotificationService())->checkIfEmailEnable($userId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id) && $userId != get_current_user_id()) {
2067 $this->sendMailAfterTaskModify('add_assignee', $userId, $task->id);
2068 }
2069
2070 // Dispatch WordPress actions
2071 //currently commented, need to check in future for bulk action
2072 // do_action('fluent_boards/task_assignee_added', $task, $userId);
2073 // if ($userId != get_current_user_id()) {
2074 // do_action('fluent_boards/assign_another_user', $task, $userId);
2075 // }
2076 }
2077 }
2078
2079 // Update task timestamp and reload all relationships
2080 $task->updated_at = current_time('mysql');
2081 $task->save();
2082 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2083
2084 $successfulTasks[] = $task;
2085 } catch (\Exception $e) {
2086 $failedTasks[] = [
2087 'id' => $task->id,
2088 'title' => $task->title,
2089 'error' => $e->getMessage()
2090 ];
2091 }
2092 }
2093
2094 $successCount = count($successfulTasks);
2095 $failureCount = count($failedTasks);
2096
2097 $message = '';
2098 if ($failureCount === 0) {
2099 // translators: %d is the number of tasks where members were assigned successfully
2100 $message = sprintf(__('%d tasks assigned members successfully', 'fluent-boards'), $successCount);
2101 } elseif ($successCount === 0) {
2102 // translators: %d is the number of tasks where assigning members failed
2103 $message = sprintf(__('Failed to assign members to %d tasks', 'fluent-boards'), $failureCount);
2104 } else {
2105 // translators: 1: number of tasks with members assigned successfully; 2: number of tasks where assigning members failed
2106 $message = sprintf(__('%1$d tasks assigned members successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
2107 }
2108
2109 return [
2110 'successful_tasks' => $successfulTasks,
2111 'failed_tasks' => $failedTasks,
2112 'message' => $message
2113 ];
2114 }
2115
2116 /**
2117 * Bulk add labels to tasks
2118 */
2119 private function bulkAddLabels($tasks, $params, $boardId)
2120 {
2121 $labelIds = $params['label_ids'] ?? [];
2122 if (!is_array($labelIds)) {
2123 throw new \Exception(esc_html__('Label IDs must be an array', 'fluent-boards'));
2124 }
2125
2126 // Validate that all labels exist and belong to the board
2127 $validLabels = \FluentBoards\App\Models\Label::whereIn('id', $labelIds)
2128 ->where('board_id', $boardId)
2129 ->whereNull('archived_at')
2130 ->get();
2131
2132 if (count($validLabels) !== count($labelIds)) {
2133 throw new \Exception(esc_html__('Some label IDs are invalid or do not belong to this board', 'fluent-boards'));
2134 }
2135
2136 $successfulTasks = [];
2137 $failedTasks = [];
2138
2139 foreach ($tasks as $task) {
2140 try {
2141 // Load existing labels first to avoid query issues
2142 $task->load('labels');
2143 $existingLabelIds = $task->labels->pluck('id')->toArray();
2144
2145 // Use the same logic as single task label adding
2146 foreach ($validLabels as $label) {
2147 // Check if label is already attached
2148 if (!in_array($label->id, $existingLabelIds)) {
2149 // Add the label using syncWithoutDetaching to avoid duplicates
2150 $task->labels()->syncWithoutDetaching([
2151 $label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]
2152 ]);
2153
2154 // Dispatch WordPress action for label addition
2155 //currently commented, need to check in future for bulk action
2156 // do_action('fluent_boards/task_label', $task, $label, 'added');
2157 }
2158 }
2159
2160 // Reload the task with all relationships
2161 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2162
2163 $successfulTasks[] = $task;
2164 } catch (\Exception $e) {
2165 $failedTasks[] = [
2166 'id' => $task->id,
2167 'title' => $task->title,
2168 'error' => $e->getMessage()
2169 ];
2170 }
2171 }
2172
2173 $successCount = count($successfulTasks);
2174 $failureCount = count($failedTasks);
2175
2176 $message = '';
2177 if ($failureCount === 0) {
2178 // translators: %d is the number of tasks labeled successfully
2179 $message = sprintf(__('%d tasks labeled successfully', 'fluent-boards'), $successCount);
2180 } elseif ($successCount === 0) {
2181 // translators: %d is the number of tasks that failed to label
2182 $message = sprintf(__('Failed to label %d tasks', 'fluent-boards'), $failureCount);
2183 } else {
2184 // translators: 1: number of tasks labeled successfully; 2: number of tasks failed to label
2185 $message = sprintf(__('%1$d tasks labeled successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
2186 }
2187
2188 return [
2189 'successful_tasks' => $successfulTasks,
2190 'failed_tasks' => $failedTasks,
2191 'message' => $message
2192 ];
2193 }
2194
2195 /* Delete time tracking records for one or multiple tasks
2196 * Uses try-catch for better performance - avoids table existence check overhead
2197 *
2198 * @param int|array $taskIds Single task ID or array of task IDs
2199 * @return void
2200 */
2201 public function deleteTimeTrackingRecords($taskIds)
2202 {
2203 // Check if FluentBoards Pro time tracking is available
2204 if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
2205 return;
2206 }
2207
2208 try {
2209 // Handle single task ID or array of task IDs
2210 if (is_array($taskIds)) {
2211 if (!empty($taskIds)) {
2212 \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete();
2213 }
2214 } else {
2215 if (is_numeric($taskIds) && $taskIds > 0) {
2216 \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
2217 }
2218 }
2219 } catch (\Exception $e) {
2220 // Silently fail if table doesn't exist or any other error occurs
2221 // This is intentional for cleanup operations
2222 }
2223 }
2224
2225 }
2226