PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.0.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.0.0
2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 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 2.0.0, at app/Services/TaskService.php

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