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
fluent-boards / app / Services / UserService.php

UserService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.2.0, at app/Services/UserService.php

798 lines 31.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 use Exception;
6 use FluentBoards\App\Models\Activity;
7 use FluentBoards\App\Models\Meta;
8 use FluentBoards\App\Models\Notification;
9 use FluentBoards\App\Models\Task;
10 use FluentBoards\App\Models\User;
11 use FluentBoards\App\Models\Board;
12 use FluentBoards\App\Models\Relation;
13 use FluentBoards\Framework\Support\Arr;
14
15
16 class UserService
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
224 public function allFluentBoardsUsers($boardId = null)
225 {
226 // Get the global admins first
227 $adminUserIds = Meta::query()->where('object_type', Constant::FLUENT_BOARD_ADMIN)
228 ->get()->pluck('object_id')->toArray();
229
230 $boardObjects = Relation::where('object_type', 'board_user');
231
232 if ($adminUserIds) {
233 $boardObjects = $boardObjects->whereNotIn('foreign_id', $adminUserIds);
234 }
235
236 if ($boardId) {
237 $boardObjects = $boardObjects->where('object_id', $boardId);
238 }
239
240 $boardObjects = $boardObjects->whereNotIn('foreign_id', $adminUserIds)
241 ->get();
242
243 $boardUserMaps = [];
244
245 $accessBoardIds = [];
246
247 foreach ($boardObjects as $boardObject) {
248 if (!isset($boardUserMaps[$boardObject->foreign_id])) {
249 $boardUserMaps[$boardObject->foreign_id] = [];
250 }
251
252 $accessBoardIds[] = $boardObject->object_id;
253
254 $boardUserMaps[$boardObject->foreign_id][] = [
255 'board_id' => $boardObject->object_id,
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')
261 ];
262 }
263
264 $accessBoardIds = array_unique($accessBoardIds);
265 $allUserIds = array_unique(array_merge($adminUserIds, array_keys($boardUserMaps)));
266
267 $users = get_users([
268 'include' => $allUserIds
269 ]);
270
271 $boardUsers = [];
272 $allBoards = Board::query()->whereIn('id', $accessBoardIds)->get()->keyBy('id');
273
274 foreach ($users as $user) {
275
276 $boards = Arr::get($boardUserMaps, $user->ID, []);
277
278 $formattedBoars = [];
279
280 foreach ($boards as $board) {
281 if (empty($allBoards[$board['board_id']])) {
282 continue;
283 }
284
285 $boardModel = $allBoards[$board['board_id']];
286 $formattedBoars[] = [
287 'id' => $boardModel->id,
288 'title' => $boardModel->title,
289 'role' => $board['role']
290 ];
291 }
292
293 $name = trim($user->first_name . ' ' . $user->last_name);
294
295 if (!$name) {
296 $name = $user->display_name;
297 }
298
299 $photo = fluent_boards_user_avatar($user->user_email, $name);
300
301 $boardUsers[] = [
302 'ID' => $user->ID,
303 'display_name' => $user->display_name,
304 'photo' => $photo,
305 'email' => $user->user_email,
306 'boards' => $formattedBoars,
307 'is_super' => in_array($user->ID, $adminUserIds),
308 'is_wpadmin' => $user->has_cap('manage_options')
309 ];
310 }
311
312 usort($boardUsers, function ($a, $b) {
313 return strcmp($a['display_name'], $b['display_name']);
314 });
315
316 return $boardUsers;
317 }
318
319 /**
320 * Return associated users and roles only from boards shared with the requester.
321 */
322 public function memberAssociatedTaskUsers($userId)
323 {
324 $user = User::find($userId);
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)
343 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
344 ->pluck('foreign_id')->toArray();
345 $uniqueUsersIds = array_unique($boardUsers);
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();
352
353 $userWiseBoardDesignation = Relation::query()->whereIn('foreign_id', $uniqueUsersIds)
354 ->whereIn('object_id', $sharedBoardIds)
355 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
356 ->get();
357
358 $data = array();
359 $data['userWiseBoardDesignation'] = $userWiseBoardDesignation;
360
361 foreach ($uniqueUsers as &$uniqueUser) {
362 if (user_can($uniqueUser['ID'], 'manage_options') && PermissionManager::isFluentBoardsAdmin($uniqueUser['ID'])) {
363 $uniqueUser['is_super'] = true;
364 $uniqueUser['is_wpadmin'] = true;
365 } elseif (user_can($uniqueUser['ID'], 'manage_options')) {
366 $uniqueUser['is_wpadmin'] = true;
367 $uniqueUser['is_super'] = false;
368 } elseif (PermissionManager::isFluentBoardsAdmin($uniqueUser['ID'])) {
369 $uniqueUser['is_super'] = true;
370 $uniqueUser['is_wpadmin'] = false;
371 } else {
372 $uniqueUser['all_boards'] = $uniqueUser->getRelation('whichBoards');
373 $uniqueUser['is_super'] = false;
374 $uniqueUser['is_wpadmin'] = false;
375 }
376 }
377
378 $data['uniqueUsers'] = $uniqueUsers;
379
380 return $data;
381 }
382
383 public function searchFluentBoardsUser($search_input)
384 {
385
386 $boardUsers = User::whereHas('whichBoards', function ($query) use ($search_input) {
387 $query->where('display_name', 'like', '%' . $search_input . '%');
388 })
389 ->where('display_name', 'like', '%' . $search_input . '%')
390 ->with('whichBoards')
391 ->get()
392 ->toArray();
393
394 foreach ($boardUsers as &$boardUser) {
395 if (user_can($boardUser['ID'], 'manage_options') && PermissionManager::isFluentBoardsAdmin($boardUser['ID'])) {
396 $boardUser['is_super'] = true;
397 $boardUser['is_wpadmin'] = true;
398 } elseif (user_can($boardUser['ID'], 'manage_options')) {
399 $boardUser['is_wpadmin'] = true;
400 $boardUser['is_super'] = false;
401 } elseif (PermissionManager::isFluentBoardsAdmin($boardUser['ID'])) {
402 $boardUser['is_super'] = true;
403 $boardUser['is_wpadmin'] = false;
404 } else {
405 $boardUser['all_boards'] = Arr::get($boardUser, 'boards');
406 $boardUser['is_super'] = false;
407 $boardUser['is_wpadmin'] = false;
408 }
409 }
410
411 return $boardUsers;
412 }
413
414 public function searchMemberUser($search_input, $userId)
415 {
416 $user = User::find($userId);
417 $boards = $user->boards->pluck('id');
418 $boardUsers = Relation::whereIn('board_id', $boards)->orWhere('board_id', null)->where('status', 'ACTIVE')->pluck('user_id')->toArray();
419 $uniqueUsersIds = array_unique($boardUsers);
420 // $uniqueUsers = User::whereIn('ID', $uniqueUsersIds)->with('boards')->get();
421
422 $searchResult = User::query()->whereIn('ID', $uniqueUsersIds)->with('boards')
423 ->where('display_name', 'like', '%' . $search_input . '%')
424 ->take(20)->get();
425
426 foreach ($searchResult as &$uniqueUser) {
427 if (PermissionManager::isAdmin($uniqueUser['ID'])) {
428 $uniqueUser['all_boards'] = Board::query()->where('is_archived', 0)->get();
429 $isFluentBoardsAdmin = Relation::where('user_id', $uniqueUser['ID'])
430 ->whereNull('board_id')
431 ->where('status', 'ACTIVE')
432 ->first();
433 if ($isFluentBoardsAdmin) {
434 $uniqueUser['is_super'] = true;
435 $uniqueUser['is_wpadmin'] = false;
436 } else {
437 $uniqueUser['is_super'] = false;
438 $uniqueUser['is_wpadmin'] = true;
439 }
440 } else {
441 $uniqueUser['all_boards'] = $uniqueUser['boards'];
442 $uniqueUser['is_super'] = false;
443 $uniqueUser['is_wpadmin'] = false;
444 }
445 }
446
447 return $searchResult;
448 }
449
450
451 public function getMemberAssociatedTasks($user_id, $requestData)
452 {
453 $taskType = $requestData['taskType'] ?? null;
454 $boardIds = !empty($requestData['boardIds']) ? $requestData['boardIds'] : [];
455 $orderBy = $requestData['orderBy'] ?? 'created_at';
456 $order = strtoupper($requestData['order'] ?? 'ASC');
457 $per_page = Sanitize::perPage($requestData['per_page'] ?? 15, 50);
458 $page = max(1, (int) ($requestData['page'] ?? 1));
459 $user = User::find($user_id);
460
461 $loggedInUserId = get_current_user_id();
462
463 $allowedBoardIds = PermissionManager::getBoardIdsForUser($loggedInUserId);
464
465 if (!$user || empty($allowedBoardIds)) {
466 return [
467 'tasks' => [],
468 'paginationInfo' => [
469 'current_page' => 1,
470 'last_page' => 1,
471 'per_page' => (int) $per_page,
472 'total' => 0,
473 ],
474 ];
475 }
476
477 // Table view needs the labels column; list view ignores the extra relation.
478 $taskRelations = ['stage', 'board', 'labels'];
479
480 if ($taskType == 'assigned') {
481 $tasksQuery = $user->assignedTasks()
482 ->with($taskRelations)
483 ->whereNull('archived_at')
484 ->whereNull('parent_id')
485 ->whereIn('board_id', $allowedBoardIds)
486 ->where('status', '!=', 'closed')
487 ->onActiveAvailableBoards();
488 } else if ($taskType == 'mentioned') {
489 $tasksQuery = $user->mentionedTasks()
490 ->with($taskRelations)
491 ->whereNull('archived_at')
492 ->whereNull('parent_id')
493 ->whereIn('board_id', $allowedBoardIds)
494 ->onActiveAvailableBoards();
495 } else {
496 // Watched tasks power the date-based and completed profile tabs.
497 $tasksQuery = $user->tasks()
498 ->with($taskRelations)
499 ->whereNull('archived_at')
500 ->whereIn('board_id', $allowedBoardIds)
501 ->onActiveAvailableBoards();
502
503 switch ($taskType) {
504 case 'upcoming':
505 $tasksQuery->upcoming()
506 ->whereIn('board_id', $allowedBoardIds);
507 break;
508 case 'due_today':
509 $tasksQuery->dueToday()
510 ->whereIn('board_id', $allowedBoardIds);
511 break;
512 case 'overdue':
513 $tasksQuery->overdue()
514 ->whereIn('board_id', $allowedBoardIds);
515 break;
516 case 'completed':
517 $tasksQuery->where('status', 'closed')
518 ->whereIn('board_id', $allowedBoardIds);
519 break;
520 default:
521 $tasksQuery->whereNull('due_at')
522 ->whereIn('board_id', $allowedBoardIds);
523 break;
524 }
525 }
526
527 $currentUserId = get_current_user_id();
528 if ($currentUserId != $user->ID && !PermissionManager::isAdmin()) {
529 $currentUser = User::find($currentUserId);
530 $currentUserBoardIds = $currentUser->boards->pluck('id')->toArray();
531
532 if (empty($boardIds)) {
533 $boardIds = $currentUserBoardIds;
534 } else {
535 // Get the intersection of the two arrays
536 $boardIds = array_intersect($boardIds, $currentUserBoardIds);
537 }
538 }
539
540 if (!empty($boardIds)) {
541 $tasksQuery->whereIn('board_id', $boardIds);
542 }
543
544 $sortOptions = ['priority', 'due_at', 'position', 'created_at', 'title'];
545 $orderOptions = ['ASC', 'DESC'];
546
547 // Validate order and orderBy parameters
548 if (!in_array($order, $orderOptions) || !in_array($orderBy, $sortOptions)) {
549 throw new \Exception(esc_html__('Invalid sort or orderBy parameter', 'fluent-boards'));
550 }
551
552 // Apply ordering based on the specified order and orderBy
553 if ($orderBy === 'priority') {
554 $tasksQuery->orderByRaw("FIELD(priority, 'urgent', 'high', 'medium', 'low') {$order}");
555 } else if ($orderBy === 'due_at') {
556 $tasksQuery->orderByRaw("ISNULL(due_at), due_at {$order}");
557 } else {
558 $tasksQuery->orderBy($orderBy, $order);
559 }
560
561 $tasks = $tasksQuery->paginate($per_page, ['*'], 'page', $page);
562
563 return [
564 'tasks' => $tasks->values()->toArray(),
565 'paginationInfo' => [
566 'current_page' => $tasks->currentPage(),
567 'last_page' => $tasks->lastPage(),
568 'per_page' => (int) $tasks->perPage(),
569 'total' => $tasks->total(),
570 ],
571 ];
572 }
573
574 /**
575 * Get totals for each task category available in the profile task tabs.
576 */
577 public function getMemberTaskCounts($userId, $boardIds = [])
578 {
579 $user = User::find($userId);
580 $currentUserId = get_current_user_id();
581 $allowedBoardIds = PermissionManager::getBoardIdsForUser($currentUserId);
582
583 if (!$user || empty($allowedBoardIds)) {
584 return array_fill_keys(
585 ['due_today', 'assigned', 'upcoming', 'overdue', 'mentioned', 'completed', 'others'],
586 0
587 );
588 }
589
590 if ($currentUserId != $user->ID && !PermissionManager::isAdmin()) {
591 $currentUser = User::find($currentUserId);
592 $currentUserBoardIds = $currentUser->boards->pluck('id')->toArray();
593 $boardIds = empty($boardIds)
594 ? $currentUserBoardIds
595 : array_intersect($boardIds, $currentUserBoardIds);
596 }
597
598 // Keep the count queries aligned with the profile list without loading task models or relations.
599 $applyTaskScope = function ($query) use ($allowedBoardIds, $boardIds) {
600 $query->whereNull('archived_at')
601 ->whereNull('parent_id')
602 ->whereIn('board_id', $allowedBoardIds)
603 ->onActiveAvailableBoards();
604
605 if (!empty($boardIds)) {
606 $query->whereIn('board_id', $boardIds);
607 }
608
609 return $query;
610 };
611 $watchedTasks = function () use ($user, $applyTaskScope) {
612 return $applyTaskScope($user->tasks());
613 };
614
615 return [
616 'due_today' => (int) $watchedTasks()->dueToday()->count(),
617 'assigned' => (int) $applyTaskScope($user->assignedTasks())
618 ->where('status', '!=', 'closed')
619 ->count(),
620 'upcoming' => (int) $watchedTasks()->upcoming()->count(),
621 'overdue' => (int) $watchedTasks()->overdue()->count(),
622 'mentioned' => (int) $applyTaskScope($user->mentionedTasks())->count(),
623 'completed' => (int) $watchedTasks()->where('status', 'closed')->count(),
624 'others' => (int) $watchedTasks()->whereNull('due_at')->count(),
625 ];
626 }
627
628
629 /**
630 * Get a member's activities scoped to boards the requesting user can access.
631 */
632 public function getMemberRelatedAcitivies($user_id, $page)
633 {
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 })
664 ->orderBy('created_at', 'desc')
665 ->with('user')
666 ->paginate($perPage, ['*'], 'page', $page);
667
668 $activitiesToShow = [];
669
670 foreach ($activities as $activity) {
671 if ($activity->object_type == Constant::ACTIVITY_BOARD) {
672 $activity->load('board');
673 $activitiesToShow[] = $activity;
674 } elseif ($activity->object_type == Constant::ACTIVITY_TASK) {
675 $activity->load('task');
676 $activitiesToShow[] = $activity;
677 }
678 }
679
680 return [
681 'activities' => $activitiesToShow,
682 'pagination' => $this->getPaginationInfo($activities),
683 ];
684 }
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
712 private function getTaskById($taskId)
713 {
714 return Task::findOrFail($taskId);
715 }
716
717 public function getMemberBoards($user_id)
718 {
719 if(!$user_id) {
720 return [];
721 }
722 $user = User::find($user_id);
723 $currentUserId = get_current_user_id();
724
725 if ($currentUserId == $user->ID || PermissionManager::isAdmin($currentUserId)) {
726 return $user->whichBoards;
727 }
728
729 $currentUser = User::find($currentUserId);
730 $boardIds = $currentUser ? $currentUser->whichBoards->pluck('id')->toArray() : [];
731
732 if (empty($boardIds)) {
733 return [];
734 }
735
736 return $user->whichBoards()->whereIn('fbs_boards.id', $boardIds)->get();
737 }
738
739 /**
740 * Returns four aggregate counts for a member's stats widget.
741 *
742 * Cross-user access is scoped to boards shared with the requesting user,
743 * matching the contract of getMemberBoards() and getMemberAssociatedTasks().
744 * Unread notifications are personal and only returned for self or admin.
745 */
746 public function getMemberStats(int $user_id): array
747 {
748 $empty = ['assigned_tasks' => 0, 'completed_tasks' => 0, 'total_boards' => 0, 'unread_notifications' => 0];
749
750 $user = User::find($user_id);
751 if (!$user) {
752 return $empty;
753 }
754
755 $currentUserId = get_current_user_id();
756 $isSelfOrAdmin = ($currentUserId === $user_id) || PermissionManager::isAdmin($currentUserId);
757
758 // For cross-user access, restrict counts to boards the requesting user can also see.
759 $allowedBoardIds = null;
760 if (!$isSelfOrAdmin) {
761 $currentUser = User::find($currentUserId);
762 $allowedBoardIds = $currentUser ? $currentUser->whichBoards->pluck('id')->toArray() : [];
763 if (empty($allowedBoardIds)) {
764 return $empty;
765 }
766 }
767
768 $assignedBase = $user->assignedTasks()->whereNull('archived_at')->whereNull('parent_id');
769 if ($allowedBoardIds !== null) {
770 $assignedBase->whereIn('board_id', $allowedBoardIds);
771 }
772
773 $boardsQuery = $user->whichBoards();
774 if ($allowedBoardIds !== null) {
775 $boardsQuery->whereIn('fbs_boards.id', $allowedBoardIds);
776 }
777
778 // Use Notification model with object_type guard — matches NotificationService::newNotificationNumber().
779 // Notifications are personal; return 0 when a non-admin views another member's profile.
780 $unreadNotifications = 0;
781 if ($isSelfOrAdmin) {
782 $unreadNotifications = Notification::query()
783 ->where('object_type', Constant::OBJECT_TYPE_BOARD_NOTIFICATION)
784 ->whereHas('users', function ($q) use ($user_id) {
785 $q->where('user_id', $user_id)->whereNull('marked_read_at');
786 })
787 ->count();
788 }
789
790 return [
791 'assigned_tasks' => (int) (clone $assignedBase)->where('status', '!=', 'closed')->count(),
792 'completed_tasks' => (int) (clone $assignedBase)->where('status', 'closed')->count(),
793 'total_boards' => (int) $boardsQuery->count(),
794 'unread_notifications' => (int) $unreadNotifications,
795 ];
796 }
797 }
798