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
← All changes | app/Services/StageService.php +375 -71 1.352.1.0 View file →
@@ -4,10 +4,17 @@
4 4
5 5 use FluentBoards\App\Models\Task;
6 6 use FluentBoards\App\Models\Board;
7 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;
8 12 use FluentBoards\Framework\Http\Request\Request;
9 13 use FluentBoards\Framework\Support\Arr;
14 +use FluentBoards\App\Services\Constant;
15 +use FluentBoards\App\Services\AttachmentFileService;
16 +use FluentBoards\App\Services\PermissionManager;
10 17
11 18 class StageService
12 19 {
13 20 public function createDefaultStages($board)
@@ -54,11 +61,21 @@
54 61 ]
55 62 ]
56 63 ];
57 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 + */
58 74 public function updateStageProperty($col, $value, $stageId)
59 75 {
60 76 $stage = Stage::findOrFail($stageId);
77 + $stageBeforeUpdate = clone $stage;
61 78
62 79 if ('title' == $col) {
63 80 $stage = $this->updateTitle($value, $stage);
64 81 } elseif ('status' == $col) {
@@ -69,19 +86,111 @@
69 86 $stage = $this->updateBackgroundColor($value, $stage);
70 87 } elseif ('archived_at' == $col) {
71 88 $stage = $this->updateArchivedAt($value, $stage);
72 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 +
73 98 return $stage;
74 99 }
75 100
76 - public function getLastOneMinuteUpdatedStages($boardId)
101 + public function getLastOneMinuteUpdatedStages($boardId, $lastUpdated = null, $includeArchived = true)
77 102 {
78 - $oneMinuteAgoTimestamp = current_time('timestamp') - 60;
79 - return Stage::where('board_id', $boardId)
80 - ->where('updated_at', '>=', date_i18n('Y-m-d H:i:s', $oneMinuteAgoTimestamp))
81 - ->get();
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();
82 116 }
83 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 +
84 193 private function updateTitle($value, $stage)
85 194 {
86 195 $stage->title = $value;
87 196 $stage->save();
@@ -126,18 +235,15 @@
126 235 $stage->settings = [
127 236 'default_task_status' => Arr::get($stageData, 'status') ?? 'open',
128 237 'default_task_assignees' => []
129 238 ];
130 - if (!Arr::get($stageData, 'position')) {
131 - $lastStagePosition = $this->getLastPositionOfStagesOfBoard($boardId);
132 - $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
133 - $stage->save();
134 - } else {
135 - $stage->position = (int)$stageData['position'];
136 - $stage->save();
137 - $this->moveOtherStages($stage);
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);
138 245 }
139 -
140 246 return $stage;
141 247 }
142 248
143 249 public function getLastPositionOfStagesOfBoard($boardId)
@@ -162,36 +268,110 @@
162 268 $stage->save();
163 269 }
164 270 }
165 271
166 - public function copyStagesOfBoard($board, $fromBoardId)
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)
167 282 {
168 - $stages = Stage::where('board_id', $fromBoardId)->where('type', 'stage')->whereNull('archived_at')->get();
169 - $stageMapForCopyingTask = array();
170 - foreach($stages as $key => $stage)
171 - {
172 - $stageToSave = array();
173 - $stageToSave['title'] = $stage['title'];
174 - $stageToSave['board_id'] = $board->id;
175 - $stageToSave['slug'] = str_replace(' ', '-', strtolower($stage['title']));
176 - $stageToSave['type'] = 'stage';
177 - $stageToSave['position'] = $key + 1;
178 - $stageToSave['settings'] = [
179 - 'default_task_status' => $stage->settings['default_task_status']
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,
180 316 ];
181 - $newStage = Stage::create($stageToSave);
182 - $stageMapForCopyingTask[$stage['id']] = $newStage->id;
317 +
318 + $newStage = Stage::create($stageData);
319 + $stageMap[$stage->id] = $newStage->id;
183 320 }
184 - return $stageMapForCopyingTask;
321 +
322 + return $stageMap;
185 323 }
186 324
187 - public function importStagesFromBoard($board_id, $selectedStages)
325 + public function importStagesFromBoard($board_id, $selectedStages, $position = null)
188 326 {
189 - $targetStages = Stage::whereIn('id', $selectedStages)->get();
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 +
190 371 $stageMapForCopyingTask = array();
191 372 $stageIdsToCopy = array();
192 373
193 - //need to define position of stage
194 374 $numberOfStages = Stage::where('board_id', $board_id)->whereNull('archived_at')->count();
195 375
196 376 foreach($targetStages as $key => $stage)
197 377 {
@@ -204,8 +384,11 @@
204 384 $stageToSave['settings'] = [
205 385 'default_task_status' => $stage->settings['default_task_status']
206 386 ];
207 387 $newStage = Stage::create($stageToSave);
388 + if($position) {
389 + $newStage->moveToNewPosition( (int) $position + $key );
390 + }
208 391 $stageMapForCopyingTask[$stage['id']] = $newStage->id;
209 392 $stageIdsToCopy[] = $stage['id'];
210 393 }
211 394
@@ -213,36 +396,84 @@
213 396 }
214 397
215 398 public function importTasks($boardId, $stageMapper, $stageIds)
216 399 {
217 - $tasksToImport = Task::whereIn('stage_id', $stageIds)->whereNull('archived_at')->get();
400 + $tasksToImport = $this->getAllParentAndSubTasksOfStages($stageIds);
401 + $subtaskGroupChildRelations = [];
402 + $taskIdsToImport = $tasksToImport->pluck('id')->toArray();
218 403
219 - foreach($tasksToImport as $task)
220 - {
221 - $newTask = array();
222 - $newTask['title'] = $task->title;
223 - $newTask['description'] = $task->description;
224 - $newTask['board_id'] = $boardId;
225 - $newTask['stage_id'] = $stageMapper[$task->stage_id];
226 - $newTask['status'] = $task->status;
227 - $newTask['priority'] = $task->priority;
228 - $newTask['position'] = $task->position;
229 - $newTask['due_at'] = $task->due_at;
230 - Task::create($newTask);
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 + }
231 410 }
411 + $taskMap = [];
412 + $subtaskGroupMap = [];
413 + $attachmentFileService = new AttachmentFileService();
414 + $dbInstance = App::getInstance('db');
232 415
233 - //update task count of board
234 - $totalTasks = sizeof($tasksToImport);
235 - $board = Board::findOrFail($boardId);
236 - $settings = $board->settings ?? [];
416 + $dbInstance->beginTransaction();
237 417
238 - if (isset($settings['tasks_count'])) {
239 - $settings['tasks_count'] += $totalTasks;
240 - } else {
241 - $settings['tasks_count'] = $totalTasks;
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;
242 475 }
243 - $board->settings = $settings;
244 - $board->save();
245 476 }
246 477
247 478 public function updateStageTemplate($stage_id)
248 479 {
@@ -277,23 +508,62 @@
277 508
278 509 public function moveAllTasks($oldStageId, $newStageId)
279 510 {
280 511 $tasks = Task::where('stage_id', $oldStageId)->whereNull('parent_id')->whereNull('archived_at')->get();
512 + $sourceStage = Stage::findOrFail($oldStageId);
513 + $targetStage = Stage::findOrFail($newStageId);
281 514
515 + // Load shared watcher data once; stage data is injected per task below.
516 + $tasks->load('watchers');
517 +
282 518 // get the last position available of that stage
283 519 $position = (new TaskService())->getLastPositionOfTasks($newStageId);
284 520
285 521 // update tasks stage and position
286 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 +
287 530 $task->stage_id = $newStageId;
288 531 $task->position = $position + $key;
532 + $task->setRelation('stage', $targetStage);
289 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 + }
290 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 +
291 553 return $tasks;
292 554 }
293 - public function archiveAllTasksInStage($stage_id)
555 + public function archiveAllTasksInStage($stage_id, $boardId = null)
294 556 {
295 - $tasks = Task::where('stage_id', $stage_id)->whereNull('parent_id')->whereNull('archived_at')->get();
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();
296 566 foreach ($tasks as $task) {
297 567 $task->position = 0;
298 568 $task->archived_at = current_time('mysql');
299 569 $task->save();
@@ -338,16 +608,14 @@
338 608 $stageToPush['title'] = $stage['title'];
339 609 $stageToPush['board_id'] = $board->id;
340 610 $stageToPush['position'] = $index + 1;
341 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 + ];
342 617
343 - if (Arr::get($stage, 'title') == 'Completed') {
344 - $stageToPush['settings'] = [
345 - 'default_task_status' => 'closed',
346 - 'is_template' => false
347 - ];
348 - }
349 -
350 618 $stage = Stage::create($stageToPush);
351 619 if($index == 0){
352 620 $firstStage = $stage;
353 621 }
@@ -361,9 +629,9 @@
361 629 }
362 630
363 631 public function stagesByBoardId($boardId)
364 632 {
365 - return Stage::where('board_id', $boardId)->whereNull('archived_at')->get();
633 + return Stage::where('board_id', $boardId)->whereNull('archived_at')->orderBy('position', 'asc')->get();
366 634 }
367 635
368 636
369 637
@@ -373,9 +641,9 @@
373 641 $orderOptions = ['ASC', 'DESC'];
374 642
375 643 // Validate order and orderBy parameters
376 644 if (!in_array($order, $sortOptions) || !in_array($orderBy, $orderOptions)) {
377 - throw new \Exception(__('Invalid sort or orderBy parameter', 'fluent-boards'));
645 + throw new \Exception(esc_html__('Invalid sort or orderBy parameter', 'fluent-boards'));
378 646 }
379 647
380 648 $tasksQuery = Task::where('stage_id', $stage_id)
381 649 ->whereNull('parent_id')
@@ -384,9 +652,9 @@
384 652
385 653 // Apply ordering based on the specified order and orderBy
386 654 switch ($order) {
387 655 case 'priority':
388 - $tasksQuery->orderByRaw("FIELD(priority, 'High', 'Medium', 'Low') {$orderBy}");
656 + $tasksQuery->orderByRaw("FIELD(priority, 'urgent', 'high', 'medium', 'low') {$orderBy}");
389 657 break;
390 658
391 659 case 'due_at':
392 660 if ($orderBy === 'ASC') {
@@ -435,11 +703,11 @@
435 703 public function setDefaultAssignees($stage_id, $assignees)
436 704 {
437 705 $stage = Stage::findOrFail($stage_id);
438 706 if ($stage) {
439 - $oldSettings = $stage->settings;
440 - $oldSettings['default_task_assignees'] = $assignees;
441 - $stage->settings = $oldSettings;
707 + $settings = $stage->settings;
708 + $settings['default_task_assignees'] = $assignees;
709 + $stage->settings = $settings;
442 710 $stage->save();
443 711
444 712 do_action('fluent_boards/default_assignees_updated', $stage, $assignees);
445 713
@@ -444,6 +712,42 @@
444 712 do_action('fluent_boards/default_assignees_updated', $stage, $assignees);
445 713
446 714 return $stage;
447 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);
448 752 }
449 753 }