| @@ -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 | } |