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

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

754 lines 24.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\App\Models\Task;
6 use FluentBoards\App\Models\Board;
7 use FluentBoards\App\Models\Stage;
8 use FluentBoards\App\Models\TaskMeta;
9 use FluentBoards\App\Models\Relation;
10 use FluentBoards\App\Models\User;
11 use FluentBoards\App\App;
12 use FluentBoards\Framework\Http\Request\Request;
13 use FluentBoards\Framework\Support\Arr;
14 use FluentBoards\App\Services\Constant;
15 use FluentBoards\App\Services\AttachmentFileService;
16 use FluentBoards\App\Services\PermissionManager;
17
18 class StageService
19 {
20 public function createDefaultStages($board)
21 {
22 $stages = $this->defaultStages($board);
23 foreach ($stages as $stage) {
24 Stage::create($stage);
25 }
26 }
27
28 public function defaultStages($board)
29 {
30 return $this->defaultStagesForTodos($board->id);
31 }
32
33 public function defaultStagesForTodos($boardId)
34 {
35 return [
36 [
37 'board_id' => $boardId,
38 'title' => 'Open',
39 'position' => 1,
40 'slug' => 'open',
41 'settings' => [
42 'default_task_status' => 'open'
43 ]
44 ],
45 [
46 'board_id' => $boardId,
47 'title' => 'In Progress',
48 'position' => 2,
49 'slug' => 'in-progress',
50 'settings' => [
51 'default_task_status' => 'open'
52 ]
53 ],
54 [
55 'board_id' => $boardId,
56 'title' => 'Completed',
57 'position' => 3,
58 'slug' => 'completed',
59 'settings' => [
60 'default_task_status' => 'closed'
61 ]
62 ]
63 ];
64 }
65
66 /**
67 * Update one stage property and emit side effects for real title transitions.
68 *
69 * @param string $col
70 * @param mixed $value
71 * @param int $stageId
72 * @return Stage
73 */
74 public function updateStageProperty($col, $value, $stageId)
75 {
76 $stage = Stage::findOrFail($stageId);
77 $stageBeforeUpdate = clone $stage;
78
79 if ('title' == $col) {
80 $stage = $this->updateTitle($value, $stage);
81 } elseif ('status' == $col) {
82 $stage = $this->updateStatus($value, $stage);
83 } elseif ('color' == $col) {
84 $stage = $this->updateColor($value, $stage);
85 } elseif ('bg_color' == $col) {
86 $stage = $this->updateBackgroundColor($value, $stage);
87 } elseif ('archived_at' == $col) {
88 $stage = $this->updateArchivedAt($value, $stage);
89 }
90
91 if ('title' == $col && $stageBeforeUpdate->title !== $stage->title) {
92 do_action('fluent_boards/stage_updated', $stage->board_id, [
93 'title' => $stage->title,
94 'cover_bg' => $stage->bg_color
95 ], $stageBeforeUpdate);
96 }
97
98 return $stage;
99 }
100
101 public function getLastOneMinuteUpdatedStages($boardId, $lastUpdated = null, $includeArchived = true)
102 {
103 if (!$lastUpdated) {
104 $oneMinuteAgoTimestamp = current_time('timestamp') - 60;
105 $lastUpdated = date_i18n('Y-m-d H:i:s', $oneMinuteAgoTimestamp);
106 }
107
108 $stagesQuery = Stage::where('board_id', $boardId)
109 ->where('updated_at', '>=', $lastUpdated);
110
111 if (!$includeArchived) {
112 $stagesQuery->whereNull('archived_at');
113 }
114
115 return $stagesQuery->get();
116 }
117
118 /**
119 * Get archived stages newest-first with archive actor metadata.
120 *
121 * @param array $data
122 * @param int $boardId
123 * @return mixed
124 * @throws \Exception
125 */
126 public function getArchivedStages($data, $boardId)
127 {
128 if (!$boardId) {
129 throw new \Exception(esc_html__('Board id is required', 'fluent-boards'));
130 }
131
132 $noPagination = !empty($data['noPagination']);
133 $perPage = max(1, min(50, absint($data['per_page'] ?? 30)));
134 $page = max(1, absint($data['page'] ?? 1));
135
136 $stagesQuery = Stage::where('board_id', $boardId)
137 ->whereNotNull('archived_at')
138 ->orderBy('archived_at', 'DESC')
139 ->orderBy('id', 'DESC');
140
141 if ($noPagination) {
142 $stages = $stagesQuery->get();
143 } else {
144 $stages = $stagesQuery->paginate($perPage, ['*'], 'page', $page);
145 }
146
147 $this->attachArchivedStageActors($stages);
148
149 return $stages;
150 }
151
152 /**
153 * Attach nullable archived-by fields from stage settings without per-stage user queries.
154 *
155 * @param mixed $stages
156 * @return void
157 */
158 private function attachArchivedStageActors($stages)
159 {
160 $archivedByIds = [];
161
162 foreach ($stages as $stage) {
163 $stage->archived_by_id = null;
164 $stage->archived_by = null;
165
166 $settings = is_array($stage->settings) ? $stage->settings : [];
167 $archivedById = !empty($settings['archived_by_id']) ? absint($settings['archived_by_id']) : 0;
168
169 if ($archivedById) {
170 $stage->archived_by_id = $archivedById;
171 $archivedByIds[] = $archivedById;
172 }
173 }
174
175 $archivedByIds = array_values(array_unique(array_filter($archivedByIds)));
176
177 if (empty($archivedByIds)) {
178 return;
179 }
180
181 $users = User::whereIn('ID', $archivedByIds)->get()->keyBy('ID');
182
183 foreach ($stages as $stage) {
184 if (!$stage->archived_by_id) {
185 continue;
186 }
187
188 $user = $users->get($stage->archived_by_id);
189 $stage->archived_by = $user ? Helper::sanitizeUserCollections($user) : null;
190 }
191 }
192
193 private function updateTitle($value, $stage)
194 {
195 $stage->title = $value;
196 $stage->save();
197 return $stage;
198 }
199
200 private function updateStatus($value, $stage)
201 {
202 $oldSettings = $stage->settings;
203 $oldSettings['default_task_status'] = $value;
204 $stage->settings = $oldSettings;
205 $stage->save();
206 return $stage;
207 }
208
209 private function updateColor($value, $stage)
210 {
211 $stage->color = $value;
212 $stage->save();
213 return $stage;
214 }
215
216 private function updateBackgroundColor($value, $stage)
217 {
218 $stage->bg_color = $value;
219 $stage->save();
220 return $stage;
221 }
222
223 private function updateArchivedAt($value, $stage)
224 {
225 $stage->archived_at = $value;
226 $stage->save();
227 return $stage;
228 }
229
230 public function createStage($stageData, $boardId)
231 {
232 $stage = new Stage();
233 $stage->board_id = $boardId;
234 $stage->title = $stageData['title'];
235 $stage->settings = [
236 'default_task_status' => Arr::get($stageData, 'status') ?? 'open',
237 'default_task_assignees' => []
238 ];
239 $providerPosition = Arr::get($stageData, 'position');
240 $lastStagePosition = $this->getLastPositionOfStagesOfBoard($boardId);
241 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
242 $stage->save();
243 if($providerPosition){
244 $stage->moveToNewPosition($providerPosition);
245 }
246 return $stage;
247 }
248
249 public function getLastPositionOfStagesOfBoard($boardId)
250 {
251 // return last position of stages of board
252 return Stage::where('board_id', $boardId)
253 ->whereNull('archived_at')
254 ->orderBy('position', 'desc')
255 ->first();
256 }
257
258 protected function moveOtherStages($stage)
259 {
260
261 $stages = Stage::where('board_id', $stage->board_id)
262 ->where('position', '>=', $stage->position)
263 ->whereNotIn('id', [$stage->id])
264 ->whereNull('archived_at')->get();
265
266 foreach ($stages as $stage) {
267 $stage->position = $stage->position + 1;
268 $stage->save();
269 }
270 }
271
272 /**
273 * Copy all active stages and their settings to another board in display order.
274 *
275 * @param Board $board
276 * @param int $fromBoardId
277 * @param string $isWithTemplates
278 * @param array|null $stageIds
279 * @return array
280 */
281 public function copyStagesOfBoard($board, $fromBoardId, $isWithTemplates = 'no', $stageIds = null)
282 {
283 $stageQuery = Stage::where('board_id', $fromBoardId)
284 ->where('type', 'stage')
285 ->whereNull('archived_at');
286
287 if (is_array($stageIds)) {
288 $stageIds = array_values(array_filter(array_map('absint', $stageIds)));
289
290 if (!$stageIds) {
291 return [];
292 }
293
294 $stageQuery->whereIn('id', $stageIds);
295 }
296
297 $stages = $stageQuery->orderBy('position', 'asc')->get();
298 $stageMap = [];
299
300 foreach ($stages as $key => $stage) {
301 $settings = $stage->settings ?: [];
302
303 // Board copies must not turn their stages into reusable stage templates.
304 if ($isWithTemplates !== 'yes') {
305 unset($settings['is_template']);
306 }
307
308 $stageData = [
309 'title' => $stage->title,
310 'board_id' => $board->id,
311 'slug' => str_replace(' ', '-', strtolower($stage->title)),
312 'type' => 'stage',
313 'position' => $key + 1,
314 'bg_color' => $stage->bg_color,
315 'settings' => $settings,
316 ];
317
318 $newStage = Stage::create($stageData);
319 $stageMap[$stage->id] = $newStage->id;
320 }
321
322 return $stageMap;
323 }
324
325 public function importStagesFromBoard($board_id, $selectedStages, $position = null)
326 {
327 $board_id = absint($board_id);
328 $selectedStages = array_values(array_unique(array_filter(array_map('absint', (array) $selectedStages))));
329
330 if (!$selectedStages) {
331 return;
332 }
333
334 $targetStages = Stage::whereIn('id', $selectedStages)
335 ->whereNull('archived_at')
336 ->get();
337
338 if (count($targetStages) !== count($selectedStages)) {
339 throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
340 }
341
342 $sourceBoardIds = array_values(array_unique(array_map('absint', $targetStages->pluck('board_id')->toArray())));
343
344 if (in_array(0, $sourceBoardIds, true)) {
345 throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
346 }
347
348 $userId = get_current_user_id();
349
350 if (!$userId) {
351 throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
352 }
353
354 if (!PermissionManager::isAdmin($userId)) {
355 $allowedBoardIds = Relation::where('foreign_id', $userId)
356 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
357 ->whereIn('object_id', $sourceBoardIds)
358 ->pluck('object_id')
359 ->toArray();
360
361 $allowedBoardIds = array_map('intval', $allowedBoardIds);
362 $unauthorizedBoardIds = array_filter($sourceBoardIds, function ($sourceBoardId) use ($allowedBoardIds) {
363 return !in_array($sourceBoardId, $allowedBoardIds, true);
364 });
365
366 if ($unauthorizedBoardIds) {
367 throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
368 }
369 }
370
371 $stageMapForCopyingTask = array();
372 $stageIdsToCopy = array();
373
374 $numberOfStages = Stage::where('board_id', $board_id)->whereNull('archived_at')->count();
375
376 foreach($targetStages as $key => $stage)
377 {
378 $stageToSave = array();
379 $stageToSave['title'] = $stage['title'] . ' - imported';
380 $stageToSave['board_id'] = $board_id;
381 $stageToSave['slug'] = str_replace(' ', '-', strtolower($stage['title']));
382 $stageToSave['type'] = 'stage';
383 $stageToSave['position'] = $numberOfStages + $key + 1;
384 $stageToSave['settings'] = [
385 'default_task_status' => $stage->settings['default_task_status']
386 ];
387 $newStage = Stage::create($stageToSave);
388 if($position) {
389 $newStage->moveToNewPosition( (int) $position + $key );
390 }
391 $stageMapForCopyingTask[$stage['id']] = $newStage->id;
392 $stageIdsToCopy[] = $stage['id'];
393 }
394
395 $this->importTasks($board_id, $stageMapForCopyingTask, $stageIdsToCopy);
396 }
397
398 public function importTasks($boardId, $stageMapper, $stageIds)
399 {
400 $tasksToImport = $this->getAllParentAndSubTasksOfStages($stageIds);
401 $subtaskGroupChildRelations = [];
402 $taskIdsToImport = $tasksToImport->pluck('id')->toArray();
403
404 if ($taskIdsToImport) {
405 foreach (TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
406 ->whereIn('task_id', $taskIdsToImport)
407 ->get() as $relation) {
408 $subtaskGroupChildRelations[$relation->task_id] = $relation;
409 }
410 }
411 $taskMap = [];
412 $subtaskGroupMap = [];
413 $attachmentFileService = new AttachmentFileService();
414 $dbInstance = App::getInstance('db');
415
416 $dbInstance->beginTransaction();
417
418 try {
419 foreach($tasksToImport as $task)
420 {
421 $newTask = array();
422 $newTask['title'] = $task->title;
423 $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
424 $newTask['description'] = $task->description;
425 $newTask['board_id'] = $boardId;
426 $newTask['stage_id'] = $stageMapper[$task->stage_id];
427 $newTask['status'] = $task->status;
428 $newTask['priority'] = $task->priority;
429 $newTask['position'] = $task->position;
430 $newTask['due_at'] = $task->due_at;
431 $backgroundColor = $task->settings['cover']['backgroundColor'] ?? '';
432 $newTask['settings'] = [
433 'cover' => [
434 'backgroundColor' => $backgroundColor,
435 ]
436 ];
437 $newTask = Task::create($newTask);
438 $attachmentFileService->cloneTaskFilesToBoard($task, $newTask, $boardId);
439
440 if (!$task->parent_id) {
441 $taskMap[$task->id] = $newTask->id;
442 //group mapping
443 $subtaskGroupMap = (new TaskService())->copySubtaskGroup($task, $newTask, $subtaskGroupMap);
444 } else {
445 $groupRelationOfTask = $subtaskGroupChildRelations[$task->id] ?? null;
446
447 if ($groupRelationOfTask && $subtaskGroupMap[$groupRelationOfTask->value]) {
448 TaskMeta::create([
449 'task_id' => $newTask->id,
450 'key' => Constant::SUBTASK_GROUP_CHILD,
451 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
452 ]);
453 }
454 }
455 }
456
457 //update task count of board
458 $totalTasks = sizeof($tasksToImport);
459 $board = Board::findOrFail($boardId);
460 $settings = $board->settings ?? [];
461
462 if (isset($settings['tasks_count'])) {
463 $settings['tasks_count'] += $totalTasks;
464 } else {
465 $settings['tasks_count'] = $totalTasks;
466 }
467 $board->settings = $settings;
468 $board->save();
469
470 $dbInstance->commit();
471 } catch (\Exception $e) {
472 $dbInstance->rollBack();
473 $attachmentFileService->rollbackCreatedFiles();
474 throw $e;
475 }
476 }
477
478 public function updateStageTemplate($stage_id)
479 {
480 $stage = Stage::findOrFail($stage_id);
481 $stageSettings = $stage->settings;
482 if($stageSettings && array_key_exists('is_template', $stageSettings))
483 {
484 $currentlyIsTemplate = $stageSettings['is_template'];
485 if($currentlyIsTemplate){
486 $stageSettings['is_template'] = false;
487 $stage->settings = $stageSettings;
488 }else{
489 $stageSettings['is_template'] = true;
490 $stage->settings = $stageSettings;
491 }
492 }else {
493 if(!$stageSettings) {
494 $stage->settings = [
495 'is_template' => true
496 ];
497 } else {
498 $stage->settings = array_merge($stage->settings, [
499 'is_template' => true
500 ]);
501 }
502
503 }
504 $stage->save();
505
506 return $stage;
507 }
508
509 public function moveAllTasks($oldStageId, $newStageId)
510 {
511 $tasks = Task::where('stage_id', $oldStageId)->whereNull('parent_id')->whereNull('archived_at')->get();
512 $sourceStage = Stage::findOrFail($oldStageId);
513 $targetStage = Stage::findOrFail($newStageId);
514
515 // Load shared watcher data once; stage data is injected per task below.
516 $tasks->load('watchers');
517
518 // get the last position available of that stage
519 $position = (new TaskService())->getLastPositionOfTasks($newStageId);
520
521 // update tasks stage and position
522 foreach ($tasks as $key => $task) {
523 $taskOldStageId = $task->stage_id;
524
525 // Clean up archived_by_stage meta when moving to different stage
526 TaskMeta::where('task_id', $task->id)
527 ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
528 ->delete();
529
530 $task->stage_id = $newStageId;
531 $task->position = $position + $key;
532 $task->setRelation('stage', $targetStage);
533 $task->save();
534
535 if ((int) $taskOldStageId !== (int) $newStageId) {
536 do_action('fluent_boards/task_stage_updated', $task, $taskOldStageId, [
537 'source' => $sourceStage,
538 'target' => $targetStage,
539 ]);
540 }
541 }
542
543 if ($tasks->count() && (int) $sourceStage->id !== (int) $targetStage->id) {
544 do_action(
545 'fluent_boards/tasks_moved_between_stages',
546 $sourceStage->board_id,
547 $sourceStage,
548 $targetStage,
549 $tasks->count()
550 );
551 }
552
553 return $tasks;
554 }
555 public function archiveAllTasksInStage($stage_id, $boardId = null)
556 {
557 $tasksQuery = Task::where('stage_id', absint($stage_id))
558 ->whereNull('parent_id')
559 ->whereNull('archived_at');
560
561 if ($boardId) {
562 $tasksQuery->where('board_id', absint($boardId));
563 }
564
565 $tasks = $tasksQuery->get();
566 foreach ($tasks as $task) {
567 $task->position = 0;
568 $task->archived_at = current_time('mysql');
569 $task->save();
570 do_action('fluent_boards/task_archived', $task);
571 }
572 return $tasks;
573 }
574
575 public function createRoadmapStages($board, $stagesData)
576 {
577 foreach ($stagesData as $index => $formStageData) {
578 $stage = new Stage();
579 $stage->board_id = $board->id;
580 $stage->title = $formStageData['title'];
581 $stage->slug = $formStageData['slug'];
582 $stage->position = $formStageData['position'] ? $formStageData['position'] : 1;
583 $stage->settings = $this->roadmapStageSetting($index);
584 $stage->save();
585 }
586 return $stagesData;
587 }
588
589 /*
590 * I have no idea what this function does, but I am doing it
591 * to make the code work
592 */
593 public function roadmapStageSetting($index)
594 {
595 return [
596 'is_public' => $index > 0 ? true : false,
597 'default_task_status' => 'open',
598 'is_template' => false,
599 ];
600 }
601
602
603 public function createStages($board, $stageData)
604 {
605 $firstStage = null;
606 foreach ($stageData as $index => $stage) {
607 $stageToPush = array();
608 $stageToPush['title'] = $stage['title'];
609 $stageToPush['board_id'] = $board->id;
610 $stageToPush['position'] = $index + 1;
611 $stageToPush['slug'] = $this->createSlug($stage['title']);
612 $closedStageTitles = ['completed', 'done'];
613 $stageToPush['settings'] = [
614 'default_task_status' => in_array(strtolower(Arr::get($stage, 'title')), $closedStageTitles) ? 'closed' : 'open',
615 'is_template' => false
616 ];
617
618 $stage = Stage::create($stageToPush);
619 if($index == 0){
620 $firstStage = $stage;
621 }
622 }
623 return $firstStage;
624 }
625
626 private function createSlug($title)
627 {
628 return str_replace(' ', '-', strtolower($title));
629 }
630
631 public function stagesByBoardId($boardId)
632 {
633 return Stage::where('board_id', $boardId)->whereNull('archived_at')->orderBy('position', 'asc')->get();
634 }
635
636
637
638 public function sortStageTasks($order, $orderBy, $stage_id)
639 {
640 $sortOptions = ['priority', 'due_at', 'position', 'created_at', 'title'];
641 $orderOptions = ['ASC', 'DESC'];
642
643 // Validate order and orderBy parameters
644 if (!in_array($order, $sortOptions) || !in_array($orderBy, $orderOptions)) {
645 throw new \Exception(esc_html__('Invalid sort or orderBy parameter', 'fluent-boards'));
646 }
647
648 $tasksQuery = Task::where('stage_id', $stage_id)
649 ->whereNull('parent_id')
650 ->whereNull('archived_at')
651 ->with(['assignees', 'labels', 'watchers']);
652
653 // Apply ordering based on the specified order and orderBy
654 switch ($order) {
655 case 'priority':
656 $tasksQuery->orderByRaw("FIELD(priority, 'urgent', 'high', 'medium', 'low') {$orderBy}");
657 break;
658
659 case 'due_at':
660 if ($orderBy === 'ASC') {
661 // Separate ordering for tasks with and without due dates
662 $tasksWithDueDate = (clone $tasksQuery)->whereNotNull('due_at')->orderBy('due_at')->get();
663 $tasksWithoutDueDate = (clone $tasksQuery)->whereNull('due_at')->get();
664 $tasks = $tasksWithDueDate->merge($tasksWithoutDueDate);
665 } else {
666 $tasksQuery->orderBy('due_at', $orderBy);
667 }
668 break;
669
670 default:
671 $tasksQuery->orderBy($order, $orderBy);
672 break;
673 }
674
675 // Fetch tasks if not already fetched
676 if (!isset($tasks)) {
677 $tasks = $tasksQuery->get();
678 }
679
680 // Update tasks with additional attributes
681 $tasks->each(function ($task, $key) {
682 $task->position = $key + 1;
683 $task->save();
684 $task->isOverdue = $task->isOverdue();
685 $task->isUpcoming = $task->upcoming();
686 $task->is_watching = $task->isWatching();
687 $task->contact = Task::lead_contact($task->crm_contact_id);
688 });
689 return $tasks;
690 }
691
692
693 public function updateStage($updatedStage, $board_id, $oldStage)
694 {
695 $stageBeforeUpdate = clone $oldStage;
696 $oldStage->title = sanitize_text_field(Arr::get($updatedStage, 'title'));
697 $oldStage->bg_color = sanitize_text_field(Arr::get($updatedStage, 'cover_bg'));
698 $oldStage->save();
699 do_action('fluent_boards/stage_updated', $board_id, $updatedStage, $stageBeforeUpdate);
700 return $oldStage;
701 }
702
703 public function setDefaultAssignees($stage_id, $assignees)
704 {
705 $stage = Stage::findOrFail($stage_id);
706 if ($stage) {
707 $settings = $stage->settings;
708 $settings['default_task_assignees'] = $assignees;
709 $stage->settings = $settings;
710 $stage->save();
711
712 do_action('fluent_boards/default_assignees_updated', $stage, $assignees);
713
714 return $stage;
715 }
716 }
717
718 public function setDefaultWatchers($stage_id, $watchers)
719 {
720 $stage = Stage::findOrFail($stage_id);
721 if ($stage) {
722 $settings = $stage->settings;
723 $settings['default_task_watchers'] = $watchers;
724 $stage->settings = $settings;
725 $stage->save();
726
727 do_action('fluent_boards/default_watchers_updated', $stage, $watchers);
728
729 return $stage;
730 }
731 }
732
733 public function getAllParentAndSubTasksOfStages($stageIds)
734 {
735 // Fetch parent task IDs for the given stage_ids
736 $parentTaskIds = Task::whereIn('stage_id', $stageIds)
737 ->whereNull('archived_at')
738 ->whereNull('parent_id')
739 ->pluck('id');
740
741 // Fetch parent tasks and subtasks in a single query
742 $tasks = Task::whereIn('stage_id', $stageIds)
743 ->whereNull('parent_id')
744 ->whereNull('archived_at')
745 ->get();
746
747 $subtasks = Task::whereIn('parent_id', $parentTaskIds)
748 ->get();
749
750 // Combine parent tasks and subtasks into a single collection
751 return $tasks->merge($subtasks);
752 }
753 }
754