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.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 All 43 releases
← All changes | app/Services/BoardService.php +420 -167 1.95.3 → 2.2.0 View file →
@@ -1,11 +1,14 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\App\Hooks\Handlers\FileHandler;
5 6 use FluentBoards\App\Models\Activity;
7 +use FluentBoards\App\Models\Attachment;
6 8 use FluentBoards\App\Models\Board;
7 9 use FluentBoards\App\Models\Comment;
10 +use FluentBoards\App\Models\Folder;
8 11 use FluentBoards\App\Models\Label;
9 12 use FluentBoards\App\Models\Meta;
10 13 use FluentBoards\App\Models\Relation;
11 14 use FluentBoards\App\Models\Stage;
@@ -12,12 +15,15 @@
12 15 use FluentBoards\App\Models\Task;
13 16 use FluentBoards\App\Models\TaskMeta;
14 17 use FluentBoards\App\Models\User;
15 18 use FluentBoards\App\Services\Libs\FileSystem;
16 -use FluentBoardsPro\App\Models\Folder;
19 +use FluentBoards\App\Services\DescriptionMarkdownConverter;
20 +use FluentBoardsPro\App\Models\CustomField;
17 21
18 22 class BoardService
19 23 {
24 + private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
25 +
20 26 public function getBoardsByType($type)
21 27 {
22 28 return Board::where('type', sanitize_text_field($type))
23 29 ->whereNull('archived_at')
@@ -42,8 +48,39 @@
42 48
43 49 // Delete time tracking records for all tasks in the board
44 50 (new TaskService())->deleteTimeTrackingRecords($allTaskIdsInBoard->toArray());
45 51
52 + // Task files, task description images and the background image are keyed by
53 + // task or board id. Their local files go with the board_<id> directory below;
54 + // files on a cloud driver are queued for deletion once the rows are gone.
55 + $boardAttachments = function () use ($allTaskIdsInBoard, $boardId) {
56 + // One group, so filters chained on the result apply to both branches.
57 + return Attachment::where(function ($owned) use ($allTaskIdsInBoard, $boardId) {
58 + $owned->where(function ($query) use ($allTaskIdsInBoard) {
59 + $query->whereIn('object_id', $allTaskIdsInBoard)
60 + ->whereIn('object_type', [Constant::TASK_ATTACHMENT, Constant::TASK_DESCRIPTION]);
61 + })->orWhere(function ($query) use ($boardId) {
62 + $query->where('object_id', $boardId)
63 + ->where('object_type', Constant::BOARD_BACKGROUND_IMAGE);
64 + });
65 + });
66 + };
67 + $remoteFiles = $boardAttachments()
68 + ->where('attachment_type', '!=', 'url')
69 + ->whereNotNull('driver')
70 + ->where('driver', '!=', 'local')
71 + ->get(['file_path', 'driver'])
72 + ->filter(function ($attachment) {
73 + return !empty($attachment->file_path);
74 + })
75 + ->map(function ($attachment) {
76 + return ['file_path' => $attachment->file_path, 'driver' => $attachment->driver];
77 + })
78 + ->values()
79 + ->toArray();
80 + $boardAttachments()->delete();
81 + $this->queueRemoteAttachmentFileDeletes($remoteFiles);
82 +
46 83 Task::whereIn('id', $allTaskIdsInBoard)->delete();
47 84
48 85 // delete all activities
49 86 Activity::whereIn('object_id', $allTaskIdsInBoard)->where('object_type', Constant::ACTIVITY_TASK)->delete();
@@ -91,8 +128,29 @@
91 128 $board->delete();
92 129 FileSystem::deleteDir('board_'.$boardId);
93 130 }
94 131
132 + /**
133 + * Hand remote attachment files to the storage add-on in the background.
134 + *
135 + * A board can hold hundreds of cloud files, one HTTP request each, so they
136 + * are deleted by Action Scheduler in batches instead of inside this request.
137 + * Without Action Scheduler they are deleted right away.
138 + *
139 + * @param array $files List of ['file_path' => string, 'driver' => string].
140 + * @return void
141 + */
142 + private function queueRemoteAttachmentFileDeletes($files)
143 + {
144 + foreach (array_chunk($files, 50) as $batch) {
145 + if (function_exists('as_enqueue_async_action')) {
146 + as_enqueue_async_action('fluent_boards/delete_remote_attachment_files', [$batch], 'fluent-boards');
147 + } else {
148 + (new FileHandler())->deleteRemoteAttachmentFiles($batch);
149 + }
150 + }
151 + }
152 +
95 153 public function fetchBoardMeta($boardId)
96 154 {
97 155 $boardMeta = Meta::where('object_id', $boardId)
98 156 ->where('object_type', 'board')
@@ -146,9 +204,9 @@
146 204 {
147 205 $boardData = [
148 206 'title' => $boardData['title'],
149 207 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
150 - 'description' => $boardData['description'],
208 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
151 209 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
152 210 'background' => isset($boardData['background']) ? $boardData['background'] : '',
153 211 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
154 212 ];
@@ -161,12 +219,23 @@
161 219
162 220 return $board;
163 221 }
164 222
223 + /**
224 + * Attach a user-owned board to its creator with Board Admin preferences.
225 + *
226 + * @param Board $board
227 + * @return void
228 + */
165 229 public function setCurrentUserPreferencesOnBoardCreate($board)
166 230 {
231 + $creatorId = absint($board->created_by);
232 + if (!$creatorId) {
233 + return;
234 + }
235 +
167 236 $board->users()->attach(
168 - $board->created_by,
237 + $creatorId,
169 238 [
170 239 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
171 240 'settings' => maybe_serialize([
172 241 Constant::IS_BOARD_ADMIN => true
@@ -214,9 +283,18 @@
214 283 ->first();
215 284 if ($recentlyOpened) {
216 285 $recentBoardIds = $recentlyOpened->value;
217 286
287 + // Recently opened meta can be empty or legacy-shaped; only splice a usable board ID list.
288 + if (!is_array($recentBoardIds)) {
289 + return;
290 + }
291 +
218 292 $index = array_search($boardId, $recentBoardIds);
293 + if ($index === false) {
294 + return;
295 + }
296 +
219 297 array_splice($recentBoardIds, $index, 1);
220 298
221 299 $recentlyOpened->value = $recentBoardIds;
222 300 $recentlyOpened->save();
@@ -231,9 +309,9 @@
231 309 } else {
232 310 throw new \Exception(esc_html__('Title cannot be empty', 'fluent-boards'));
233 311 }
234 312 if (isset($data['description'])) {
235 - $data['description'] = $data['description'];
313 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
236 314 }
237 315 $board->fill($data);
238 316 $board->save();
239 317 // do_action('fluent_boards/board_updated', $board);
@@ -290,12 +368,19 @@
290 368 }
291 369 return $processedStages;
292 370 }
293 371
372 + /**
373 + * Archive a stage and persist the user who archived it for future archive-list metadata.
374 + */
294 375 public function archiveStage($boardId, $stage)
295 376 {
377 + $settings = $stage->settings ?: [];
378 + $settings['archived_by_id'] = absint(get_current_user_id()) ?: null;
379 +
296 380 $stage->archived_at = current_time('mysql');
297 381 $stage->position = 0;
382 + $stage->settings = $settings;
298 383 $stage->save();
299 384
300 385 do_action('fluent_boards/stage_archived', $boardId, $stage); // Old hook
301 386 do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
@@ -301,14 +386,21 @@
301 386 do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
302 387 return $stage;
303 388 }
304 389
390 + /**
391 + * Restore an archived stage and clear stale archived-by metadata.
392 + */
305 393 public function restoreStage($boardId, $stage)
306 394 {
307 395 $stageService = new StageService();
308 396 $lastStagePosition = $stageService->getLastPositionOfStagesOfBoard($stage->board_id);
397 + $settings = $stage->settings ?: [];
398 + $settings['archived_by_id'] = null;
399 +
309 400 $stage->archived_at = null;
310 401 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
402 + $stage->settings = $settings;
311 403 $stage->save();
312 404 do_action('fluent_boards/board_stage_restored', $boardId, $stage->title); // Old hook
313 405 do_action('fluent_boards/stage_restored_with_tasks', $boardId, $stage); // New hook
314 406 return $stage;
@@ -315,10 +407,10 @@
315 407 }
316 408
317 409 public function getActivities($id, $data)
318 410 {
319 - $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
320 - $page = isset($data['page']) ? $data['page'] : 1;
411 + $per_page = Sanitize::perPage($data['per_page'] ?? 40);
412 + $page = max(1, (int) ($data['page'] ?? 1));
321 413 $activities = Activity::where('object_id', $id)->where('object_type', Constant::ACTIVITY_BOARD)->with(['user'])
322 414 ->orderBy('id', 'DESC')
323 415 ->paginate($per_page, ['*'], 'page', $page);
324 416
@@ -335,14 +427,29 @@
335 427
336 428 return $isAlreadyMember ?? false;
337 429 }
338 430
431 + /**
432 + * Add a WordPress user to a board.
433 + *
434 + * @return User|false|null User on success, false for an existing relation,
435 + * or null when the board/user does not exist.
436 + */
339 437 public function addMembersInBoard($boardId, $memberId, $isViewerOnly = null)
340 438 {
439 + $boardId = intval($boardId);
440 + $memberId = intval($memberId);
441 + $isViewerOnly = sanitize_text_field((string)$isViewerOnly);
442 +
443 + if ($boardId <= 0 || $memberId <= 0) {
444 + return null;
445 + }
446 +
341 447 $board = Board::find($boardId);
448 + $boardMember = User::find($memberId);
342 449
343 - if (!$board) {
344 - return false;
450 + if (!$board || !$boardMember) {
451 + return null;
345 452 }
346 453 $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
347 454 if($isAlreadyMember) {
348 455 return false;
@@ -362,9 +469,8 @@
362 469 'settings' => maybe_serialize($settings),
363 470 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
364 471 ]
365 472 );
366 - $boardMember = User::find($memberId);
367 473 if(!$isViewerOnly) {
368 474 do_action('fluent_boards/board_member_added', $boardId, $boardMember);
369 475 } else {
370 476 do_action('fluent_boards/board_viewer_added', $boardId, $boardMember);
@@ -406,8 +512,75 @@
406 512 $user['is_board_admin'] = false;
407 513 return $user;
408 514 }
409 515
516 + /**
517 + * Create or update a board access relation with the selected member role.
518 + */
519 + public function syncBoardUserRole($boardId, $userId, $role)
520 + {
521 + $boardId = absint($boardId);
522 + $userId = absint($userId);
523 + $role = sanitize_text_field($role);
524 +
525 + if (!$boardId || !$userId || !in_array($role, ['admin', 'member', 'viewer'], true)) {
526 + return false;
527 + }
528 +
529 + $board = Board::find($boardId);
530 + $user = User::find($userId);
531 +
532 + if (!$board || !$user) {
533 + return false;
534 + }
535 +
536 + $boardUser = Relation::where('object_id', $boardId)
537 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
538 + ->where('foreign_id', $userId)
539 + ->first();
540 +
541 + $previousSettings = $boardUser ? (array)$boardUser->settings : [];
542 +
543 + // Board roles live as flags on the board_user relation; member access means both flags stay false.
544 + $settings = [
545 + 'is_admin' => 'admin' === $role,
546 + 'is_viewer_only' => 'viewer' === $role,
547 + ];
548 +
549 + if ($boardUser) {
550 + $boardUser->settings = $settings;
551 + $boardUser->save();
552 + } else {
553 + // New access should get the same default notification preferences as the normal add-member flow.
554 + $board->users()->attach(
555 + $userId,
556 + [
557 + 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
558 + 'settings' => maybe_serialize($settings),
559 + 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
560 + ]
561 + );
562 + }
563 +
564 + // Only emit admin transition hooks when the role actually changes.
565 + if ('admin' === $role && empty($previousSettings['is_admin'])) {
566 + do_action('fluent_boards/board_admin_added', $boardId, $userId);
567 + } elseif (!empty($previousSettings['is_admin'])) {
568 + do_action('fluent_boards/board_admin_removed', $boardId, $userId);
569 + }
570 +
571 + if ('viewer' === $role) {
572 + do_action('fluent_boards/board_viewer_added', $boardId, $user);
573 + } elseif ('member' === $role) {
574 + do_action('fluent_boards/board_member_added', $boardId, $user);
575 + }
576 +
577 + $user['is_admin'] = 'admin' === $role;
578 + $user['is_board_admin'] = 'admin' === $role;
579 +
580 + return $user;
581 + }
582 +
410 583 public function getUsersOfBoards()
411 584 {
412 585 $userBoards = Relation::whereNotNull('board_id')
413 586 ->where('user_id', get_current_user_id())
@@ -416,37 +589,68 @@
416 589 return $userBoards;
417 590 }
418 591
419 592 /**
420 - * change board background
421 - * @param mixed $backgroundData
422 - * @return string
593 + * Change or clear the board background.
594 + *
595 + * Image attachments must belong to the target board and use the board
596 + * background attachment type before their identifiers can be persisted.
597 + *
598 + * @param array $backgroundData
599 + * @param int $board_id
600 + * @return array|string
601 + * @throws \Exception
423 602 */
424 -
425 603 public function setBoardBackground($backgroundData, $board_id)
426 604 {
427 - $board = Board::find($board_id);
605 + $boardId = absint($board_id);
606 + $board = Board::find($boardId);
607 +
608 + if (!$board) {
609 + throw new \Exception(esc_html__('Board not found.', 'fluent-boards'));
610 + }
611 +
428 612 $oldBackground = $board->background;
613 +
614 + if (!empty($backgroundData['reset'])) {
615 + $board->background = '';
616 + $board->save();
617 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
618 +
619 + return $board->background;
620 + }
621 +
429 622 $background = $board->background;
430 -
431 - // if board background has color
432 - if (isset($backgroundData['color'])) {
433 - $background['color'] = $backgroundData['color'];
434 - $background['image_url'] = null;
435 - $background['is_image'] = false;
623 + if (!is_array($background)) {
624 + $background = [];
436 625 }
437 626
438 - // if board background has image
627 + // Resolve image metadata from the board-owned attachment, never from the client URL.
439 628 if (isset($backgroundData['image_url'])) {
440 - $background['image_url'] = $backgroundData['image_url'];
629 + $attachmentId = absint($backgroundData['id'] ?? 0);
630 + $attachment = Attachment::where('id', $attachmentId)
631 + ->where('object_id', $boardId)
632 + ->where('object_type', Constant::BOARD_BACKGROUND_IMAGE)
633 + ->first();
634 +
635 + if (!$attachment) {
636 + throw new \Exception(esc_html__('Background image not found.', 'fluent-boards'));
637 + }
638 +
639 + $background['id'] = (int) $attachment->id;
640 + $background['image_url'] = (new CommentService())->createPublicUrl($attachment, $boardId);
441 641 $background['is_image'] = true;
442 642 $background['color'] = null;
643 + } elseif (isset($backgroundData['color'])) {
644 + $background['id'] = $backgroundData['id'];
645 + $background['color'] = $backgroundData['color'];
646 + $background['image_url'] = null;
647 + $background['is_image'] = false;
443 648 }
444 - $background['id'] = $backgroundData['id'];
445 649
446 650 $board->background = $background;
447 651 $board->save();
448 - do_action('fluent_boards/board_background_updated', $board_id, $oldBackground);
652 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
449 653
450 654 return $board->background;
451 655 }
452 656
@@ -659,9 +863,15 @@
659 863
660 864 $this->updateRecentBoardCheckMeta();
661 865 }
662 866
663 - return Board::whereIn('id', $recentBoardIds)->withCount('completedTasks')->with(['stages', 'users'])->get();
867 + return Board::whereIn('id', $recentBoardIds)
868 + ->whereNull('archived_at')
869 + ->excludeTemplates()
870 + ->availableInCurrentInstall()
871 + ->withCount('completedTasks')
872 + ->with(['stages', 'users'])
873 + ->get();
664 874 }
665 875
666 876 public function getRecentBoardCheckMeta($userId = null){
667 877 if (!$userId) {
@@ -759,10 +969,15 @@
759 969
760 970 $contactOfBoard->delete();
761 971 }
762 972
763 - public function sendInvitationToBoard($boardId, $email)
973 + public function sendInvitationToBoard($boardId, $email, $role = 'member')
764 974 {
975 + $role = sanitize_text_field($role);
976 + if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
977 + $role = 'member';
978 + }
979 +
765 980 $user = User::query()->where('user_email', $email)->first();
766 981
767 982 if ($user) {
768 983 return $user;
@@ -769,9 +984,9 @@
769 984 }
770 985
771 986 $current_user_id = get_current_user_id();
772 987
773 - do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id);
988 + do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
774 989
775 990 return;
776 991
777 992 }
@@ -783,11 +998,35 @@
783 998 ->where('key', Constant::BOARD_INVITATION)
784 999 ->get();
785 1000 }
786 1001
787 - public function deleteInvitation($invitationId)
1002 + /**
1003 + * Delete an invitation only when it belongs to the supplied board.
1004 + *
1005 + * The optional second argument lets older Pro releases receive a controlled
1006 + * error instead of reporting a successful deletion that never happened.
1007 + */
1008 + public function deleteInvitation($boardId, $invitationId = null)
788 1009 {
789 - Meta::findOrFail($invitationId)->delete();
1010 + if ($invitationId === null) {
1011 + throw new \Exception(
1012 + __('A board ID is required to delete an invitation.', 'fluent-boards')
1013 + );
1014 + }
1015 +
1016 + $boardId = intval($boardId);
1017 + $invitationId = intval($invitationId);
1018 +
1019 + if ($boardId <= 0 || $invitationId <= 0) {
1020 + return false;
1021 + }
1022 +
1023 + return (bool) Meta::query()
1024 + ->where('id', $invitationId)
1025 + ->where('object_id', $boardId)
1026 + ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1027 + ->where('key', Constant::BOARD_INVITATION)
1028 + ->delete();
790 1029 }
791 1030
792 1031 public function hasDataChanged($boardId, $includeArchived = false, $since = null)
793 1032 {
@@ -955,8 +1194,15 @@
955 1194
956 1195 return (string) $timestamp;
957 1196 }
958 1197
1198 + /**
1199 + * Get CRM-associated boards from non-archived task links and board-level contact metadata.
1200 + *
1201 + * @param int $associatedId CRM contact/subscriber id.
1202 + * @param int|null $userId WordPress user id used for board access checks.
1203 + * @return \FluentBoards\Framework\Database\Orm\Collection|array
1204 + */
959 1205 public function getAssociatedBoards($associatedId, $userId = null)
960 1206 {
961 1207 $associatedId = absint($associatedId);
962 1208 $userId = $userId ?: get_current_user_id();
@@ -964,18 +1210,52 @@
964 1210 if (!$associatedId || !$userId) {
965 1211 return [];
966 1212 }
967 1213
968 - $boardIds = Meta::query()->where('value', $associatedId)
1214 + $taskBoardIds = Task::query()
1215 + ->select('board_id')
1216 + ->where('crm_contact_id', $associatedId)
1217 + ->whereNull('archived_at')
1218 + ->whereNotNull('board_id')
1219 + ->distinct()
1220 + ->pluck('board_id')
1221 + ->toArray();
1222 +
1223 + $metaBoardIds = Meta::query()
1224 + ->select('object_id')
1225 + ->where('value', $associatedId)
969 1226 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
970 - ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
971 - ->pluck('object_id');
1227 + ->whereIn('key', [
1228 + Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1229 + self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1230 + ])
1231 + ->distinct()
1232 + ->pluck('object_id')
1233 + ->toArray();
972 1234
973 - return Board::query()
1235 + $boardIds = array_values(array_unique(array_filter(array_map('intval', array_merge(
1236 + $taskBoardIds,
1237 + $metaBoardIds
1238 + )))));
1239 +
1240 + if (!$boardIds) {
1241 + return [];
1242 + }
1243 +
1244 + $boards = Board::query()
974 1245 ->whereIn('id', $boardIds)
1246 + ->whereNull('archived_at')
975 1247 ->byAccessUser($userId)
976 - ->with('stages', 'users')
1248 + ->withCount('completedTasks')
1249 + ->with(['stages', 'users'])
1250 + ->orderBy('created_at', 'DESC')
977 1251 ->get();
1252 +
1253 + foreach ($boards as $board) {
1254 + $board->users = Helper::sanitizeUserCollections($board->users);
1255 + }
1256 +
1257 + return $boards;
978 1258 }
979 1259
980 1260 private function deleteBoardMeta($boardId)
981 1261 {
@@ -987,8 +1267,11 @@
987 1267 public function copyBoard($boardData)
988 1268 {
989 1269 $sourceBoard = Board::findOrFail($boardData['source_board_id']);
990 1270 $boardData['background'] = $sourceBoard->background;
1271 + if (isset($boardData['description'])) {
1272 + $boardData['description'] = DescriptionMarkdownConverter::normalize($boardData['description']);
1273 + }
991 1274 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
992 1275
993 1276 $board = Board::create($boardData);
994 1277
@@ -996,160 +1279,84 @@
996 1279
997 1280 return $board;
998 1281 }
999 1282
1000 - public function getBoardReports($board_id)
1283 + /**
1284 + * Copy board member relations and their role preferences to a duplicated board.
1285 + */
1286 + public function copyMembersOfBoard($sourceBoardId, Board $newBoard)
1001 1287 {
1002 - $board = Board::findOrFail($board_id);
1003 - $taskQuery = Task::where('board_id', $board_id)
1004 - ->whereNull('parent_id')
1005 - ->whereNull('archived_at');
1288 + $existingMemberIds = Relation::where('object_id', $newBoard->id)
1289 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1290 + ->pluck('foreign_id')
1291 + ->toArray();
1006 1292
1007 - if($board->type == 'roadmap') {
1008 - $pendingStage = $this->getNewIdeaStage($board->id);
1009 - return $this->getIdeaReports($taskQuery, $pendingStage);
1010 - } else {
1011 - return $this->getTaskReports($taskQuery);
1012 - }
1013 - }
1293 + $sourceMembers = Relation::where('object_id', $sourceBoardId)
1294 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1295 + ->get();
1014 1296
1015 - private function getNewIdeaStage($boardId)
1016 - {
1017 - return Stage::where('board_id', $boardId)
1018 - ->where('type', 'stage')
1019 - ->where('archived_at', null)
1020 - ->orderBy('position', 'ASC')
1021 - ->first();
1022 - }
1297 + foreach ($sourceMembers as $sourceMember) {
1298 + if (in_array($sourceMember->foreign_id, $existingMemberIds)) {
1299 + continue;
1300 + }
1023 1301
1024 - public function getAllBoardReports(){
1025 - $userId = get_current_user_id();
1302 + Relation::create([
1303 + 'object_id' => $newBoard->id,
1304 + 'foreign_id' => $sourceMember->foreign_id,
1305 + 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
1306 + 'settings' => $sourceMember->settings,
1307 + 'preferences' => $sourceMember->preferences
1308 + ]);
1026 1309
1027 - $taskQuery = Task::whereNull('parent_id')
1028 - ->whereNull('archived_at')
1029 - ->whereHas('board', function ($query) {
1030 - $query->where('type', 'to-do');
1031 - });
1310 + $boardMember = User::find($sourceMember->foreign_id);
1311 + $settings = is_array($sourceMember->settings) ? $sourceMember->settings : [];
1032 1312
1033 - if (!PermissionManager::isAdmin($userId))
1034 - {
1035 - $currentUser = User::find($userId);
1036 - $relatedBoardIds = $currentUser->whichBoards->where('type', 'to-do')->pluck('id');
1037 - $taskQuery->whereIn('board_id', $relatedBoardIds);
1038 - }
1313 + if ($boardMember) {
1314 + if (!empty($settings['is_viewer_only'])) {
1315 + do_action('fluent_boards/board_viewer_added', $newBoard->id, $boardMember);
1316 + } else {
1317 + do_action('fluent_boards/board_member_added', $newBoard->id, $boardMember);
1318 + }
1039 1319
1040 - return $this->getTaskReports($taskQuery);
1041 - }
1320 + if (!empty($settings[Constant::IS_BOARD_ADMIN])) {
1321 + do_action('fluent_boards/board_admin_added', $newBoard->id, $sourceMember->foreign_id);
1322 + }
1323 + }
1042 1324
1043 - private function getTaskReports($taskQuery)
1044 - {
1045 - $totalTasksQuery = clone $taskQuery;
1046 - $completedTaskQuery = clone $taskQuery;
1047 - $openTaskQuery = clone $taskQuery;
1048 - $overDueTaskQuery = clone $taskQuery;
1049 -
1050 - $completedTaskCount = $completedTaskQuery->where('status', 'closed')->count();
1051 - $openTaskCount = $openTaskQuery->where('status', 'open')->count();
1052 - $overDueTasks = $overDueTaskQuery->overdue(true)->count();
1053 - $totalTasks = $totalTasksQuery->count();
1054 -
1055 - $taskQuery->where('status', 'open');
1056 -
1057 - $highQuery = clone $taskQuery;
1058 - $mediumQuery = clone $taskQuery;
1059 - $lowQuery = clone $taskQuery;
1060 -
1061 - $high = $highQuery->where('priority', 'high')->count();
1062 - $low = $mediumQuery->where('priority', 'low')->count();
1063 - $medium = $lowQuery->where('priority', 'medium')->count();
1064 -
1065 - $reportData = [
1066 - 'completion' => [
1067 - 'completed' => $completedTaskCount,
1068 - 'incomplete' => $openTaskCount,
1069 - 'overdue' => $overDueTasks,
1070 - 'total' => $totalTasks
1071 - ],
1072 - 'priority' => [
1073 - 'high' => $high,
1074 - 'medium' => $medium,
1075 - 'low' => $low
1076 - ]
1077 -
1078 - ];
1079 - return $reportData;
1325 + $existingMemberIds[] = $sourceMember->foreign_id;
1326 + }
1080 1327 }
1081 1328
1082 - private function getIdeaReports($taskQuery, $pendingStage)
1329 + /**
1330 + * Copy Pro custom field definitions and return an old-to-new ID map.
1331 + */
1332 + public function copyCustomFieldsOfBoard($fromBoardId, $board)
1083 1333 {
1084 - $pendingIdeaQuery = clone $taskQuery;
1085 - $completedIdeaQuery = clone $taskQuery;
1086 - $openIdeaQueryPage = clone $taskQuery;
1087 - $openIdeaQueryWeb = clone $taskQuery;
1334 + if (!defined('FLUENT_BOARDS_PRO') || !class_exists(CustomField::class)) {
1335 + return [];
1336 + }
1088 1337
1089 - $pendingIdeaCount = $pendingIdeaQuery->where('status', 'open')->where('stage_id', $pendingStage->id)->count();
1090 - $completedIdeaCount = $completedIdeaQuery->where('status', 'closed')->count();
1091 - $openIdeaCountPage = $openIdeaQueryPage->where('status', 'open')->where('source', 'page')->count();
1092 - $openIdeaCountWeb = $openIdeaQueryWeb->where('status', 'open')->where('source', 'web')->count();
1093 - $totalIdeas = $openIdeaCountPage + $openIdeaCountWeb;
1338 + $fromBoardId = absint($fromBoardId);
1094 1339
1095 - $taskQuery->where('status', 'open');
1340 + if (!$fromBoardId || !$board) {
1341 + return [];
1342 + }
1096 1343
1097 - $highQuery = clone $taskQuery;
1098 - $mediumQuery = clone $taskQuery;
1099 - $lowQuery = clone $taskQuery;
1100 -
1101 - $high = $highQuery->where('priority', 'high')->count();
1102 - $low = $mediumQuery->where('priority', 'low')->count();
1103 - $medium = $lowQuery->where('priority', 'medium')->count();
1104 -
1105 - $reportData = [
1106 - 'completion' => [
1107 - 'pending' => $pendingIdeaCount,
1108 - 'completed' => $completedIdeaCount,
1109 - 'ideaFromPage' => $openIdeaCountPage,
1110 - 'total' => $totalIdeas
1111 - ],
1112 - 'priority' => [
1113 - 'high' => $high,
1114 - 'medium' => $medium,
1115 - 'low' => $low
1116 - ]
1117 - ];
1118 - return $reportData;
1119 - }
1120 -
1121 - public function getStageWiseBoardReports($board_id)
1122 - {
1123 - $stages = Stage::where('board_id', $board_id)
1124 - ->where('type', 'stage')
1344 + $customFieldMap = [];
1345 + $customFields = CustomField::where('board_id', $fromBoardId)
1125 1346 ->whereNull('archived_at')
1347 + ->orderBy('position', 'asc')
1126 1348 ->get();
1127 1349
1128 - foreach ($stages as $stage) {
1129 - $completedTaskCount = Task::where('stage_id', $stage->id)
1130 - ->where('status', 'closed')
1131 - ->count();
1350 + foreach ($customFields as $customField) {
1351 + $newCustomField = $customField->replicate();
1352 + $newCustomField->board_id = $board->id;
1353 + $newCustomField->save();
1132 1354
1133 - $openTaskCount = Task::where('stage_id', $stage->id)
1134 - ->whereNull('due_at')
1135 - ->where('status', 'open')
1136 - ->count();
1137 -
1138 - $overDue = Task::where('stage_id', $stage->id)
1139 - ->whereNotNull('due_at')
1140 - ->where('status', 'open')
1141 - ->overdue(true)
1142 - ->count();
1143 -
1144 - $stage->report = [
1145 - 'completed' => $completedTaskCount,
1146 - 'incomplete' => $openTaskCount,
1147 - 'overdue' => $overDue
1148 - ];
1355 + $customFieldMap[(int) $customField->id] = $newCustomField->id;
1149 1356 }
1150 1357
1151 - return $stages;
1358 + return $customFieldMap;
1152 1359 }
1153 1360
1154 1361 public function archiveBoard($boardId)
1155 1362 {
@@ -1220,8 +1427,54 @@
1220 1427
1221 1428 return $pinnedBoardMeta;
1222 1429 }
1223 1430
1431 + /**
1432 + * Sidebar counts cover every board the user can access, so they are counted
1433 + * with their own queries rather than derived from the filtered/paginated list.
1434 + *
1435 + * byAccessUser() re-reads the user's accessible board ids from the database on
1436 + * every call, so the access scope is resolved once and cloned per count.
1437 + *
1438 + * @return array{all: int, pinned: int, archived: int}
1439 + */
1440 + public function getBoardCounts($userId)
1441 + {
1442 + $baseQuery = Board::byAccessUser($userId)
1443 + ->excludeTemplates()
1444 + ->availableInCurrentInstall();
1445 +
1446 + $counts = [
1447 + 'all' => (clone $baseQuery)->whereNull('archived_at')->count(),
1448 + 'pinned' => 0,
1449 + 'archived' => (clone $baseQuery)->whereNotNull('archived_at')->count()
1450 + ];
1451 +
1452 + $pinnedIds = $this->getPinnedBoardIds();
1453 +
1454 + if ($pinnedIds) {
1455 + $counts['pinned'] = (clone $baseQuery)->whereNull('archived_at')
1456 + ->whereIn('id', $pinnedIds)
1457 + ->count();
1458 + }
1459 +
1460 + return $counts;
1461 + }
1462 +
1463 + /**
1464 + * @return array board ids the current user has pinned
1465 + */
1466 + public function getPinnedBoardIds()
1467 + {
1468 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1469 +
1470 + if (!$pinnedBoardMeta) {
1471 + return [];
1472 + }
1473 +
1474 + return array_map('intval', (array) $pinnedBoardMeta->value);
1475 + }
1476 +
1224 1477 public function getPinnedBoards()
1225 1478 {
1226 1479 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1227 1480
@@ -1319,9 +1572,9 @@
1319 1572 if (!$relation) {
1320 1573 return null;
1321 1574 }
1322 1575
1323 - return Folder::findOrFail($relation->object_id);
1576 + return Folder::find($relation->object_id);
1324 1577 }
1325 1578
1326 1579 public function deleteWebhookData($boardId)
1327 1580 {