PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.0
2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 All 42 releases
fluent-boards / app / Services / TaskService.php

TaskService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.1.0, at app/Services/TaskService.php

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