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

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