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 +440 -169 1.95 → 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,15 +15,22 @@
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 - return Board::where('type', $type)->whereNull('archived_at')->orderBy('created_at', 'ASC')->get();
28 + return Board::where('type', sanitize_text_field($type))
29 + ->whereNull('archived_at')
30 + ->byAccessUser(get_current_user_id())
31 + ->orderBy('created_at', 'ASC')
32 + ->get();
23 33 }
24 34
25 35 public function deleteBoard($boardId)
26 36 {
@@ -38,8 +48,39 @@
38 48
39 49 // Delete time tracking records for all tasks in the board
40 50 (new TaskService())->deleteTimeTrackingRecords($allTaskIdsInBoard->toArray());
41 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 +
42 83 Task::whereIn('id', $allTaskIdsInBoard)->delete();
43 84
44 85 // delete all activities
45 86 Activity::whereIn('object_id', $allTaskIdsInBoard)->where('object_type', Constant::ACTIVITY_TASK)->delete();
@@ -87,8 +128,29 @@
87 128 $board->delete();
88 129 FileSystem::deleteDir('board_'.$boardId);
89 130 }
90 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 +
91 153 public function fetchBoardMeta($boardId)
92 154 {
93 155 $boardMeta = Meta::where('object_id', $boardId)
94 156 ->where('object_type', 'board')
@@ -142,9 +204,9 @@
142 204 {
143 205 $boardData = [
144 206 'title' => $boardData['title'],
145 207 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
146 - 'description' => $boardData['description'],
208 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
147 209 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
148 210 'background' => isset($boardData['background']) ? $boardData['background'] : '',
149 211 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
150 212 ];
@@ -157,12 +219,23 @@
157 219
158 220 return $board;
159 221 }
160 222
223 + /**
224 + * Attach a user-owned board to its creator with Board Admin preferences.
225 + *
226 + * @param Board $board
227 + * @return void
228 + */
161 229 public function setCurrentUserPreferencesOnBoardCreate($board)
162 230 {
231 + $creatorId = absint($board->created_by);
232 + if (!$creatorId) {
233 + return;
234 + }
235 +
163 236 $board->users()->attach(
164 - $board->created_by,
237 + $creatorId,
165 238 [
166 239 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
167 240 'settings' => maybe_serialize([
168 241 Constant::IS_BOARD_ADMIN => true
@@ -210,9 +283,18 @@
210 283 ->first();
211 284 if ($recentlyOpened) {
212 285 $recentBoardIds = $recentlyOpened->value;
213 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 +
214 292 $index = array_search($boardId, $recentBoardIds);
293 + if ($index === false) {
294 + return;
295 + }
296 +
215 297 array_splice($recentBoardIds, $index, 1);
216 298
217 299 $recentlyOpened->value = $recentBoardIds;
218 300 $recentlyOpened->save();
@@ -227,9 +309,9 @@
227 309 } else {
228 310 throw new \Exception(esc_html__('Title cannot be empty', 'fluent-boards'));
229 311 }
230 312 if (isset($data['description'])) {
231 - $data['description'] = $data['description'];
313 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
232 314 }
233 315 $board->fill($data);
234 316 $board->save();
235 317 // do_action('fluent_boards/board_updated', $board);
@@ -286,12 +368,19 @@
286 368 }
287 369 return $processedStages;
288 370 }
289 371
372 + /**
373 + * Archive a stage and persist the user who archived it for future archive-list metadata.
374 + */
290 375 public function archiveStage($boardId, $stage)
291 376 {
377 + $settings = $stage->settings ?: [];
378 + $settings['archived_by_id'] = absint(get_current_user_id()) ?: null;
379 +
292 380 $stage->archived_at = current_time('mysql');
293 381 $stage->position = 0;
382 + $stage->settings = $settings;
294 383 $stage->save();
295 384
296 385 do_action('fluent_boards/stage_archived', $boardId, $stage); // Old hook
297 386 do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
@@ -297,14 +386,21 @@
297 386 do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
298 387 return $stage;
299 388 }
300 389
390 + /**
391 + * Restore an archived stage and clear stale archived-by metadata.
392 + */
301 393 public function restoreStage($boardId, $stage)
302 394 {
303 395 $stageService = new StageService();
304 396 $lastStagePosition = $stageService->getLastPositionOfStagesOfBoard($stage->board_id);
397 + $settings = $stage->settings ?: [];
398 + $settings['archived_by_id'] = null;
399 +
305 400 $stage->archived_at = null;
306 401 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
402 + $stage->settings = $settings;
307 403 $stage->save();
308 404 do_action('fluent_boards/board_stage_restored', $boardId, $stage->title); // Old hook
309 405 do_action('fluent_boards/stage_restored_with_tasks', $boardId, $stage); // New hook
310 406 return $stage;
@@ -311,10 +407,10 @@
311 407 }
312 408
313 409 public function getActivities($id, $data)
314 410 {
315 - $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
316 - $page = isset($data['page']) ? $data['page'] : 1;
411 + $per_page = Sanitize::perPage($data['per_page'] ?? 40);
412 + $page = max(1, (int) ($data['page'] ?? 1));
317 413 $activities = Activity::where('object_id', $id)->where('object_type', Constant::ACTIVITY_BOARD)->with(['user'])
318 414 ->orderBy('id', 'DESC')
319 415 ->paginate($per_page, ['*'], 'page', $page);
320 416
@@ -331,14 +427,29 @@
331 427
332 428 return $isAlreadyMember ?? false;
333 429 }
334 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 + */
335 437 public function addMembersInBoard($boardId, $memberId, $isViewerOnly = null)
336 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 +
337 447 $board = Board::find($boardId);
448 + $boardMember = User::find($memberId);
338 449
339 - if (!$board) {
340 - return false;
450 + if (!$board || !$boardMember) {
451 + return null;
341 452 }
342 453 $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
343 454 if($isAlreadyMember) {
344 455 return false;
@@ -358,9 +469,8 @@
358 469 'settings' => maybe_serialize($settings),
359 470 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
360 471 ]
361 472 );
362 - $boardMember = User::find($memberId);
363 473 if(!$isViewerOnly) {
364 474 do_action('fluent_boards/board_member_added', $boardId, $boardMember);
365 475 } else {
366 476 do_action('fluent_boards/board_viewer_added', $boardId, $boardMember);
@@ -402,8 +512,75 @@
402 512 $user['is_board_admin'] = false;
403 513 return $user;
404 514 }
405 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 +
406 583 public function getUsersOfBoards()
407 584 {
408 585 $userBoards = Relation::whereNotNull('board_id')
409 586 ->where('user_id', get_current_user_id())
@@ -412,37 +589,68 @@
412 589 return $userBoards;
413 590 }
414 591
415 592 /**
416 - * change board background
417 - * @param mixed $backgroundData
418 - * @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
419 602 */
420 -
421 603 public function setBoardBackground($backgroundData, $board_id)
422 604 {
423 - $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 +
424 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 +
425 622 $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;
623 + if (!is_array($background)) {
624 + $background = [];
432 625 }
433 626
434 - // if board background has image
627 + // Resolve image metadata from the board-owned attachment, never from the client URL.
435 628 if (isset($backgroundData['image_url'])) {
436 - $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);
437 641 $background['is_image'] = true;
438 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;
439 648 }
440 - $background['id'] = $backgroundData['id'];
441 649
442 650 $board->background = $background;
443 651 $board->save();
444 - do_action('fluent_boards/board_background_updated', $board_id, $oldBackground);
652 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
445 653
446 654 return $board->background;
447 655 }
448 656
@@ -655,9 +863,15 @@
655 863
656 864 $this->updateRecentBoardCheckMeta();
657 865 }
658 866
659 - 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();
660 874 }
661 875
662 876 public function getRecentBoardCheckMeta($userId = null){
663 877 if (!$userId) {
@@ -755,10 +969,15 @@
755 969
756 970 $contactOfBoard->delete();
757 971 }
758 972
759 - public function sendInvitationToBoard($boardId, $email)
973 + public function sendInvitationToBoard($boardId, $email, $role = 'member')
760 974 {
975 + $role = sanitize_text_field($role);
976 + if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
977 + $role = 'member';
978 + }
979 +
761 980 $user = User::query()->where('user_email', $email)->first();
762 981
763 982 if ($user) {
764 983 return $user;
@@ -765,9 +984,9 @@
765 984 }
766 985
767 986 $current_user_id = get_current_user_id();
768 987
769 - do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id);
988 + do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
770 989
771 990 return;
772 991
773 992 }
@@ -779,11 +998,35 @@
779 998 ->where('key', Constant::BOARD_INVITATION)
780 999 ->get();
781 1000 }
782 1001
783 - 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)
784 1009 {
785 - 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();
786 1029 }
787 1030
788 1031 public function hasDataChanged($boardId, $includeArchived = false, $since = null)
789 1032 {
@@ -951,16 +1194,68 @@
951 1194
952 1195 return (string) $timestamp;
953 1196 }
954 1197
955 - public function getAssociatedBoards($associatedId)
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 + */
1205 + public function getAssociatedBoards($associatedId, $userId = null)
956 1206 {
957 - $boardIds = Meta::query()->where('value', $associatedId)
1207 + $associatedId = absint($associatedId);
1208 + $userId = $userId ?: get_current_user_id();
1209 +
1210 + if (!$associatedId || !$userId) {
1211 + return [];
1212 + }
1213 +
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)
958 1226 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
959 - ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
960 - ->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();
961 1234
962 - return Board::query()->whereIn('id', $boardIds)->with('stages', 'users')->get();
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()
1245 + ->whereIn('id', $boardIds)
1246 + ->whereNull('archived_at')
1247 + ->byAccessUser($userId)
1248 + ->withCount('completedTasks')
1249 + ->with(['stages', 'users'])
1250 + ->orderBy('created_at', 'DESC')
1251 + ->get();
1252 +
1253 + foreach ($boards as $board) {
1254 + $board->users = Helper::sanitizeUserCollections($board->users);
1255 + }
1256 +
1257 + return $boards;
963 1258 }
964 1259
965 1260 private function deleteBoardMeta($boardId)
966 1261 {
@@ -972,8 +1267,11 @@
972 1267 public function copyBoard($boardData)
973 1268 {
974 1269 $sourceBoard = Board::findOrFail($boardData['source_board_id']);
975 1270 $boardData['background'] = $sourceBoard->background;
1271 + if (isset($boardData['description'])) {
1272 + $boardData['description'] = DescriptionMarkdownConverter::normalize($boardData['description']);
1273 + }
976 1274 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
977 1275
978 1276 $board = Board::create($boardData);
979 1277
@@ -981,160 +1279,84 @@
981 1279
982 1280 return $board;
983 1281 }
984 1282
985 - 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)
986 1287 {
987 - $board = Board::findOrFail($board_id);
988 - $taskQuery = Task::where('board_id', $board_id)
989 - ->whereNull('parent_id')
990 - ->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();
991 1292
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 - }
1293 + $sourceMembers = Relation::where('object_id', $sourceBoardId)
1294 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1295 + ->get();
999 1296
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 - }
1297 + foreach ($sourceMembers as $sourceMember) {
1298 + if (in_array($sourceMember->foreign_id, $existingMemberIds)) {
1299 + continue;
1300 + }
1008 1301
1009 - public function getAllBoardReports(){
1010 - $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 + ]);
1011 1309
1012 - $taskQuery = Task::whereNull('parent_id')
1013 - ->whereNull('archived_at')
1014 - ->whereHas('board', function ($query) {
1015 - $query->where('type', 'to-do');
1016 - });
1310 + $boardMember = User::find($sourceMember->foreign_id);
1311 + $settings = is_array($sourceMember->settings) ? $sourceMember->settings : [];
1017 1312
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 - }
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 + }
1024 1319
1025 - return $this->getTaskReports($taskQuery);
1026 - }
1320 + if (!empty($settings[Constant::IS_BOARD_ADMIN])) {
1321 + do_action('fluent_boards/board_admin_added', $newBoard->id, $sourceMember->foreign_id);
1322 + }
1323 + }
1027 1324
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;
1325 + $existingMemberIds[] = $sourceMember->foreign_id;
1326 + }
1065 1327 }
1066 1328
1067 - 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)
1068 1333 {
1069 - $pendingIdeaQuery = clone $taskQuery;
1070 - $completedIdeaQuery = clone $taskQuery;
1071 - $openIdeaQueryPage = clone $taskQuery;
1072 - $openIdeaQueryWeb = clone $taskQuery;
1334 + if (!defined('FLUENT_BOARDS_PRO') || !class_exists(CustomField::class)) {
1335 + return [];
1336 + }
1073 1337
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;
1338 + $fromBoardId = absint($fromBoardId);
1079 1339
1080 - $taskQuery->where('status', 'open');
1340 + if (!$fromBoardId || !$board) {
1341 + return [];
1342 + }
1081 1343
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')
1344 + $customFieldMap = [];
1345 + $customFields = CustomField::where('board_id', $fromBoardId)
1110 1346 ->whereNull('archived_at')
1347 + ->orderBy('position', 'asc')
1111 1348 ->get();
1112 1349
1113 - foreach ($stages as $stage) {
1114 - $completedTaskCount = Task::where('stage_id', $stage->id)
1115 - ->where('status', 'closed')
1116 - ->count();
1350 + foreach ($customFields as $customField) {
1351 + $newCustomField = $customField->replicate();
1352 + $newCustomField->board_id = $board->id;
1353 + $newCustomField->save();
1117 1354
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 - ];
1355 + $customFieldMap[(int) $customField->id] = $newCustomField->id;
1134 1356 }
1135 1357
1136 - return $stages;
1358 + return $customFieldMap;
1137 1359 }
1138 1360
1139 1361 public function archiveBoard($boardId)
1140 1362 {
@@ -1205,8 +1427,54 @@
1205 1427
1206 1428 return $pinnedBoardMeta;
1207 1429 }
1208 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 +
1209 1477 public function getPinnedBoards()
1210 1478 {
1211 1479 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1212 1480
@@ -1217,9 +1485,12 @@
1217 1485
1218 1486 // Convert to array of integers
1219 1487 $intIds = array_map('intval', $ids);
1220 1488
1221 - return Board::whereIn('id', $intIds)->whereNull('archived_at')->get();
1489 + return Board::whereIn('id', $intIds)
1490 + ->whereNull('archived_at')
1491 + ->byAccessUser(get_current_user_id())
1492 + ->get();
1222 1493 }
1223 1494 }
1224 1495
1225 1496 public function pinBoard($boardId)
@@ -1301,9 +1572,9 @@
1301 1572 if (!$relation) {
1302 1573 return null;
1303 1574 }
1304 1575
1305 - return Folder::findOrFail($relation->object_id);
1576 + return Folder::find($relation->object_id);
1306 1577 }
1307 1578
1308 1579 public function deleteWebhookData($boardId)
1309 1580 {