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/UserService.php +562 -35 1.13 → 2.2.0 View file →
@@ -1,10 +1,12 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use Exception;
5 6 use FluentBoards\App\Models\Activity;
6 7 use FluentBoards\App\Models\Meta;
8 +use FluentBoards\App\Models\Notification;
7 9 use FluentBoards\App\Models\Task;
8 10 use FluentBoards\App\Models\User;
9 11 use FluentBoards\App\Models\Board;
10 12 use FluentBoards\App\Models\Relation;
@@ -12,8 +14,214 @@
12 14
13 15
14 16 class UserService
15 17 {
18 + /** Save only the current member's public display name. */
19 + public function updateDisplayName($userId, $displayName)
20 + {
21 + $userId = absint($userId);
22 + $this->assertCanUpdateProfile($userId);
23 + $displayName = is_string($displayName) ? trim(sanitize_text_field($displayName)) : '';
24 + if ($displayName === '') {
25 + throw new Exception(__('Display name is required', 'fluent-boards'), 400);
26 + }
27 + // WordPress treats "0" as empty and replaces it with the login name.
28 + if ($displayName === '0') {
29 + throw new Exception(__('Display name cannot be "0"', 'fluent-boards'), 400);
30 + }
31 +
32 + if (mb_strlen($displayName, 'UTF-8') > Constant::PROFILE_DISPLAY_NAME_MAX_LENGTH) {
33 + throw new Exception(__('Display name must be 250 characters or fewer', 'fluent-boards'), 400);
34 + }
35 +
36 + $expectedName = null;
37 + // Validate the final, unslashed storage value after WordPress and plugin filters.
38 + $validateStorage = function ($data, $update, $targetUserId) use ($userId, &$expectedName) {
39 + if (!$update || (int) $targetUserId !== $userId) {
40 + return $data;
41 + }
42 + $expectedName = $data['display_name'] ?? null;
43 + if (!is_string($expectedName) || $expectedName === '') {
44 + throw new Exception(__('Display name is required', 'fluent-boards'), 400);
45 + }
46 + if (mb_strlen($expectedName, 'UTF-8') > Constant::PROFILE_DISPLAY_NAME_MAX_LENGTH) {
47 + throw new Exception(__('Display name must be 250 characters or fewer', 'fluent-boards'), 400);
48 + }
49 + return $data;
50 + };
51 + add_filter('wp_pre_insert_user_data', $validateStorage, PHP_INT_MAX, 3);
52 + try {
53 + $result = wp_update_user(['ID' => $userId, 'display_name' => wp_slash($displayName)]);
54 + } finally {
55 + remove_filter('wp_pre_insert_user_data', $validateStorage, PHP_INT_MAX);
56 + }
57 + if (is_wp_error($result)) {
58 + throw new Exception($result->get_error_message(), 400);
59 + }
60 +
61 + // WordPress can return a user ID after a failed database update.
62 + clean_user_cache($userId);
63 + $user = get_userdata($userId);
64 + if (!$user) {
65 + throw new Exception(__('Could not load the updated profile', 'fluent-boards'), 400);
66 + }
67 + if ($expectedName === null || $user->display_name !== $expectedName) {
68 + throw new Exception(__('Could not save the display name. Please try again.', 'fluent-boards'), 400);
69 + }
70 + $displayName = $user->display_name;
71 + do_action('fluent_boards/profile_name_updated', $userId, $displayName);
72 + return ['display_name' => $displayName];
73 + }
74 +
75 + /** Validate and store a custom avatar without requiring media-library access. */
76 + public function updateProfilePhoto($userId, $file)
77 + {
78 + $userId = absint($userId);
79 + $this->assertCanUpdateProfile($userId);
80 + if (!is_array($file) || !isset($file['error'], $file['size'], $file['tmp_name'], $file['name'])
81 + || $file['error'] !== UPLOAD_ERR_OK || !is_string($file['tmp_name']) || !is_string($file['name'])
82 + || !is_uploaded_file($file['tmp_name'])) {
83 + throw new Exception(__('No photo uploaded or upload error', 'fluent-boards'), 400);
84 + }
85 + if (filesize($file['tmp_name']) <= 0 || filesize($file['tmp_name']) > Constant::PROFILE_PHOTO_MAX_BYTES) {
86 + throw new Exception(__('Photo must be 2 MB or smaller', 'fluent-boards'), 400);
87 + }
88 +
89 + $mimes = ['jpg|jpeg|jpe' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp'];
90 + // Inspect the file contents, not the browser MIME or filename alone.
91 + if (!in_array(wp_get_image_mime($file['tmp_name']), $mimes, true)) {
92 + throw new Exception(__('Choose a JPG, PNG, GIF or WebP image', 'fluent-boards'), 400);
93 + }
94 + $dimensions = wp_getimagesize($file['tmp_name']);
95 + if (!$dimensions || $dimensions[0] < 1 || $dimensions[1] < 1
96 + || max($dimensions[0], $dimensions[1]) > Constant::PROFILE_PHOTO_MAX_DIMENSION
97 + || $dimensions[0] * $dimensions[1] > Constant::PROFILE_PHOTO_MAX_PIXELS) {
98 + throw new Exception(__('Photo dimensions must not exceed 4096 pixels per side or 12 megapixels', 'fluent-boards'));
99 + }
100 + if (!function_exists('wp_handle_upload')) {
101 + require_once ABSPATH . 'wp-admin/includes/file.php';
102 + }
103 + $uploaded = wp_handle_upload($file, ['test_form' => false, 'mimes' => $mimes]);
104 + if (isset($uploaded['error'])) {
105 + throw new Exception($uploaded['error'], 400);
106 + }
107 +
108 + $uploaded = $this->createProfileAvatar($uploaded);
109 + $this->replaceProfilePhoto($userId, $uploaded);
110 + $photoUrl = esc_url_raw($uploaded['url']);
111 + do_action('fluent_boards/profile_photo_updated', $userId, $photoUrl);
112 + return ['photo' => fluent_boards_user_avatar(wp_get_current_user()->user_email)];
113 + }
114 +
115 + /** Re-encode a bounded avatar and discard the source before persisting profile changes. */
116 + private function createProfileAvatar(array $uploaded)
117 + {
118 + $directory = dirname($uploaded['file']);
119 + $filename = wp_unique_filename($directory, pathinfo($uploaded['file'], PATHINFO_FILENAME) . '-avatar.png');
120 + $avatarPath = $directory . '/' . $filename;
121 +
122 + try {
123 + $editor = wp_get_image_editor($uploaded['file']);
124 + if (is_wp_error($editor)) {
125 + throw new Exception($editor->get_error_message());
126 + }
127 + $size = $editor->get_size();
128 + if (max($size['width'], $size['height']) > Constant::PROFILE_AVATAR_SIZE) {
129 + $resized = $editor->resize(Constant::PROFILE_AVATAR_SIZE, Constant::PROFILE_AVATAR_SIZE, false);
130 + if (is_wp_error($resized)) {
131 + throw new Exception($resized->get_error_message());
132 + }
133 + }
134 + $avatar = $editor->save($avatarPath, 'image/png');
135 + if (is_wp_error($avatar)) {
136 + throw new Exception($avatar->get_error_message());
137 + }
138 +
139 + return [
140 + 'file' => $avatar['path'],
141 + 'url' => trailingslashit(dirname($uploaded['url'])) . wp_basename($avatar['path']),
142 + 'type' => $avatar['mime-type'],
143 + ];
144 + } catch (Exception $e) {
145 + wp_delete_file($avatarPath);
146 + throw $e;
147 + } finally {
148 + wp_delete_file($uploaded['file']);
149 + }
150 + }
151 +
152 + /** Serialize replacement across requests sharing the same WordPress user table. */
153 + private function replaceProfilePhoto($userId, array $uploaded)
154 + {
155 + global $wpdb;
156 +
157 + $lockName = Constant::USER_PROFILE_PHOTO_UPLOAD . ':' . md5(DB_NAME . ':' . $wpdb->users . ':' . $userId);
158 + if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lockName)) !== 1) {
159 + wp_delete_file($uploaded['file']);
160 + throw new Exception(__('The profile photo is being updated. Please try again.', 'fluent-boards'));
161 + }
162 +
163 + try {
164 + // A waiting request may have cached metadata from before the previous upload.
165 + wp_cache_delete($userId, 'user_meta');
166 + $this->persistProfilePhoto($userId, $uploaded);
167 + } finally {
168 + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
169 + }
170 + }
171 +
172 + /** Track uploads we own; legacy or externally supplied avatar URLs never authorize deletion. */
173 + private function persistProfilePhoto($userId, array $uploaded)
174 + {
175 + $previousUrl = get_user_meta($userId, Constant::USER_PROFILE_PHOTO, true);
176 + $previousUpload = get_user_meta($userId, Constant::USER_PROFILE_PHOTO_UPLOAD, true);
177 + $ownedUpload = [
178 + 'file' => $uploaded['file'],
179 + 'url' => esc_url_raw($uploaded['url']),
180 + 'blog_id' => get_current_blog_id(),
181 + ];
182 +
183 + if (!update_user_meta($userId, Constant::USER_PROFILE_PHOTO_UPLOAD, $ownedUpload)) {
184 + wp_delete_file($uploaded['file']);
185 + throw new Exception(__('The profile photo could not be saved. Please try again.', 'fluent-boards'));
186 + }
187 + if (!update_user_meta($userId, Constant::USER_PROFILE_PHOTO, $ownedUpload['url'])) {
188 + if ($previousUpload) {
189 + update_user_meta($userId, Constant::USER_PROFILE_PHOTO_UPLOAD, $previousUpload);
190 + } else {
191 + delete_user_meta($userId, Constant::USER_PROFILE_PHOTO_UPLOAD);
192 + }
193 + wp_delete_file($uploaded['file']);
194 + throw new Exception(__('The profile photo could not be saved. Please try again.', 'fluent-boards'));
195 + }
196 +
197 + // Only a matching, locally tracked upload can be retired after both writes succeed.
198 + if (!is_array($previousUpload)
199 + || ($previousUpload['url'] ?? null) !== $previousUrl
200 + || ($previousUpload['blog_id'] ?? null) !== get_current_blog_id()
201 + || !is_string($previousUpload['file'] ?? null)
202 + || $previousUpload['file'] === $ownedUpload['file']) {
203 + return;
204 + }
205 +
206 + $uploads = wp_get_upload_dir();
207 + $previousFile = realpath($previousUpload['file']);
208 + $uploadsDirectory = realpath($uploads['basedir']);
209 + if (!$uploads['error'] && $previousFile && $uploadsDirectory
210 + && strpos(wp_normalize_path($previousFile), trailingslashit(wp_normalize_path($uploadsDirectory))) === 0
211 + && is_file($previousFile)) {
212 + wp_delete_file($previousFile);
213 + }
214 + }
215 +
216 + /** Profile editing requires Fluent Boards membership and remains self-service for administrators. */
217 + private function assertCanUpdateProfile($userId)
218 + {
219 + if (!$userId || $userId !== get_current_user_id() || !PermissionManager::isFluentBoardsUser($userId)) {
220 + throw new Exception(__('You can only update your own profile', 'fluent-boards'), 403);
221 + }
222 + }
223 +
16 224 public function allFluentBoardsUsers($boardId = null)
17 225 {
18 226 // Get the global admins first
19 227 $adminUserIds = Meta::query()->where('object_type', Constant::FLUENT_BOARD_ADMIN)
@@ -44,9 +252,13 @@
44 252 $accessBoardIds[] = $boardObject->object_id;
45 253
46 254 $boardUserMaps[$boardObject->foreign_id][] = [
47 255 'board_id' => $boardObject->object_id,
48 - 'role' => Arr::get($boardObject->settings, 'is_admin') ? 'admin' : 'member'
256 + 'role' => Arr::get($boardObject->settings, 'is_admin')
257 + ? 'admin'
258 + : (Arr::has($boardObject->settings, 'is_viewer_only') && Arr::get($boardObject->settings, 'is_viewer_only')
259 + ? 'viewer'
260 + : 'member')
49 261 ];
50 262 }
51 263
52 264 $accessBoardIds = array_unique($accessBoardIds);
@@ -103,20 +315,46 @@
103 315
104 316 return $boardUsers;
105 317 }
106 318
319 + /**
320 + * Return associated users and roles only from boards shared with the requester.
321 + */
107 322 public function memberAssociatedTaskUsers($userId)
108 323 {
109 324 $user = User::find($userId);
110 - $boards = $user->whichBoards->pluck('id');
111 - $boardUsers = Relation::whereIn('object_id', $boards)
325 + $memberBoardIds = array_values(array_unique(array_filter(array_map(
326 + 'intval',
327 + $user->whichBoards->pluck('id')->toArray()
328 + ))));
329 + $requesterBoardIds = array_values(array_unique(array_filter(array_map(
330 + 'intval',
331 + PermissionManager::getBoardIdsForUser(get_current_user_id())
332 + ))));
333 + $sharedBoardIds = array_values(array_intersect($memberBoardIds, $requesterBoardIds));
334 +
335 + if (!$sharedBoardIds) {
336 + return [
337 + 'uniqueUsers' => [],
338 + 'userWiseBoardDesignation' => [],
339 + ];
340 + }
341 +
342 + $boardUsers = Relation::whereIn('object_id', $sharedBoardIds)
112 343 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
113 344 ->pluck('foreign_id')->toArray();
114 345 $uniqueUsersIds = array_unique($boardUsers);
115 - $uniqueUsers = User::whereIn('ID', $uniqueUsersIds)->with('whichBoards')->get();
346 + $boardTable = (new Board())->getTable();
347 + $uniqueUsers = User::whereIn('ID', $uniqueUsersIds)
348 + ->with(['whichBoards' => function ($query) use ($boardTable, $sharedBoardIds) {
349 + $query->whereIn($boardTable . '.id', $sharedBoardIds);
350 + }])
351 + ->get();
116 352
117 353 $userWiseBoardDesignation = Relation::query()->whereIn('foreign_id', $uniqueUsersIds)
118 - ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)->get();
354 + ->whereIn('object_id', $sharedBoardIds)
355 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
356 + ->get();
119 357
120 358 $data = array();
121 359 $data['userWiseBoardDesignation'] = $userWiseBoardDesignation;
122 360
@@ -130,9 +368,9 @@
130 368 } elseif (PermissionManager::isFluentBoardsAdmin($uniqueUser['ID'])) {
131 369 $uniqueUser['is_super'] = true;
132 370 $uniqueUser['is_wpadmin'] = false;
133 371 } else {
134 - $uniqueUser['all_boards'] = Arr::get($uniqueUser, 'boards');
372 + $uniqueUser['all_boards'] = $uniqueUser->getRelation('whichBoards');
135 373 $uniqueUser['is_super'] = false;
136 374 $uniqueUser['is_wpadmin'] = false;
137 375 }
138 376 }
@@ -207,64 +445,353 @@
207 445 }
208 446
209 447 return $searchResult;
210 448 }
449 +
211 450
212 - public function getMemberAssociatedTasks($user_id, $page)
451 + public function getMemberAssociatedTasks($user_id, $requestData)
213 452 {
214 - // get the user
453 + $taskType = $requestData['taskType'] ?? null;
454 + $boardIds = !empty($requestData['boardIds']) ? $requestData['boardIds'] : [];
455 + $orderBy = $requestData['orderBy'] ?? 'created_at';
456 + $order = strtoupper($requestData['order'] ?? 'ASC');
457 + $per_page = Sanitize::perPage($requestData['per_page'] ?? 15, 50);
458 + $page = max(1, (int) ($requestData['page'] ?? 1));
215 459 $user = User::find($user_id);
216 - // get the task created by the user
217 - $tasks = Task::query()->where('created_by', $user_id)
218 - ->orderBy('created_at', 'desc')
219 - ->with(['stage', 'board'])
220 - ->paginate(10, ['*'], 'page', $page);
221 - // get the task assigned to the user
222 - $assignedTasks = $user->tasks()
223 - ->orderBy('created_at', 'desc')
224 - ->with(['stage', 'board'])
225 - ->paginate(10, ['*'], 'page', $page);
460 +
461 + $loggedInUserId = get_current_user_id();
462 +
463 + $allowedBoardIds = PermissionManager::getBoardIdsForUser($loggedInUserId);
464 +
465 + if (!$user || empty($allowedBoardIds)) {
466 + return [
467 + 'tasks' => [],
468 + 'paginationInfo' => [
469 + 'current_page' => 1,
470 + 'last_page' => 1,
471 + 'per_page' => (int) $per_page,
472 + 'total' => 0,
473 + ],
474 + ];
475 + }
476 +
477 + // Table view needs the labels column; list view ignores the extra relation.
478 + $taskRelations = ['stage', 'board', 'labels'];
479 +
480 + if ($taskType == 'assigned') {
481 + $tasksQuery = $user->assignedTasks()
482 + ->with($taskRelations)
483 + ->whereNull('archived_at')
484 + ->whereNull('parent_id')
485 + ->whereIn('board_id', $allowedBoardIds)
486 + ->where('status', '!=', 'closed')
487 + ->onActiveAvailableBoards();
488 + } else if ($taskType == 'mentioned') {
489 + $tasksQuery = $user->mentionedTasks()
490 + ->with($taskRelations)
491 + ->whereNull('archived_at')
492 + ->whereNull('parent_id')
493 + ->whereIn('board_id', $allowedBoardIds)
494 + ->onActiveAvailableBoards();
495 + } else {
496 + // Watched tasks power the date-based and completed profile tabs.
497 + $tasksQuery = $user->tasks()
498 + ->with($taskRelations)
499 + ->whereNull('archived_at')
500 + ->whereIn('board_id', $allowedBoardIds)
501 + ->onActiveAvailableBoards();
502 +
503 + switch ($taskType) {
504 + case 'upcoming':
505 + $tasksQuery->upcoming()
506 + ->whereIn('board_id', $allowedBoardIds);
507 + break;
508 + case 'due_today':
509 + $tasksQuery->dueToday()
510 + ->whereIn('board_id', $allowedBoardIds);
511 + break;
512 + case 'overdue':
513 + $tasksQuery->overdue()
514 + ->whereIn('board_id', $allowedBoardIds);
515 + break;
516 + case 'completed':
517 + $tasksQuery->where('status', 'closed')
518 + ->whereIn('board_id', $allowedBoardIds);
519 + break;
520 + default:
521 + $tasksQuery->whereNull('due_at')
522 + ->whereIn('board_id', $allowedBoardIds);
523 + break;
524 + }
525 + }
526 +
527 + $currentUserId = get_current_user_id();
528 + if ($currentUserId != $user->ID && !PermissionManager::isAdmin()) {
529 + $currentUser = User::find($currentUserId);
530 + $currentUserBoardIds = $currentUser->boards->pluck('id')->toArray();
531 +
532 + if (empty($boardIds)) {
533 + $boardIds = $currentUserBoardIds;
534 + } else {
535 + // Get the intersection of the two arrays
536 + $boardIds = array_intersect($boardIds, $currentUserBoardIds);
537 + }
538 + }
539 +
540 + if (!empty($boardIds)) {
541 + $tasksQuery->whereIn('board_id', $boardIds);
542 + }
543 +
544 + $sortOptions = ['priority', 'due_at', 'position', 'created_at', 'title'];
545 + $orderOptions = ['ASC', 'DESC'];
546 +
547 + // Validate order and orderBy parameters
548 + if (!in_array($order, $orderOptions) || !in_array($orderBy, $sortOptions)) {
549 + throw new \Exception(esc_html__('Invalid sort or orderBy parameter', 'fluent-boards'));
550 + }
551 +
552 + // Apply ordering based on the specified order and orderBy
553 + if ($orderBy === 'priority') {
554 + $tasksQuery->orderByRaw("FIELD(priority, 'urgent', 'high', 'medium', 'low') {$order}");
555 + } else if ($orderBy === 'due_at') {
556 + $tasksQuery->orderByRaw("ISNULL(due_at), due_at {$order}");
557 + } else {
558 + $tasksQuery->orderBy($orderBy, $order);
559 + }
560 +
561 + $tasks = $tasksQuery->paginate($per_page, ['*'], 'page', $page);
562 +
226 563 return [
227 - 'tasks' => $tasks->merge($assignedTasks)->sortByDesc('created_at')->values()->toArray(),
564 + 'tasks' => $tasks->values()->toArray(),
228 565 'paginationInfo' => [
229 566 'current_page' => $tasks->currentPage(),
230 - 'last_page' => $tasks->lastPage() > $assignedTasks->lastPage() ? $tasks->lastPage() : $assignedTasks->lastPage()
231 - ]
567 + 'last_page' => $tasks->lastPage(),
568 + 'per_page' => (int) $tasks->perPage(),
569 + 'total' => $tasks->total(),
570 + ],
232 571 ];
572 + }
233 573
574 + /**
575 + * Get totals for each task category available in the profile task tabs.
576 + */
577 + public function getMemberTaskCounts($userId, $boardIds = [])
578 + {
579 + $user = User::find($userId);
580 + $currentUserId = get_current_user_id();
581 + $allowedBoardIds = PermissionManager::getBoardIdsForUser($currentUserId);
582 +
583 + if (!$user || empty($allowedBoardIds)) {
584 + return array_fill_keys(
585 + ['due_today', 'assigned', 'upcoming', 'overdue', 'mentioned', 'completed', 'others'],
586 + 0
587 + );
588 + }
589 +
590 + if ($currentUserId != $user->ID && !PermissionManager::isAdmin()) {
591 + $currentUser = User::find($currentUserId);
592 + $currentUserBoardIds = $currentUser->boards->pluck('id')->toArray();
593 + $boardIds = empty($boardIds)
594 + ? $currentUserBoardIds
595 + : array_intersect($boardIds, $currentUserBoardIds);
596 + }
597 +
598 + // Keep the count queries aligned with the profile list without loading task models or relations.
599 + $applyTaskScope = function ($query) use ($allowedBoardIds, $boardIds) {
600 + $query->whereNull('archived_at')
601 + ->whereNull('parent_id')
602 + ->whereIn('board_id', $allowedBoardIds)
603 + ->onActiveAvailableBoards();
604 +
605 + if (!empty($boardIds)) {
606 + $query->whereIn('board_id', $boardIds);
607 + }
608 +
609 + return $query;
610 + };
611 + $watchedTasks = function () use ($user, $applyTaskScope) {
612 + return $applyTaskScope($user->tasks());
613 + };
614 +
615 + return [
616 + 'due_today' => (int) $watchedTasks()->dueToday()->count(),
617 + 'assigned' => (int) $applyTaskScope($user->assignedTasks())
618 + ->where('status', '!=', 'closed')
619 + ->count(),
620 + 'upcoming' => (int) $watchedTasks()->upcoming()->count(),
621 + 'overdue' => (int) $watchedTasks()->overdue()->count(),
622 + 'mentioned' => (int) $applyTaskScope($user->mentionedTasks())->count(),
623 + 'completed' => (int) $watchedTasks()->where('status', 'closed')->count(),
624 + 'others' => (int) $watchedTasks()->whereNull('due_at')->count(),
625 + ];
234 626 }
235 627
628 +
629 + /**
630 + * Get a member's activities scoped to boards the requesting user can access.
631 + */
236 632 public function getMemberRelatedAcitivies($user_id, $page)
237 633 {
238 - $activities = Activity::query()->where('created_by', $user_id)
634 + $perPage = 40;
635 + $user_id = absint($user_id);
636 + $page = max(1, absint($page));
637 + $allowedBoardIds = array_values(array_filter(array_map(
638 + 'intval',
639 + PermissionManager::getBoardIdsForUser(get_current_user_id())
640 + )));
641 +
642 + if (empty($allowedBoardIds)) {
643 + return [
644 + 'activities' => [],
645 + 'pagination' => $this->getEmptyPaginationInfo($page, $perPage),
646 + ];
647 + }
648 +
649 + $allowedTaskIds = Task::query()
650 + ->select('id')
651 + ->whereIn('board_id', $allowedBoardIds);
652 +
653 + $activities = Activity::query()
654 + ->where('created_by', $user_id)
655 + ->where(function ($query) use ($allowedBoardIds, $allowedTaskIds) {
656 + $query->where(function ($boardQuery) use ($allowedBoardIds) {
657 + $boardQuery->where('object_type', Constant::ACTIVITY_BOARD)
658 + ->whereIn('object_id', $allowedBoardIds);
659 + })->orWhere(function ($taskQuery) use ($allowedTaskIds) {
660 + $taskQuery->where('object_type', Constant::ACTIVITY_TASK)
661 + ->whereIn('object_id', $allowedTaskIds);
662 + });
663 + })
239 664 ->orderBy('created_at', 'desc')
240 - ->with('user')->paginate(40, ['*'], 'page', $page);
665 + ->with('user')
666 + ->paginate($perPage, ['*'], 'page', $page);
241 667
242 - $activitiesToShow = array();
668 + $activitiesToShow = [];
243 669
244 670 foreach ($activities as $activity) {
245 671 if ($activity->object_type == Constant::ACTIVITY_BOARD) {
246 672 $activity->load('board');
247 -// if($activity->settings && $activity->settings['task_id']){
248 -// $activity->task = Task::findOrFail($activity->settings['task_id']);
249 -// }
250 - if (PermissionManager::userHasPermission($activity->board_id, get_current_user_id())) {
251 - $activitiesToShow[] = $activity;
252 - }
673 + $activitiesToShow[] = $activity;
253 674 } elseif ($activity->object_type == Constant::ACTIVITY_TASK) {
254 675 $activity->load('task');
255 - if ($activity->task && PermissionManager::userHasPermission($activity->task->board_id, get_current_user_id())) {
256 - $activitiesToShow[] = $activity;
257 - }
676 + $activitiesToShow[] = $activity;
258 677 }
259 678 }
679 +
260 680 return [
261 - 'activities' => $activitiesToShow,
262 - 'pagination' => $activities->toArray(),
681 + 'activities' => $activitiesToShow,
682 + 'pagination' => $this->getPaginationInfo($activities),
263 683 ];
264 684 }
265 685
686 + /**
687 + * Return pagination metadata without duplicating serialized row data.
688 + */
689 + private function getPaginationInfo($paginator)
690 + {
691 + return [
692 + 'current_page' => $paginator->currentPage(),
693 + 'last_page' => $paginator->lastPage(),
694 + 'per_page' => (int) $paginator->perPage(),
695 + 'total' => $paginator->total(),
696 + ];
697 + }
698 +
699 + /**
700 + * Return an empty pagination payload for callers without accessible boards.
701 + */
702 + private function getEmptyPaginationInfo($page, $perPage)
703 + {
704 + return [
705 + 'current_page' => max(1, (int) $page),
706 + 'last_page' => 1,
707 + 'per_page' => (int) $perPage,
708 + 'total' => 0,
709 + ];
710 + }
711 +
266 712 private function getTaskById($taskId)
267 713 {
268 714 return Task::findOrFail($taskId);
715 + }
716 +
717 + public function getMemberBoards($user_id)
718 + {
719 + if(!$user_id) {
720 + return [];
721 + }
722 + $user = User::find($user_id);
723 + $currentUserId = get_current_user_id();
724 +
725 + if ($currentUserId == $user->ID || PermissionManager::isAdmin($currentUserId)) {
726 + return $user->whichBoards;
727 + }
728 +
729 + $currentUser = User::find($currentUserId);
730 + $boardIds = $currentUser ? $currentUser->whichBoards->pluck('id')->toArray() : [];
731 +
732 + if (empty($boardIds)) {
733 + return [];
734 + }
735 +
736 + return $user->whichBoards()->whereIn('fbs_boards.id', $boardIds)->get();
737 + }
738 +
739 + /**
740 + * Returns four aggregate counts for a member's stats widget.
741 + *
742 + * Cross-user access is scoped to boards shared with the requesting user,
743 + * matching the contract of getMemberBoards() and getMemberAssociatedTasks().
744 + * Unread notifications are personal and only returned for self or admin.
745 + */
746 + public function getMemberStats(int $user_id): array
747 + {
748 + $empty = ['assigned_tasks' => 0, 'completed_tasks' => 0, 'total_boards' => 0, 'unread_notifications' => 0];
749 +
750 + $user = User::find($user_id);
751 + if (!$user) {
752 + return $empty;
753 + }
754 +
755 + $currentUserId = get_current_user_id();
756 + $isSelfOrAdmin = ($currentUserId === $user_id) || PermissionManager::isAdmin($currentUserId);
757 +
758 + // For cross-user access, restrict counts to boards the requesting user can also see.
759 + $allowedBoardIds = null;
760 + if (!$isSelfOrAdmin) {
761 + $currentUser = User::find($currentUserId);
762 + $allowedBoardIds = $currentUser ? $currentUser->whichBoards->pluck('id')->toArray() : [];
763 + if (empty($allowedBoardIds)) {
764 + return $empty;
765 + }
766 + }
767 +
768 + $assignedBase = $user->assignedTasks()->whereNull('archived_at')->whereNull('parent_id');
769 + if ($allowedBoardIds !== null) {
770 + $assignedBase->whereIn('board_id', $allowedBoardIds);
771 + }
772 +
773 + $boardsQuery = $user->whichBoards();
774 + if ($allowedBoardIds !== null) {
775 + $boardsQuery->whereIn('fbs_boards.id', $allowedBoardIds);
776 + }
777 +
778 + // Use Notification model with object_type guard — matches NotificationService::newNotificationNumber().
779 + // Notifications are personal; return 0 when a non-admin views another member's profile.
780 + $unreadNotifications = 0;
781 + if ($isSelfOrAdmin) {
782 + $unreadNotifications = Notification::query()
783 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_NOTIFICATION)
784 + ->whereHas('users', function ($q) use ($user_id) {
785 + $q->where('user_id', $user_id)->whereNull('marked_read_at');
786 + })
787 + ->count();
788 + }
789 +
790 + return [
791 + 'assigned_tasks' => (int) (clone $assignedBase)->where('status', '!=', 'closed')->count(),
792 + 'completed_tasks' => (int) (clone $assignedBase)->where('status', 'closed')->count(),
793 + 'total_boards' => (int) $boardsQuery->count(),
794 + 'unread_notifications' => (int) $unreadNotifications,
795 + ];
269 796 }
270 797 }