PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.1
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.1
2.2.1 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 All 44 releases
← All changes | app/Services/UserService.php +488 -129 1.35 → 2.2.1 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 }
@@ -211,87 +445,10 @@
211 445 }
212 446
213 447 return $searchResult;
214 448 }
449 +
215 450
216 -// public function getMemberAssociatedTasks($user_id, $requestData)
217 -// {
218 -// $taskType = $requestData['taskType'];
219 -// $boardIds = !empty($requestData['boardIds']) ? $requestData['boardIds'] : [];
220 -// $orderBy = $requestData['orderBy'];
221 -// $order = $requestData['order'];
222 -// $per_page = 15;
223 -// $user = User::find($user_id);
224 -//
225 -// // get the task assigned to the user
226 -// $tasksQuery = $user->tasks()->with(['stage', 'board'])->whereNull('archived_at');
227 -// if ($taskType == 'upcoming') {
228 -// $tasksQuery = $tasksQuery->upcoming();
229 -// } elseif ($taskType == 'overdue') {
230 -// $tasksQuery = $tasksQuery->overdue();
231 -// } elseif ($taskType == 'completed') {
232 -// $tasksQuery = $tasksQuery->where('status', 'closed');
233 -// } else {
234 -// $tasksQuery = $tasksQuery->whereNull('due_at');
235 -// }
236 -//
237 -// $currentUserId = get_current_user_id();
238 -// if($currentUserId != $user->ID) {
239 -// if(!PermissionManager::isAdmin()){
240 -// $currentUser = User::find($currentUserId);
241 -// $currentUserBoardIds = $currentUser->whichBoards->pluck('id')->toArray();
242 -// if (empty($boardIds)) {
243 -// $boardIds = $currentUserBoardIds;
244 -// } else {
245 -// // Get the intersection of the two arrays
246 -// $boardIds = array_intersect($boardIds, $currentUserBoardIds);
247 -// }
248 -// }
249 -// }
250 -//
251 -// if($boardIds){
252 -// $tasksQuery = $tasksQuery->whereIn('board_id', $boardIds);
253 -// }
254 -//
255 -// $sortOptions = ['priority', 'due_at', 'position', 'created_at', 'title'];
256 -// $orderOptions = ['ASC', 'DESC'];
257 -//
258 -// // Validate order and orderBy parameters
259 -// if (!in_array($order, $sortOptions) || !in_array($orderBy, $orderOptions)) {
260 -// throw new \Exception(__('Invalid sort or orderBy parameter', 'fluent-boards'));
261 -// }
262 -//
263 -// // Apply ordering based on the specified order and orderBy
264 -// switch ($order) {
265 -// case 'priority':
266 -// $tasksQuery->orderByRaw("FIELD(priority, 'High', 'Medium', 'Low') {$orderBy}");
267 -// break;
268 -//
269 -// case 'due_at':
270 -// if ($orderBy === 'ASC') {
271 -// // Separate ordering for tasks with and without due dates
272 -// $tasksQuery = $tasksQuery->orderBy('due_at');
273 -// } else {
274 -// $tasksQuery->orderBy('due_at', $orderBy);
275 -// }
276 -// break;
277 -//
278 -// default:
279 -// $tasksQuery->orderBy($order, $orderBy);
280 -// break;
281 -// }
282 -//
283 -// $tasks = $tasksQuery->paginate($per_page, ['*'], 'page', $requestData->page);
284 -// return [
285 -// 'tasks' => $tasks->values()->toArray(),
286 -// 'paginationInfo' => [
287 -// 'current_page' => $tasks->currentPage(),
288 -// 'last_page' => $tasks->lastPage(),
289 -// 'total' => $tasks->total(),
290 -// ],
291 -// ];
292 -//
293 -// }
294 451 public function getMemberAssociatedTasks($user_id, $requestData)
295 452 {
296 453 $taskType = $requestData['taskType'] ?? null;
297 454 $boardIds = !empty($requestData['boardIds']) ? $requestData['boardIds'] : [];
@@ -296,39 +453,76 @@
296 453 $taskType = $requestData['taskType'] ?? null;
297 454 $boardIds = !empty($requestData['boardIds']) ? $requestData['boardIds'] : [];
298 455 $orderBy = $requestData['orderBy'] ?? 'created_at';
299 456 $order = strtoupper($requestData['order'] ?? 'ASC');
300 - $per_page = $requestData['per_page'] ?? 15;
301 - $page = $requestData['page'] ?? 1;
457 + $per_page = Sanitize::perPage($requestData['per_page'] ?? 15, 50);
458 + $page = max(1, (int) ($requestData['page'] ?? 1));
302 459 $user = User::find($user_id);
303 460
304 - if (!$user) {
461 + $loggedInUserId = get_current_user_id();
462 +
463 + $allowedBoardIds = PermissionManager::getBoardIdsForUser($loggedInUserId);
464 +
465 + if (!$user || empty($allowedBoardIds)) {
305 466 return [
306 467 'tasks' => [],
307 468 'paginationInfo' => [
308 469 'current_page' => 1,
309 470 'last_page' => 1,
471 + 'per_page' => (int) $per_page,
310 472 'total' => 0,
311 473 ],
312 474 ];
313 475 }
314 476
315 - // Get the task assigned to the user
316 - $tasksQuery = $user->tasks()->with(['stage', 'board'])->whereNull('archived_at');
477 + // Table view needs the labels column; list view ignores the extra relation.
478 + $taskRelations = ['stage', 'board', 'labels'];
317 479
318 - switch ($taskType) {
319 - case 'upcoming':
320 - $tasksQuery->upcoming();
321 - break;
322 - case 'overdue':
323 - $tasksQuery->overdue();
324 - break;
325 - case 'completed':
326 - $tasksQuery->where('status', 'closed');
327 - break;
328 - default:
329 - $tasksQuery->whereNull('due_at');
330 - break;
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 + }
331 525 }
332 526
333 527 $currentUserId = get_current_user_id();
334 528 if ($currentUserId != $user->ID && !PermissionManager::isAdmin()) {
@@ -351,14 +545,14 @@
351 545 $orderOptions = ['ASC', 'DESC'];
352 546
353 547 // Validate order and orderBy parameters
354 548 if (!in_array($order, $orderOptions) || !in_array($orderBy, $sortOptions)) {
355 - throw new \Exception(__('Invalid sort or orderBy parameter', 'fluent-boards'));
549 + throw new \Exception(esc_html__('Invalid sort or orderBy parameter', 'fluent-boards'));
356 550 }
357 551
358 552 // Apply ordering based on the specified order and orderBy
359 553 if ($orderBy === 'priority') {
360 - $tasksQuery->orderByRaw("FIELD(priority, 'High', 'Medium', 'Low') {$order}");
554 + $tasksQuery->orderByRaw("FIELD(priority, 'urgent', 'high', 'medium', 'low') {$order}");
361 555 } else if ($orderBy === 'due_at') {
362 556 $tasksQuery->orderByRaw("ISNULL(due_at), due_at {$order}");
363 557 } else {
364 558 $tasksQuery->orderBy($orderBy, $order);
@@ -370,44 +564,152 @@
370 564 'tasks' => $tasks->values()->toArray(),
371 565 'paginationInfo' => [
372 566 'current_page' => $tasks->currentPage(),
373 567 'last_page' => $tasks->lastPage(),
568 + 'per_page' => (int) $tasks->perPage(),
374 569 'total' => $tasks->total(),
375 570 ],
376 571 ];
377 572 }
378 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);
379 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 + */
380 632 public function getMemberRelatedAcitivies($user_id, $page)
381 633 {
382 - $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 + })
383 664 ->orderBy('created_at', 'desc')
384 - ->with('user')->paginate(40, ['*'], 'page', $page);
665 + ->with('user')
666 + ->paginate($perPage, ['*'], 'page', $page);
385 667
386 - $activitiesToShow = array();
668 + $activitiesToShow = [];
387 669
388 670 foreach ($activities as $activity) {
389 671 if ($activity->object_type == Constant::ACTIVITY_BOARD) {
390 672 $activity->load('board');
391 -// if($activity->settings && $activity->settings['task_id']){
392 -// $activity->task = Task::findOrFail($activity->settings['task_id']);
393 -// }
394 - if (PermissionManager::userHasPermission($activity->board_id, get_current_user_id())) {
395 - $activitiesToShow[] = $activity;
396 - }
673 + $activitiesToShow[] = $activity;
397 674 } elseif ($activity->object_type == Constant::ACTIVITY_TASK) {
398 675 $activity->load('task');
399 - if ($activity->task && PermissionManager::userHasPermission($activity->task->board_id, get_current_user_id())) {
400 - $activitiesToShow[] = $activity;
401 - }
676 + $activitiesToShow[] = $activity;
402 677 }
403 678 }
679 +
404 680 return [
405 - 'activities' => $activitiesToShow,
406 - 'pagination' => $activities->toArray(),
681 + 'activities' => $activitiesToShow,
682 + 'pagination' => $this->getPaginationInfo($activities),
407 683 ];
408 684 }
409 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 +
410 712 private function getTaskById($taskId)
411 713 {
412 714 return Task::findOrFail($taskId);
413 715 }
@@ -416,23 +718,80 @@
416 718 {
417 719 if(!$user_id) {
418 720 return [];
419 721 }
420 - $boardIds = [];
421 - $boards = [];
422 722 $user = User::find($user_id);
423 723 $currentUserId = get_current_user_id();
424 - if($currentUserId != $user->ID) {
425 - if(!PermissionManager::isAdmin()){
426 - $currentUser = User::find($currentUserId);
427 - $currentUserBoardIds = $currentUser->whichBoards->pluck('id')->toArray();
428 - $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;
429 765 }
430 766 }
431 - if (!empty($boardIds)) {
432 - $boards = $user->whichBoards()->whereIn('fbs_boards.id', $boardIds)->get();
433 - } else {
434 - $boards = $user->whichBoards;
767 +
768 + $assignedBase = $user->assignedTasks()->whereNull('archived_at')->whereNull('parent_id');
769 + if ($allowedBoardIds !== null) {
770 + $assignedBase->whereIn('board_id', $allowedBoardIds);
435 771 }
436 - 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 + ];
437 796 }
438 797 }