PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 1.95.3
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v1.95.3
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.3, at app/Services/TaskService.php

2,943 lines 109.8 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('users.ID', $commonIds);
1450 })->orWhereHas('watchers', function ($watcherQuery) use ($commonIds) {
1451 $watcherQuery->whereIn('users.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('users.ID', $assigneeOnlyIds);
1465 });
1466 }
1467 });
1468 }
1469
1470 if ($watcherOnlyIds) {
1471 $tasksQuery->whereHas('watchers', function ($watcherQuery) use ($watcherOnlyIds) {
1472 $watcherQuery->whereIn('users.ID', $watcherOnlyIds);
1473 })->whereDoesntHave('assignees', function ($assigneeQuery) use ($watcherOnlyIds) {
1474 $assigneeQuery->whereIn('users.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 $isAdmin = PermissionManager::isAdmin($userId);
1653 $editableBoardIds = [];
1654
1655 $tasksQuery = Task::query()
1656 ->where('crm_contact_id', $associatedId)
1657 ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
1658 ->orderBy('due_at', 'ASC');
1659
1660 if ($isAdmin) {
1661 $tasksQuery->whereHas('board', function ($query) {
1662 $query->whereNull('archived_at');
1663 });
1664 } else {
1665 $editableBoardIds = array_map('intval', PermissionManager::getBoardIdsForUser($userId));
1666
1667 if (!$editableBoardIds) {
1668 return [];
1669 }
1670
1671 $tasksQuery->whereIn('board_id', $editableBoardIds);
1672 }
1673
1674 $tasks = $tasksQuery->get();
1675
1676 foreach ($tasks as $task) {
1677 $task->isOverdue = $task->isOverdue();
1678 $task->isUpcoming = $task->upcoming();
1679 $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
1680
1681 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1682
1683 foreach ($task->subtaskGroup as $group) {
1684 foreach ($group->subtasks as $subtask) {
1685 $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
1686 }
1687 }
1688 $task->subtask_group = $task->subtaskGroup;
1689 }
1690
1691 return $tasks;
1692 }
1693
1694 public function copySubtaskGroup($task, $newTask, $subtaskGroupMap)
1695 {
1696 $subtaskGroups = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_NAME)->get();
1697 foreach ($subtaskGroups as $group) {
1698 $newGroup = TaskMeta::create([
1699 'task_id' => $newTask->id,
1700 'key' => Constant::SUBTASK_GROUP_NAME,
1701 'value' => $group->value
1702 ]);
1703
1704 $subtaskGroupMap[$group->id] = $newGroup->id;
1705 }
1706
1707 return $subtaskGroupMap;
1708 }
1709
1710 public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
1711 {
1712 $allActiveTasks = Task::where('board_id', $boardId)->whereNull('archived_at')->get();
1713 $taskMap = [];
1714 $subtaskGroupMap = [];
1715 $parentTaskCount = 0;
1716 $attachmentFileService = new AttachmentFileService();
1717 $dbInstance = App::getInstance('db');
1718
1719 $dbInstance->beginTransaction();
1720
1721 try {
1722 foreach ($allActiveTasks as $task) {
1723 $newTask = array();
1724 $newTask['title'] = $task->title;
1725 $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
1726 $newTask['description'] = $task->description;
1727 $newTask['board_id'] = $newBoard->id;
1728 $newTask['stage_id'] = $stageMap[$task->stage_id];
1729 $newTask['status'] = $task->status;
1730 $newTask['priority'] = $task->priority;
1731 $newTask['position'] = $task->position;
1732 $newTask['due_at'] = $task->due_at;
1733 $backgroundColor = $task->settings['cover']['backgroundColor'] ?? '';
1734 $newTask['settings'] = [
1735 'cover' => [
1736 'backgroundColor' => $backgroundColor,
1737 ]
1738 ];
1739
1740 $newTask = Task::create($newTask);
1741 $attachmentFileService->cloneTaskFilesToBoard($task, $newTask, $newBoard->id);
1742
1743 if (!$task->parent_id) {
1744 //group mapping
1745 $subtaskGroupMap = $this->copySubtaskGroup($task, $newTask, $subtaskGroupMap);
1746 } else {
1747 $groupRelationOfTask = TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
1748 ->where('task_id', $task->id)
1749 ->first();
1750
1751 if ($groupRelationOfTask && $subtaskGroupMap[$groupRelationOfTask->value]) {
1752 TaskMeta::create([
1753 'task_id' => $newTask->id,
1754 'key' => Constant::SUBTASK_GROUP_CHILD,
1755 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
1756 ]);
1757 }
1758 }
1759
1760 if($isWithTemplates == 'yes') {
1761 $isTemplate = TaskMeta::where('task_id', $task->id)
1762 ->where('key', 'is_template')
1763 ->first();
1764 if($isTemplate) {
1765 TaskMeta::create([
1766 'task_id' => $newTask->id,
1767 'key' => 'is_template',
1768 'value' => $isTemplate->value
1769 ]);
1770 }
1771 }
1772 if(!$task->parent_id){
1773 ++$parentTaskCount;
1774 $taskMap[$task['id']] = $newTask->id;
1775 //duplicate labels to task
1776 $labelIds = $task->labels->pluck('id')->toArray();
1777 if($labelIds){
1778 $flipLabelIds = array_flip($labelIds);
1779 $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
1780
1781 $newTask->labels()->attach($labelsToAttach, [
1782 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
1783 ]);
1784 }
1785 }
1786 }
1787
1788 $board = Board::findOrFail($newBoard->id);
1789 $settings = [];
1790 $settings['tasks_count'] = $parentTaskCount;
1791 $board->settings = $settings;
1792 $board->save();
1793
1794 $dbInstance->commit();
1795 } catch (\Exception $e) {
1796 $dbInstance->rollBack();
1797 $attachmentFileService->rollbackCreatedFiles();
1798 throw $e;
1799 }
1800 }
1801
1802 private function subtaskCountUpdate($taskId){
1803 $parentTask = Task::findOrFail($taskId);
1804 $settings = $parentTask->settings;
1805 $settings['subtask_count'] = (int)($settings['subtask_count'] ?? 0) + 1;
1806 $parentTask->settings = $settings;
1807 $parentTask->save();
1808 }
1809
1810 /**
1811 * @param $taskId
1812 * @param $perPage
1813 * @param $offset
1814 * @param string $filter
1815 * @return array
1816 */
1817 public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null): array
1818 {
1819 // Fetch the task
1820 $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
1821
1822 // Fetch comments and activities separately
1823 $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
1824 $activities = $task->activities()
1825 ->with('user')
1826 ->where(function($query) {
1827 $query->whereNotIn('column', [ 'comment', 'a reply'])
1828 ->orWhere(function($subQuery) {
1829 $subQuery->whereNotIn('action', ['added', 'updated']);
1830 });
1831 })
1832 ->orderBy('created_at', 'desc')
1833 ->get()
1834 ->toArray();
1835
1836
1837
1838 // Merge comments and activities into a single array
1839 $commentsAndActivities = array_merge($comments, $activities);
1840
1841 // Sort the merged array by created_at date in ascending or descending order
1842 $order = $filter == 'newest' ? -1 : 1;
1843 usort($commentsAndActivities, function ($a, $b) use ($order) {
1844 return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
1845 });
1846
1847 // Paginate the results
1848 $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
1849 $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
1850
1851 // Get the total count of comments and activities
1852 $total = count($commentsAndActivities);
1853 $lastPage = (int) ceil($total / $perPage);
1854
1855 // Construct pagination metadata
1856 $path = "https://wordpress.test/wp-json/fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities";
1857 return [
1858 'current_page' => (int) $page,
1859 'data' => $paginatedResults,
1860 'first_page_url' => "{$path}?page=1",
1861 'from' => $total > 0 ? (int) ($offset + 1) : null,
1862 'last_page' => (int) $lastPage,
1863 'last_page_url' => "{$path}?page={$lastPage}",
1864 'links' => [
1865 [
1866 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
1867 'label' => 'pagination.previous',
1868 'active' => false
1869 ],
1870 [
1871 'url' => "{$path}?page={$page}",
1872 'label' => (int) $page,
1873 'active' => true
1874 ],
1875 [
1876 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
1877 'label' => 'pagination.next',
1878 'active' => false
1879 ]
1880 ],
1881 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
1882 'path' => $path,
1883 'per_page' => (int) $perPage,
1884 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
1885 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
1886 'total' => (int) $total
1887 ];
1888 }
1889
1890 /**
1891 * @param $task_id
1892 * @param $fileData
1893 * @param $type
1894 * @return Attachment
1895 */
1896 public function uploadMediaFileFromWpEditor($task_id, $fileData, $type)
1897 {
1898 $initialDataData = [
1899 'type' => 'url',
1900 'url' => '',
1901 'name' => '',
1902 'size' => 0,
1903 ];
1904
1905 $attachData = array_merge($initialDataData, $fileData);
1906 $UrlMeta = [];
1907 if($attachData['type'] == 'url') {
1908 $UrlMeta = RemoteUrlParser::parse($attachData['url']);
1909 }
1910 $attachment = new TaskImage();
1911 $attachment->object_id = $task_id;
1912 $attachment->object_type = $type;
1913 $attachment->attachment_type = $attachData['type'];
1914 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
1915 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
1916 $attachment->full_url = esc_url($attachData['url']);
1917 $attachment->file_size = $attachData['size'];
1918 $attachment->settings = $attachData['type'] == 'url' ? [
1919 'meta' => $UrlMeta
1920 ] : '';
1921 $attachment->driver = 'local';
1922 $attachment->save();
1923 return $attachment;
1924 }
1925
1926
1927 /**
1928 * @param $type
1929 * @param $title
1930 * @param $UrlMeta
1931 * @return mixed|string
1932 */
1933 public function setTitle($type, $title, $UrlMeta)
1934 {
1935 if($type != 'url') {
1936 return sanitize_file_name($title);
1937 }
1938 return $title ?? $UrlMeta['title'] ?? '';
1939 }
1940
1941 public function manageDefaultAssignees($task, $stageId)
1942 {
1943 $stage = Stage::findOrFail($stageId);
1944 if ($stage && isset($stage->settings['default_task_assignees'])) {
1945 $defaultAssignees = $stage->settings['default_task_assignees'];
1946 foreach ($defaultAssignees as $assigneeId) {
1947 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
1948 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
1949 if (!$IfAlreadyAssignee) {
1950 $this->updateAssignee($assigneeId, $task);
1951 }
1952 }
1953 }
1954 }
1955
1956 public function manageDefaultWatchers($task, $stageId)
1957 {
1958 $stage = Stage::findOrFail($stageId);
1959 if ($stage) {
1960 $settings = $stage->settings;
1961 $defaultWatchers = [];
1962 if (isset($settings['default_task_watchers']) && is_array($settings['default_task_watchers'])) {
1963 $defaultWatchers = $settings['default_task_watchers'];
1964 }
1965 if (isset($settings['default_task_assignees']) && is_array($settings['default_task_assignees'])) {
1966 $defaultWatchers = array_unique(array_merge($defaultWatchers, $settings['default_task_assignees']));
1967 }
1968 foreach ($defaultWatchers as $watcherId) {
1969 $alreadyWatcherIds = $task->watchers->pluck('ID')->toArray();
1970 $isAlreadyWatcher = in_array($watcherId, $alreadyWatcherIds);
1971 if (!$isAlreadyWatcher) {
1972 $task->watchers()->syncWithoutDetaching([
1973 $watcherId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]
1974 ]);
1975 }
1976 }
1977 }
1978 }
1979
1980 public function setDefaultAssigneesToEveryTasks($stage)
1981 {
1982 $tasks = $stage->tasks->whereNull('archived_at');
1983 foreach ($tasks as $task) {
1984 $this->manageDefaultAssignees($task, $stage->id);
1985 }
1986 }
1987
1988 public function createTaskFromImage($board_id, $stage_id, $uploadInfo, $file)
1989 {
1990
1991 $board = Board::find($board_id);
1992 $stage = Stage::where('id', absint($stage_id))
1993 ->where('board_id', absint($board_id))
1994 ->first();
1995
1996 if (!$board || !$stage) {
1997 throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
1998 }
1999
2000 $task = new Task();
2001 $taskType = $board->type === 'to-do' ? 'task' : 'roadmap' ;
2002 $taskData = [
2003 'title' => $uploadInfo[0]['name'],
2004 'board_id' => $board_id,
2005 'stage_id' => $stage_id,
2006 'type' => $taskType,
2007 ];
2008 $task->fill($taskData);
2009 $task->save();
2010
2011 $fileData = $uploadInfo[0];
2012 $fileUploadedData = $this->uploadMediaFileFromWpEditor($task->id, $fileData, Constant::TASK_DESCRIPTION);
2013 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
2014 $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
2015 $fileUploadedData['driver'] = $mediaData['driver'];
2016 $fileUploadedData['file_path'] = $mediaData['file_path'];
2017 $fileUploadedData['full_url'] = $mediaData['full_url'];
2018 $fileUploadedData->save();
2019 }
2020
2021 $settings = $task->settings;
2022 $settings['cover'] = [
2023 'imageId' => $fileUploadedData['id'],
2024 'backgroundImage' => (new CommentService())->createPublicUrl($fileUploadedData, $board_id),
2025 ];
2026 $task->settings = $settings;
2027 $task = $task->moveToNewPosition(1);
2028 $task->save();
2029 $task->load(['board', 'stage', 'labels', 'assignees']);
2030
2031 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
2032
2033 $task->isOverdue = $task->isOverdue();
2034 $task->contact = Task::lead_contact($task->crm_contact_id);
2035 $task->board->stages = (new StageService())->stagesByBoardId($board_id);
2036 $task->is_watching = (new NotificationService())->isCurrentUserObservingTask($task);
2037
2038 $task = $this->loadNextStage($task);
2039
2040 if ($task->type == 'roadmap') {
2041 $task->vote_statistics = $this->getIdeaVoteStatistics($task->id);
2042 }
2043
2044 return $task;
2045 }
2046 public function deleteTaskCoverImage($settings)
2047 {
2048 if (isset($settings['cover']['imageId']) && $settings['cover']['imageId']) {
2049 $image = TaskImage::find($settings['cover']['imageId']);
2050 if ($image) {
2051 $deletedImage = clone $image;
2052 $deletedImage->delete();
2053
2054 do_action('fluent_boards/task_attachment_deleted', $deletedImage);
2055 }
2056 }
2057
2058 }
2059
2060 private function deleteTaskAttachments($task)
2061 {
2062 $attachments = TaskAttachment::where('object_id', $task->id)
2063 ->where('object_type', Constant::TASK_ATTACHMENT)
2064 ->get();
2065 foreach ($attachments as $attachment) {
2066 $deletedAttachment = clone $attachment;
2067 $attachment->delete();
2068
2069 do_action('fluent_boards/task_attachment_deleted', $deletedAttachment);
2070 }
2071 }
2072
2073 public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2074 {
2075 global $wpdb;
2076 $attachmentFileService = new AttachmentFileService();
2077
2078 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2079 $wpdb->query('START TRANSACTION');
2080
2081 try {
2082 // Load task with all necessary relationships
2083 $taskQuery = Task::with([
2084 'assignees',
2085 'labels',
2086 'watchers',
2087 ])->where('id', $taskId);
2088
2089 if ($boardId) {
2090 $taskQuery->where('board_id', absint($boardId));
2091 }
2092
2093 $task = $taskQuery->first();
2094
2095 if (!$task) {
2096 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
2097 }
2098
2099 // Create new task with cloned data
2100 $clonedTask = $task->replicate();
2101 $clonedTask->title = $taskData['title'] ?? $task->title . ' (' . \__('cloned', 'fluent-boards') . ')';
2102
2103 $settings = $clonedTask->settings ?? [];
2104
2105 unset(
2106 $settings['attachment_count'],
2107 $settings['subtask_completed_count'],
2108 $settings['subtask_count']
2109 );
2110 $clonedTask->settings = $settings;
2111 $clonedTask->stage_id = $taskData['stage_id'] ?? $task->stage_id;
2112
2113 // Validate that target stage belongs to the same board
2114 $targetStage = Stage::where('id', $clonedTask->stage_id)
2115 ->where('board_id', $task->board_id)
2116 ->first();
2117
2118 if (!$targetStage) {
2119 throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
2120 }
2121
2122 $clonedTask->board_id = $targetStage->board_id;
2123
2124 $clonedTask->comments_count = 0; // Reset comments count for cloned task
2125 $clonedTask->save();
2126
2127 $positionIndex = 1; // Default position index for new task
2128 if($task->stage_id === $clonedTask->stage_id) {
2129 // Calculate position for the cloned task next to original task
2130 $positionIndex = $this->calculateClonedTaskPosition($task);
2131 }
2132 // Move cloned task to the new position
2133 $clonedTask->moveToNewPosition($positionIndex);
2134
2135 $this->cloneTaskMeta($task, $clonedTask);
2136
2137 $this->cloneTaskCustomFields($task, $clonedTask);
2138
2139 // Apply stage default assignees if any are set
2140 $this->manageDefaultAssignees($clonedTask, $clonedTask->stage_id);
2141
2142 if($taskData['assignee']) {
2143 $this->cloneAssignees($task, $clonedTask);
2144 }
2145 if($taskData['label']) {
2146 $this->cloneTaskLabels($task, $clonedTask);
2147 }
2148 $this->cloneTaskWatchers($task, $clonedTask);
2149
2150 $attachmentFileService->cloneTaskFilesToBoard($task, $clonedTask, $clonedTask->board_id, [
2151 'description_images' => true,
2152 'cover' => true,
2153 'task_attachments' => (bool) $taskData['attachment'],
2154 ]);
2155
2156 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
2157 // Clone time tracking data if Pro version is active
2158 if ($taskData['subtask']) {
2159 $this->cloneSubtasks($task, $clonedTask, (bool) $taskData['attachment'], $attachmentFileService);
2160 }
2161 }
2162
2163 if($taskData['comment']) {
2164 $this->cloneCommentsAndReplies($task, $clonedTask);
2165 }
2166
2167 // Load and prepare the cloned task for response
2168 $clonedTask = $this->prepareClonedTaskForResponse($clonedTask);
2169 do_action('fluent_boards/task_cloned', $task, $clonedTask);
2170
2171 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2172 $wpdb->query('COMMIT');
2173 return $clonedTask;
2174
2175 } catch (\Exception $e) {
2176 $attachmentFileService->rollbackCreatedFiles();
2177 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2178 $wpdb->query('ROLLBACK');
2179 throw new \Exception(
2180 esc_html(\__('Failed to clone task: ', 'fluent-boards') . $e->getMessage()),
2181 (int) ($e->getCode() ?: 500)
2182 );
2183 }
2184 }
2185
2186 private function calculateClonedTaskPosition(Task $originalTask): int
2187 {
2188 $tasks = Task::where('stage_id', $originalTask->stage_id)
2189 ->whereNull('archived_at')
2190 ->orderBy('position', 'asc')
2191 ->get();
2192
2193 $index = $tasks->search(function($task) use ($originalTask) {
2194 return $task->id === $originalTask->id;
2195 });
2196
2197 return $index !== false ? $index + 2 : 1; // Return 1-based index
2198 }
2199
2200 private function cloneTaskMeta(Task $originalTask, Task $clonedTask): void
2201 {
2202 $taskMetas = TaskMeta::where('task_id', $originalTask->id)
2203 ->where('key', '!=', Constant::SUBTASK_GROUP_NAME)
2204 ->get();
2205 foreach ($taskMetas as $meta) {
2206 TaskMeta::create([
2207 'task_id' => $clonedTask->id,
2208 'key' => $meta->key,
2209 'value' => $meta->value
2210 ]);
2211 }
2212 }
2213
2214 private function cloneAssignees($originalTask, $clonedTask)
2215 {
2216 // Clone assignees
2217 if ($originalTask->assignees) {
2218 foreach ($originalTask->assignees as $assignee) {
2219 $clonedTask->assignees()->syncWithoutDetaching([$assignee->ID => ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]]);
2220 }
2221 }
2222 }
2223
2224 private function cloneTaskLabels(Task $originalTask, Task $clonedTask): void
2225 {
2226 // Clone labels
2227 if ($originalTask->labels) {
2228 foreach ($originalTask->labels as $label) {
2229 $clonedTask->labels()->syncWithoutDetaching([$label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]]);
2230 }
2231 }
2232 }
2233
2234 private function cloneTaskWatchers(Task $originalTask, Task $clonedTask): void
2235 {
2236 /// Clone watchers
2237 if ($originalTask->watchers) {
2238 foreach ($originalTask->watchers as $watcher) {
2239 $clonedTask->watchers()->syncWithoutDetaching([$watcher->ID => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
2240 }
2241 }
2242 }
2243 private function cloneTaskCustomFields(Task $originalTask, Task $clonedTask): void
2244 {
2245
2246 // Clone custom fields
2247 if ($originalTask->taskCustomFields) {
2248 foreach ($originalTask->taskCustomFields as $customField) {
2249 $clonedField = $customField->replicate();
2250 $clonedField->object_id = $clonedTask->id;
2251 $clonedField->save();
2252 }
2253 }
2254 }
2255 private function cloneAttachments(Task $originalTask, Task $clonedTask): void
2256 {
2257 $attachments = $originalTask->attachments;
2258 foreach ($attachments as $attachment) {
2259 $clonedAttachment = $attachment->replicate();
2260 $clonedAttachment->object_id = $clonedTask->id;
2261 $clonedAttachment->save();
2262
2263 // If this is a cover image, update task settings
2264 if ($attachment->type === 'cover_image') {
2265 $settings = $clonedTask->settings;
2266 if (isset($settings['cover_image'])) {
2267 $settings['cover_image'] = $clonedAttachment->id;
2268 $clonedTask->settings = $settings;
2269 $clonedTask->save();
2270 }
2271 }
2272 }
2273 $settings = $clonedTask->settings;
2274 $settings['attachment_count'] = $clonedTask->attachments()->count();
2275 $clonedTask['settings'] = $settings;
2276 $clonedTask->save();
2277 }
2278 private function cloneCommentsAndReplies(Task $originalTask, Task $clonedTask)
2279 {
2280 // Get comments ordered by created_at
2281 $comments = Comment::where('task_id', $originalTask->id)
2282 ->where('type', 'comment')
2283 ->whereNull('parent_id')
2284 ->orderBy('created_at', 'asc')
2285 ->get();
2286
2287 if ($comments->isEmpty()) {
2288 return;
2289 }
2290
2291 foreach ($comments as $comment) {
2292 $clonedComment = $comment->replicate();
2293 $clonedComment->task_id = $clonedTask->id;
2294 $clonedComment->save();
2295
2296 // Get replies ordered by created_at
2297 $replies = Comment::where('parent_id', $comment->id)
2298 ->where('type', 'reply')
2299 ->orderBy('created_at', 'asc')
2300 ->get();
2301
2302 foreach ($replies as $reply) {
2303 $clonedReply = $reply->replicate();
2304 $clonedReply->task_id = $clonedTask->id;
2305 $clonedReply->parent_id = $clonedComment->id;
2306 $clonedReply->save();
2307
2308 // Clone reply image if any
2309 $this->cloneCommentOrReplyImage($reply, $clonedReply);
2310 }
2311
2312 // Clone comment image if any
2313 $this->cloneCommentOrReplyImage($comment, $clonedComment);
2314 }
2315 return;
2316 }
2317 private function cloneCommentOrReplyImage($oldCommentOrReply, $clonedCommentOrReply)
2318 {
2319 $images = CommentImage::where('object_id', $oldCommentOrReply->id)
2320 ->where('object_type', Constant::COMMENT_IMAGE)
2321 ->orderBy('created_at', 'asc')
2322 ->get();
2323
2324 if ($images->count() > 0) {
2325 foreach ($images as $image) {
2326 $clonedImage = $image->replicate();
2327 $clonedImage->object_id = $clonedCommentOrReply->id;
2328 $clonedImage->save();
2329 }
2330 }
2331 }
2332 private function cloneSubtasks(Task $originalTask, Task $clonedTask, bool $cloneAttachments = false, ?AttachmentFileService $attachmentFileService = null): void
2333 {
2334 // First clone subtask groups
2335 $subtaskGroupMap = $this->cloneSubtaskGroups($originalTask, $clonedTask);
2336 $completedSubtasksCount = 0;
2337
2338 if ($originalTask->subtasks) {
2339 foreach ($originalTask->subtasks as $subtask) {
2340 $clonedSubtask = $subtask->replicate();
2341 $clonedSubtask->parent_id = $clonedTask->id;
2342 $clonedSubtask->board_id = $clonedTask->board_id; // Ensure subtask has same board_id as parent
2343 $clonedSubtask->save();
2344 $attachmentFileService = $attachmentFileService ?: new AttachmentFileService();
2345 $attachmentFileService->cloneTaskFilesToBoard($subtask, $clonedSubtask, $clonedTask->board_id, [
2346 'description_images' => true,
2347 'cover' => true,
2348 'task_attachments' => $cloneAttachments,
2349 ]);
2350 if($clonedSubtask->status == 'closed') {
2351 $completedSubtasksCount++;
2352 }
2353
2354 // Update subtask group relationship if exists
2355 $groupRelation = TaskMeta::where('task_id', $subtask->id)
2356 ->where('key', Constant::SUBTASK_GROUP_CHILD)
2357 ->first();
2358
2359 if ($groupRelation && isset($subtaskGroupMap[$groupRelation->value])) {
2360 TaskMeta::create([
2361 'task_id' => $clonedSubtask->id,
2362 'key' => Constant::SUBTASK_GROUP_CHILD,
2363 'value' => $subtaskGroupMap[$groupRelation->value]
2364 ]);
2365 }
2366 }
2367 }
2368 $settings = $clonedTask->settings;
2369 $settings['subtask_count'] = $clonedTask->subtasks()->count();
2370 $clonedTask['settings'] = $settings;
2371 $clonedTask->settings['subtask_completed_count'] = $completedSubtasksCount;
2372 $clonedTask->save();
2373 }
2374 private function cloneSubtaskGroups(Task $originalTask, Task $clonedTask): array
2375 {
2376 $subtaskGroupMap = [];
2377
2378 if ($originalTask->subtaskGroup) {
2379 foreach ($originalTask->subtaskGroup as $group) {
2380 $clonedGroup = TaskMeta::create([
2381 'task_id' => $clonedTask->id,
2382 'key' => Constant::SUBTASK_GROUP_NAME,
2383 'value' => $group->value
2384 ]);
2385
2386 $subtaskGroupMap[$group->id] = $clonedGroup->id;
2387 }
2388 }
2389
2390 return $subtaskGroupMap;
2391 }
2392 private function prepareClonedTaskForResponse(Task $clonedTask): Task
2393 {
2394 // Load relationships
2395 $clonedTask->load(['board', 'stage', 'labels', 'assignees', 'subtasks']);
2396
2397 // Sanitize assignees
2398 $clonedTask->assignees = Helper::sanitizeUserCollections($clonedTask->assignees);
2399
2400 // Set additional properties
2401 $clonedTask->isOverdue = $clonedTask->isOverdue();
2402 $clonedTask->contact = Task::lead_contact($clonedTask->crm_contact_id);
2403 $clonedTask->board->stages = (new StageService())->stagesByBoardId($clonedTask->board_id);
2404 $clonedTask->is_watching = (new NotificationService())->isCurrentUserObservingTask($clonedTask);
2405
2406 // Load next stage if applicable
2407 return $this->loadNextStage($clonedTask);
2408 }
2409
2410 /**
2411 * Clean up archived_by_stage metadata if it exists for a task
2412 *
2413 * @param int $taskId
2414 * @return void
2415 */
2416 private function cleanupArchivedByStageMetaIfExists($taskId)
2417 {
2418 TaskMeta::where('task_id', $taskId)
2419 ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
2420 ->delete();
2421 }
2422
2423 /**
2424 * Handle bulk actions for multiple tasks
2425 *
2426 * @param array $taskIds
2427 * @param string $action
2428 * @param array $params
2429 * @param int $boardId
2430 * @return array
2431 * @throws \Exception
2432 */
2433 public function bulkActions($taskIds, $action, $params, $boardId)
2434 {
2435 if (empty($taskIds) || !is_array($taskIds)) {
2436 throw new \Exception(esc_html__('No tasks selected', 'fluent-boards'));
2437 }
2438
2439 if (count($taskIds) > 150) {
2440 throw new \Exception(esc_html__('Cannot process more than 150 tasks at once. Please select fewer tasks.', 'fluent-boards'));
2441 }
2442
2443 if (empty($action)) {
2444 throw new \Exception(esc_html__('No action specified', 'fluent-boards'));
2445 }
2446
2447 $tasks = Task::whereIn('id', $taskIds)
2448 ->where('board_id', $boardId)
2449 ->get();
2450
2451 if ($tasks->isEmpty()) {
2452 throw new \Exception(esc_html__('No valid tasks found', 'fluent-boards'));
2453 }
2454
2455 $result = [
2456 'successful_tasks' => [],
2457 'failed_tasks' => [],
2458 'message' => ''
2459 ];
2460
2461 switch ($action) {
2462 case 'move_tasks':
2463 $result = $this->bulkMoveTasks($tasks, $params, $boardId);
2464 break;
2465
2466 case 'move_to_stage':
2467 // Backward compatibility - redirect to move_tasks
2468 $result = $this->bulkMoveTasks($tasks, $params, $boardId);
2469 break;
2470
2471 case 'archive_tasks':
2472 $result = $this->bulkArchiveTasks($tasks);
2473 break;
2474
2475 case 'change_priority':
2476 $result = $this->bulkChangePriority($tasks, $params);
2477 break;
2478
2479 case 'assign_members':
2480 $result = $this->bulkAssignMembers($tasks, $params, $boardId);
2481 break;
2482
2483 case 'add_labels':
2484 $result = $this->bulkAddLabels($tasks, $params, $boardId);
2485 break;
2486
2487 default:
2488 throw new \Exception(esc_html__('Invalid action specified', 'fluent-boards'));
2489 }
2490
2491 // Dispatch WordPress action for other plugins to hook into
2492 do_action('fluent_boards/bulk_action_completed', $action, $tasks, $boardId);
2493
2494 return $result;
2495 }
2496
2497 /**
2498 * Bulk move tasks to a stage (same board) or to another board
2499 * Unified method that handles both same-board stage moves and cross-board moves
2500 */
2501 private function bulkMoveTasks($tasks, $params, $sourceBoardId)
2502 {
2503 $targetStageId = $params['target_stage_id'] ?? null;
2504 if (!$targetStageId) {
2505 throw new \Exception(esc_html__('Target stage ID is required', 'fluent-boards'));
2506 }
2507
2508 $targetBoardId = $params['target_board_id'] ?? null;
2509 $isMovingToAnotherBoard = $targetBoardId && $targetBoardId != $sourceBoardId;
2510
2511 // Determine effective target board ID
2512 $effectiveTargetBoardId = $isMovingToAnotherBoard ? $targetBoardId : $sourceBoardId;
2513
2514 // Validate target board exists and is not archived
2515 $targetBoard = Board::find($effectiveTargetBoardId);
2516 if (!$targetBoard) {
2517 throw new \Exception(esc_html__('Target board not found', 'fluent-boards'));
2518 }
2519
2520 if ($targetBoard->archived_at) {
2521 throw new \Exception(esc_html__('Cannot move tasks to an archived board', 'fluent-boards'));
2522 }
2523
2524 // Verify user has write access to target board if moving to different board
2525 if ($isMovingToAnotherBoard && !PermissionManager::userHasBoardPermission($effectiveTargetBoardId, 'POST')) {
2526 throw new \Exception(esc_html__('You do not have permission to add tasks to this board', 'fluent-boards'));
2527 }
2528
2529 // Validate target stage exists and belongs to target board
2530 $targetStage = Stage::where('id', $targetStageId)
2531 ->where('board_id', $effectiveTargetBoardId)
2532 ->first();
2533
2534 if (!$targetStage) {
2535 throw new \Exception(esc_html__('Target stage not found in the selected board', 'fluent-boards'));
2536 }
2537
2538 $successfulTasks = [];
2539
2540 foreach ($tasks as $task) {
2541 if ($isMovingToAnotherBoard) {
2542 // Cross-board move - use existing method for data cleanup and security
2543 $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2544 $task->stage_id = $targetStageId;
2545 $task = $task->moveToNewPosition(null);
2546 } else {
2547 // Same board - simple stage move
2548 $oldStageId = $task->stage_id;
2549 $task->stage_id = $targetStageId;
2550 $task = $task->moveToNewPosition(1);
2551
2552 // Only process stage-specific logic if stage actually changed
2553 if ($oldStageId != $targetStageId) {
2554 $this->manageDefaultAssignees($task, $targetStageId);
2555
2556 $defaultPosition = $task->stage->defaultTaskStatus();
2557 if ($defaultPosition == 'closed' && $task->status != 'closed') {
2558 $task = $task->close();
2559 }
2560
2561 $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
2562 $this->sendMailAfterTaskModify('stage_change', $usersToSendEmail, $task->id);
2563 }
2564 }
2565
2566 // Reload task with all relationships
2567 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2568
2569 $successfulTasks[] = $task;
2570 }
2571
2572 $successCount = count($successfulTasks);
2573
2574 if ($isMovingToAnotherBoard) {
2575 // translators: %d is the number of tasks successfully moved to another board
2576 $message = sprintf(__('%d tasks moved to new board successfully', 'fluent-boards'), $successCount);
2577 } else {
2578 // translators: %d is the number of tasks successfully moved to the stage
2579 $message = sprintf(__('%d tasks moved to stage successfully', 'fluent-boards'), $successCount);
2580 }
2581
2582 return [
2583 'successful_tasks' => $successfulTasks,
2584 'failed_tasks' => [],
2585 'message' => $message,
2586 'moved_to_another_board' => $isMovingToAnotherBoard
2587 ];
2588 }
2589
2590 /**
2591 * Legacy method - kept for backward compatibility
2592 * @deprecated Use bulkMoveTasks instead
2593 */
2594 private function bulkMoveToStage($tasks, $params, $boardId)
2595 {
2596 return $this->bulkMoveTasks($tasks, $params, $boardId);
2597 }
2598
2599 /**
2600 * Bulk archive tasks
2601 */
2602 private function bulkArchiveTasks($tasks)
2603 {
2604 $successfulTasks = [];
2605 $failedTasks = [];
2606
2607 foreach ($tasks as $task) {
2608 try {
2609 // Use the same logic as single task archiving
2610 $this->updateTaskProperty('archived_at', current_time('mysql'), $task);
2611
2612 // Reload task with all relationships
2613 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2614
2615 $successfulTasks[] = $task;
2616 } catch (\Exception $e) {
2617 $failedTasks[] = [
2618 'id' => $task->id,
2619 'title' => $task->title,
2620 'error' => $e->getMessage()
2621 ];
2622 }
2623 }
2624
2625 $successCount = count($successfulTasks);
2626 $failureCount = count($failedTasks);
2627
2628 $message = '';
2629 if ($failureCount === 0) {
2630 // translators: %d is the number of tasks archived successfully
2631 $message = sprintf(__('%d tasks archived successfully', 'fluent-boards'), $successCount);
2632 } elseif ($successCount === 0) {
2633 // translators: %d is the number of tasks that failed to archive
2634 $message = sprintf(__('Failed to archive %d tasks', 'fluent-boards'), $failureCount);
2635 } else {
2636 // translators: 1: number of tasks archived successfully; 2: number of tasks failed to archive
2637 $message = sprintf(__('%1$d tasks archived successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
2638 }
2639
2640 return [
2641 'successful_tasks' => $successfulTasks,
2642 'failed_tasks' => $failedTasks,
2643 'message' => $message
2644 ];
2645 }
2646
2647 /**
2648 * Bulk change task priority
2649 */
2650 private function bulkChangePriority($tasks, $params)
2651 {
2652 $priority = $params['priority'] ?? null;
2653
2654 // Get valid priorities including custom ones added by hooks
2655 $validPriorities = array_keys(apply_filters('fluent_boards/task_priorities', [
2656 'low' => __('Low', 'fluent-boards'),
2657 'medium' => __('Medium', 'fluent-boards'),
2658 'high' => __('High', 'fluent-boards')
2659 ]));
2660
2661 if (!in_array($priority, $validPriorities)) {
2662 throw new \Exception(esc_html__('Invalid priority level', 'fluent-boards'));
2663 }
2664
2665 $successfulTasks = [];
2666 $failedTasks = [];
2667
2668 foreach ($tasks as $task) {
2669 try {
2670 // Use the same logic as single task priority update
2671 $this->updateTaskProperty('priority', $priority, $task);
2672
2673 // Reload task with all relationships
2674 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2675
2676 $successfulTasks[] = $task;
2677 } catch (\Exception $e) {
2678 $failedTasks[] = [
2679 'id' => $task->id,
2680 'title' => $task->title,
2681 'error' => $e->getMessage()
2682 ];
2683 }
2684 }
2685
2686 $successCount = count($successfulTasks);
2687 $failureCount = count($failedTasks);
2688
2689 $message = '';
2690 if ($failureCount === 0) {
2691 // translators: %d is the number of tasks whose priorities were updated successfully
2692 $message = sprintf(__('%d task priorities updated successfully', 'fluent-boards'), $successCount);
2693 } elseif ($successCount === 0) {
2694 // translators: %d is the number of tasks whose priorities failed to update
2695 $message = sprintf(__('Failed to update %d task priorities', 'fluent-boards'), $failureCount);
2696 } else {
2697 // translators: 1: number of tasks priorities updated; 2: number of tasks priorities failed to update
2698 $message = sprintf(__('%1$d task priorities updated successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
2699 }
2700
2701 return [
2702 'successful_tasks' => $successfulTasks,
2703 'failed_tasks' => $failedTasks,
2704 'message' => $message
2705 ];
2706 }
2707
2708 /**
2709 * Bulk assign members to tasks
2710 */
2711 private function bulkAssignMembers($tasks, $params, $boardId)
2712 {
2713 $userIds = $params['user_ids'] ?? [];
2714 if (!is_array($userIds)) {
2715 throw new \Exception(esc_html__('User IDs must be an array', 'fluent-boards'));
2716 }
2717
2718 // Validate that all users are valid WordPress users
2719 $validUsers = get_users(['include' => $userIds]);
2720 $validUserIds = array_map(function($user) {
2721 return $user->ID;
2722 }, $validUsers);
2723
2724 if (count($validUserIds) !== count($userIds)) {
2725 throw new \Exception(esc_html__('Some user IDs are invalid', 'fluent-boards'));
2726 }
2727
2728 // Filter only users who are already board members (skip non-members)
2729 $boardService = new \FluentBoards\App\Services\BoardService();
2730 $boardMemberIds = [];
2731 foreach ($validUserIds as $userId) {
2732 if ($boardService->isAlreadyMember($boardId, $userId)) {
2733 $boardMemberIds[] = $userId;
2734 }
2735 }
2736
2737 // If no valid board members, skip assignment silently
2738 if (empty($boardMemberIds)) {
2739 return [
2740 'successful_tasks' => [],
2741 'failed_tasks' => [],
2742 'message' => __('No valid board members selected for assignment', 'fluent-boards')
2743 ];
2744 }
2745
2746 // Use only board members for assignment
2747 $validUserIds = $boardMemberIds;
2748
2749 $successfulTasks = [];
2750 $failedTasks = [];
2751
2752 foreach ($tasks as $task) {
2753 try {
2754 // Use pure "add-only" logic for bulk assignment - never remove existing assignees
2755 $currentAssigneeIds = $task->assignees->pluck('ID')->toArray();
2756 $newAssignees = [];
2757
2758 foreach ($validUserIds as $userId) {
2759 // Only add if not already assigned
2760 if (!in_array($userId, $currentAssigneeIds)) {
2761 $newAssignees[] = $userId;
2762 }
2763 }
2764
2765 // Add all new assignees at once
2766 if (!empty($newAssignees)) {
2767 $assigneeData = [];
2768 foreach ($newAssignees as $userId) {
2769 $assigneeData[$userId] = ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE];
2770 }
2771 $task->assignees()->syncWithoutDetaching($assigneeData);
2772
2773 // Add as watchers
2774 $watcherData = [];
2775 foreach ($newAssignees as $userId) {
2776 $watcherData[$userId] = ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH];
2777 }
2778 $task->watchers()->syncWithoutDetaching($watcherData);
2779
2780 // Send notifications and actions only for new assignees
2781 foreach ($newAssignees as $userId) {
2782 // Send email notification if enabled and not current user
2783 if ((new \FluentBoards\App\Services\NotificationService())->checkIfEmailEnable($userId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id) && $userId != get_current_user_id()) {
2784 $this->sendMailAfterTaskModify('add_assignee', $userId, $task->id);
2785 }
2786
2787 // Dispatch WordPress actions
2788 //currently commented, need to check in future for bulk action
2789 // do_action('fluent_boards/task_assignee_added', $task, $userId);
2790 // if ($userId != get_current_user_id()) {
2791 // do_action('fluent_boards/assign_another_user', $task, $userId);
2792 // }
2793 }
2794 }
2795
2796 // Update task timestamp and reload all relationships
2797 $task->updated_at = current_time('mysql');
2798 $task->save();
2799 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2800
2801 $successfulTasks[] = $task;
2802 } catch (\Exception $e) {
2803 $failedTasks[] = [
2804 'id' => $task->id,
2805 'title' => $task->title,
2806 'error' => $e->getMessage()
2807 ];
2808 }
2809 }
2810
2811 $successCount = count($successfulTasks);
2812 $failureCount = count($failedTasks);
2813
2814 $message = '';
2815 if ($failureCount === 0) {
2816 // translators: %d is the number of tasks where members were assigned successfully
2817 $message = sprintf(__('%d tasks assigned members successfully', 'fluent-boards'), $successCount);
2818 } elseif ($successCount === 0) {
2819 // translators: %d is the number of tasks where assigning members failed
2820 $message = sprintf(__('Failed to assign members to %d tasks', 'fluent-boards'), $failureCount);
2821 } else {
2822 // translators: 1: number of tasks with members assigned successfully; 2: number of tasks where assigning members failed
2823 $message = sprintf(__('%1$d tasks assigned members successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
2824 }
2825
2826 return [
2827 'successful_tasks' => $successfulTasks,
2828 'failed_tasks' => $failedTasks,
2829 'message' => $message
2830 ];
2831 }
2832
2833 /**
2834 * Bulk add labels to tasks
2835 */
2836 private function bulkAddLabels($tasks, $params, $boardId)
2837 {
2838 $labelIds = $params['label_ids'] ?? [];
2839 if (!is_array($labelIds)) {
2840 throw new \Exception(esc_html__('Label IDs must be an array', 'fluent-boards'));
2841 }
2842
2843 // Validate that all labels exist and belong to the board
2844 $validLabels = \FluentBoards\App\Models\Label::whereIn('id', $labelIds)
2845 ->where('board_id', $boardId)
2846 ->whereNull('archived_at')
2847 ->get();
2848
2849 if (count($validLabels) !== count($labelIds)) {
2850 throw new \Exception(esc_html__('Some label IDs are invalid or do not belong to this board', 'fluent-boards'));
2851 }
2852
2853 $successfulTasks = [];
2854 $failedTasks = [];
2855
2856 foreach ($tasks as $task) {
2857 try {
2858 // Load existing labels first to avoid query issues
2859 $task->load('labels');
2860 $existingLabelIds = $task->labels->pluck('id')->toArray();
2861
2862 // Use the same logic as single task label adding
2863 foreach ($validLabels as $label) {
2864 // Check if label is already attached
2865 if (!in_array($label->id, $existingLabelIds)) {
2866 // Add the label using syncWithoutDetaching to avoid duplicates
2867 $task->labels()->syncWithoutDetaching([
2868 $label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]
2869 ]);
2870
2871 // Dispatch WordPress action for label addition
2872 //currently commented, need to check in future for bulk action
2873 // do_action('fluent_boards/task_label', $task, $label, 'added');
2874 }
2875 }
2876
2877 // Reload the task with all relationships
2878 $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
2879
2880 $successfulTasks[] = $task;
2881 } catch (\Exception $e) {
2882 $failedTasks[] = [
2883 'id' => $task->id,
2884 'title' => $task->title,
2885 'error' => $e->getMessage()
2886 ];
2887 }
2888 }
2889
2890 $successCount = count($successfulTasks);
2891 $failureCount = count($failedTasks);
2892
2893 $message = '';
2894 if ($failureCount === 0) {
2895 // translators: %d is the number of tasks labeled successfully
2896 $message = sprintf(__('%d tasks labeled successfully', 'fluent-boards'), $successCount);
2897 } elseif ($successCount === 0) {
2898 // translators: %d is the number of tasks that failed to label
2899 $message = sprintf(__('Failed to label %d tasks', 'fluent-boards'), $failureCount);
2900 } else {
2901 // translators: 1: number of tasks labeled successfully; 2: number of tasks failed to label
2902 $message = sprintf(__('%1$d tasks labeled successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
2903 }
2904
2905 return [
2906 'successful_tasks' => $successfulTasks,
2907 'failed_tasks' => $failedTasks,
2908 'message' => $message
2909 ];
2910 }
2911
2912 /* Delete time tracking records for one or multiple tasks
2913 * Uses try-catch for better performance - avoids table existence check overhead
2914 *
2915 * @param int|array $taskIds Single task ID or array of task IDs
2916 * @return void
2917 */
2918 public function deleteTimeTrackingRecords($taskIds)
2919 {
2920 // Check if FluentBoards Pro time tracking is available
2921 if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
2922 return;
2923 }
2924
2925 try {
2926 // Handle single task ID or array of task IDs
2927 if (is_array($taskIds)) {
2928 if (!empty($taskIds)) {
2929 \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete();
2930 }
2931 } else {
2932 if (is_numeric($taskIds) && $taskIds > 0) {
2933 \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
2934 }
2935 }
2936 } catch (\Exception $e) {
2937 // Silently fail if table doesn't exist or any other error occurs
2938 // This is intentional for cleanup operations
2939 }
2940 }
2941
2942 }
2943