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

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

2,929 lines 109.5 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\Label;
12 use FluentBoards\App\Models\Stage;
13 use FluentBoards\App\Models\Task;
14 use FluentBoards\App\Models\Board;
15 use FluentBoards\App\Models\TaskMeta;
16 use FluentBoards\App\Models\Meta;
17 use FluentBoards\App\Models\Activity;
18 use FluentBoards\App\Models\CommentImage;
19 use FluentBoards\App\Models\Relation;
20 use FluentBoards\Framework\Support\Arr;
21 use FluentBoardsPro\App\Models\TaskAttachment;
22 use FluentBoardsPro\App\Services\AttachmentService;
23 use FluentBoardsPro\App\Services\RemoteUrlParser;
24 use FluentRoadmap\App\Models\IdeaReaction;
25
26 class TaskService
27 {
28 /**
29 * Resolve a task only when it belongs to the requested board.
30 *
31 * Subtasks normally carry the same board_id as their parent, but the parent
32 * fallback protects older data where that relationship may be incomplete.
33 *
34 * @param int $taskId
35 * @param int $boardId
36 * @param bool $allowParentFallback
37 * @return Task
38 * @throws \Exception
39 */
40 public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true)
41 {
42 $taskId = absint($taskId);
43 $boardId = absint($boardId);
44
45 if (!$taskId || !$boardId) {
46 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
47 }
48
49 $task = Task::where('id', $taskId)
50 ->where('board_id', $boardId)
51 ->first();
52
53 if ($task) {
54 return $task;
55 }
56
57 if ($allowParentFallback) {
58 $task = Task::where('id', $taskId)
59 ->whereNull('board_id')
60 ->whereNotNull('parent_id')
61 ->first();
62
63 if ($task) {
64 $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
65
66 if ((int) $parentBoardId === $boardId) {
67 return $task;
68 }
69 }
70 }
71
72 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
73 }
74
75 public function createTask($data, $boardId)
76 {
77 $board = Board::select('id', 'type')->find($boardId);
78
79 if (!$board) {
80 throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
81 }
82
83 $stage = Stage::find($data['stage_id']);
84 if (!$stage) {
85 throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
86 }
87
88 if ((int) $stage->board_id !== (int) $boardId) {
89 throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
90 }
91
92 $data['status'] = $stage->defaultTaskStatus();
93
94 if ($board->type == 'roadmap') {
95 $current_user = wp_get_current_user();
96 $settingData = array(
97 'integration_type' => 'feature',
98 'logo' => '',
99 'author' => [
100 'email' => $current_user->user_email // email of who posted this feature
101 ],
102 );
103 $data['settings'] = $settingData;
104 $data['type'] = 'roadmap';
105 }
106
107 $providerPosition = Arr::get($data, 'position');
108
109 $data['position'] = $this->getLastPositionOfTasks($stage->id);
110
111 $data['board_id'] = $boardId;
112 $data = Helper::normalizeDates($data, ['due_at', 'started_at', 'last_completed_at', 'archived_at', 'remind_at']);
113
114 $data = array_filter($data);
115 $task = (new Task())->createTask($data);
116
117 $this->manageDefaultAssignees($task, $stage->id);
118 $this->manageDefaultWatchers($task, $stage->id);
119
120 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
121 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $data['is_template']);
122 }
123
124 if ($providerPosition) {
125 $task->moveToNewPosition($providerPosition);
126 }
127
128 // $this->taskCreatedAction($task);
129 $this->loadWithRelations($task, ['assignees', 'labels', 'board']);
130
131 return $task;
132 }
133
134 public function loadWithRelations($task, $relations)
135 {
136 if (!is_array($relations)) {
137 return $task;
138 }
139 $task->load($relations); // $relations = ['assignees', 'board'] in this case
140 $task->isOverdue = $task->isOverdue();
141
142 return $task;
143 }
144
145 public function getTasksForBoards($filters = ['overdue', 'upcoming'], $limit = 5, $task_ids = [])
146 {
147 $assigned = $this->getTasksForBoardsByCategory('assigned', $limit, $task_ids);
148 $overDue = $this->getTasksForBoardsByCategory('overdue', $limit, $task_ids);
149 $dueToday = $this->getTasksForBoardsByCategory('due_today', $limit, $task_ids);
150 $completed = $this->getTasksForBoardsByCategory('completed', $limit, $task_ids);
151 $mentioned = $this->getTasksForBoardsByCategory('mentioned', $limit, $task_ids);
152 $upcoming = $this->getTasksForBoardsByCategory('upcoming', $limit, $task_ids);
153 $others = $this->getTasksForBoardsByCategory('others', $limit, $task_ids);
154
155 return [
156 'assigned' => $assigned ?? [],
157 'overdue' => $overDue ?? [],
158 'due_today' => $dueToday ?? [],
159 'upcoming' => $upcoming ?? [],
160 'mentioned' => $mentioned ?? [],
161 'completed' => $completed ?? [],
162 'others' => $others ?? []
163 ];
164 }
165
166 public function getTaskCountsForBoards($categories = ['assigned', 'overdue', 'upcoming', 'completed', 'others'], $taskIds = [])
167 {
168 $counts = [];
169
170 foreach ($categories as $category) {
171 $counts[$category] = $this->getTaskCountForBoardsByCategory($category, $taskIds);
172 }
173
174 return $counts;
175 }
176
177 public function getTasksForBoardsByCategory($category, $limit, $taskIds)
178 {
179 unset($taskQuery);
180 $taskQuery = Task::whereIn('id', $taskIds)
181 ->with(['assignees', 'board', 'stage'])
182 ->whereNull('archived_at')
183 ->where('parent_id', null)
184 ->orderBy('due_at', 'DESC');
185
186 switch ($category) {
187 case 'overdue':
188 $taskQuery->overdue();
189 break;
190 case 'upcoming':
191 $taskQuery->upcoming();
192 break;
193 case 'due_today':
194 $taskQuery->dueToday();
195 break;
196 case 'others':
197 $taskQuery->whereNull('due_at');
198 break;
199 case 'completed':
200 $taskQuery->where('status', 'closed');
201 break;
202 case 'assigned':
203 // Rebuild query to order by latest assignment (pivot created_at) so the most recently assigned tasks come first.
204 $currentUserId = get_current_user_id();
205 $taskQuery = Task::query()
206 ->select('fbs_tasks.*')
207 ->distinct()
208 ->with(['assignees', 'board', 'stage'])
209 ->whereIn('fbs_tasks.id', $taskIds)
210 ->whereNull('fbs_tasks.archived_at')
211 ->whereNull('fbs_tasks.parent_id')
212 ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
213 $join->on('rel.object_id', '=', 'fbs_tasks.id')
214 ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
215 ->where('rel.foreign_id', $currentUserId);
216 })
217 ->orderBy('rel.created_at', 'DESC')
218 ->orderBy('fbs_tasks.updated_at', 'DESC');
219 break;
220 case 'mentioned':
221 $currentUserId = get_current_user_id();
222 $userNotifications = NotificationUser::where('user_id', $currentUserId)
223 ->with(['notification' => function ($query) {
224 $query->where('action', 'task_comment_mentioned');
225 }])
226 ->orderBy('created_at', 'desc')
227 ->get();
228 $taskIds = $userNotifications->filter(function ($userNotification) {
229 $notification = $userNotification->notification;
230 return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
231 })->pluck('notification.task_id')->unique();
232 $validTasks = Task::whereIn('id', $taskIds)
233 ->with(['assignees', 'board', 'stage'])
234 ->get();
235
236 return $validTasks->toArray();
237 default:
238 return [];
239 }
240
241 $tasks = $taskQuery->take($limit)->get();
242
243 return $tasks->toArray();
244 }
245
246 public function getTaskCountForBoardsByCategory($category, $taskIds)
247 {
248 if (empty($taskIds)) {
249 return 0;
250 }
251
252 $taskQuery = Task::query()
253 ->whereIn('id', $taskIds)
254 ->whereNull('archived_at')
255 ->whereNull('parent_id');
256
257 switch ($category) {
258 case 'overdue':
259 $taskQuery->overdue();
260 break;
261 case 'upcoming':
262 $taskQuery->upcoming();
263 break;
264 case 'due_today':
265 $taskQuery->dueToday();
266 break;
267 case 'completed':
268 $taskQuery->where('status', 'closed');
269 break;
270 case 'others':
271 $taskQuery->whereNull('due_at');
272 break;
273 case 'assigned':
274 $currentUserId = get_current_user_id();
275 $taskQuery = Task::query()
276 ->select('fbs_tasks.id')
277 ->whereIn('fbs_tasks.id', $taskIds)
278 ->whereNull('fbs_tasks.archived_at')
279 ->whereNull('fbs_tasks.parent_id')
280 ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
281 $join->on('rel.object_id', '=', 'fbs_tasks.id')
282 ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
283 ->where('rel.foreign_id', $currentUserId);
284 });
285
286 return (int) $taskQuery->distinct()->count('fbs_tasks.id');
287 default:
288 return 0;
289 }
290
291 return (int) $taskQuery->count();
292 }
293
294 /*
295 * TODO: Refactor this function - For me.
296 */
297 public function updateTaskProperty($col, $value, $task)
298 {
299 $oldTask = clone $task; // normal assigning won't work here. because objects are passed by reference in php
300 $validColumns = [
301 'board_id',
302 'type',
303 // 'reminder_type',
304 'remind_at',
305 'log_minutes',
306 'settings'
307 ];
308
309 if (in_array($col, $validColumns) && $task->{$col} != $value) {
310 if ($col === 'remind_at') {
311 $value = Helper::normalizeDateValue($value);
312 }
313
314 if ($col == 'settings' && isset($value['cover']['backgroundColor']) && $value['cover']['backgroundColor']) {
315 $settings = $task->settings;
316 $this->deleteTaskCoverImage($settings);
317 unset($value['cover']['imageId']);
318 unset($value['cover']['backgroundImage']);
319 }
320 $task->{$col} = $value ?: null;
321 $task->save();
322 // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
323 } else {
324 switch ($col) {
325 case 'assignees':
326 if (is_array($value)) {
327 foreach ($value as $id) {
328 $this->updateAssignee($id, $task);
329 }
330 } else {
331 $this->updateAssignee($value, $task);
332 }
333 break;
334
335 case 'crm_contact_id':
336 $this->updateAssociate($value, $task);
337 break;
338
339 case 'archived_at':
340 $this->updateArchive($value, $task);
341 break;
342
343 case 'status':
344 $this->updateStatus($value, $task);
345 break;
346
347 case 'parent_id':
348 $this->updateParent($value, $task);
349 break;
350
351 case 'title':
352 $this->updateTitle($col, $value, $task, $oldTask);
353 break;
354
355 case 'description':
356 $this->updateDescription($col, $value, $task, $oldTask);
357 break;
358
359 case 'due_at':
360 $this->updateDueDate($value, $task);
361 break;
362
363 case 'started_at':
364 $this->updateStartedDate($value, $task);
365 break;
366
367 case 'priority':
368 $this->updatePriority($value, $task);
369 break;
370
371 case 'is_watching':
372 $this->updateObservationOfUser($value, $task);
373 break;
374
375 case 'last_completed_at':
376 $isClosed = $value == 'true' || $value === true;
377 if ($isClosed) {
378 $task = $task->close();
379 } else {
380 $task = $task->reopen();
381 }
382 $task->save();
383 break;
384
385 case 'attachment_count':
386 $settings = $task->settings;
387 $settings['attachment_count'] = $task->attachments()->count();
388 $task->settings = $settings;
389 $task->save();
390 break;
391
392 case 'subtask_count':
393 $settings = $task->settings;
394 $subtasksCount = Task::where('parent_id', $task->id)->count();
395 $settings['subtask_count'] = $subtasksCount;
396 $task->settings = $settings;
397 $task->save();
398 break;
399
400 case 'is_template':
401 if (defined('FLUENT_BOARDS_PRO')) {
402 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $value);
403 }
404 break;
405
406 case 'reminder_type':
407 if (defined('FLUENT_BOARDS_PRO')) {
408 $allowedTypes = Helper::taskReminderTypes();
409
410 // check in keys of allowed types
411 if (array_key_exists($value, $allowedTypes)) {
412
413 $value = $value;
414 } else {
415 $value = null;
416 }
417
418 $task->reminder_type = $value;
419 $task->save();
420 do_action('fluent_boards/task_reminder_type_changed', $task, $value);
421 }
422 break;
423 }
424 }
425
426 return $task;
427 }
428
429 public function updateAssignee($payloadAssigneeId, $task)
430 {
431 $operation = $task->addOrRemoveAssignee($payloadAssigneeId);
432 $task->load('assignees');
433 $task->updated_at = current_time('mysql');
434
435 $task->save();
436
437 if ($operation == 'added') {
438 if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id) && $payloadAssigneeId != get_current_user_id()) {
439 $this->sendMailAfterTaskModify('add_assignee', $payloadAssigneeId, $task->id);
440 }
441 // $assigneeIdsToSendEmail = $this->filterAssigneeToSendEmail($task, $idArray, Constant::BOARD_EMAIL_TASK_ASSIGN);
442 // $this->sendMailAfterAddAssignees($assigneeIdsToSendEmail, $task->id);
443 do_action('fluent_boards/task_assignee_added', $task, $payloadAssigneeId);
444 if($payloadAssigneeId != get_current_user_id()){
445 do_action('fluent_boards/assign_another_user', $task, $payloadAssigneeId);
446 }
447 } else {
448 if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_REMOVE_FROM_TASK, $task->board_id) && $payloadAssigneeId != get_current_user_id()) {
449 $this->sendMailAfterTaskModify('remove_assignee', $payloadAssigneeId, $task->id);
450 }
451 do_action('fluent_boards/task_assignee_removed', $task, $payloadAssigneeId);
452 }
453
454 }
455
456 // public function filterAssigneeToSendEmail($task, $newAssigneeIds, $purpose)
457 // {
458 // $toSendEmail = array();
459 // foreach ($newAssigneeIds as $assigneeId) {
460 // if ((new NotificationService())->checkIfEmailEnabled($task->board_id, $assigneeId, $purpose)) {
461 // $toSendEmail[] = $assigneeId;
462 // }
463 // }
464 // return $toSendEmail;
465 // }
466
467 // public function defaultWatchingTaskByNewUsers($task, $newIds)
468 // {
469 // foreach ($newIds as $newId) {
470 // if (!$task->watchers->contains($newId)) {
471 // $task->watchers()->attach(
472 // $newId,
473 // [
474 // 'object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH,
475 // ]
476 // );
477 // }
478 // }
479 // }
480
481 // public function checkIfAnybodyRemovedFromTask($newAssigneeIds, $oldAssigneeIds, $task)
482 // {
483 // $removedAssignees = array_diff($oldAssigneeIds, $newAssigneeIds);
484 // $this->sendMailAfterTaskModify('removed_from_task', $removedAssignees, $task->id);
485 // dd($removedAssignees);
486 // }
487
488 private function updateAssociate($value, $task)
489 {
490 // if task has no crm contact and got value null then return current task
491 if (($task->crm_contact_id == null || $task->crm_contact_id == 0) && $value == null) {
492 return $task;
493 }
494
495 $oldAssociateId = $task->crm_contact_id;
496 $task->crm_contact_id = $value;
497 $task->save();
498 $task->contact = Task::lead_contact($task->crm_contact_id);
499 do_action('fluent_boards/contact_added_to_task', $task);
500 do_action('fluent_boards/associate_user_add_change_remove_activity', $oldAssociateId, $task->crm_contact_id, $task->id);
501 }
502
503 private function updateArchive($value, $task)
504 {
505 if ($value != null) {
506 // Archiving task
507 $task->position = 0;
508 } else {
509 // Restoring task - check if stage is archived
510 $stage = Stage::find($task->stage_id);
511 if ($stage && $stage->archived_at !== null) {
512 throw new \Exception(
513 sprintf(
514 // translators: %s is the archived stage title.
515 esc_html__('This task cannot be restored because its stage "%s" is archived. Please restore the stage first.', 'fluent-boards'),
516 esc_html($stage->title)
517 ),
518 400
519 );
520 }
521
522 $task->moveToNewPosition(1);
523
524 // Clean up archived_by_stage meta when task is manually restored
525 $this->cleanupArchivedByStageMetaIfExists($task->id);
526 }
527 $task->archived_at = $value == null ? null : current_time('mysql');
528 $task->save();
529 do_action('fluent_boards/task_archived', $task);
530 $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
531 $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
532 }
533
534 private function updateStatus($value, $task)
535 {
536 if ($value == 'closed') {
537 $task = $task->close();
538 } else {
539 $task = $task->reopen();
540 }
541
542 do_action('fluent_boards/task_completed_activity', $task, $value);
543 }
544
545 private function updateParent($value, $task)
546 {
547 $task->parent_id = $value;
548 $task->save();
549 }
550
551 private function updateTitle($col, $value, $task, $oldTask)
552 {
553 $task->title = $value;
554 $task->save();
555 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
556 }
557
558 private function updateDescription($col, $value, $task, $oldTask)
559 {
560 $task->description = $value;
561 $task->save();
562 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
563 }
564
565 private function updateDueDate($value, $task)
566 {
567 $oldValue = $task->due_at;
568 $value = Helper::normalizeDateValue($value);
569 $task->due_at = $value;
570 $task->save();
571
572 $task = $task->reopen();
573
574 if($value){
575 do_action('fluent_boards/task_due_date_changed', $task, $oldValue);
576 } else {
577 do_action('fluent_boards/task_due_date_removed', $task);
578 }
579
580 $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_DUE_DATE_CHANGE);
581 $this->sendMailAfterTaskModify('due_date_update', $wathersToSendEmail, $task->id);
582 }
583
584 private function updateStartedDate($value, $task)
585 {
586 $oldValue = $task->started_at;
587 $value = Helper::normalizeDateValue($value);
588 $task->started_at = $value;
589 $task->save();
590
591 if($value){
592 do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
593 }
594 }
595
596 private function updatePriority($value, $task)
597 {
598 $oldPriority = $task->priority;
599 $task->priority = $value;
600 $task->save();
601 do_action('fluent_boards/task_priority_changed', $task, $oldPriority);
602 }
603
604 public function updateObservationOfUser($value, $task)
605 {
606 if (is_array($value) && isset($value['userId'])) {
607 $userId = intval($value['userId']);
608 $action = isset($value['action']) ? $value['action'] : 'start';
609 } else {
610 $userId = get_current_user_id();
611 $action = is_string($value) ? $value : 'start';
612 }
613
614 if (!$userId || !in_array($action, ['stop', 'start'])) {
615 return;
616 }
617
618 if ($action == 'stop') {
619 $task->watchers()->detach($userId);
620 } else {
621 $task->watchers()->syncWithoutDetaching([$userId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
622 }
623 $task->updated_at = current_time('mysql');
624 $task->save();
625 }
626
627 public function taskCoverPhotoUpdate($taskId, $imagePath, $boardId = null)
628 {
629 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
630 if (!$task) {
631 return null;
632 }
633
634 $settings = $task->settings;
635 if (!is_array($settings)) {
636 $settings = [];
637 }
638
639 $settings['logo'] = $imagePath;
640 $task->settings = $settings;
641 $task->save();
642
643 return $task;
644 }
645
646 public function taskStatusUpdate($taskId, $integrationType, $boardId = null)
647 {
648 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
649 if (!$task) {
650 return null;
651 }
652
653 $settings = $task->settings;
654 $settings['integration_type'] = $integrationType;
655 $task->settings = $settings;
656 $task->save();
657
658 return $task;
659 }
660
661 public function assignYourselfInTask($boardId, $taskId)
662 {
663 $task = $this->findTaskOnBoard($taskId, $boardId);
664 $authUserId = get_current_user_id();
665
666 $boardService = new BoardService();
667 if (!$boardService->isAlreadyMember($boardId, $authUserId)) {
668 $boardService->addMembersInBoard($boardId, $authUserId);
669 }
670
671 $task->addOrRemoveAssignee($authUserId);
672 // when user assign himself then he will be watching that task
673 $task->watchers()->syncWithoutDetaching([$authUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
674
675 $task->load('assignees');
676 do_action('fluent_boards/task_assignee_added', $task, $authUserId);
677
678 return $task;
679 }
680
681 public function detachYourselfFromTask($boardId, $taskId)
682 {
683 $task = $this->findTaskOnBoard($taskId, $boardId);
684 $currentUserId = get_current_user_id();
685 $task->addOrRemoveAssignee($currentUserId);
686 $task->load('assignees');
687 do_action('fluent_boards/task_assignee_removed', $task, $currentUserId);
688
689 return $task;
690 }
691
692 public function deleteTask($task)
693 {
694 // If this is a parent task, delete all subtasks first
695 if (!$task->parent_id) {
696 $subtasks = Task::where('parent_id', $task->id)->get();
697 foreach ($subtasks as $subtask) {
698 // Recursively delete each subtask (cleans up all their relations)
699 $this->deleteTask($subtask);
700 }
701 }
702
703 $deleted = $task->delete();
704 $dbInstance = App::getInstance('db');
705 $dbInstance->beginTransaction();
706
707 $deletedTask = clone $task;
708 //cloning because after delete $task object will be useless
709
710 try {
711 $deleted = $task->delete();
712
713 if ($deleted) {
714 //task assignees watchers removed
715 $task->watchers()->detach();
716 $task->assignees()->detach();
717
718 //removing all task related notifications
719 $notificationIds = $task->notifications->pluck('id');
720 $task->notifications()->delete();
721 NotificationUser::whereIn('notification_id', $notificationIds)->delete();
722
723 //task labels removed
724 $task->labels()->detach();
725
726 //task custom field value
727 if (defined('FLUENT_BOARDS_PRO')) {
728 $task->customFields()->detach();
729 }
730 $this->deleteTaskAttachments($task);
731 //task custom field value
732 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
733 $task->customFields()->detach();
734 $this->deleteTaskAttachments($task);
735 }
736
737 // Delete time tracking records for this task
738 $this->deleteTimeTrackingRecords($task->id);
739
740 do_action('fluent_boards/task_deleted', $task);
741 TaskMeta::where('task_id', $task->id)->delete();
742 do_action('fluent_boards/task_deleted', $deletedTask);
743 TaskMeta::where('task_id', $task->id)->delete();
744 }
745
746 $dbInstance->commit();
747 } catch (\Exception $e) {
748 $dbInstance->rollBack();
749 throw $e; // Re-throw the exception after rolling back
750 }
751
752 }
753 public function deleteTaskForBulk($task)
754 {
755 // If this is a parent task, delete all subtasks first
756 if (!$task->parent_id) {
757 $subtasks = Task::where('parent_id', $task->id)->get();
758 foreach ($subtasks as $subtask) {
759 // Recursively delete each subtask (cleans up all their relations)
760 $this->deleteTaskForBulk($subtask);
761 }
762 }
763
764 $deleted = $task->delete();
765
766 if ($deleted) {
767
768 //task assignees watchers removed
769 $task->watchers()->detach();
770 $task->assignees()->detach();
771
772 //removing all task related notifications
773 $notificationIds = $task->notifications->pluck('id');
774 $task->notifications()->delete();
775 NotificationUser::whereIn('notification_id', $notificationIds)->delete();
776
777 //task labels removed
778 $task->labels()->detach();
779
780 //task custom field value
781 if (defined('FLUENT_BOARDS_PRO_VERSION')) {
782 $task->customFields()->detach();
783 $this->deleteTaskAttachments($task);
784 }
785
786 // For bulk delete, you might want to avoid firing hooks/actions,
787 // so 'fluent_boards/task_deleted' is not triggered here.
788 TaskMeta::where('task_id', $task->id)->delete();
789 }
790 }
791 // this is invoked when task is moved to another board
792
793 /**
794 * @throws \Exception
795 */
796 public function changeBoardByTask($task, $targetBoardId)
797 {
798 // Input validation - must be positive integer
799 if (!is_numeric($targetBoardId) || $targetBoardId <= 0 || !is_int($targetBoardId + 0) || $targetBoardId != (int)$targetBoardId) {
800 throw new \Exception(esc_html__('Invalid board id - must be a positive integer', 'fluent-boards'), 400);
801 }
802
803
804 if ($task->board_id == $targetBoardId) {
805 return $task;
806 }
807
808 $oldBoard = Board::find($task->board_id);
809 $newBoard = Board::find($targetBoardId);
810
811 if (!$oldBoard) {
812 throw new \Exception(esc_html__('Source board not found', 'fluent-boards'), 404);
813 }
814
815 if (!$newBoard) {
816 throw new \Exception(esc_html__('Target board not found', 'fluent-boards'), 404);
817 }
818
819
820 $dbInstance = App::getInstance('db');
821 $attachmentFileService = new AttachmentFileService();
822 $oldBoardId = (int) $task->board_id;
823
824 $dbInstance->beginTransaction();
825
826 try {
827 $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId);
828
829 $task->board_id = (int) $targetBoardId;
830 $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task';
831
832 // REMOVE: Board-dependent data
833 $task->labels()->detach();
834 $task->assignees()->detach();
835 $task->watchers()->detach();
836 $this->removeCustomFieldAssociations($task);
837
838 // REMOVE: User-specific data to prevent security issues
839 $this->removeCommentsAndReplies($task->id);
840 $this->removeTimeTrackingRecords($task->id);
841
842 // REMOVE: Recurring task settings for security
843 $this->removeRecurringTaskSettings($task->id);
844
845 $task->save();
846
847 // MOVE: Subtasks to new board (preserves subtask groups)
848 $this->moveSubtasksToNewBoard($task->id, $targetBoardId, $newBoard->type, $attachmentFileService);
849
850 $dbInstance->commit();
851 $attachmentFileService->commitMovedOriginalFiles();
852 } catch (\Exception $e) {
853 $dbInstance->rollBack();
854 $attachmentFileService->rollbackCreatedFiles();
855 throw $e;
856 }
857
858 do_action('fluent_boards/task_moved_from_board', $task, $oldBoard, $newBoard);
859 return $task;
860 }
861
862 /**
863 * Move all subtasks to the new board when parent task is moved
864 * Preserves subtask groups and their relationships
865 */
866 private function moveSubtasksToNewBoard($parentTaskId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
867 {
868 // Get all subtasks of the parent task
869 $subtasks = Task::where('parent_id', $parentTaskId)->get();
870
871 if ($subtasks->isEmpty()) {
872 return;
873 }
874
875 foreach ($subtasks as $subtask) {
876 // Update board_id and type
877 $oldBoardId = (int) $subtask->board_id;
878 $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId);
879
880 $subtask->board_id = (int) $targetBoardId;
881 $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task';
882
883 // REMOVE: Board-dependent data for subtasks
884 $subtask->labels()->detach();
885 $subtask->assignees()->detach();
886 $subtask->watchers()->detach();
887
888 // Remove custom fields but preserve subtask group relationships
889 $subtask->taskMeta()
890 ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD)
891 ->delete();
892
893 // REMOVE: User-specific data for security
894 $this->removeCommentsAndReplies($subtask->id);
895 $this->removeTimeTrackingRecords($subtask->id);
896
897 // REMOVE: Recurring task settings
898 $this->removeRecurringTaskSettings($subtask->id);
899
900 $subtask->save();
901 }
902 }
903
904 /**
905 * Remove task cover image for security reasons
906 * Keeps background colors but removes image references
907 */
908 private function removeTaskCoverImage($task)
909 {
910 $settings = $task->settings;
911 if (empty($settings) || !is_array($settings)) {
912 return;
913 }
914
915 if (isset($settings['cover']) && is_array($settings['cover'])) {
916 $cover = $settings['cover'];
917
918 // Remove image references
919 unset($cover['imageId']);
920 unset($cover['backgroundImage']);
921
922 // Keep only background color if it exists
923 if (isset($cover['backgroundColor'])) {
924 $settings['cover'] = array('backgroundColor' => $cover['backgroundColor']);
925 } else {
926 unset($settings['cover']);
927 }
928
929 $task->settings = $settings;
930 }
931 }
932
933 /**
934 * Remove custom field associations for board move
935 * Custom field values are stored in fbs_relations table, not fbs_task_meta
936 * This method removes task-to-customfield associations from fbs_relations
937 */
938 private function removeCustomFieldAssociations($task)
939 {
940 // Remove custom field values from fbs_relations table
941 // Custom fields are board-specific, so they must be removed when task moves to different board
942 if (defined('FLUENT_BOARDS_PRO')) {
943 $task->customFields()->detach();
944 }
945 }
946
947 /**
948 * Remove comments and replies for security reasons
949 * Prevents exposing user-specific data to unauthorized users
950 */
951 private function removeCommentsAndReplies($taskId)
952 {
953 // Input validation
954 if (!is_numeric($taskId) || $taskId <= 0) {
955 return;
956 }
957
958 // Remove all comments and replies for this task (delete individually to fire model events and clean up images)
959 $comments = Comment::where('task_id', (int) $taskId)->get();
960 foreach ($comments as $comment) {
961 $comment->delete();
962 }
963
964 }
965
966 /**
967 * Remove time tracking records for security reasons
968 * Prevents exposing user-specific time data to unauthorized users
969 */
970 private function removeTimeTrackingRecords($taskId)
971 {
972 // Input validation
973 if (!is_numeric($taskId) || $taskId <= 0) {
974 return;
975 }
976
977 // Remove all time tracking records for this task
978 $this->deleteTimeTrackingRecords((int) $taskId);
979 }
980
981 /**
982 * Remove attachments for security reasons
983 * Prevents file access issues across boards
984 */
985 private function removeAttachments($taskId)
986 {
987 // Input validation
988 if (!is_numeric($taskId) || $taskId <= 0) {
989 return;
990 }
991
992 // Remove all attachments for this task
993 if (class_exists('FluentBoardsPro\App\Models\TaskAttachment')) {
994 \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', (int) $taskId)
995 ->where('object_type', 'task')
996 ->delete();
997 }
998 }
999
1000 /**
1001 * Remove recurring task settings for security reasons
1002 * Prevents recurring task settings from being moved between boards
1003 */
1004 private function removeRecurringTaskSettings($taskId)
1005 {
1006 // Input validation
1007 if (!is_numeric($taskId) || $taskId <= 0) {
1008 return;
1009 }
1010
1011 // Remove recurring task settings for this task from fbs_metas table
1012 Meta::where('object_id', (int) $taskId)
1013 ->where('object_type', Constant::REPEAT_TASK_META)
1014 ->delete();
1015 }
1016
1017 public function getIdeaVoteStatistics($taskId)
1018 {
1019 return IdeaReaction::where('object_id', $taskId)
1020 ->where('object_type', 'idea')
1021 ->where('type', 'upvote')
1022 ->count();
1023 }
1024
1025
1026 /**
1027 * Summary of getArchivedOrCompletedTasks
1028 * this function will return completd tasks or archived tasks based on users input and also can search by name
1029 * @param mixed $data
1030 * @param mixed $taskType
1031 * @return mixed
1032 * @throws \Exception
1033 */
1034 public function getArchivedTasks($data, $boardId)
1035 {
1036 if (!$boardId) {
1037 throw new \Exception(esc_html__('Board id is required', 'fluent-boards'));
1038 }
1039
1040 $per_page = isset($data['per_page']) ? $data['per_page'] : 20;
1041 $page = isset($data['page']) ? $data['page'] : 1;
1042 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
1043
1044 if (!empty($data['query'])) {
1045 $query = strtolower($data['query']);
1046 $firstThreeChars = substr($query, 0, 3);
1047
1048 if($firstThreeChars == 'id:') {
1049 $idPart = substr($query, 3);
1050 $idPart = preg_replace('/[^a-zA-Z0-9]/', '', $idPart);
1051 $tasksQuery = $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
1052 } else {
1053 $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
1054 }
1055 }
1056
1057 return $tasksQuery->orderBy('created_at', 'DESC')->with('assignees')->paginate($per_page, ['*'], 'page', $page);
1058 }
1059
1060 public function getTableTasks($boardId, $data = [])
1061 {
1062 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20;
1063 $page = isset($data['page']) ? intval($data['page']) : 1;
1064 $sortBy = isset($data['sort_by']) ? sanitize_text_field($data['sort_by']) : 'position';
1065 $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc';
1066 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1067 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1068 $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', []));
1069 $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []));
1070 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1071 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1072 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
1073 $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
1074 $customFieldFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'custom_fields', []));
1075 $dueDateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'due_date', []));
1076 $includeArchived = !empty($data['include_archived']) || in_array('archived', $taskStatusFilters, true);
1077
1078 $perPage = max(1, min(150, $perPage));
1079 $page = max(1, $page);
1080 $sortDirection = strtolower($sortDirection) === 'desc' ? 'desc' : 'asc';
1081
1082 $sortColumnMap = [
1083 'title' => 'title',
1084 'status' => 'status',
1085 'created_at' => 'created_at',
1086 'position' => 'position',
1087 ];
1088 $sortColumn = Arr::get($sortColumnMap, $sortBy, 'position');
1089
1090 $tasksQuery = Task::query()
1091 // Table rows only need row-level fields; modal open rehydrates the full task.
1092 ->select([
1093 'id',
1094 'title',
1095 'slug',
1096 'board_id',
1097 'parent_id',
1098 'stage_id',
1099 'status',
1100 'priority',
1101 'archived_at',
1102 'remind_at',
1103 'reminder_type',
1104 'started_at',
1105 'due_at',
1106 'last_completed_at',
1107 'position',
1108 'comments_count',
1109 'created_by',
1110 'settings',
1111 'source',
1112 'source_id',
1113 'created_at',
1114 ])
1115 ->with(['assignees', 'labels', 'watchers'])
1116 ->where('board_id', $boardId)
1117 ->whereNull('parent_id');
1118
1119 if (!$includeArchived && !$taskStatusFilters) {
1120 $tasksQuery->whereNull('archived_at');
1121 }
1122
1123 $this->applyTableTaskSearch($tasksQuery, $search);
1124 $this->applyTableTaskFilters($tasksQuery, [
1125 'stage' => $stageFilters,
1126 'task_status' => $taskStatusFilters,
1127 'priority' => $priorityFilters,
1128 'assignee' => $assigneeFilters,
1129 'labels' => $labelFilters,
1130 'watchers' => $watcherFilters,
1131 'contact' => $contactFilters,
1132 'custom_fields' => $customFieldFilters,
1133 'due_date' => $dueDateFilters,
1134 ]);
1135
1136 if ($sortColumn === 'position') {
1137 $tasksQuery->orderBy('stage_id', 'asc');
1138 }
1139
1140 return $tasksQuery
1141 ->orderBy($sortColumn, $sortDirection)
1142 ->paginate($perPage, ['*'], 'page', $page);
1143 }
1144
1145 public function getBoardViewTasks($boardId, $data = [])
1146 {
1147 $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1148 $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1149 $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', []));
1150 $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []));
1151 $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1152 $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1153 $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
1154 $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
1155 $customFieldFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'custom_fields', []));
1156 $dueDateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'due_date', []));
1157 $includeArchived = !empty($data['include_archived']) || in_array('archived', $taskStatusFilters, true);
1158
1159 $tasksQuery = Task::query()
1160 // Kanban/List filtering only need board-card fields because opening a
1161 // task already rehydrates the full payload through the detail endpoint.
1162 ->select([
1163 'id',
1164 'title',
1165 'slug',
1166 'board_id',
1167 'parent_id',
1168 'crm_contact_id',
1169 'type',
1170 'stage_id',
1171 'status',
1172 'reminder_type',
1173 'priority',
1174 'archived_at',
1175 'remind_at',
1176 'started_at',
1177 'due_at',
1178 'last_completed_at',
1179 'position',
1180 'comments_count',
1181 'created_by',
1182 'settings',
1183 'source',
1184 'source_id',
1185 ])
1186 ->with(['assignees', 'labels', 'watchers'])
1187 ->where('board_id', $boardId)
1188 ->whereNull('parent_id');
1189
1190 if (!$includeArchived && !$taskStatusFilters) {
1191 $tasksQuery->whereNull('archived_at');
1192 }
1193
1194 $this->applyTableTaskSearch($tasksQuery, $search);
1195 $this->applyTableTaskFilters($tasksQuery, [
1196 'stage' => $stageFilters,
1197 'task_status' => $taskStatusFilters,
1198 'priority' => $priorityFilters,
1199 'assignee' => $assigneeFilters,
1200 'labels' => $labelFilters,
1201 'watchers' => $watcherFilters,
1202 'contact' => $contactFilters,
1203 'custom_fields' => $customFieldFilters,
1204 'due_date' => $dueDateFilters,
1205 ]);
1206
1207 return $tasksQuery
1208 ->orderBy('stage_id', 'asc')
1209 ->orderBy('position', 'asc')
1210 ->get();
1211 }
1212
1213 private function sanitizeTableFilterValues($values)
1214 {
1215 if (!is_array($values)) {
1216 $values = ($values === null || $values === '') ? [] : [$values];
1217 }
1218
1219 return array_values(array_filter(array_map(static function ($value) {
1220 return sanitize_text_field($value);
1221 }, $values), static function ($value) {
1222 return $value !== '';
1223 }));
1224 }
1225
1226 private function applyTableTaskSearch($tasksQuery, $search)
1227 {
1228 if (!$search) {
1229 return;
1230 }
1231
1232 global $wpdb;
1233
1234 $query = strtolower($search);
1235 $firstThreeChars = substr($query, 0, 3);
1236
1237 if ($firstThreeChars === 'id:') {
1238 $idPart = preg_replace('/[^a-zA-Z0-9]/', '', substr($query, 3));
1239 if ($idPart !== '') {
1240 $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
1241 }
1242 return;
1243 }
1244
1245 $escapedSearch = $wpdb->esc_like($search);
1246 $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%');
1247 }
1248
1249 private function applyTableTaskFilters($tasksQuery, $filters)
1250 {
1251 $stageFilters = Arr::get($filters, 'stage', []);
1252 $taskStatusFilters = Arr::get($filters, 'task_status', []);
1253 $priorityFilters = Arr::get($filters, 'priority', []);
1254 $assigneeFilters = Arr::get($filters, 'assignee', []);
1255 $labelFilters = Arr::get($filters, 'labels', []);
1256 $watcherFilters = Arr::get($filters, 'watchers', []);
1257 $contactFilters = Arr::get($filters, 'contact', []);
1258 $customFieldFilters = Arr::get($filters, 'custom_fields', []);
1259 $dueDateFilters = Arr::get($filters, 'due_date', []);
1260
1261 if ($stageFilters) {
1262 $this->applyTableStageFilters($tasksQuery, $stageFilters);
1263 }
1264
1265 if ($taskStatusFilters) {
1266 $this->applyTableTaskStatusFilters($tasksQuery, $taskStatusFilters);
1267 }
1268
1269 if ($priorityFilters) {
1270 $tasksQuery->whereIn('priority', array_map('strtolower', $priorityFilters));
1271 }
1272
1273 if ($contactFilters) {
1274 $contactIds = array_values(array_filter(array_map('intval', $contactFilters)));
1275 if ($contactIds) {
1276 $tasksQuery->whereIn('crm_contact_id', $contactIds);
1277 }
1278 }
1279
1280 if ($labelFilters) {
1281 $labelIds = array_values(array_filter(array_map('intval', array_diff($labelFilters, ['no-label']))));
1282 $includeNoLabel = in_array('no-label', $labelFilters, true);
1283 $labelTable = (new Label())->getTable();
1284
1285 if ($labelIds || $includeNoLabel) {
1286 $tasksQuery->where(function ($query) use ($labelIds, $includeNoLabel, $labelTable) {
1287 if ($includeNoLabel) {
1288 $query->orWhereDoesntHave('labels');
1289 }
1290
1291 if ($labelIds) {
1292 $query->orWhereHas('labels', function ($labelQuery) use ($labelIds, $labelTable) {
1293 $labelQuery->whereIn($labelTable . '.id', $labelIds);
1294 });
1295 }
1296 });
1297 }
1298 }
1299
1300 if ($customFieldFilters) {
1301 $customFieldIds = array_values(array_filter(array_map('intval', array_diff($customFieldFilters, ['no-custom-field']))));
1302 $includeNoCustomField = in_array('no-custom-field', $customFieldFilters, true);
1303
1304 if ($customFieldIds || $includeNoCustomField) {
1305 $tasksQuery->where(function ($query) use ($customFieldIds, $includeNoCustomField) {
1306 if ($includeNoCustomField) {
1307 $query->orWhereDoesntHave('taskCustomFields');
1308 }
1309
1310 if ($customFieldIds) {
1311 $query->orWhereHas('taskCustomFields', function ($customFieldQuery) use ($customFieldIds) {
1312 $customFieldQuery->whereIn('foreign_id', $customFieldIds);
1313 });
1314 }
1315 });
1316 }
1317 }
1318
1319 if ($dueDateFilters) {
1320 $this->applyTableDueDateFilters($tasksQuery, $dueDateFilters);
1321 }
1322
1323 if ($assigneeFilters || $watcherFilters) {
1324 $this->applyTableAssignmentFilters($tasksQuery, $assigneeFilters, $watcherFilters);
1325 }
1326 }
1327
1328 private function applyTableStageFilters($tasksQuery, $stageFilters)
1329 {
1330 $stageIds = array_values(array_filter(array_map('intval', array_diff($stageFilters, ['archived']))));
1331 $includeArchivedStages = in_array('archived', $stageFilters, true);
1332
1333 if (!$stageIds && !$includeArchivedStages) {
1334 return;
1335 }
1336
1337 $tasksQuery->where(function ($query) use ($stageIds, $includeArchivedStages) {
1338 if ($stageIds) {
1339 $query->orWhereIn('stage_id', $stageIds);
1340 }
1341
1342 if ($includeArchivedStages) {
1343 $query->orWhereHas('stage', function ($stageQuery) {
1344 $stageQuery->whereNotNull('archived_at');
1345 });
1346 }
1347 });
1348 }
1349
1350 private function applyTableTaskStatusFilters($tasksQuery, $taskStatusFilters)
1351 {
1352 $statuses = array_values(array_diff($taskStatusFilters, ['archived']));
1353 $includeArchived = in_array('archived', $taskStatusFilters, true);
1354
1355 if (!$statuses && !$includeArchived) {
1356 return;
1357 }
1358
1359 $tasksQuery->where(function ($query) use ($statuses, $includeArchived) {
1360 if ($statuses) {
1361 $query->orWhere(function ($statusQuery) use ($statuses) {
1362 $statusQuery->whereNull('archived_at')
1363 ->whereIn('status', $statuses);
1364 });
1365 }
1366
1367 if ($includeArchived) {
1368 $query->orWhereNotNull('archived_at');
1369 }
1370 });
1371 }
1372
1373 private function applyTableDueDateFilters($tasksQuery, $dueDateFilters)
1374 {
1375 $dueDateFilters = array_values(array_intersect($dueDateFilters, [
1376 'overdue',
1377 'no-dates',
1378 'today',
1379 'this-week',
1380 'next-week',
1381 'this-month',
1382 'upcoming',
1383 ]));
1384
1385 if (!$dueDateFilters) {
1386 return;
1387 }
1388
1389 $nowTimestamp = current_time('timestamp');
1390 $startOfToday = gmdate('Y-m-d 00:00:00', $nowTimestamp);
1391 $endOfToday = gmdate('Y-m-d 23:59:59', $nowTimestamp);
1392 $startOfThisWeek = gmdate('Y-m-d 00:00:00', strtotime('sunday this week', $nowTimestamp));
1393 $startOfNextWeek = gmdate('Y-m-d 00:00:00', strtotime('sunday next week', $nowTimestamp));
1394 $startOfWeekAfterNext = gmdate('Y-m-d 00:00:00', strtotime('+1 week', strtotime($startOfNextWeek)));
1395 $endOfThisMonth = gmdate('Y-m-t 23:59:59', $nowTimestamp);
1396 $nowMysql = current_time('mysql');
1397
1398 $tasksQuery->where(function ($query) use ($dueDateFilters, $startOfToday, $endOfToday, $startOfThisWeek, $startOfNextWeek, $startOfWeekAfterNext, $endOfThisMonth, $nowMysql) {
1399 foreach ($dueDateFilters as $filter) {
1400 switch ($filter) {
1401 case 'overdue':
1402 $query->orWhere(function ($dueQuery) use ($nowMysql) {
1403 $dueQuery->whereNull('last_completed_at')
1404 ->whereNotNull('due_at')
1405 ->where('due_at', '<=', $nowMysql);
1406 });
1407 break;
1408 case 'no-dates':
1409 $query->orWhereNull('due_at');
1410 break;
1411 case 'today':
1412 $query->orWhereBetween('due_at', [$startOfToday, $endOfToday]);
1413 break;
1414 case 'this-week':
1415 $query->orWhereBetween('due_at', [$startOfThisWeek, $startOfNextWeek]);
1416 break;
1417 case 'next-week':
1418 $query->orWhereBetween('due_at', [$startOfNextWeek, $startOfWeekAfterNext]);
1419 break;
1420 case 'this-month':
1421 $query->orWhereBetween('due_at', [$nowMysql, $endOfThisMonth]);
1422 break;
1423 case 'upcoming':
1424 $query->orWhere(function ($upcomingQuery) use ($nowMysql) {
1425 $upcomingQuery->whereNull('last_completed_at')
1426 ->whereNotNull('due_at')
1427 ->where('due_at', '>=', $nowMysql);
1428 });
1429 break;
1430 }
1431 }
1432 });
1433 }
1434
1435 private function applyTableAssignmentFilters($tasksQuery, $assigneeFilters, $watcherFilters)
1436 {
1437 $assigneeIds = array_values(array_filter(array_map('intval', array_diff($assigneeFilters, ['no-assignee']))));
1438 $watcherIds = array_values(array_filter(array_map('intval', $watcherFilters)));
1439 $includeNoAssignee = in_array('no-assignee', $assigneeFilters, true);
1440 $commonIds = array_values(array_intersect($assigneeIds, $watcherIds));
1441 $assigneeOnlyIds = array_values(array_diff($assigneeIds, $commonIds));
1442 $watcherOnlyIds = array_values(array_diff($watcherIds, $commonIds));
1443
1444 if ($commonIds) {
1445 // Shared watcher/assignee filters are treated as an OR group, matching
1446 // the existing client-side filter semantics.
1447 $tasksQuery->where(function ($query) use ($commonIds) {
1448 $query->whereHas('assignees', function ($assigneeQuery) use ($commonIds) {
1449 $assigneeQuery->whereIn('ID', $commonIds);
1450 })->orWhereHas('watchers', function ($watcherQuery) use ($commonIds) {
1451 $watcherQuery->whereIn('ID', $commonIds);
1452 });
1453 });
1454 }
1455
1456 if ($includeNoAssignee || $assigneeOnlyIds) {
1457 $tasksQuery->where(function ($query) use ($includeNoAssignee, $assigneeOnlyIds) {
1458 if ($includeNoAssignee) {
1459 $query->orWhereDoesntHave('assignees');
1460 }
1461
1462 if ($assigneeOnlyIds) {
1463 $query->orWhereHas('assignees', function ($assigneeQuery) use ($assigneeOnlyIds) {
1464 $assigneeQuery->whereIn('ID', $assigneeOnlyIds);
1465 });
1466 }
1467 });
1468 }
1469
1470 if ($watcherOnlyIds) {
1471 $tasksQuery->whereHas('watchers', function ($watcherQuery) use ($watcherOnlyIds) {
1472 $watcherQuery->whereIn('ID', $watcherOnlyIds);
1473 })->whereDoesntHave('assignees', function ($assigneeQuery) use ($watcherOnlyIds) {
1474 $assigneeQuery->whereIn('ID', $watcherOnlyIds);
1475 });
1476 }
1477 }
1478
1479 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
1480 {
1481 $current_user_id = get_current_user_id();
1482 /* this will run in background as soon as possible */
1483 /* sending Model or Model Instance won't work here */
1484
1485 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_'.$column, [$taskId, $assigneeIds, $current_user_id], 'fluent-boards');
1486 }
1487
1488 public function getStageByTask($task_id)
1489 {
1490 $task = Task::find($task_id);
1491 if (!$task || !PermissionManager::userHasPermission($task->board_id)) {
1492 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1493 }
1494 return $task->stage;
1495 }
1496
1497 public function moveTaskToNextStage($task_id, $boardId = null)
1498 {
1499 $task = $boardId ? $this->findTaskOnBoard($task_id, $boardId) : Task::findOrFail($task_id);
1500
1501 $oldStage = $task->stage;
1502
1503 $nextStage = Stage::where('board_id', $task->board_id)
1504 ->where('position', '>', $oldStage->position)
1505 ->orderBy('position', 'ASC')
1506 ->first();
1507
1508 if (!$nextStage) {
1509 return $task;
1510 }
1511
1512 if ($nextStage->defaultTaskStatus() == 'closed' && $task->status != 'closed') {
1513 $task->status = 'closed';
1514 if (!$task->last_completed_at) {
1515 $task->last_completed_at = current_time('mysql');
1516 }
1517 }
1518
1519 // Clean up archived_by_stage meta when moving to different stage
1520 $this->cleanupArchivedByStageMetaIfExists($task->id);
1521
1522 $task->stage_id = $nextStage->id;
1523 $task->save();
1524
1525 $task->load(['board', 'stage', 'attachments']);
1526
1527 $task = $this->loadNextStage($task);
1528
1529 return $task;
1530 }
1531
1532 public function loadNextStage($task)
1533 {
1534 $stage = $task->stage;
1535 $nextStage = Stage::where('board_id', $task->board_id)
1536 ->where('position', '>', $stage->position)
1537 ->orderBy('position', 'ASC')
1538 ->first();
1539
1540 $task->nextStage = $nextStage ? $nextStage->title : null;
1541 return $task;
1542 }
1543
1544 public function getActivities($taskId, $perPage, $filter = 'newest')
1545 {
1546 $activityQuery = Activity::where('object_id', $taskId)
1547 ->where('object_type', Constant::ACTIVITY_TASK);
1548 if ($filter == 'newest') {
1549 $activityQuery = $activityQuery->latest();
1550 } else if ($filter == 'oldest') {
1551 $activityQuery = $activityQuery->oldest();
1552 }
1553 $activities = $activityQuery->with('user')->paginate($perPage);
1554
1555 Helper::translateActivities($activities);
1556
1557 return $activities;
1558 }
1559
1560 public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null, $includeArchived = true)
1561 {
1562 if (!$lastUpdated) {
1563 $lastUpdated = date_i18n('Y-m-d H:i:s', current_time('timestamp') - 60); // 1 minute ago
1564 }
1565
1566 $tasksQuery = Task::query()
1567 ->where([
1568 'board_id' => $boardId,
1569 'parent_id' => null,
1570 ])
1571 ->where('updated_at', '>=', $lastUpdated) // updated since the sync cursor
1572 ->with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
1573 ->orderBy('due_at', 'ASC');
1574
1575 if (!$includeArchived) {
1576 $tasksQuery->whereNull('archived_at');
1577 }
1578
1579 $tasks = $tasksQuery->get();
1580
1581 foreach ($tasks as $task) {
1582 $task->isOverdue = $task->isOverdue();
1583 $task->isUpcoming = $task->upcoming();
1584 $task->is_watching = $task->isWatching();
1585 $task->contact = Task::lead_contact($task->crm_contact_id);
1586 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1587 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
1588 }
1589 return $tasks;
1590 }
1591
1592 public function getLastPositionOfTasks($stage_id)
1593 {
1594 $lastPosition = Task::query()
1595 ->where('stage_id', $stage_id)
1596 ->where('parent_id', null)
1597 ->whereNull('archived_at')
1598 ->orderBy('position', 'desc')
1599 ->pluck('position')
1600 ->first();
1601
1602 return $lastPosition + 1;
1603 }
1604
1605 /**
1606 * Pin a task: set is_pinned in task meta only. No position change.
1607 * Only parent tasks can be pinned.
1608 *
1609 * @param \FluentBoards\App\Models\Task $task
1610 * @return \FluentBoards\App\Models\Task
1611 */
1612 public function pinTask($task)
1613 {
1614 if ($task->parent_id !== null) {
1615 return $task;
1616 }
1617
1618 $task->updateMeta(Constant::IS_TASK_PINNED, 1);
1619
1620 return $task;
1621 }
1622
1623 /**
1624 * Unpin a task: set is_pinned in task meta only. No position change.
1625 *
1626 * @param \FluentBoards\App\Models\Task $task
1627 * @return \FluentBoards\App\Models\Task
1628 */
1629 public function unpinTask($task)
1630 {
1631 $task->updateMeta(Constant::IS_TASK_PINNED, 0);
1632
1633 return $task;
1634 }
1635
1636 /**
1637 * Get CRM-associated tasks and mark whether the current user may edit each task's board.
1638 *
1639 * @param int $associatedId CRM contact/subscriber id associated with tasks.
1640 * @param int|null $userId User id for board permission checks.
1641 * @return \FluentBoards\Framework\Database\Orm\Collection|array
1642 */
1643 public function getAssociatedTasks($associatedId, $userId = null)
1644 {
1645 $associatedId = absint($associatedId);
1646 $userId = $userId ?: get_current_user_id();
1647
1648 if (!$associatedId || !$userId) {
1649 return [];
1650 }
1651
1652 // Load only the relationships rendered by the FluentCRM profile tab.
1653 $tasks = Task::query()
1654 ->where('crm_contact_id', $associatedId)
1655 ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
1656 ->orderBy('due_at', 'ASC')
1657 ->get();
1658
1659 // Batch board access once so each task avoids its own permission query.
1660 $editableBoardIds = array_map('intval', PermissionManager::getBoardIdsForUser($userId));
1661
1662 foreach ($tasks as $task) {
1663 $task->isOverdue = $task->isOverdue();
1664 $task->isUpcoming = $task->upcoming();
1665 $task->can_edit = in_array((int)$task->board_id, $editableBoardIds, true);
1666
1667 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1668
1669 foreach ($task->subtaskGroup as $group) {
1670 foreach ($group->subtasks as $subtask) {
1671 $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
1672 }
1673 }
1674 $task->subtask_group = $task->subtaskGroup;
1675 }
1676
1677 return $tasks;
1678 }
1679
1680 public function copySubtaskGroup($task, $newTask, $subtaskGroupMap)
1681 {
1682 $subtaskGroups = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_NAME)->get();
1683 foreach ($subtaskGroups as $group) {
1684 $newGroup = TaskMeta::create([
1685 'task_id' => $newTask->id,
1686 'key' => Constant::SUBTASK_GROUP_NAME,
1687 'value' => $group->value
1688 ]);
1689
1690 $subtaskGroupMap[$group->id] = $newGroup->id;
1691 }
1692
1693 return $subtaskGroupMap;
1694 }
1695
1696 public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
1697 {
1698 $allActiveTasks = Task::where('board_id', $boardId)->whereNull('archived_at')->get();
1699 $taskMap = [];
1700 $subtaskGroupMap = [];
1701 $parentTaskCount = 0;
1702 $attachmentFileService = new AttachmentFileService();
1703 $dbInstance = App::getInstance('db');
1704
1705 $dbInstance->beginTransaction();
1706
1707 try {
1708 foreach ($allActiveTasks as $task) {
1709 $newTask = array();
1710 $newTask['title'] = $task->title;
1711 $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
1712 $newTask['description'] = $task->description;
1713 $newTask['board_id'] = $newBoard->id;
1714 $newTask['stage_id'] = $stageMap[$task->stage_id];
1715 $newTask['status'] = $task->status;
1716 $newTask['priority'] = $task->priority;
1717 $newTask['position'] = $task->position;
1718 $newTask['due_at'] = $task->due_at;
1719 $backgroundColor = $task->settings['cover']['backgroundColor'] ?? '';
1720 $newTask['settings'] = [
1721 'cover' => [
1722 'backgroundColor' => $backgroundColor,
1723 ]
1724 ];
1725
1726 $newTask = Task::create($newTask);
1727 $attachmentFileService->cloneTaskFilesToBoard($task, $newTask, $newBoard->id);
1728
1729 if (!$task->parent_id) {
1730 //group mapping
1731 $subtaskGroupMap = $this->copySubtaskGroup($task, $newTask, $subtaskGroupMap);
1732 } else {
1733 $groupRelationOfTask = TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
1734 ->where('task_id', $task->id)
1735 ->first();
1736
1737 if ($groupRelationOfTask && $subtaskGroupMap[$groupRelationOfTask->value]) {
1738 TaskMeta::create([
1739 'task_id' => $newTask->id,
1740 'key' => Constant::SUBTASK_GROUP_CHILD,
1741 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
1742 ]);
1743 }
1744 }
1745
1746 if($isWithTemplates == 'yes') {
1747 $isTemplate = TaskMeta::where('task_id', $task->id)
1748 ->where('key', 'is_template')
1749 ->first();
1750 if($isTemplate) {
1751 TaskMeta::create([
1752 'task_id' => $newTask->id,
1753 'key' => 'is_template',
1754 'value' => $isTemplate->value
1755 ]);
1756 }
1757 }
1758 if(!$task->parent_id){
1759 ++$parentTaskCount;
1760 $taskMap[$task['id']] = $newTask->id;
1761 //duplicate labels to task
1762 $labelIds = $task->labels->pluck('id')->toArray();
1763 if($labelIds){
1764 $flipLabelIds = array_flip($labelIds);
1765 $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
1766
1767 $newTask->labels()->attach($labelsToAttach, [
1768 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
1769 ]);
1770 }
1771 }
1772 }
1773
1774 $board = Board::findOrFail($newBoard->id);
1775 $settings = [];
1776 $settings['tasks_count'] = $parentTaskCount;
1777 $board->settings = $settings;
1778 $board->save();
1779
1780 $dbInstance->commit();
1781 } catch (\Exception $e) {
1782 $dbInstance->rollBack();
1783 $attachmentFileService->rollbackCreatedFiles();
1784 throw $e;
1785 }
1786 }
1787
1788 private function subtaskCountUpdate($taskId){
1789 $parentTask = Task::findOrFail($taskId);
1790 $settings = $parentTask->settings;
1791 $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1;
1792 $parentTask->settings = $settings;
1793 $parentTask->save();
1794 }
1795
1796 /**
1797 * @param $taskId
1798 * @param $perPage
1799 * @param $offset
1800 * @param string $filter
1801 * @return array
1802 */
1803 public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null): array
1804 {
1805 // Fetch the task
1806 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
1807
1808 // Fetch comments and activities separately
1809 $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
1810 $activities = $task->activities()
1811 ->with('user')
1812 ->where(function($query) {
1813 $query->whereNotIn('column', [ 'comment', 'a reply'])
1814 ->orWhere(function($subQuery) {
1815 $subQuery->whereNotIn('action', ['added', 'updated']);
1816 });
1817 })
1818 ->orderBy('created_at', 'desc')
1819 ->get()
1820 ->toArray();
1821
1822
1823
1824 // Merge comments and activities into a single array
1825 $commentsAndActivities = array_merge($comments, $activities);
1826
1827 // Sort the merged array by created_at date in ascending or descending order
1828 $order = $filter == 'newest' ? -1 : 1;
1829 usort($commentsAndActivities, function ($a, $b) use ($order) {
1830 return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
1831 });
1832
1833 // Paginate the results
1834 $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
1835 $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
1836
1837 // Get the total count of comments and activities
1838 $total = count($commentsAndActivities);
1839 $lastPage = (int) ceil($total / $perPage);
1840
1841 // Construct pagination metadata
1842 $path = "https://wordpress.test/wp-json/fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities";
1843 return [
1844 'current_page' => (int) $page,
1845 'data' => $paginatedResults,
1846 'first_page_url' => "{$path}?page=1",
1847 'from' => $total > 0 ? (int) ($offset + 1) : null,
1848 'last_page' => (int) $lastPage,
1849 'last_page_url' => "{$path}?page={$lastPage}",
1850 'links' => [
1851 [
1852 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
1853 'label' => 'pagination.previous',
1854 'active' => false
1855 ],
1856 [
1857 'url' => "{$path}?page={$page}",
1858 'label' => (int) $page,
1859 'active' => true
1860 ],
1861 [
1862 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
1863 'label' => 'pagination.next',
1864 'active' => false
1865 ]
1866 ],
1867 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
1868 'path' => $path,
1869 'per_page' => (int) $perPage,
1870 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
1871 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
1872 'total' => (int) $total
1873 ];
1874 }
1875
1876 /**
1877 * @param $task_id
1878 * @param $fileData
1879 * @param $type
1880 * @return Attachment
1881 */
1882 public function uploadMediaFileFromWpEditor($task_id, $fileData, $type)
1883 {
1884 $initialDataData = [
1885 'type' => 'url',
1886 'url' => '',
1887 'name' => '',
1888 'size' => 0,
1889 ];
1890
1891 $attachData = array_merge($initialDataData, $fileData);
1892 $UrlMeta = [];
1893 if($attachData['type'] == 'url') {
1894 $UrlMeta = RemoteUrlParser::parse($attachData['url']);
1895 }
1896 $attachment = new TaskImage();
1897 $attachment->object_id = $task_id;
1898 $attachment->object_type = $type;
1899 $attachment->attachment_type = $attachData['type'];
1900 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
1901 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
1902 $attachment->full_url = esc_url($attachData['url']);
1903 $attachment->file_size = $attachData['size'];
1904 $attachment->settings = $attachData['type'] == 'url' ? [
1905 'meta' => $UrlMeta
1906 ] : '';
1907 $attachment->driver = 'local';
1908 $attachment->save();
1909 return $attachment;
1910 }
1911
1912
1913 /**
1914 * @param $type
1915 * @param $title
1916 * @param $UrlMeta
1917 * @return mixed|string
1918 */
1919 public function setTitle($type, $title, $UrlMeta)
1920 {
1921 if($type != 'url') {
1922 return sanitize_file_name($title);
1923 }
1924 return $title ?? $UrlMeta['title'] ?? '';
1925 }
1926
1927 public function manageDefaultAssignees($task, $stageId)
1928 {
1929 $stage = Stage::findOrFail($stageId);
1930 if ($stage && isset($stage->settings['default_task_assignees'])) {
1931 $defaultAssignees = $stage->settings['default_task_assignees'];
1932 foreach ($defaultAssignees as $assigneeId) {
1933 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
1934 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
1935 if (!$IfAlreadyAssignee) {
1936 $this->updateAssignee($assigneeId, $task);
1937 }
1938 }
1939 }
1940 }
1941
1942 public function manageDefaultWatchers($task, $stageId)
1943 {
1944 $stage = Stage::findOrFail($stageId);
1945 if ($stage) {
1946 $settings = $stage->settings;
1947 $defaultWatchers = [];
1948 if (isset($settings['default_task_watchers']) && is_array($settings['default_task_watchers'])) {
1949 $defaultWatchers = $settings['default_task_watchers'];
1950 }
1951 if (isset($settings['default_task_assignees']) && is_array($settings['default_task_assignees'])) {
1952 $defaultWatchers = array_unique(array_merge($defaultWatchers, $settings['default_task_assignees']));
1953 }
1954 foreach ($defaultWatchers as $watcherId) {
1955 $alreadyWatcherIds = $task->watchers->pluck('ID')->toArray();
1956 $isAlreadyWatcher = in_array($watcherId, $alreadyWatcherIds);
1957 if (!$isAlreadyWatcher) {
1958 $task->watchers()->syncWithoutDetaching([
1959 $watcherId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]
1960 ]);
1961 }
1962 }
1963 }
1964 }
1965
1966 public function setDefaultAssigneesToEveryTasks($stage)
1967 {
1968 $tasks = $stage->tasks->whereNull('archived_at');
1969 foreach ($tasks as $task) {
1970 $this->manageDefaultAssignees($task, $stage->id);
1971 }
1972 }
1973
1974 public function createTaskFromImage($board_id, $stage_id, $uploadInfo, $file)
1975 {
1976
1977 $board = Board::find($board_id);
1978 $stage = Stage::where('id', absint($stage_id))
1979 ->where('board_id', absint($board_id))
1980 ->first();
1981
1982 if (!$board || !$stage) {
1983 throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
1984 }
1985
1986 $task = new Task();
1987 $taskType = $board->type === 'to-do' ? 'task' : 'roadmap' ;
1988 $taskData = [
1989 'title' => $uploadInfo[0]['name'],
1990 'board_id' => $board_id,
1991 'stage_id' => $stage_id,
1992 'type' => $taskType,
1993 ];
1994 $task->fill($taskData);
1995 $task->save();
1996
1997 $fileData = $uploadInfo[0];
1998 $fileUploadedData = $this->uploadMediaFileFromWpEditor($task->id, $fileData, Constant::TASK_DESCRIPTION);
1999 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
2000 $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
2001 $fileUploadedData['driver'] = $mediaData['driver'];
2002 $fileUploadedData['file_path'] = $mediaData['file_path'];
2003 $fileUploadedData['full_url'] = $mediaData['full_url'];
2004 $fileUploadedData->save();
2005 }
2006
2007 $settings = $task->settings;
2008 $settings['cover'] = [
2009 'imageId' => $fileUploadedData['id'],
2010 'backgroundImage' => (new CommentService())->createPublicUrl($fileUploadedData, $board_id),
2011 ];
2012 $task->settings = $settings;
2013 $task = $task->moveToNewPosition(1);
2014 $task->save();
2015 $task->load(['board', 'stage', 'labels', 'assignees']);
2016
2017 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
2018
2019 $task->isOverdue = $task->isOverdue();
2020 $task->contact = Task::lead_contact($task->crm_contact_id);
2021 $task->board->stages = (new StageService())->stagesByBoardId($board_id);
2022 $task->is_watching = (new NotificationService())->isCurrentUserObservingTask($task);
2023
2024 $task = $this->loadNextStage($task);
2025
2026 if ($task->type == 'roadmap') {
2027 $task->vote_statistics = $this->getIdeaVoteStatistics($task->id);
2028 }
2029
2030 return $task;
2031 }
2032 public function deleteTaskCoverImage($settings)
2033 {
2034 if (isset($settings['cover']['imageId']) && $settings['cover']['imageId']) {
2035 $image = TaskImage::find($settings['cover']['imageId']);
2036 if ($image) {
2037 $deletedImage = clone $image;
2038 $deletedImage->delete();
2039
2040 do_action('fluent_boards/task_attachment_deleted', $deletedImage);
2041 }
2042 }
2043
2044 }
2045
2046 private function deleteTaskAttachments($task)
2047 {
2048 $attachments = TaskAttachment::where('object_id', $task->id)
2049 ->where('object_type', Constant::TASK_ATTACHMENT)
2050 ->get();
2051 foreach ($attachments as $attachment) {
2052 $deletedAttachment = clone $attachment;
2053 $attachment->delete();
2054
2055 do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2056 }
2057 }
2058
2059 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2060 {
2061 global $wpdb;
2062 $attachmentFileService = new AttachmentFileService();
2063
2064 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2065 $wpdb->query('START TRANSACTION');
2066
2067 try {
2068 // Load task with all necessary relationships
2069 $taskQuery = Task::with([
2070 'assignees',
2071 'labels',
2072 'watchers',
2073 ])->where('id', $taskId);
2074
2075 if ($boardId) {
2076 $taskQuery->where('board_id', absint($boardId));
2077 }
2078
2079 $task = $taskQuery->first();
2080
2081 if (!$task) {
2082 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
2083 }
2084
2085 // Create new task with cloned data
2086 $clonedTask = $task->replicate();
2087 $clonedTask->title = $taskData['title'] ?? $task->title . ' (' . \__('cloned', 'fluent-boards') . ')';
2088
2089 $settings = $clonedTask->settings ?? [];
2090
2091 unset(
2092 $settings['attachment_count'],
2093 $settings['subtask_completed_count'],
2094 $settings['subtask_count']
2095 );
2096 $clonedTask->settings = $settings;
2097 $clonedTask->stage_id = $taskData['stage_id'] ?? $task->stage_id;
2098
2099 // Validate that target stage belongs to the same board
2100 $targetStage = Stage::where('id', $clonedTask->stage_id)
2101 ->where('board_id', $task->board_id)
2102 ->first();
2103
2104 if (!$targetStage) {
2105 throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
2106 }
2107
2108 $clonedTask->board_id = $targetStage->board_id;
2109
2110 $clonedTask->comments_count = 0; // Reset comments count for cloned task
2111 $clonedTask->save();
2112
2113 $positionIndex = 1; // Default position index for new task
2114 if($task->stage_id === $clonedTask->stage_id) {
2115 // Calculate position for the cloned task next to original task
2116 $positionIndex = $this->calculateClonedTaskPosition($task);
2117 }
2118 // Move cloned task to the new position
2119 $clonedTask->moveToNewPosition($positionIndex);
2120
2121 $this->cloneTaskMeta($task, $clonedTask);
2122
2123 $this->cloneTaskCustomFields($task, $clonedTask);
2124
2125 // Apply stage default assignees if any are set
2126 $this->manageDefaultAssignees($clonedTask, $clonedTask->stage_id);
2127
2128 if($taskData['assignee']) {
2129 $this->cloneAssignees($task, $clonedTask);
2130 }
2131 if($taskData['label']) {
2132 $this->cloneTaskLabels($task, $clonedTask);
2133 }
2134 $this->cloneTaskWatchers($task, $clonedTask);
2135
2136 $attachmentFileService->cloneTaskFilesToBoard($task, $clonedTask, $clonedTask->board_id, [
2137 'description_images' => true,
2138 'cover' => true,
2139 'task_attachments' => (bool) $taskData['attachment'],
2140 ]);
2141
2142 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
2143 // Clone time tracking data if Pro version is active
2144 if ($taskData['subtask']) {
2145 $this->cloneSubtasks($task, $clonedTask, (bool) $taskData['attachment'], $attachmentFileService);
2146 }
2147 }
2148
2149 if($taskData['comment']) {
2150 $this->cloneCommentsAndReplies($task, $clonedTask);
2151 }
2152
2153 // Load and prepare the cloned task for response
2154 $clonedTask = $this->prepareClonedTaskForResponse($clonedTask);
2155 do_action('fluent_boards/task_cloned', $task, $clonedTask);
2156
2157 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2158 $wpdb->query('COMMIT');
2159 return $clonedTask;
2160
2161 } catch (\Exception $e) {
2162 $attachmentFileService->rollbackCreatedFiles();
2163 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2164 $wpdb->query('ROLLBACK');
2165 throw new \Exception(
2166 esc_html(\__('Failed to clone task: ', 'fluent-boards') . $e->getMessage()),
2167 (int) ($e->getCode() ?: 500)
2168 );
2169 }
2170 }
2171
2172 private function calculateClonedTaskPosition(Task $originalTask): int
2173 {
2174 $tasks = Task::where('stage_id', $originalTask->stage_id)
2175 ->whereNull('archived_at')
2176 ->orderBy('position', 'asc')
2177 ->get();
2178
2179 $index = $tasks->search(function($task) use ($originalTask) {
2180 return $task->id === $originalTask->id;
2181 });
2182
2183 return $index !== false ? $index + 2 : 1; // Return 1-based index
2184 }
2185
2186 private function cloneTaskMeta(Task $originalTask, Task $clonedTask): void
2187 {
2188 $taskMetas = TaskMeta::where('task_id', $originalTask->id)
2189 ->where('key', '!=', Constant::SUBTASK_GROUP_NAME)
2190 ->get();
2191 foreach ($taskMetas as $meta) {
2192 TaskMeta::create([
2193 'task_id' => $clonedTask->id,
2194 'key' => $meta->key,
2195 'value' => $meta->value
2196 ]);
2197 }
2198 }
2199
2200 private function cloneAssignees($originalTask, $clonedTask)
2201 {
2202 // Clone assignees
2203 if ($originalTask->assignees) {
2204 foreach ($originalTask->assignees as $assignee) {
2205 $clonedTask->assignees()->syncWithoutDetaching([$assignee->ID => ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]]);
2206 }
2207 }
2208 }
2209
2210 private function cloneTaskLabels(Task $originalTask, Task $clonedTask): void
2211 {
2212 // Clone labels
2213 if ($originalTask->labels) {
2214 foreach ($originalTask->labels as $label) {
2215 $clonedTask->labels()->syncWithoutDetaching([$label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]]);
2216 }
2217 }
2218 }
2219
2220 private function cloneTaskWatchers(Task $originalTask, Task $clonedTask): void
2221 {
2222 /// Clone watchers
2223 if ($originalTask->watchers) {
2224 foreach ($originalTask->watchers as $watcher) {
2225 $clonedTask->watchers()->syncWithoutDetaching([$watcher->ID => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
2226 }
2227 }
2228 }
2229 private function cloneTaskCustomFields(Task $originalTask, Task $clonedTask): void
2230 {
2231
2232 // Clone custom fields
2233 if ($originalTask->taskCustomFields) {
2234 foreach ($originalTask->taskCustomFields as $customField) {
2235 $clonedField = $customField->replicate();
2236 $clonedField->object_id = $clonedTask->id;
2237 $clonedField->save();
2238 }
2239 }
2240 }
2241 private function cloneAttachments(Task $originalTask, Task $clonedTask): void
2242 {
2243 $attachments = $originalTask->attachments;
2244 foreach ($attachments as $attachment) {
2245 $clonedAttachment = $attachment->replicate();
2246 $clonedAttachment->object_id = $clonedTask->id;
2247 $clonedAttachment->save();
2248
2249 // If this is a cover image, update task settings
2250 if ($attachment->type === 'cover_image') {
2251 $settings = $clonedTask->settings;
2252 if (isset($settings['cover_image'])) {
2253 $settings['cover_image'] = $clonedAttachment->id;
2254 $clonedTask->settings = $settings;
2255 $clonedTask->save();
2256 }
2257 }
2258 }
2259 $settings = $clonedTask->settings;
2260 $settings['attachment_count'] = $clonedTask->attachments()->count();
2261 $clonedTask['settings'] = $settings;
2262 $clonedTask->save();
2263 }
2264 private function cloneCommentsAndReplies(Task $originalTask, Task $clonedTask)
2265 {
2266 // Get comments ordered by created_at
2267 $comments = Comment::where('task_id', $originalTask->id)
2268 ->where('type', 'comment')
2269 ->whereNull('parent_id')
2270 ->orderBy('created_at', 'asc')
2271 ->get();
2272
2273 if ($comments->isEmpty()) {
2274 return;
2275 }
2276
2277 foreach ($comments as $comment) {
2278 $clonedComment = $comment->replicate();
2279 $clonedComment->task_id = $clonedTask->id;
2280 $clonedComment->save();
2281
2282 // Get replies ordered by created_at
2283 $replies = Comment::where('parent_id', $comment->id)
2284 ->where('type', 'reply')
2285 ->orderBy('created_at', 'asc')
2286 ->get();
2287
2288 foreach ($replies as $reply) {
2289 $clonedReply = $reply->replicate();
2290 $clonedReply->task_id = $clonedTask->id;
2291 $clonedReply->parent_id = $clonedComment->id;
2292 $clonedReply->save();
2293
2294 // Clone reply image if any
2295 $this->cloneCommentOrReplyImage($reply, $clonedReply);
2296 }
2297
2298 // Clone comment image if any
2299 $this->cloneCommentOrReplyImage($comment, $clonedComment);
2300 }
2301 return;
2302 }
2303 private function cloneCommentOrReplyImage($oldCommentOrReply, $clonedCommentOrReply)
2304 {
2305 $images = CommentImage::where('object_id', $oldCommentOrReply->id)
2306 ->where('object_type', Constant::COMMENT_IMAGE)
2307 ->orderBy('created_at', 'asc')
2308 ->get();
2309
2310 if ($images->count() > 0) {
2311 foreach ($images as $image) {
2312 $clonedImage = $image->replicate();
2313 $clonedImage->object_id = $clonedCommentOrReply->id;
2314 $clonedImage->save();
2315 }
2316 }
2317 }
2318 private function cloneSubtasks(Task $originalTask, Task $clonedTask, bool $cloneAttachments = false, ?AttachmentFileService $attachmentFileService = null): void
2319 {
2320 // First clone subtask groups
2321 $subtaskGroupMap = $this->cloneSubtaskGroups($originalTask, $clonedTask);
2322 $completedSubtasksCount = 0;
2323
2324 if ($originalTask->subtasks) {
2325 foreach ($originalTask->subtasks as $subtask) {
2326 $clonedSubtask = $subtask->replicate();
2327 $clonedSubtask->parent_id = $clonedTask->id;
2328 $clonedSubtask->board_id = $clonedTask->board_id; // Ensure subtask has same board_id as parent
2329 $clonedSubtask->save();
2330 $attachmentFileService = $attachmentFileService ?: new AttachmentFileService();
2331 $attachmentFileService->cloneTaskFilesToBoard($subtask, $clonedSubtask, $clonedTask->board_id, [
2332 'description_images' => true,
2333 'cover' => true,
2334 'task_attachments' => $cloneAttachments,
2335 ]);
2336 if($clonedSubtask->status == 'closed') {
2337 $completedSubtasksCount++;
2338 }
2339
2340 // Update subtask group relationship if exists
2341 $groupRelation = TaskMeta::where('task_id', $subtask->id)
2342 ->where('key', Constant::SUBTASK_GROUP_CHILD)
2343 ->first();
2344
2345 if ($groupRelation && isset($subtaskGroupMap[$groupRelation->value])) {
2346 TaskMeta::create([
2347 'task_id' => $clonedSubtask->id,
2348 'key' => Constant::SUBTASK_GROUP_CHILD,
2349 'value' => $subtaskGroupMap[$groupRelation->value]
2350 ]);
2351 }
2352 }
2353 }
2354 $settings = $clonedTask->settings;
2355 $settings['subtask_count'] = $clonedTask->subtasks()->count();
2356 $clonedTask['settings'] = $settings;
2357 $clonedTask->settings['subtask_completed_count'] = $completedSubtasksCount;
2358 $clonedTask->save();
2359 }
2360 private function cloneSubtaskGroups(Task $originalTask, Task $clonedTask): array
2361 {
2362 $subtaskGroupMap = [];
2363
2364 if ($originalTask->subtaskGroup) {
2365 foreach ($originalTask->subtaskGroup as $group) {
2366 $clonedGroup = TaskMeta::create([
2367 'task_id' => $clonedTask->id,
2368 'key' => Constant::SUBTASK_GROUP_NAME,
2369 'value' => $group->value
2370 ]);
2371
2372 $subtaskGroupMap[$group->id] = $clonedGroup->id;
2373 }
2374 }
2375
2376 return $subtaskGroupMap;
2377 }
2378 private function prepareClonedTaskForResponse(Task $clonedTask): Task
2379 {
2380 // Load relationships
2381 $clonedTask->load(['board', 'stage', 'labels', 'assignees', 'subtasks']);
2382
2383 // Sanitize assignees
2384 $clonedTask->assignees = Helper::sanitizeUserCollections($clonedTask->assignees);
2385
2386 // Set additional properties
2387 $clonedTask->isOverdue = $clonedTask->isOverdue();
2388 $clonedTask->contact = Task::lead_contact($clonedTask->crm_contact_id);
2389 $clonedTask->board->stages = (new StageService())->stagesByBoardId($clonedTask->board_id);
2390 $clonedTask->is_watching = (new NotificationService())->isCurrentUserObservingTask($clonedTask);
2391
2392 // Load next stage if applicable
2393 return $this->loadNextStage($clonedTask);
2394 }
2395
2396 /**
2397 * Clean up archived_by_stage metadata if it exists for a task
2398 *
2399 * @param int $taskId
2400 * @return void
2401 */
2402 private function cleanupArchivedByStageMetaIfExists($taskId)
2403 {
2404 TaskMeta::where('task_id', $taskId)
2405 ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
2406 ->delete();
2407 }
2408
2409 /**
2410 * Handle bulk actions for multiple tasks
2411 *
2412 * @param array $taskIds
2413 * @param string $action
2414 * @param array $params
2415 * @param int $boardId
2416 * @return array
2417 * @throws \Exception
2418 */
2419 public function bulkActions($taskIds, $action, $params, $boardId)
2420 {
2421 if (empty($taskIds) || !is_array($taskIds)) {
2422 throw new \Exception(esc_html__('No tasks selected', 'fluent-boards'));
2423 }
2424
2425 if (count($taskIds) > 150) {
2426 throw new \Exception(esc_html__('Cannot process more than 150 tasks at once. Please select fewer tasks.', 'fluent-boards'));
2427 }
2428
2429 if (empty($action)) {
2430 throw new \Exception(esc_html__('No action specified', 'fluent-boards'));
2431 }
2432
2433 $tasks = Task::whereIn('id', $taskIds)
2434 ->where('board_id', $boardId)
2435 ->get();
2436
2437 if ($tasks->isEmpty()) {
2438 throw new \Exception(esc_html__('No valid tasks found', 'fluent-boards'));
2439 }
2440
2441 $result = [
2442 'successful_tasks' => [],
2443 'failed_tasks' => [],
2444 'message' => ''
2445 ];
2446
2447 switch ($action) {
2448 case 'move_tasks':
2449 $result = $this->bulkMoveTasks($tasks, $params, $boardId);
2450 break;
2451
2452 case 'move_to_stage':
2453 // Backward compatibility - redirect to move_tasks
2454 $result = $this->bulkMoveTasks($tasks, $params, $boardId);
2455 break;
2456
2457 case 'archive_tasks':
2458 $result = $this->bulkArchiveTasks($tasks);
2459 break;
2460
2461 case 'change_priority':
2462 $result = $this->bulkChangePriority($tasks, $params);
2463 break;
2464
2465 case 'assign_members':
2466 $result = $this->bulkAssignMembers($tasks, $params, $boardId);
2467 break;
2468
2469 case 'add_labels':
2470 $result = $this->bulkAddLabels($tasks, $params, $boardId);
2471 break;
2472
2473 default:
2474 throw new \Exception(esc_html__('Invalid action specified', 'fluent-boards'));
2475 }
2476
2477 // Dispatch WordPress action for other plugins to hook into
2478 do_action('fluent_boards/bulk_action_completed', $action, $tasks, $boardId);
2479
2480 return $result;
2481 }
2482
2483 /**
2484 * Bulk move tasks to a stage (same board) or to another board
2485 * Unified method that handles both same-board stage moves and cross-board moves
2486 */
2487 private function bulkMoveTasks($tasks, $params, $sourceBoardId)
2488 {
2489 $targetStageId = $params['target_stage_id'] ?? null;
2490 if (!$targetStageId) {
2491 throw new \Exception(esc_html__('Target stage ID is required', 'fluent-boards'));
2492 }
2493
2494 $targetBoardId = $params['target_board_id'] ?? null;
2495 $isMovingToAnotherBoard = $targetBoardId && $targetBoardId != $sourceBoardId;
2496
2497 // Determine effective target board ID
2498 $effectiveTargetBoardId = $isMovingToAnotherBoard ? $targetBoardId : $sourceBoardId;
2499
2500 // Validate target board exists and is not archived
2501 $targetBoard = Board::find($effectiveTargetBoardId);
2502 if (!$targetBoard) {
2503 throw new \Exception(esc_html__('Target board not found', 'fluent-boards'));
2504 }
2505
2506 if ($targetBoard->archived_at) {
2507 throw new \Exception(esc_html__('Cannot move tasks to an archived board', 'fluent-boards'));
2508 }
2509
2510 // Verify user has write access to target board if moving to different board
2511 if ($isMovingToAnotherBoard && !PermissionManager::userHasBoardPermission($effectiveTargetBoardId, 'POST')) {
2512 throw new \Exception(esc_html__('You do not have permission to add tasks to this board', 'fluent-boards'));
2513 }
2514
2515 // Validate target stage exists and belongs to target board
2516 $targetStage = Stage::where('id', $targetStageId)
2517 ->where('board_id', $effectiveTargetBoardId)
2518 ->first();
2519
2520 if (!$targetStage) {
2521 throw new \Exception(esc_html__('Target stage not found in the selected board', 'fluent-boards'));
2522 }
2523
2524 $successfulTasks = [];
2525
2526 foreach ($tasks as $task) {
2527 if ($isMovingToAnotherBoard) {
2528 // Cross-board move - use existing method for data cleanup and security
2529 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2530 $task->stage_id = $targetStageId;
2531 $task = $task->moveToNewPosition(null);
2532 } else {
2533 // Same board - simple stage move
2534 $oldStageId = $task->stage_id;
2535 $task->stage_id = $targetStageId;
2536 $task = $task->moveToNewPosition(1);
2537
2538 // Only process stage-specific logic if stage actually changed
2539 if ($oldStageId != $targetStageId) {
2540 $this->manageDefaultAssignees($task, $targetStageId);
2541
2542 $defaultPosition = $task->stage->defaultTaskStatus();
2543 if ($defaultPosition == 'closed' && $task->status != 'closed') {
2544 $task = $task->close();
2545 }
2546
2547 $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
2548 $this->sendMailAfterTaskModify('stage_change', $usersToSendEmail, $task->id);
2549 }
2550 }
2551
2552 // Reload task with all relationships
2553 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2554
2555 $successfulTasks[] = $task;
2556 }
2557
2558 $successCount = count($successfulTasks);
2559
2560 if ($isMovingToAnotherBoard) {
2561 // translators: %d is the number of tasks successfully moved to another board
2562 $message = sprintf(__('%d tasks moved to new board successfully', 'fluent-boards'), $successCount);
2563 } else {
2564 // translators: %d is the number of tasks successfully moved to the stage
2565 $message = sprintf(__('%d tasks moved to stage successfully', 'fluent-boards'), $successCount);
2566 }
2567
2568 return [
2569 'successful_tasks' => $successfulTasks,
2570 'failed_tasks' => [],
2571 'message' => $message,
2572 'moved_to_another_board' => $isMovingToAnotherBoard
2573 ];
2574 }
2575
2576 /**
2577 * Legacy method - kept for backward compatibility
2578 * @deprecated Use bulkMoveTasks instead
2579 */
2580 private function bulkMoveToStage($tasks, $params, $boardId)
2581 {
2582 return $this->bulkMoveTasks($tasks, $params, $boardId);
2583 }
2584
2585 /**
2586 * Bulk archive tasks
2587 */
2588 private function bulkArchiveTasks($tasks)
2589 {
2590 $successfulTasks = [];
2591 $failedTasks = [];
2592
2593 foreach ($tasks as $task) {
2594 try {
2595 // Use the same logic as single task archiving
2596 $this->updateTaskProperty('archived_at', current_time('mysql'), $task);
2597
2598 // Reload task with all relationships
2599 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2600
2601 $successfulTasks[] = $task;
2602 } catch (\Exception $e) {
2603 $failedTasks[] = [
2604 'id' => $task->id,
2605 'title' => $task->title,
2606 'error' => $e->getMessage()
2607 ];
2608 }
2609 }
2610
2611 $successCount = count($successfulTasks);
2612 $failureCount = count($failedTasks);
2613
2614 $message = '';
2615 if ($failureCount === 0) {
2616 // translators: %d is the number of tasks archived successfully
2617 $message = sprintf(__('%d tasks archived successfully', 'fluent-boards'), $successCount);
2618 } elseif ($successCount === 0) {
2619 // translators: %d is the number of tasks that failed to archive
2620 $message = sprintf(__('Failed to archive %d tasks', 'fluent-boards'), $failureCount);
2621 } else {
2622 // translators: 1: number of tasks archived successfully; 2: number of tasks failed to archive
2623 $message = sprintf(__('%1$d tasks archived successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
2624 }
2625
2626 return [
2627 'successful_tasks' => $successfulTasks,
2628 'failed_tasks' => $failedTasks,
2629 'message' => $message
2630 ];
2631 }
2632
2633 /**
2634 * Bulk change task priority
2635 */
2636 private function bulkChangePriority($tasks, $params)
2637 {
2638 $priority = $params['priority'] ?? null;
2639
2640 // Get valid priorities including custom ones added by hooks
2641 $validPriorities = array_keys(apply_filters('fluent_boards/task_priorities', [
2642 'low' => __('Low', 'fluent-boards'),
2643 'medium' => __('Medium', 'fluent-boards'),
2644 'high' => __('High', 'fluent-boards')
2645 ]));
2646
2647 if (!in_array($priority, $validPriorities)) {
2648 throw new \Exception(esc_html__('Invalid priority level', 'fluent-boards'));
2649 }
2650
2651 $successfulTasks = [];
2652 $failedTasks = [];
2653
2654 foreach ($tasks as $task) {
2655 try {
2656 // Use the same logic as single task priority update
2657 $this->updateTaskProperty('priority', $priority, $task);
2658
2659 // Reload task with all relationships
2660 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2661
2662 $successfulTasks[] = $task;
2663 } catch (\Exception $e) {
2664 $failedTasks[] = [
2665 'id' => $task->id,
2666 'title' => $task->title,
2667 'error' => $e->getMessage()
2668 ];
2669 }
2670 }
2671
2672 $successCount = count($successfulTasks);
2673 $failureCount = count($failedTasks);
2674
2675 $message = '';
2676 if ($failureCount === 0) {
2677 // translators: %d is the number of tasks whose priorities were updated successfully
2678 $message = sprintf(__('%d task priorities updated successfully', 'fluent-boards'), $successCount);
2679 } elseif ($successCount === 0) {
2680 // translators: %d is the number of tasks whose priorities failed to update
2681 $message = sprintf(__('Failed to update %d task priorities', 'fluent-boards'), $failureCount);
2682 } else {
2683 // translators: 1: number of tasks priorities updated; 2: number of tasks priorities failed to update
2684 $message = sprintf(__('%1$d task priorities updated successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
2685 }
2686
2687 return [
2688 'successful_tasks' => $successfulTasks,
2689 'failed_tasks' => $failedTasks,
2690 'message' => $message
2691 ];
2692 }
2693
2694 /**
2695 * Bulk assign members to tasks
2696 */
2697 private function bulkAssignMembers($tasks, $params, $boardId)
2698 {
2699 $userIds = $params['user_ids'] ?? [];
2700 if (!is_array($userIds)) {
2701 throw new \Exception(esc_html__('User IDs must be an array', 'fluent-boards'));
2702 }
2703
2704 // Validate that all users are valid WordPress users
2705 $validUsers = get_users(['include' => $userIds]);
2706 $validUserIds = array_map(function($user) {
2707 return $user->ID;
2708 }, $validUsers);
2709
2710 if (count($validUserIds) !== count($userIds)) {
2711 throw new \Exception(esc_html__('Some user IDs are invalid', 'fluent-boards'));
2712 }
2713
2714 // Filter only users who are already board members (skip non-members)
2715 $boardService = new \FluentBoards\App\Services\BoardService();
2716 $boardMemberIds = [];
2717 foreach ($validUserIds as $userId) {
2718 if ($boardService->isAlreadyMember($boardId, $userId)) {
2719 $boardMemberIds[] = $userId;
2720 }
2721 }
2722
2723 // If no valid board members, skip assignment silently
2724 if (empty($boardMemberIds)) {
2725 return [
2726 'successful_tasks' => [],
2727 'failed_tasks' => [],
2728 'message' => __('No valid board members selected for assignment', 'fluent-boards')
2729 ];
2730 }
2731
2732 // Use only board members for assignment
2733 $validUserIds = $boardMemberIds;
2734
2735 $successfulTasks = [];
2736 $failedTasks = [];
2737
2738 foreach ($tasks as $task) {
2739 try {
2740 // Use pure "add-only" logic for bulk assignment - never remove existing assignees
2741 $currentAssigneeIds = $task->assignees->pluck('ID')->toArray();
2742 $newAssignees = [];
2743
2744 foreach ($validUserIds as $userId) {
2745 // Only add if not already assigned
2746 if (!in_array($userId, $currentAssigneeIds)) {
2747 $newAssignees[] = $userId;
2748 }
2749 }
2750
2751 // Add all new assignees at once
2752 if (!empty($newAssignees)) {
2753 $assigneeData = [];
2754 foreach ($newAssignees as $userId) {
2755 $assigneeData[$userId] = ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE];
2756 }
2757 $task->assignees()->syncWithoutDetaching($assigneeData);
2758
2759 // Add as watchers
2760 $watcherData = [];
2761 foreach ($newAssignees as $userId) {
2762 $watcherData[$userId] = ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH];
2763 }
2764 $task->watchers()->syncWithoutDetaching($watcherData);
2765
2766 // Send notifications and actions only for new assignees
2767 foreach ($newAssignees as $userId) {
2768 // Send email notification if enabled and not current user
2769 if ((new \FluentBoards\App\Services\NotificationService())->checkIfEmailEnable($userId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id) && $userId != get_current_user_id()) {
2770 $this->sendMailAfterTaskModify('add_assignee', $userId, $task->id);
2771 }
2772
2773 // Dispatch WordPress actions
2774 //currently commented, need to check in future for bulk action
2775 // do_action('fluent_boards/task_assignee_added', $task, $userId);
2776 // if ($userId != get_current_user_id()) {
2777 // do_action('fluent_boards/assign_another_user', $task, $userId);
2778 // }
2779 }
2780 }
2781
2782 // Update task timestamp and reload all relationships
2783 $task->updated_at = current_time('mysql');
2784 $task->save();
2785 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2786
2787 $successfulTasks[] = $task;
2788 } catch (\Exception $e) {
2789 $failedTasks[] = [
2790 'id' => $task->id,
2791 'title' => $task->title,
2792 'error' => $e->getMessage()
2793 ];
2794 }
2795 }
2796
2797 $successCount = count($successfulTasks);
2798 $failureCount = count($failedTasks);
2799
2800 $message = '';
2801 if ($failureCount === 0) {
2802 // translators: %d is the number of tasks where members were assigned successfully
2803 $message = sprintf(__('%d tasks assigned members successfully', 'fluent-boards'), $successCount);
2804 } elseif ($successCount === 0) {
2805 // translators: %d is the number of tasks where assigning members failed
2806 $message = sprintf(__('Failed to assign members to %d tasks', 'fluent-boards'), $failureCount);
2807 } else {
2808 // translators: 1: number of tasks with members assigned successfully; 2: number of tasks where assigning members failed
2809 $message = sprintf(__('%1$d tasks assigned members successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
2810 }
2811
2812 return [
2813 'successful_tasks' => $successfulTasks,
2814 'failed_tasks' => $failedTasks,
2815 'message' => $message
2816 ];
2817 }
2818
2819 /**
2820 * Bulk add labels to tasks
2821 */
2822 private function bulkAddLabels($tasks, $params, $boardId)
2823 {
2824 $labelIds = $params['label_ids'] ?? [];
2825 if (!is_array($labelIds)) {
2826 throw new \Exception(esc_html__('Label IDs must be an array', 'fluent-boards'));
2827 }
2828
2829 // Validate that all labels exist and belong to the board
2830 $validLabels = \FluentBoards\App\Models\Label::whereIn('id', $labelIds)
2831 ->where('board_id', $boardId)
2832 ->whereNull('archived_at')
2833 ->get();
2834
2835 if (count($validLabels) !== count($labelIds)) {
2836 throw new \Exception(esc_html__('Some label IDs are invalid or do not belong to this board', 'fluent-boards'));
2837 }
2838
2839 $successfulTasks = [];
2840 $failedTasks = [];
2841
2842 foreach ($tasks as $task) {
2843 try {
2844 // Load existing labels first to avoid query issues
2845 $task->load('labels');
2846 $existingLabelIds = $task->labels->pluck('id')->toArray();
2847
2848 // Use the same logic as single task label adding
2849 foreach ($validLabels as $label) {
2850 // Check if label is already attached
2851 if (!in_array($label->id, $existingLabelIds)) {
2852 // Add the label using syncWithoutDetaching to avoid duplicates
2853 $task->labels()->syncWithoutDetaching([
2854 $label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]
2855 ]);
2856
2857 // Dispatch WordPress action for label addition
2858 //currently commented, need to check in future for bulk action
2859 // do_action('fluent_boards/task_label', $task, $label, 'added');
2860 }
2861 }
2862
2863 // Reload the task with all relationships
2864 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2865
2866 $successfulTasks[] = $task;
2867 } catch (\Exception $e) {
2868 $failedTasks[] = [
2869 'id' => $task->id,
2870 'title' => $task->title,
2871 'error' => $e->getMessage()
2872 ];
2873 }
2874 }
2875
2876 $successCount = count($successfulTasks);
2877 $failureCount = count($failedTasks);
2878
2879 $message = '';
2880 if ($failureCount === 0) {
2881 // translators: %d is the number of tasks labeled successfully
2882 $message = sprintf(__('%d tasks labeled successfully', 'fluent-boards'), $successCount);
2883 } elseif ($successCount === 0) {
2884 // translators: %d is the number of tasks that failed to label
2885 $message = sprintf(__('Failed to label %d tasks', 'fluent-boards'), $failureCount);
2886 } else {
2887 // translators: 1: number of tasks labeled successfully; 2: number of tasks failed to label
2888 $message = sprintf(__('%1$d tasks labeled successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
2889 }
2890
2891 return [
2892 'successful_tasks' => $successfulTasks,
2893 'failed_tasks' => $failedTasks,
2894 'message' => $message
2895 ];
2896 }
2897
2898 /* Delete time tracking records for one or multiple tasks
2899 * Uses try-catch for better performance - avoids table existence check overhead
2900 *
2901 * @param int|array $taskIds Single task ID or array of task IDs
2902 * @return void
2903 */
2904 public function deleteTimeTrackingRecords($taskIds)
2905 {
2906 // Check if FluentBoards Pro time tracking is available
2907 if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
2908 return;
2909 }
2910
2911 try {
2912 // Handle single task ID or array of task IDs
2913 if (is_array($taskIds)) {
2914 if (!empty($taskIds)) {
2915 \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete();
2916 }
2917 } else {
2918 if (is_numeric($taskIds) && $taskIds > 0) {
2919 \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
2920 }
2921 }
2922 } catch (\Exception $e) {
2923 // Silently fail if table doesn't exist or any other error occurs
2924 // This is intentional for cleanup operations
2925 }
2926 }
2927
2928 }
2929