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 +480 -64 1.95.3 → 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)
@@ -107,20 +315,46 @@
107 315
108 316 return $boardUsers;
109 317 }
110 318
319 + /**
320 + * Return associated users and roles only from boards shared with the requester.
321 + */
111 322 public function memberAssociatedTaskUsers($userId)
112 323 {
113 324 $user = User::find($userId);
114 - $boards = $user->whichBoards->pluck('id');
115 - $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)
116 343 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
117 344 ->pluck('foreign_id')->toArray();
118 345 $uniqueUsersIds = array_unique($boardUsers);
119 - $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();
120 352
121 353 $userWiseBoardDesignation = Relation::query()->whereIn('foreign_id', $uniqueUsersIds)
122 - ->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();
123 357
124 358 $data = array();
125 359 $data['userWiseBoardDesignation'] = $userWiseBoardDesignation;
126 360
@@ -134,9 +368,9 @@
134 368 } elseif (PermissionManager::isFluentBoardsAdmin($uniqueUser['ID'])) {
135 369 $uniqueUser['is_super'] = true;
136 370 $uniqueUser['is_wpadmin'] = false;
137 371 } else {
138 - $uniqueUser['all_boards'] = Arr::get($uniqueUser, 'boards');
372 + $uniqueUser['all_boards'] = $uniqueUser->getRelation('whichBoards');
139 373 $uniqueUser['is_super'] = false;
140 374 $uniqueUser['is_wpadmin'] = false;
141 375 }
142 376 }
@@ -219,10 +453,10 @@
219 453 $taskType = $requestData['taskType'] ?? null;
220 454 $boardIds = !empty($requestData['boardIds']) ? $requestData['boardIds'] : [];
221 455 $orderBy = $requestData['orderBy'] ?? 'created_at';
222 456 $order = strtoupper($requestData['order'] ?? 'ASC');
223 - $per_page = $requestData['per_page'] ?? 15;
224 - $page = $requestData['page'] ?? 1;
457 + $per_page = Sanitize::perPage($requestData['per_page'] ?? 15, 50);
458 + $page = max(1, (int) ($requestData['page'] ?? 1));
225 459 $user = User::find($user_id);
226 460
227 461 $loggedInUserId = get_current_user_id();
228 462
@@ -233,47 +467,64 @@
233 467 'tasks' => [],
234 468 'paginationInfo' => [
235 469 'current_page' => 1,
236 470 'last_page' => 1,
471 + 'per_page' => (int) $per_page,
237 472 'total' => 0,
238 473 ],
239 474 ];
240 475 }
241 476
242 - if($taskType == 'assigned') {
243 - $tasksQuery = $user->assignedTasks()->with(['stage', 'board'])->whereNull('archived_at')->whereNull('parent_id')->whereIn('board_id', $allowedBoardIds);
244 - } else if($taskType == 'mentioned') {
245 - $tasksQuery = $user->mentionedTasks()->with(['stage', 'board'])->whereNull('archived_at')->whereNull('parent_id')->whereIn('board_id', $allowedBoardIds);
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();
246 495 } else {
247 - // Get the task assigned to the user
248 - $tasksQuery = $user->tasks()->with(['stage', 'board'])->whereNull('archived_at') ->whereIn('board_id', $allowedBoardIds);
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();
249 502
250 - switch ($taskType) {
251 - case 'upcoming':
252 - $tasksQuery->upcoming()
253 - ->whereIn('board_id', $allowedBoardIds);
254 - break;
255 - case 'due_today':
256 - $tasksQuery->dueToday()
257 - ->whereIn('board_id', $allowedBoardIds);
258 - break;
259 - case 'overdue':
260 - $tasksQuery->overdue()
261 - ->whereIn('board_id', $allowedBoardIds);
262 - break;
263 - case 'completed':
264 - $tasksQuery->where('status', 'closed')
265 - ->whereIn('board_id', $allowedBoardIds);
266 - break;
267 - default:
268 - $tasksQuery->whereNull('due_at')
269 - ->whereIn('board_id', $allowedBoardIds);
270 - break;
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 + }
271 525 }
272 - }
273 526
274 -
275 -
276 527 $currentUserId = get_current_user_id();
277 528 if ($currentUserId != $user->ID && !PermissionManager::isAdmin()) {
278 529 $currentUser = User::find($currentUserId);
279 530 $currentUserBoardIds = $currentUser->boards->pluck('id')->toArray();
@@ -299,9 +550,9 @@
299 550 }
300 551
301 552 // Apply ordering based on the specified order and orderBy
302 553 if ($orderBy === 'priority') {
303 - $tasksQuery->orderByRaw("FIELD(priority, 'High', 'Medium', 'Low') {$order}");
554 + $tasksQuery->orderByRaw("FIELD(priority, 'urgent', 'high', 'medium', 'low') {$order}");
304 555 } else if ($orderBy === 'due_at') {
305 556 $tasksQuery->orderByRaw("ISNULL(due_at), due_at {$order}");
306 557 } else {
307 558 $tasksQuery->orderBy($orderBy, $order);
@@ -313,44 +564,152 @@
313 564 'tasks' => $tasks->values()->toArray(),
314 565 'paginationInfo' => [
315 566 'current_page' => $tasks->currentPage(),
316 567 'last_page' => $tasks->lastPage(),
568 + 'per_page' => (int) $tasks->perPage(),
317 569 'total' => $tasks->total(),
318 570 ],
319 571 ];
320 572 }
321 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);
322 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 + ];
626 + }
627 +
628 +
629 + /**
630 + * Get a member's activities scoped to boards the requesting user can access.
631 + */
323 632 public function getMemberRelatedAcitivies($user_id, $page)
324 633 {
325 - $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 + })
326 664 ->orderBy('created_at', 'desc')
327 - ->with('user')->paginate(40, ['*'], 'page', $page);
665 + ->with('user')
666 + ->paginate($perPage, ['*'], 'page', $page);
328 667
329 - $activitiesToShow = array();
668 + $activitiesToShow = [];
330 669
331 670 foreach ($activities as $activity) {
332 671 if ($activity->object_type == Constant::ACTIVITY_BOARD) {
333 672 $activity->load('board');
334 -// if($activity->settings && $activity->settings['task_id']){
335 -// $activity->task = Task::findOrFail($activity->settings['task_id']);
336 -// }
337 - if (PermissionManager::userHasPermission($activity->board_id, get_current_user_id())) {
338 - $activitiesToShow[] = $activity;
339 - }
673 + $activitiesToShow[] = $activity;
340 674 } elseif ($activity->object_type == Constant::ACTIVITY_TASK) {
341 675 $activity->load('task');
342 - if ($activity->task && PermissionManager::userHasPermission($activity->task->board_id, get_current_user_id())) {
343 - $activitiesToShow[] = $activity;
344 - }
676 + $activitiesToShow[] = $activity;
345 677 }
346 678 }
679 +
347 680 return [
348 - 'activities' => $activitiesToShow,
349 - 'pagination' => $activities->toArray(),
681 + 'activities' => $activitiesToShow,
682 + 'pagination' => $this->getPaginationInfo($activities),
350 683 ];
351 684 }
352 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 +
353 712 private function getTaskById($taskId)
354 713 {
355 714 return Task::findOrFail($taskId);
356 715 }
@@ -359,23 +718,80 @@
359 718 {
360 719 if(!$user_id) {
361 720 return [];
362 721 }
363 - $boardIds = [];
364 - $boards = [];
365 722 $user = User::find($user_id);
366 723 $currentUserId = get_current_user_id();
367 - if($currentUserId != $user->ID) {
368 - if(!PermissionManager::isAdmin()){
369 - $currentUser = User::find($currentUserId);
370 - $currentUserBoardIds = $currentUser->whichBoards->pluck('id')->toArray();
371 - $boardIds = $currentUserBoardIds;
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;
372 765 }
373 766 }
374 - if (!empty($boardIds)) {
375 - $boards = $user->whichBoards()->whereIn('fbs_boards.id', $boardIds)->get();
376 - } else {
377 - $boards = $user->whichBoards;
767 +
768 + $assignedBase = $user->assignedTasks()->whereNull('archived_at')->whereNull('parent_id');
769 + if ($allowedBoardIds !== null) {
770 + $assignedBase->whereIn('board_id', $allowedBoardIds);
378 771 }
379 - return $boards;
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 + ];
380 796 }
381 797 }