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

728 lines 23.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
513 // get the last position available of that stage
514 $position = (new TaskService())->getLastPositionOfTasks($newStageId);
515
516 // update tasks stage and position
517 foreach ($tasks as $key => $task) {
518 // Clean up archived_by_stage meta when moving to different stage
519 TaskMeta::where('task_id', $task->id)
520 ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
521 ->delete();
522
523 $task->stage_id = $newStageId;
524 $task->position = $position + $key;
525 $task->save();
526 }
527 return $tasks;
528 }
529 public function archiveAllTasksInStage($stage_id, $boardId = null)
530 {
531 $tasksQuery = Task::where('stage_id', absint($stage_id))
532 ->whereNull('parent_id')
533 ->whereNull('archived_at');
534
535 if ($boardId) {
536 $tasksQuery->where('board_id', absint($boardId));
537 }
538
539 $tasks = $tasksQuery->get();
540 foreach ($tasks as $task) {
541 $task->position = 0;
542 $task->archived_at = current_time('mysql');
543 $task->save();
544 do_action('fluent_boards/task_archived', $task);
545 }
546 return $tasks;
547 }
548
549 public function createRoadmapStages($board, $stagesData)
550 {
551 foreach ($stagesData as $index => $formStageData) {
552 $stage = new Stage();
553 $stage->board_id = $board->id;
554 $stage->title = $formStageData['title'];
555 $stage->slug = $formStageData['slug'];
556 $stage->position = $formStageData['position'] ? $formStageData['position'] : 1;
557 $stage->settings = $this->roadmapStageSetting($index);
558 $stage->save();
559 }
560 return $stagesData;
561 }
562
563 /*
564 * I have no idea what this function does, but I am doing it
565 * to make the code work
566 */
567 public function roadmapStageSetting($index)
568 {
569 return [
570 'is_public' => $index > 0 ? true : false,
571 'default_task_status' => 'open',
572 'is_template' => false,
573 ];
574 }
575
576
577 public function createStages($board, $stageData)
578 {
579 $firstStage = null;
580 foreach ($stageData as $index => $stage) {
581 $stageToPush = array();
582 $stageToPush['title'] = $stage['title'];
583 $stageToPush['board_id'] = $board->id;
584 $stageToPush['position'] = $index + 1;
585 $stageToPush['slug'] = $this->createSlug($stage['title']);
586 $closedStageTitles = ['completed', 'done'];
587 $stageToPush['settings'] = [
588 'default_task_status' => in_array(strtolower(Arr::get($stage, 'title')), $closedStageTitles) ? 'closed' : 'open',
589 'is_template' => false
590 ];
591
592 $stage = Stage::create($stageToPush);
593 if($index == 0){
594 $firstStage = $stage;
595 }
596 }
597 return $firstStage;
598 }
599
600 private function createSlug($title)
601 {
602 return str_replace(' ', '-', strtolower($title));
603 }
604
605 public function stagesByBoardId($boardId)
606 {
607 return Stage::where('board_id', $boardId)->whereNull('archived_at')->orderBy('position', 'asc')->get();
608 }
609
610
611
612 public function sortStageTasks($order, $orderBy, $stage_id)
613 {
614 $sortOptions = ['priority', 'due_at', 'position', 'created_at', 'title'];
615 $orderOptions = ['ASC', 'DESC'];
616
617 // Validate order and orderBy parameters
618 if (!in_array($order, $sortOptions) || !in_array($orderBy, $orderOptions)) {
619 throw new \Exception(esc_html__('Invalid sort or orderBy parameter', 'fluent-boards'));
620 }
621
622 $tasksQuery = Task::where('stage_id', $stage_id)
623 ->whereNull('parent_id')
624 ->whereNull('archived_at')
625 ->with(['assignees', 'labels', 'watchers']);
626
627 // Apply ordering based on the specified order and orderBy
628 switch ($order) {
629 case 'priority':
630 $tasksQuery->orderByRaw("FIELD(priority, 'urgent', 'high', 'medium', 'low') {$orderBy}");
631 break;
632
633 case 'due_at':
634 if ($orderBy === 'ASC') {
635 // Separate ordering for tasks with and without due dates
636 $tasksWithDueDate = (clone $tasksQuery)->whereNotNull('due_at')->orderBy('due_at')->get();
637 $tasksWithoutDueDate = (clone $tasksQuery)->whereNull('due_at')->get();
638 $tasks = $tasksWithDueDate->merge($tasksWithoutDueDate);
639 } else {
640 $tasksQuery->orderBy('due_at', $orderBy);
641 }
642 break;
643
644 default:
645 $tasksQuery->orderBy($order, $orderBy);
646 break;
647 }
648
649 // Fetch tasks if not already fetched
650 if (!isset($tasks)) {
651 $tasks = $tasksQuery->get();
652 }
653
654 // Update tasks with additional attributes
655 $tasks->each(function ($task, $key) {
656 $task->position = $key + 1;
657 $task->save();
658 $task->isOverdue = $task->isOverdue();
659 $task->isUpcoming = $task->upcoming();
660 $task->is_watching = $task->isWatching();
661 $task->contact = Task::lead_contact($task->crm_contact_id);
662 });
663 return $tasks;
664 }
665
666
667 public function updateStage($updatedStage, $board_id, $oldStage)
668 {
669 $stageBeforeUpdate = clone $oldStage;
670 $oldStage->title = sanitize_text_field(Arr::get($updatedStage, 'title'));
671 $oldStage->bg_color = sanitize_text_field(Arr::get($updatedStage, 'cover_bg'));
672 $oldStage->save();
673 do_action('fluent_boards/stage_updated', $board_id, $updatedStage, $stageBeforeUpdate);
674 return $oldStage;
675 }
676
677 public function setDefaultAssignees($stage_id, $assignees)
678 {
679 $stage = Stage::findOrFail($stage_id);
680 if ($stage) {
681 $settings = $stage->settings;
682 $settings['default_task_assignees'] = $assignees;
683 $stage->settings = $settings;
684 $stage->save();
685
686 do_action('fluent_boards/default_assignees_updated', $stage, $assignees);
687
688 return $stage;
689 }
690 }
691
692 public function setDefaultWatchers($stage_id, $watchers)
693 {
694 $stage = Stage::findOrFail($stage_id);
695 if ($stage) {
696 $settings = $stage->settings;
697 $settings['default_task_watchers'] = $watchers;
698 $stage->settings = $settings;
699 $stage->save();
700
701 do_action('fluent_boards/default_watchers_updated', $stage, $watchers);
702
703 return $stage;
704 }
705 }
706
707 public function getAllParentAndSubTasksOfStages($stageIds)
708 {
709 // Fetch parent task IDs for the given stage_ids
710 $parentTaskIds = Task::whereIn('stage_id', $stageIds)
711 ->whereNull('archived_at')
712 ->whereNull('parent_id')
713 ->pluck('id');
714
715 // Fetch parent tasks and subtasks in a single query
716 $tasks = Task::whereIn('stage_id', $stageIds)
717 ->whereNull('parent_id')
718 ->whereNull('archived_at')
719 ->get();
720
721 $subtasks = Task::whereIn('parent_id', $parentTaskIds)
722 ->get();
723
724 // Combine parent tasks and subtasks into a single collection
725 return $tasks->merge($subtasks);
726 }
727 }
728