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

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