PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.1 2.2.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 All 44 releases
← All changes | app/Services/StageService.php +262 -32 1.95 → 2.2.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,26 @@
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 + // Reuse task-user relations unless default assignments change them.
516 + $tasks->load(['assignees', 'watchers']);
517 +
346 518 // get the last position available of that stage
347 - $position = (new TaskService())->getLastPositionOfTasks($newStageId);
519 + $taskService = new TaskService();
520 + $position = $taskService->getLastPositionOfTasks($newStageId);
521 + $notificationService = new NotificationService();
522 + $currentUserId = get_current_user_id();
523 + // Stage-email eligibility per watcher, resolved in batches and shared across tasks.
524 + $emailEligibility = [];
348 525
349 526 // update tasks stage and position
350 527 foreach ($tasks as $key => $task) {
528 + $taskOldStageId = $task->stage_id;
529 +
351 530 // Clean up archived_by_stage meta when moving to different stage
352 531 TaskMeta::where('task_id', $task->id)
353 532 ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
354 533 ->delete();
@@ -354,15 +533,68 @@
354 533 ->delete();
355 534
356 535 $task->stage_id = $newStageId;
357 536 $task->position = $position + $key;
537 + $task->setRelation('stage', $targetStage);
358 538 $task->save();
539 +
540 + if ((int) $taskOldStageId !== (int) $newStageId) {
541 + $assignmentsChanged = $taskService->manageDefaultAssignees($task, $newStageId, $targetStage);
542 + if ($targetStage->defaultTaskStatus() === 'closed' && $task->status !== 'closed') {
543 + $task = $task->close();
544 + }
545 + // Assignment hooks may change assignees and watchers before notification.
546 + if ($assignmentsChanged) {
547 + $task->load(['assignees', 'watchers']);
548 + }
549 +
550 + do_action('fluent_boards/task_stage_updated', $task, $taskOldStageId, [
551 + 'source' => $sourceStage,
552 + 'target' => $targetStage,
553 + ]);
554 +
555 + $watcherIds = array_diff(array_map('intval', $task->watchers->pluck('ID')->toArray()), [$currentUserId]);
556 + $unknownIds = array_diff($watcherIds, array_keys($emailEligibility));
557 + if ($unknownIds) {
558 + $emailEligibility += array_fill_keys($unknownIds, false);
559 + $enabledIds = $notificationService->getEmailEnabledUserIds($task->board_id, $unknownIds, Constant::BOARD_EMAIL_STAGE_CHANGE);
560 + foreach ($enabledIds as $enabledId) {
561 + $emailEligibility[$enabledId] = true;
562 + }
563 + }
564 + $recipients = [];
565 + foreach ($task->watchers as $watcher) {
566 + if (!empty($emailEligibility[(int) $watcher->ID])) {
567 + $recipients[] = $watcher->user_email;
568 + }
569 + }
570 + $taskService->sendMailAfterTaskModify('stage_change', $recipients, $task->id);
571 + }
359 572 }
573 +
574 + if ($tasks->count() && (int) $sourceStage->id !== (int) $targetStage->id) {
575 + do_action(
576 + 'fluent_boards/tasks_moved_between_stages',
577 + $sourceStage->board_id,
578 + $sourceStage,
579 + $targetStage,
580 + $tasks->count()
581 + );
582 + }
583 +
360 584 return $tasks;
361 585 }
362 - public function archiveAllTasksInStage($stage_id)
586 + public function archiveAllTasksInStage($stage_id, $boardId = null)
363 587 {
364 - $tasks = Task::where('stage_id', $stage_id)->whereNull('parent_id')->whereNull('archived_at')->get();
588 + $tasksQuery = Task::where('stage_id', absint($stage_id))
589 + ->whereNull('parent_id')
590 + ->whereNull('archived_at');
591 +
592 + if ($boardId) {
593 + $tasksQuery->where('board_id', absint($boardId));
594 + }
595 +
596 + $tasks = $tasksQuery->get();
365 597 foreach ($tasks as $task) {
366 598 $task->position = 0;
367 599 $task->archived_at = current_time('mysql');
368 600 $task->save();
@@ -407,16 +639,14 @@
407 639 $stageToPush['title'] = $stage['title'];
408 640 $stageToPush['board_id'] = $board->id;
409 641 $stageToPush['position'] = $index + 1;
410 642 $stageToPush['slug'] = $this->createSlug($stage['title']);
643 + $closedStageTitles = ['completed', 'done'];
644 + $stageToPush['settings'] = [
645 + 'default_task_status' => in_array(strtolower(Arr::get($stage, 'title')), $closedStageTitles) ? 'closed' : 'open',
646 + 'is_template' => false
647 + ];
411 648
412 - if (Arr::get($stage, 'title') == 'Completed') {
413 - $stageToPush['settings'] = [
414 - 'default_task_status' => 'closed',
415 - 'is_template' => false
416 - ];
417 - }
418 -
419 649 $stage = Stage::create($stageToPush);
420 650 if($index == 0){
421 651 $firstStage = $stage;
422 652 }
@@ -453,9 +683,9 @@
453 683
454 684 // Apply ordering based on the specified order and orderBy
455 685 switch ($order) {
456 686 case 'priority':
457 - $tasksQuery->orderByRaw("FIELD(priority, 'High', 'Medium', 'Low') {$orderBy}");
687 + $tasksQuery->orderByRaw("FIELD(priority, 'urgent', 'high', 'medium', 'low') {$orderBy}");
458 688 break;
459 689
460 690 case 'due_at':
461 691 if ($orderBy === 'ASC') {