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