PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.0.12
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.0.12
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.12, at app/Services/TaskService.php

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