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 +230 -31 1.952.1.0 View file →
@@ -5,13 +5,16 @@
5 5 use FluentBoards\App\Models\Task;
6 6 use FluentBoards\App\Models\Board;
7 7 use FluentBoards\App\Models\Stage;
8 8 use FluentBoards\App\Models\TaskMeta;
9 +use FluentBoards\App\Models\Relation;
10 +use FluentBoards\App\Models\User;
9 11 use FluentBoards\App\App;
10 12 use FluentBoards\Framework\Http\Request\Request;
11 13 use FluentBoards\Framework\Support\Arr;
12 14 use FluentBoards\App\Services\Constant;
13 15 use FluentBoards\App\Services\AttachmentFileService;
16 +use FluentBoards\App\Services\PermissionManager;
14 17
15 18 class StageService
16 19 {
17 20 public function createDefaultStages($board)
@@ -58,11 +61,21 @@
58 61 ]
59 62 ]
60 63 ];
61 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 + */
62 74 public function updateStageProperty($col, $value, $stageId)
63 75 {
64 76 $stage = Stage::findOrFail($stageId);
77 + $stageBeforeUpdate = clone $stage;
65 78
66 79 if ('title' == $col) {
67 80 $stage = $this->updateTitle($value, $stage);
68 81 } elseif ('status' == $col) {
@@ -73,8 +86,16 @@
73 86 $stage = $this->updateBackgroundColor($value, $stage);
74 87 } elseif ('archived_at' == $col) {
75 88 $stage = $this->updateArchivedAt($value, $stage);
76 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 +
77 98 return $stage;
78 99 }
79 100
80 101 public function getLastOneMinuteUpdatedStages($boardId, $lastUpdated = null, $includeArchived = true)
@@ -93,8 +114,83 @@
93 114
94 115 return $stagesQuery->get();
95 116 }
96 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 +
97 193 private function updateTitle($value, $stage)
98 194 {
99 195 $stage->title = $value;
100 196 $stage->save();
@@ -172,36 +268,107 @@
172 268 $stage->save();
173 269 }
174 270 }
175 271
176 - public function copyStagesOfBoard($board, $fromBoardId, $isWithTemplates='no')
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)
177 282 {
178 - $stages = Stage::where('board_id', $fromBoardId)->where('type', 'stage')->whereNull('archived_at')->orderBy('position', 'asc')->get();
179 - $stageMapForCopyingTask = array();
180 - foreach($stages as $key => $stage)
181 - {
182 - $stageToSave = array();
183 - $stageToSave['title'] = $stage['title'];
184 - $stageToSave['board_id'] = $board->id;
185 - $stageToSave['slug'] = str_replace(' ', '-', strtolower($stage['title']));
186 - $stageToSave['type'] = 'stage';
187 - $stageToSave['position'] = $key + 1;
188 - $stageToSave['bg_color'] = $stage['bg_color'];
189 - $stageToSave['settings'] = [
190 - '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,
191 316 ];
192 - if (!empty($stage->settings['is_template']) && $isWithTemplates == 'yes') {
193 - $stageToSave['settings']['is_template'] = $stage->settings['is_template'];
194 - }
195 - $newStage = Stage::create($stageToSave);
196 - $stageMapForCopyingTask[$stage['id']] = $newStage->id;
317 +
318 + $newStage = Stage::create($stageData);
319 + $stageMap[$stage->id] = $newStage->id;
197 320 }
198 - return $stageMapForCopyingTask;
321 +
322 + return $stageMap;
199 323 }
200 324
201 325 public function importStagesFromBoard($board_id, $selectedStages, $position = null)
202 326 {
203 - $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 +
204 371 $stageMapForCopyingTask = array();
205 372 $stageIdsToCopy = array();
206 373
207 374 $numberOfStages = Stage::where('board_id', $board_id)->whereNull('archived_at')->count();
@@ -341,14 +508,21 @@
341 508
342 509 public function moveAllTasks($oldStageId, $newStageId)
343 510 {
344 511 $tasks = Task::where('stage_id', $oldStageId)->whereNull('parent_id')->whereNull('archived_at')->get();
512 + $sourceStage = Stage::findOrFail($oldStageId);
513 + $targetStage = Stage::findOrFail($newStageId);
345 514
515 + // Load shared watcher data once; stage data is injected per task below.
516 + $tasks->load('watchers');
517 +
346 518 // get the last position available of that stage
347 519 $position = (new TaskService())->getLastPositionOfTasks($newStageId);
348 520
349 521 // update tasks stage and position
350 522 foreach ($tasks as $key => $task) {
523 + $taskOldStageId = $task->stage_id;
524 +
351 525 // Clean up archived_by_stage meta when moving to different stage
352 526 TaskMeta::where('task_id', $task->id)
353 527 ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
354 528 ->delete();
@@ -354,15 +528,42 @@
354 528 ->delete();
355 529
356 530 $task->stage_id = $newStageId;
357 531 $task->position = $position + $key;
532 + $task->setRelation('stage', $targetStage);
358 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 + }
359 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 +
360 553 return $tasks;
361 554 }
362 - public function archiveAllTasksInStage($stage_id)
555 + public function archiveAllTasksInStage($stage_id, $boardId = null)
363 556 {
364 - $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();
365 566 foreach ($tasks as $task) {
366 567 $task->position = 0;
367 568 $task->archived_at = current_time('mysql');
368 569 $task->save();
@@ -407,16 +608,14 @@
407 608 $stageToPush['title'] = $stage['title'];
408 609 $stageToPush['board_id'] = $board->id;
409 610 $stageToPush['position'] = $index + 1;
410 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 + ];
411 617
412 - if (Arr::get($stage, 'title') == 'Completed') {
413 - $stageToPush['settings'] = [
414 - 'default_task_status' => 'closed',
415 - 'is_template' => false
416 - ];
417 - }
418 -
419 618 $stage = Stage::create($stageToPush);
420 619 if($index == 0){
421 620 $firstStage = $stage;
422 621 }
@@ -453,9 +652,9 @@
453 652
454 653 // Apply ordering based on the specified order and orderBy
455 654 switch ($order) {
456 655 case 'priority':
457 - $tasksQuery->orderByRaw("FIELD(priority, 'High', 'Medium', 'Low') {$orderBy}");
656 + $tasksQuery->orderByRaw("FIELD(priority, 'urgent', 'high', 'medium', 'low') {$orderBy}");
458 657 break;
459 658
460 659 case 'due_at':
461 660 if ($orderBy === 'ASC') {