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/BoardService.php +304 -187 1.95.22.1.0 View file →
@@ -2,10 +2,12 @@
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 5 use FluentBoards\App\Models\Activity;
6 +use FluentBoards\App\Models\Attachment;
6 7 use FluentBoards\App\Models\Board;
7 8 use FluentBoards\App\Models\Comment;
9 +use FluentBoards\App\Models\Folder;
8 10 use FluentBoards\App\Models\Label;
9 11 use FluentBoards\App\Models\Meta;
10 12 use FluentBoards\App\Models\Relation;
11 13 use FluentBoards\App\Models\Stage;
@@ -12,15 +14,21 @@
12 14 use FluentBoards\App\Models\Task;
13 15 use FluentBoards\App\Models\TaskMeta;
14 16 use FluentBoards\App\Models\User;
15 17 use FluentBoards\App\Services\Libs\FileSystem;
16 -use FluentBoardsPro\App\Models\Folder;
18 +use FluentBoards\App\Services\DescriptionMarkdownConverter;
17 19
18 20 class BoardService
19 21 {
22 + private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
23 +
20 24 public function getBoardsByType($type)
21 25 {
22 - return Board::where('type', $type)->whereNull('archived_at')->orderBy('created_at', 'ASC')->get();
26 + return Board::where('type', sanitize_text_field($type))
27 + ->whereNull('archived_at')
28 + ->byAccessUser(get_current_user_id())
29 + ->orderBy('created_at', 'ASC')
30 + ->get();
23 31 }
24 32
25 33 public function deleteBoard($boardId)
26 34 {
@@ -142,9 +150,9 @@
142 150 {
143 151 $boardData = [
144 152 'title' => $boardData['title'],
145 153 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
146 - 'description' => $boardData['description'],
154 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
147 155 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
148 156 'background' => isset($boardData['background']) ? $boardData['background'] : '',
149 157 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
150 158 ];
@@ -157,12 +165,23 @@
157 165
158 166 return $board;
159 167 }
160 168
169 + /**
170 + * Attach a user-owned board to its creator with Board Admin preferences.
171 + *
172 + * @param Board $board
173 + * @return void
174 + */
161 175 public function setCurrentUserPreferencesOnBoardCreate($board)
162 176 {
177 + $creatorId = absint($board->created_by);
178 + if (!$creatorId) {
179 + return;
180 + }
181 +
163 182 $board->users()->attach(
164 - $board->created_by,
183 + $creatorId,
165 184 [
166 185 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
167 186 'settings' => maybe_serialize([
168 187 Constant::IS_BOARD_ADMIN => true
@@ -210,9 +229,18 @@
210 229 ->first();
211 230 if ($recentlyOpened) {
212 231 $recentBoardIds = $recentlyOpened->value;
213 232
233 + // Recently opened meta can be empty or legacy-shaped; only splice a usable board ID list.
234 + if (!is_array($recentBoardIds)) {
235 + return;
236 + }
237 +
214 238 $index = array_search($boardId, $recentBoardIds);
239 + if ($index === false) {
240 + return;
241 + }
242 +
215 243 array_splice($recentBoardIds, $index, 1);
216 244
217 245 $recentlyOpened->value = $recentBoardIds;
218 246 $recentlyOpened->save();
@@ -227,9 +255,9 @@
227 255 } else {
228 256 throw new \Exception(esc_html__('Title cannot be empty', 'fluent-boards'));
229 257 }
230 258 if (isset($data['description'])) {
231 - $data['description'] = $data['description'];
259 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
232 260 }
233 261 $board->fill($data);
234 262 $board->save();
235 263 // do_action('fluent_boards/board_updated', $board);
@@ -286,12 +314,19 @@
286 314 }
287 315 return $processedStages;
288 316 }
289 317
318 + /**
319 + * Archive a stage and persist the user who archived it for future archive-list metadata.
320 + */
290 321 public function archiveStage($boardId, $stage)
291 322 {
323 + $settings = $stage->settings ?: [];
324 + $settings['archived_by_id'] = absint(get_current_user_id()) ?: null;
325 +
292 326 $stage->archived_at = current_time('mysql');
293 327 $stage->position = 0;
328 + $stage->settings = $settings;
294 329 $stage->save();
295 330
296 331 do_action('fluent_boards/stage_archived', $boardId, $stage); // Old hook
297 332 do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
@@ -297,14 +332,21 @@
297 332 do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
298 333 return $stage;
299 334 }
300 335
336 + /**
337 + * Restore an archived stage and clear stale archived-by metadata.
338 + */
301 339 public function restoreStage($boardId, $stage)
302 340 {
303 341 $stageService = new StageService();
304 342 $lastStagePosition = $stageService->getLastPositionOfStagesOfBoard($stage->board_id);
343 + $settings = $stage->settings ?: [];
344 + $settings['archived_by_id'] = null;
345 +
305 346 $stage->archived_at = null;
306 347 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
348 + $stage->settings = $settings;
307 349 $stage->save();
308 350 do_action('fluent_boards/board_stage_restored', $boardId, $stage->title); // Old hook
309 351 do_action('fluent_boards/stage_restored_with_tasks', $boardId, $stage); // New hook
310 352 return $stage;
@@ -331,14 +373,29 @@
331 373
332 374 return $isAlreadyMember ?? false;
333 375 }
334 376
377 + /**
378 + * Add a WordPress user to a board.
379 + *
380 + * @return User|false|null User on success, false for an existing relation,
381 + * or null when the board/user does not exist.
382 + */
335 383 public function addMembersInBoard($boardId, $memberId, $isViewerOnly = null)
336 384 {
385 + $boardId = intval($boardId);
386 + $memberId = intval($memberId);
387 + $isViewerOnly = sanitize_text_field((string)$isViewerOnly);
388 +
389 + if ($boardId <= 0 || $memberId <= 0) {
390 + return null;
391 + }
392 +
337 393 $board = Board::find($boardId);
394 + $boardMember = User::find($memberId);
338 395
339 - if (!$board) {
340 - return false;
396 + if (!$board || !$boardMember) {
397 + return null;
341 398 }
342 399 $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
343 400 if($isAlreadyMember) {
344 401 return false;
@@ -358,9 +415,8 @@
358 415 'settings' => maybe_serialize($settings),
359 416 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
360 417 ]
361 418 );
362 - $boardMember = User::find($memberId);
363 419 if(!$isViewerOnly) {
364 420 do_action('fluent_boards/board_member_added', $boardId, $boardMember);
365 421 } else {
366 422 do_action('fluent_boards/board_viewer_added', $boardId, $boardMember);
@@ -402,8 +458,75 @@
402 458 $user['is_board_admin'] = false;
403 459 return $user;
404 460 }
405 461
462 + /**
463 + * Create or update a board access relation with the selected member role.
464 + */
465 + public function syncBoardUserRole($boardId, $userId, $role)
466 + {
467 + $boardId = absint($boardId);
468 + $userId = absint($userId);
469 + $role = sanitize_text_field($role);
470 +
471 + if (!$boardId || !$userId || !in_array($role, ['admin', 'member', 'viewer'], true)) {
472 + return false;
473 + }
474 +
475 + $board = Board::find($boardId);
476 + $user = User::find($userId);
477 +
478 + if (!$board || !$user) {
479 + return false;
480 + }
481 +
482 + $boardUser = Relation::where('object_id', $boardId)
483 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
484 + ->where('foreign_id', $userId)
485 + ->first();
486 +
487 + $previousSettings = $boardUser ? (array)$boardUser->settings : [];
488 +
489 + // Board roles live as flags on the board_user relation; member access means both flags stay false.
490 + $settings = [
491 + 'is_admin' => 'admin' === $role,
492 + 'is_viewer_only' => 'viewer' === $role,
493 + ];
494 +
495 + if ($boardUser) {
496 + $boardUser->settings = $settings;
497 + $boardUser->save();
498 + } else {
499 + // New access should get the same default notification preferences as the normal add-member flow.
500 + $board->users()->attach(
501 + $userId,
502 + [
503 + 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
504 + 'settings' => maybe_serialize($settings),
505 + 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
506 + ]
507 + );
508 + }
509 +
510 + // Only emit admin transition hooks when the role actually changes.
511 + if ('admin' === $role && empty($previousSettings['is_admin'])) {
512 + do_action('fluent_boards/board_admin_added', $boardId, $userId);
513 + } elseif (!empty($previousSettings['is_admin'])) {
514 + do_action('fluent_boards/board_admin_removed', $boardId, $userId);
515 + }
516 +
517 + if ('viewer' === $role) {
518 + do_action('fluent_boards/board_viewer_added', $boardId, $user);
519 + } elseif ('member' === $role) {
520 + do_action('fluent_boards/board_member_added', $boardId, $user);
521 + }
522 +
523 + $user['is_admin'] = 'admin' === $role;
524 + $user['is_board_admin'] = 'admin' === $role;
525 +
526 + return $user;
527 + }
528 +
406 529 public function getUsersOfBoards()
407 530 {
408 531 $userBoards = Relation::whereNotNull('board_id')
409 532 ->where('user_id', get_current_user_id())
@@ -412,37 +535,68 @@
412 535 return $userBoards;
413 536 }
414 537
415 538 /**
416 - * change board background
417 - * @param mixed $backgroundData
418 - * @return string
539 + * Change or clear the board background.
540 + *
541 + * Image attachments must belong to the target board and use the board
542 + * background attachment type before their identifiers can be persisted.
543 + *
544 + * @param array $backgroundData
545 + * @param int $board_id
546 + * @return array|string
547 + * @throws \Exception
419 548 */
420 -
421 549 public function setBoardBackground($backgroundData, $board_id)
422 550 {
423 - $board = Board::find($board_id);
551 + $boardId = absint($board_id);
552 + $board = Board::find($boardId);
553 +
554 + if (!$board) {
555 + throw new \Exception(esc_html__('Board not found.', 'fluent-boards'));
556 + }
557 +
424 558 $oldBackground = $board->background;
559 +
560 + if (!empty($backgroundData['reset'])) {
561 + $board->background = '';
562 + $board->save();
563 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
564 +
565 + return $board->background;
566 + }
567 +
425 568 $background = $board->background;
426 -
427 - // if board background has color
428 - if (isset($backgroundData['color'])) {
429 - $background['color'] = $backgroundData['color'];
430 - $background['image_url'] = null;
431 - $background['is_image'] = false;
569 + if (!is_array($background)) {
570 + $background = [];
432 571 }
433 572
434 - // if board background has image
573 + // Resolve image metadata from the board-owned attachment, never from the client URL.
435 574 if (isset($backgroundData['image_url'])) {
436 - $background['image_url'] = $backgroundData['image_url'];
575 + $attachmentId = absint($backgroundData['id'] ?? 0);
576 + $attachment = Attachment::where('id', $attachmentId)
577 + ->where('object_id', $boardId)
578 + ->where('object_type', Constant::BOARD_BACKGROUND_IMAGE)
579 + ->first();
580 +
581 + if (!$attachment) {
582 + throw new \Exception(esc_html__('Background image not found.', 'fluent-boards'));
583 + }
584 +
585 + $background['id'] = (int) $attachment->id;
586 + $background['image_url'] = (new CommentService())->createPublicUrl($attachment, $boardId);
437 587 $background['is_image'] = true;
438 588 $background['color'] = null;
589 + } elseif (isset($backgroundData['color'])) {
590 + $background['id'] = $backgroundData['id'];
591 + $background['color'] = $backgroundData['color'];
592 + $background['image_url'] = null;
593 + $background['is_image'] = false;
439 594 }
440 - $background['id'] = $backgroundData['id'];
441 595
442 596 $board->background = $background;
443 597 $board->save();
444 - do_action('fluent_boards/board_background_updated', $board_id, $oldBackground);
598 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
445 599
446 600 return $board->background;
447 601 }
448 602
@@ -655,9 +809,15 @@
655 809
656 810 $this->updateRecentBoardCheckMeta();
657 811 }
658 812
659 - return Board::whereIn('id', $recentBoardIds)->withCount('completedTasks')->with(['stages', 'users'])->get();
813 + return Board::whereIn('id', $recentBoardIds)
814 + ->whereNull('archived_at')
815 + ->excludeTemplates()
816 + ->availableInCurrentInstall()
817 + ->withCount('completedTasks')
818 + ->with(['stages', 'users'])
819 + ->get();
660 820 }
661 821
662 822 public function getRecentBoardCheckMeta($userId = null){
663 823 if (!$userId) {
@@ -755,10 +915,15 @@
755 915
756 916 $contactOfBoard->delete();
757 917 }
758 918
759 - public function sendInvitationToBoard($boardId, $email)
919 + public function sendInvitationToBoard($boardId, $email, $role = 'member')
760 920 {
921 + $role = sanitize_text_field($role);
922 + if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
923 + $role = 'member';
924 + }
925 +
761 926 $user = User::query()->where('user_email', $email)->first();
762 927
763 928 if ($user) {
764 929 return $user;
@@ -765,9 +930,9 @@
765 930 }
766 931
767 932 $current_user_id = get_current_user_id();
768 933
769 - do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id);
934 + do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
770 935
771 936 return;
772 937
773 938 }
@@ -779,11 +944,35 @@
779 944 ->where('key', Constant::BOARD_INVITATION)
780 945 ->get();
781 946 }
782 947
783 - public function deleteInvitation($invitationId)
948 + /**
949 + * Delete an invitation only when it belongs to the supplied board.
950 + *
951 + * The optional second argument lets older Pro releases receive a controlled
952 + * error instead of reporting a successful deletion that never happened.
953 + */
954 + public function deleteInvitation($boardId, $invitationId = null)
784 955 {
785 - Meta::findOrFail($invitationId)->delete();
956 + if ($invitationId === null) {
957 + throw new \Exception(
958 + __('A board ID is required to delete an invitation.', 'fluent-boards')
959 + );
960 + }
961 +
962 + $boardId = intval($boardId);
963 + $invitationId = intval($invitationId);
964 +
965 + if ($boardId <= 0 || $invitationId <= 0) {
966 + return false;
967 + }
968 +
969 + return (bool) Meta::query()
970 + ->where('id', $invitationId)
971 + ->where('object_id', $boardId)
972 + ->where('object_type', Constant::OBJECT_TYPE_BOARD)
973 + ->where('key', Constant::BOARD_INVITATION)
974 + ->delete();
786 975 }
787 976
788 977 public function hasDataChanged($boardId, $includeArchived = false, $since = null)
789 978 {
@@ -951,16 +1140,46 @@
951 1140
952 1141 return (string) $timestamp;
953 1142 }
954 1143
955 - public function getAssociatedBoards($associatedId)
1144 + /**
1145 + * Get CRM-associated boards that the current user can access.
1146 + *
1147 + * @param int $associatedId CRM contact/subscriber id.
1148 + * @param int|null $userId WordPress user id used for board access checks.
1149 + * @return \FluentBoards\Framework\Database\Orm\Collection|array
1150 + */
1151 + public function getAssociatedBoards($associatedId, $userId = null)
956 1152 {
1153 + $associatedId = absint($associatedId);
1154 + $userId = $userId ?: get_current_user_id();
1155 +
1156 + if (!$associatedId || !$userId) {
1157 + return [];
1158 + }
1159 +
957 1160 $boardIds = Meta::query()->where('value', $associatedId)
958 1161 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
959 - ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
1162 + ->whereIn('key', [
1163 + Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1164 + self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1165 + ])
960 1166 ->pluck('object_id');
961 1167
962 - return Board::query()->whereIn('id', $boardIds)->with('stages', 'users')->get();
1168 + $boards = Board::query()
1169 + ->whereIn('id', array_values(array_unique(array_map('intval', $boardIds->toArray()))))
1170 + ->whereNull('archived_at')
1171 + ->byAccessUser($userId)
1172 + ->withCount('completedTasks')
1173 + ->with(['stages', 'users'])
1174 + ->orderBy('created_at', 'DESC')
1175 + ->get();
1176 +
1177 + foreach ($boards as $board) {
1178 + $board->users = Helper::sanitizeUserCollections($board->users);
1179 + }
1180 +
1181 + return $boards;
963 1182 }
964 1183
965 1184 private function deleteBoardMeta($boardId)
966 1185 {
@@ -972,8 +1191,11 @@
972 1191 public function copyBoard($boardData)
973 1192 {
974 1193 $sourceBoard = Board::findOrFail($boardData['source_board_id']);
975 1194 $boardData['background'] = $sourceBoard->background;
1195 + if (isset($boardData['description'])) {
1196 + $boardData['description'] = DescriptionMarkdownConverter::normalize($boardData['description']);
1197 + }
976 1198 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
977 1199
978 1200 $board = Board::create($boardData);
979 1201
@@ -981,162 +1203,8 @@
981 1203
982 1204 return $board;
983 1205 }
984 1206
985 - public function getBoardReports($board_id)
986 - {
987 - $board = Board::findOrFail($board_id);
988 - $taskQuery = Task::where('board_id', $board_id)
989 - ->whereNull('parent_id')
990 - ->whereNull('archived_at');
991 -
992 - if($board->type == 'roadmap') {
993 - $pendingStage = $this->getNewIdeaStage($board->id);
994 - return $this->getIdeaReports($taskQuery, $pendingStage);
995 - } else {
996 - return $this->getTaskReports($taskQuery);
997 - }
998 - }
999 -
1000 - private function getNewIdeaStage($boardId)
1001 - {
1002 - return Stage::where('board_id', $boardId)
1003 - ->where('type', 'stage')
1004 - ->where('archived_at', null)
1005 - ->orderBy('position', 'ASC')
1006 - ->first();
1007 - }
1008 -
1009 - public function getAllBoardReports(){
1010 - $userId = get_current_user_id();
1011 -
1012 - $taskQuery = Task::whereNull('parent_id')
1013 - ->whereNull('archived_at')
1014 - ->whereHas('board', function ($query) {
1015 - $query->where('type', 'to-do');
1016 - });
1017 -
1018 - if (!PermissionManager::isAdmin($userId))
1019 - {
1020 - $currentUser = User::find($userId);
1021 - $relatedBoardIds = $currentUser->whichBoards->where('type', 'to-do')->pluck('id');
1022 - $taskQuery->whereIn('board_id', $relatedBoardIds);
1023 - }
1024 -
1025 - return $this->getTaskReports($taskQuery);
1026 - }
1027 -
1028 - private function getTaskReports($taskQuery)
1029 - {
1030 - $totalTasksQuery = clone $taskQuery;
1031 - $completedTaskQuery = clone $taskQuery;
1032 - $openTaskQuery = clone $taskQuery;
1033 - $overDueTaskQuery = clone $taskQuery;
1034 -
1035 - $completedTaskCount = $completedTaskQuery->where('status', 'closed')->count();
1036 - $openTaskCount = $openTaskQuery->where('status', 'open')->count();
1037 - $overDueTasks = $overDueTaskQuery->overdue(true)->count();
1038 - $totalTasks = $totalTasksQuery->count();
1039 -
1040 - $taskQuery->where('status', 'open');
1041 -
1042 - $highQuery = clone $taskQuery;
1043 - $mediumQuery = clone $taskQuery;
1044 - $lowQuery = clone $taskQuery;
1045 -
1046 - $high = $highQuery->where('priority', 'high')->count();
1047 - $low = $mediumQuery->where('priority', 'low')->count();
1048 - $medium = $lowQuery->where('priority', 'medium')->count();
1049 -
1050 - $reportData = [
1051 - 'completion' => [
1052 - 'completed' => $completedTaskCount,
1053 - 'incomplete' => $openTaskCount,
1054 - 'overdue' => $overDueTasks,
1055 - 'total' => $totalTasks
1056 - ],
1057 - 'priority' => [
1058 - 'high' => $high,
1059 - 'medium' => $medium,
1060 - 'low' => $low
1061 - ]
1062 -
1063 - ];
1064 - return $reportData;
1065 - }
1066 -
1067 - private function getIdeaReports($taskQuery, $pendingStage)
1068 - {
1069 - $pendingIdeaQuery = clone $taskQuery;
1070 - $completedIdeaQuery = clone $taskQuery;
1071 - $openIdeaQueryPage = clone $taskQuery;
1072 - $openIdeaQueryWeb = clone $taskQuery;
1073 -
1074 - $pendingIdeaCount = $pendingIdeaQuery->where('status', 'open')->where('stage_id', $pendingStage->id)->count();
1075 - $completedIdeaCount = $completedIdeaQuery->where('status', 'closed')->count();
1076 - $openIdeaCountPage = $openIdeaQueryPage->where('status', 'open')->where('source', 'page')->count();
1077 - $openIdeaCountWeb = $openIdeaQueryWeb->where('status', 'open')->where('source', 'web')->count();
1078 - $totalIdeas = $openIdeaCountPage + $openIdeaCountWeb;
1079 -
1080 - $taskQuery->where('status', 'open');
1081 -
1082 - $highQuery = clone $taskQuery;
1083 - $mediumQuery = clone $taskQuery;
1084 - $lowQuery = clone $taskQuery;
1085 -
1086 - $high = $highQuery->where('priority', 'high')->count();
1087 - $low = $mediumQuery->where('priority', 'low')->count();
1088 - $medium = $lowQuery->where('priority', 'medium')->count();
1089 -
1090 - $reportData = [
1091 - 'completion' => [
1092 - 'pending' => $pendingIdeaCount,
1093 - 'completed' => $completedIdeaCount,
1094 - 'ideaFromPage' => $openIdeaCountPage,
1095 - 'total' => $totalIdeas
1096 - ],
1097 - 'priority' => [
1098 - 'high' => $high,
1099 - 'medium' => $medium,
1100 - 'low' => $low
1101 - ]
1102 - ];
1103 - return $reportData;
1104 - }
1105 -
1106 - public function getStageWiseBoardReports($board_id)
1107 - {
1108 - $stages = Stage::where('board_id', $board_id)
1109 - ->where('type', 'stage')
1110 - ->whereNull('archived_at')
1111 - ->get();
1112 -
1113 - foreach ($stages as $stage) {
1114 - $completedTaskCount = Task::where('stage_id', $stage->id)
1115 - ->where('status', 'closed')
1116 - ->count();
1117 -
1118 - $openTaskCount = Task::where('stage_id', $stage->id)
1119 - ->whereNull('due_at')
1120 - ->where('status', 'open')
1121 - ->count();
1122 -
1123 - $overDue = Task::where('stage_id', $stage->id)
1124 - ->whereNotNull('due_at')
1125 - ->where('status', 'open')
1126 - ->overdue(true)
1127 - ->count();
1128 -
1129 - $stage->report = [
1130 - 'completed' => $completedTaskCount,
1131 - 'incomplete' => $openTaskCount,
1132 - 'overdue' => $overDue
1133 - ];
1134 - }
1135 -
1136 - return $stages;
1137 - }
1138 -
1139 1207 public function archiveBoard($boardId)
1140 1208 {
1141 1209 $board = Board::findOrFail($boardId);
1142 1210 $board->archived_at = current_time('mysql');
@@ -1205,8 +1273,54 @@
1205 1273
1206 1274 return $pinnedBoardMeta;
1207 1275 }
1208 1276
1277 + /**
1278 + * Sidebar counts cover every board the user can access, so they are counted
1279 + * with their own queries rather than derived from the filtered/paginated list.
1280 + *
1281 + * byAccessUser() re-reads the user's accessible board ids from the database on
1282 + * every call, so the access scope is resolved once and cloned per count.
1283 + *
1284 + * @return array{all: int, pinned: int, archived: int}
1285 + */
1286 + public function getBoardCounts($userId)
1287 + {
1288 + $baseQuery = Board::byAccessUser($userId)
1289 + ->excludeTemplates()
1290 + ->availableInCurrentInstall();
1291 +
1292 + $counts = [
1293 + 'all' => (clone $baseQuery)->whereNull('archived_at')->count(),
1294 + 'pinned' => 0,
1295 + 'archived' => (clone $baseQuery)->whereNotNull('archived_at')->count()
1296 + ];
1297 +
1298 + $pinnedIds = $this->getPinnedBoardIds();
1299 +
1300 + if ($pinnedIds) {
1301 + $counts['pinned'] = (clone $baseQuery)->whereNull('archived_at')
1302 + ->whereIn('id', $pinnedIds)
1303 + ->count();
1304 + }
1305 +
1306 + return $counts;
1307 + }
1308 +
1309 + /**
1310 + * @return array board ids the current user has pinned
1311 + */
1312 + public function getPinnedBoardIds()
1313 + {
1314 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1315 +
1316 + if (!$pinnedBoardMeta) {
1317 + return [];
1318 + }
1319 +
1320 + return array_map('intval', (array) $pinnedBoardMeta->value);
1321 + }
1322 +
1209 1323 public function getPinnedBoards()
1210 1324 {
1211 1325 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1212 1326
@@ -1217,9 +1331,12 @@
1217 1331
1218 1332 // Convert to array of integers
1219 1333 $intIds = array_map('intval', $ids);
1220 1334
1221 - return Board::whereIn('id', $intIds)->whereNull('archived_at')->get();
1335 + return Board::whereIn('id', $intIds)
1336 + ->whereNull('archived_at')
1337 + ->byAccessUser(get_current_user_id())
1338 + ->get();
1222 1339 }
1223 1340 }
1224 1341
1225 1342 public function pinBoard($boardId)
@@ -1301,9 +1418,9 @@
1301 1418 if (!$relation) {
1302 1419 return null;
1303 1420 }
1304 1421
1305 - return Folder::findOrFail($relation->object_id);
1422 + return Folder::find($relation->object_id);
1306 1423 }
1307 1424
1308 1425 public function deleteWebhookData($boardId)
1309 1426 {