diff --git a/src/Analysim.Web/Controllers/AccountController.cs b/src/Analysim.Web/Controllers/AccountController.cs index f2300e03..c33bf865 100644 --- a/src/Analysim.Web/Controllers/AccountController.cs +++ b/src/Analysim.Web/Controllers/AccountController.cs @@ -139,7 +139,9 @@ private void ClearAuthenticationCookies() private async Task LoadUserNavigationsAsync(User user) { - _dbContext.Entry(user).Collection(u => u.ProjectUsers).Load(); + await _dbContext.Entry(user).Collection(u => u.ProjectUsers).Query() + .Include(pu => pu.Project).ThenInclude(p => p.ProjectUsers) + .LoadAsync(); _dbContext.Entry(user).Collection(u => u.BlobFiles).Load(); _dbContext.Entry(user).Collection(u => u.Followers).Load(); _dbContext.Entry(user).Collection(u => u.Following).Load(); @@ -162,6 +164,78 @@ private bool IsCurrentUserAdmin() return admins.Any(u => string.Equals(u, currentUsername, StringComparison.OrdinalIgnoreCase)); } + private object ToUserResponse(User user, bool isSessionUser = false) + { + if (user == null) return null; + + int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var currentUserId); + var isAdmin = IsCurrentUserAdmin(); + + // Login and refresh have verified this user independently of the incoming access token. + if (isSessionUser) + { + currentUserId = user.Id; + var admins = _configuration.GetSection("AdminUsers").Get>() ?? new List(); + isAdmin = admins.Any(u => string.Equals(u, user.UserName, StringComparison.OrdinalIgnoreCase)); + } + + var includeAccountDetails = currentUserId == user.Id || isAdmin; + + return new + { + user.Id, + user.UserName, + user.Bio, + user.DateCreated, + user.LastOnline, + Email = includeAccountDetails ? user.Email : null, + ReceiveCommentReplyEmails = includeAccountDetails ? (bool?)user.ReceiveCommentReplyEmails : null, + LockoutEnabled = includeAccountDetails ? (bool?)user.LockoutEnabled : null, + LockoutEnd = includeAccountDetails ? user.LockoutEnd : null, + Followers = user.Followers.Select(f => new { f.UserID, f.FollowerID }).ToList(), + Following = user.Following.Select(f => new { f.UserID, f.FollowerID }).ToList(), + ProjectUsers = user.ProjectUsers + .Where(pu => pu.Project != null && (isAdmin || + string.Equals(pu.Project.Visibility, "public", StringComparison.OrdinalIgnoreCase) || + pu.Project.ProjectUsers.Any(member => member.UserID == currentUserId && + member.UserRole != null && !string.Equals(member.UserRole, "follower", StringComparison.OrdinalIgnoreCase)))) + .Select(pu => new { pu.ProjectID, pu.UserID, pu.UserRole, pu.IsFollowing }).ToList(), + BlobFiles = user.BlobFiles + .Where(b => b.ProjectID == null && b.Container == "profile" && b.Name == "profileImage") + .Select(ToBlobFileResponse).ToList() + }; + } + + private object ToUserConnectionResponse(UserUser connection) + { + return new + { + connection.UserID, + connection.FollowerID, + User = ToUserResponse(connection.User), + Follower = ToUserResponse(connection.Follower) + }; + } + + private static object ToBlobFileResponse(BlobFile file) + { + return new + { + file.BlobFileID, + file.Container, + file.Directory, + file.Name, + file.Extension, + file.Size, + file.Uri, + file.DateCreated, + file.LastModified, + file.UserID, + file.ProjectID, + User = file.User == null ? null : new { file.User.Id, file.User.UserName } + }; + } + #region GET REQUEST /* * Type : GET @@ -176,14 +250,14 @@ public IActionResult GetUserByID([FromRoute] int id) var user = _dbContext.Users .Include(u => u.Followers) .Include(u => u.Following) - .Include(u => u.ProjectUsers) + .Include(u => u.ProjectUsers).ThenInclude(pu => pu.Project).ThenInclude(p => p.ProjectUsers) .Include(u => u.BlobFiles) .SingleOrDefault(x => x.Id == id); if (user == null) return NotFound(); // user.EmailConfirmed ; return Ok(new { - result = user, + result = ToUserResponse(user), message = "Received User: " + user.UserName }); } @@ -224,13 +298,13 @@ public IActionResult GetUserByName([FromRoute] string username) var user = _dbContext.Users .Include(u => u.Followers) .Include(u => u.Following) - .Include(u => u.ProjectUsers) + .Include(u => u.ProjectUsers).ThenInclude(pu => pu.Project).ThenInclude(p => p.ProjectUsers) .Include(u => u.BlobFiles) .SingleOrDefault(u => u.UserName == username); if (user == null) return NotFound(new { message = "User Not Found" }); return Ok(new { - result = user, + result = ToUserResponse(user), message = "Received User: " + user.UserName }); } @@ -248,7 +322,7 @@ public IActionResult GetUserRange([FromQuery(Name = "id")] List ids) var users = _dbContext.Users .Include(u => u.Followers) .Include(u => u.Following) - .Include(u => u.ProjectUsers) + .Include(u => u.ProjectUsers).ThenInclude(pu => pu.Project).ThenInclude(p => p.ProjectUsers) .Include(u => u.BlobFiles) .Where(u => ids.Contains(u.Id)) .ToList(); @@ -256,7 +330,7 @@ public IActionResult GetUserRange([FromQuery(Name = "id")] List ids) return Ok(new { - result = users, + result = users.Select(user => ToUserResponse(user)).ToList(), message = "Received User Range" }); } @@ -274,13 +348,13 @@ public IActionResult GetUserList() var users = _dbContext.Users .Include(u => u.Followers) .Include(u => u.Following) - .Include(u => u.ProjectUsers) + .Include(u => u.ProjectUsers).ThenInclude(pu => pu.Project).ThenInclude(p => p.ProjectUsers) .Include(u => u.BlobFiles) .ToList(); return Ok(new { - result = users, + result = users.Select(user => ToUserResponse(user)).ToList(), message = "Received User List" }); } @@ -295,7 +369,7 @@ public IActionResult GetUserList() public IActionResult GetProfileImage([FromQuery(Name="id")] int id) { var blobfile = _dbContext.BlobFiles - .Where(b => b.UserID == id && b.Name == "profileImage") + .Where(b => b.UserID == id && b.ProjectID == null && b.Container == "profile" && b.Name == "profileImage") .FirstOrDefault(); if (blobfile == null) return NotFound(new { message = "Profile Image Not Found"}); @@ -303,7 +377,7 @@ public IActionResult GetProfileImage([FromQuery(Name="id")] int id) return Ok(new { - result = blobfile, + result = ToBlobFileResponse(blobfile), message = "Received user's profile picture" }); } @@ -320,7 +394,7 @@ public IActionResult Search([FromQuery(Name = "term")] List searchTerms) var matchedUser = _dbContext.Users .Include(u => u.Followers) .Include(u => u.Following) - .Include(u => u.ProjectUsers) + .Include(u => u.ProjectUsers).ThenInclude(pu => pu.Project).ThenInclude(p => p.ProjectUsers) .Include(u => u.BlobFiles) .ToList() .Where(u => searchTerms.All(k => u.UserName.ToLower().Contains(k.ToLower()))); @@ -328,7 +402,7 @@ public IActionResult Search([FromQuery(Name = "term")] List searchTerms) return Ok(new { - result = matchedUser, + result = matchedUser.Select(user => ToUserResponse(user)).ToList(), message = "Search Successful" }); } @@ -373,7 +447,7 @@ public async Task Follow([FromForm] AccountFollowVM formdata) return Ok(new { - result = userFollower, + result = ToUserConnectionResponse(userFollower), message = user.UserName + " is now following " + userToFollow.UserName }); } @@ -447,7 +521,6 @@ public async Task Register([FromForm] AccountRegisterVM formdata) // Return Ok Request return Ok(new { - result = user, message = $"Registration successful and confirmation email sent" }); } @@ -603,7 +676,6 @@ public async Task SendConfirmationEmail([FromQuery(Name = "EmailA return Ok(new { - result = user, message = "Successfully sent verification email" }); } @@ -649,7 +721,6 @@ public async Task ForgotPassword([FromForm] ForgotPasswordVM form return Ok(new { - result = user, message = "Password Reset mail sent" }); } @@ -814,14 +885,20 @@ public async Task Login([FromForm] AccountLoginVM formdata) var username = await _userManager.FindByNameAsync(formdata.Username); var email = await _userManager.FindByEmailAsync(formdata.Username); - // Check Login Status - if ((username != null && await _userManager.CheckPasswordAsync(username, formdata.Password)) || (email != null && await _userManager.CheckPasswordAsync(email, formdata.Password))) + // Select the user whose password was verified + User user = null; + if (username != null && await _userManager.CheckPasswordAsync(username, formdata.Password)) { - var user = username; - if (email != null){ - user = email; - } + user = username; + } + else if (email != null && await _userManager.CheckPasswordAsync(email, formdata.Password)) + { + user = email; + } + // Check Login Status + if (user != null) + { if (IsUserDisabled(user)) { return Unauthorized(new @@ -854,7 +931,7 @@ public async Task Login([FromForm] AccountLoginVM formdata) // Return OK Request return Ok(new { - result = user, + result = ToUserResponse(user, isSessionUser: true), expiration = tokenResult.AccessTokenExpiresAt, message = "Login successful" }); @@ -899,7 +976,7 @@ public async Task Me() return Ok(new { - result = user, + result = ToUserResponse(user), message = "Session is active" }); } @@ -931,7 +1008,7 @@ public async Task Refresh() return Ok(new { - result = tokenResult.User, + result = ToUserResponse(tokenResult.User, isSessionUser: true), expiration = tokenResult.AccessTokenExpiresAt, message = "Authentication renewed" }); @@ -1001,7 +1078,7 @@ public async Task SetAccountStatus([FromRoute] int userId, [FromB return Ok(new { - result = user, + result = ToUserResponse(user), message = formdata.Disabled ? "User account disabled." : "User account enabled." }); } @@ -1073,7 +1150,7 @@ public async Task UploadProfileImage([FromForm] AccountUploadVM f blobFile.BlobFileContents = null; return Ok(new { - result = blobFile, + result = ToBlobFileResponse(blobFile), message = "Profile Image Updated" }); } @@ -1113,7 +1190,7 @@ public async Task UploadProfileImage([FromForm] AccountUploadVM f // Return Ok Status return Ok(new { - result = newBlobFile, + result = ToBlobFileResponse(newBlobFile), message = "File Successfully Uploaded" }); } @@ -1156,7 +1233,7 @@ public async Task UpdateUser([FromRoute] int userID, [FromForm] A var newuser = _dbContext.Users .Include(u => u.Followers) .Include(u => u.Following) - .Include(u => u.ProjectUsers) + .Include(u => u.ProjectUsers).ThenInclude(pu => pu.Project).ThenInclude(p => p.ProjectUsers) .Include(u => u.BlobFiles) .FirstOrDefault(u => u.Id == user.Id); if (newuser == null) return NotFound(new { message = "User Not Found" }); @@ -1170,7 +1247,7 @@ public async Task UpdateUser([FromRoute] int userID, [FromForm] A // Return Ok Status return Ok(new { - result = user, + result = ToUserResponse(user), message = "User has been updated" }); @@ -1232,7 +1309,7 @@ [FromBody] NotificationPreferencesUpdateVM formdata // Return updated user return Ok(new { - result = user, + result = ToUserResponse(user), message = "Notification preferences have been updated" }); } @@ -1275,7 +1352,7 @@ public async Task Unfollow([FromRoute] int userID, [FromRoute] in return Ok(new { - result = userFollower, + result = ToUserConnectionResponse(userFollower), message = user.UserName + " has unfollow " + userToFollow.UserName }); } @@ -1421,7 +1498,35 @@ public IActionResult GetProjects([FromRoute] int userID) // Return Ok Status return Ok(new { - result = userProjects, + result = userProjects.Select(pu => new + { + pu.ProjectID, + pu.UserID, + pu.UserRole, + pu.IsFollowing, + Project = new + { + pu.Project.ProjectID, + pu.Project.Name, + pu.Project.Visibility, + pu.Project.Description, + pu.Project.DateCreated, + pu.Project.LastUpdated, + pu.Project.Route, + pu.Project.ForkedFromProjectID, + Notebooks = Array.Empty(), + BlobFiles = pu.Project.BlobFiles.Select(ToBlobFileResponse).ToList(), + ProjectUsers = pu.Project.ProjectUsers.Select(member => new + { + member.ProjectID, member.UserID, member.UserRole, member.IsFollowing + }).ToList(), + ProjectTags = pu.Project.ProjectTags.Select(pt => new + { + pt.ProjectID, pt.TagID, + Tag = new { pt.Tag.TagID, pt.Tag.Name } + }).ToList() + } + }).ToList(), message = "Received User Project" }); } @@ -1450,7 +1555,7 @@ public IActionResult GetFollowers([FromRoute] int userID) return Ok(new { - result = userFollowers, + result = userFollowers.Select(ToUserConnectionResponse).ToList(), message = "Received User Follower" }); } @@ -1481,7 +1586,7 @@ public IActionResult GetFollowings([FromRoute] int userID) return Ok(new { - result = userFollowings, + result = userFollowings.Select(ToUserConnectionResponse).ToList(), message = "Received User Following" }); } @@ -1508,7 +1613,12 @@ public async Task DeleteProfileImage([FromRoute] int fileID) if (user == null) return NotFound(new { message = "User Not Found" }); // Find File - var blobFile = await _dbContext.BlobFiles.FindAsync(fileID); + var blobFile = await _dbContext.BlobFiles.SingleOrDefaultAsync(file => + file.BlobFileID == fileID && + file.UserID == user.Id && + file.ProjectID == null && + file.Container == "profile" && + file.Name == "profileImage"); if (blobFile == null) return NotFound(new { message = "File Not Found" }); //await _blobService.DeleteBlobAsync(blobFile); @@ -1522,7 +1632,7 @@ public async Task DeleteProfileImage([FromRoute] int fileID) // Return Ok Status return Ok(new { - result = blobFile, + result = ToBlobFileResponse(blobFile), message = "File Successfully Deleted" }); } diff --git a/src/Analysim.Web/Controllers/CommunicationController.cs b/src/Analysim.Web/Controllers/CommunicationController.cs index df89f395..f1a19f5c 100644 --- a/src/Analysim.Web/Controllers/CommunicationController.cs +++ b/src/Analysim.Web/Controllers/CommunicationController.cs @@ -14,6 +14,9 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; namespace Core.Controllers { @@ -24,11 +27,16 @@ public class CommunicationController : ControllerBase { private readonly IMailNetService _mailNetService; private readonly ILoggerManager _loggerManager; + private readonly IConfiguration _configuration; - public CommunicationController(IMailNetService mailNetService, ILoggerManager loggerManager) + public CommunicationController( + IMailNetService mailNetService, + ILoggerManager loggerManager, + IConfiguration configuration) { _mailNetService = mailNetService; _loggerManager = loggerManager; + _configuration = configuration; } /* @@ -38,9 +46,16 @@ public CommunicationController(IMailNetService mailNetService, ILoggerManager lo * Description: Send an Email * Response Status: 200 Ok, 400 Bad Request */ + [Authorize] [HttpPost("[action]")] public async Task SendEmail([FromForm] SendEmailVM emailInfo) { + var currentUsername = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.Name); + var admins = _configuration.GetSection("AdminUsers").Get>() ?? new List(); + + var isAdmin = admins.Any(u => + string.Equals(u, currentUsername, StringComparison.OrdinalIgnoreCase)); + if (!isAdmin) return Forbid(); await _mailNetService.SendEmail(emailInfo.EmailAddress, emailInfo.Username, emailInfo.Subject, emailInfo.BodyHtml, emailInfo.BodyText); diff --git a/src/Analysim.Web/Controllers/NotificationsController.cs b/src/Analysim.Web/Controllers/NotificationsController.cs index dbc6ac83..60224420 100644 --- a/src/Analysim.Web/Controllers/NotificationsController.cs +++ b/src/Analysim.Web/Controllers/NotificationsController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using System; using System.Collections.Generic; using System.Linq; @@ -29,10 +30,12 @@ public class NotificationsController : ControllerBase }; private readonly ApplicationDbContext _dbContext; + private readonly IConfiguration _configuration; - public NotificationsController(ApplicationDbContext dbContext) + public NotificationsController(ApplicationDbContext dbContext, IConfiguration configuration) { _dbContext = dbContext; + _configuration = configuration; } [HttpGet] @@ -53,6 +56,8 @@ public async Task GetNotifications( .AsNoTracking() .Where(n => n.RecipientUserID == userId); + query = ApplyVisibleNotificationScope(query, userId); + if (unreadOnly) query = query.Where(n => !n.IsRead); @@ -96,9 +101,12 @@ public async Task GetUnreadCount() var userIdResult = TryGetCurrentUserId(out var userId); if (userIdResult != null) return userIdResult; - var count = await _dbContext.Notifications - .AsNoTracking() - .CountAsync(n => n.RecipientUserID == userId && !n.IsRead); + var count = await ApplyVisibleNotificationScope( + _dbContext.Notifications + .AsNoTracking() + .Where(n => n.RecipientUserID == userId), + userId) + .CountAsync(n => !n.IsRead); return Ok(new { @@ -113,8 +121,10 @@ public async Task MarkAsRead([FromRoute] int notificationId) var userIdResult = TryGetCurrentUserId(out var userId); if (userIdResult != null) return userIdResult; - var notification = await _dbContext.Notifications - .SingleOrDefaultAsync(n => n.NotificationID == notificationId && n.RecipientUserID == userId); + var notification = await ApplyVisibleNotificationScope( + _dbContext.Notifications.Where(n => n.RecipientUserID == userId), + userId) + .SingleOrDefaultAsync(n => n.NotificationID == notificationId); if (notification == null) return NotFound(new { message = "Notification not found." }); @@ -139,8 +149,10 @@ public async Task MarkAllAsRead() var userIdResult = TryGetCurrentUserId(out var userId); if (userIdResult != null) return userIdResult; - var unreadNotifications = await _dbContext.Notifications - .Where(n => n.RecipientUserID == userId && !n.IsRead) + var unreadNotifications = await ApplyVisibleNotificationScope( + _dbContext.Notifications.Where(n => n.RecipientUserID == userId), + userId) + .Where(n => !n.IsRead) .ToListAsync(); var now = DateTime.UtcNow; @@ -258,6 +270,31 @@ private IActionResult TryGetCurrentUserId(out int userId) return null; } + private IQueryable ApplyVisibleNotificationScope( + IQueryable notifications, + int userId) + { + if (IsCurrentUserAdmin()) return notifications; + + return notifications.Where(n => + n.Type == NotificationTypes.ProjectInvitationReceived || + !n.ProjectID.HasValue || + (n.Project.Visibility != null && n.Project.Visibility.ToLower() == "public") || + n.Project.ProjectUsers.Any(pu => + pu.UserID == userId && + pu.UserRole != null && + pu.UserRole.ToLower() != "follower")); + } + + private bool IsCurrentUserAdmin() + { + var currentUsername = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(currentUsername)) return false; + + var admins = _configuration.GetSection("AdminUsers").Get>() ?? new List(); + return admins.Any(u => string.Equals(u, currentUsername, StringComparison.OrdinalIgnoreCase)); + } + private static bool GetDefaultEmailPreference(User user, string notificationType) { return notificationType == NotificationTypes.CommentReply && user.ReceiveCommentReplyEmails; diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs index 502041f6..c65b95a7 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -179,12 +179,152 @@ private async Task IsProjectOwnerAsync(int projectID, int userID) private IQueryable ProjectMembershipRequestsWithUsers() { return _dbContext.ProjectMembershipRequests - .Include(pmr => pmr.Project) + .Include(pmr => pmr.Project).ThenInclude(p => p.ProjectUsers) .Include(pmr => pmr.RequesterUser) .Include(pmr => pmr.TargetUser) .Include(pmr => pmr.CreatedByUser); } + // Return only response fields, never tracked navigation graphs or Identity properties. + private static object ToUserResponse(User user) + { + if (user == null) return null; + + return new + { + user.Id, + user.UserName, + user.Bio, + user.DateCreated, + user.LastOnline, + Followers = user.Followers.Select(f => new { f.UserID, f.FollowerID }).ToList(), + Following = user.Following.Select(f => new { f.UserID, f.FollowerID }).ToList(), + BlobFiles = user.BlobFiles + .Where(b => b.ProjectID == null && b.Container == "profile" && b.Name == "profileImage") + .Select(ToBlobFileResponse).ToList() + }; + } + + private static object ToProjectUserResponse(ProjectUser member) + { + return new + { + member.ProjectID, + member.UserID, + member.UserRole, + member.IsFollowing, + User = ToUserResponse(member.User) + }; + } + + private static object ToBlobFileResponse(BlobFile file) + { + return new + { + file.BlobFileID, + file.Container, + file.Directory, + file.Name, + file.Extension, + file.Size, + file.Uri, + file.DateCreated, + file.LastModified, + file.UserID, + file.ProjectID, + User = file.User == null ? null : new { file.User.Id, file.User.UserName } + }; + } + + private static object ToDatasetResponse(ObservableNotebookDataset dataset) + { + return new + { + dataset.ID, + dataset.NotebookID, + dataset.BlobFileID, + dataset.datasetName, + dataset.datasetURL + }; + } + + private static object ToNotebookResponse(Notebook notebook) + { + return new + { + notebook.NotebookID, + notebook.Container, + notebook.Directory, + notebook.Name, + notebook.Route, + notebook.Extension, + notebook.Size, + notebook.Uri, + notebook.DateCreated, + notebook.LastModified, + notebook.ProjectID, + notebook.type, + observableNotebookDatasets = notebook.observableNotebookDatasets?.Select(ToDatasetResponse).ToList() + }; + } + + private static object ToProjectTagResponse(ProjectTag projectTag) + { + return new + { + projectTag.ProjectID, + projectTag.TagID, + Tag = projectTag.Tag == null ? null : new { projectTag.Tag.TagID, projectTag.Tag.Name } + }; + } + + private static object ToProjectResponse(Project project) + { + return new + { + project.ProjectID, + project.Name, + project.Visibility, + project.Description, + project.DateCreated, + project.LastUpdated, + project.Route, + project.ForkedFromProjectID, + ProjectUsers = project.ProjectUsers.Select(ToProjectUserResponse).ToList(), + BlobFiles = project.BlobFiles.Select(ToBlobFileResponse).ToList(), + Notebooks = project.Notebooks.Select(ToNotebookResponse).ToList(), + ProjectTags = project.ProjectTags?.Select(ToProjectTagResponse).ToList() + }; + } + + private object ToMembershipRequestResponse(ProjectMembershipRequest request) + { + var canViewProject = request.Project != null && CanCurrentUserViewProject(request.Project); + var isInvitee = request.Type == MembershipRequestTypeInvitation && + TryGetCurrentUserId(out var userId) && request.TargetUserID == userId; + + return new + { + request.ProjectMembershipRequestID, + request.ProjectID, + // An invitee needs the name and route to identify a private project before joining. + Project = request.Project != null && (canViewProject || isInvitee) + ? new { request.Project.ProjectID, request.Project.Name, request.Project.Route } + : null, + request.RequesterUserID, + RequesterUser = ToUserResponse(request.RequesterUser), + request.TargetUserID, + TargetUser = ToUserResponse(request.TargetUser), + request.CreatedByUserID, + CreatedByUser = ToUserResponse(request.CreatedByUser), + request.Type, + request.Status, + request.Message, + request.CreatedAt, + request.RespondedAt + }; + } + private async Task AddProjectMemberAsync(int projectID, int userID) { var projectUser = await _dbContext.ProjectUsers.FindAsync(userID, projectID); @@ -235,7 +375,7 @@ public async Task GetProjectByID([FromRoute] int projectID) // Return Ok Request return Ok(new { - result = project, + result = ToProjectResponse(project), message = "Received Project" }); } @@ -261,7 +401,7 @@ public async Task GetProjectByRoute([FromRoute] string owner, [Fr // Return Ok Request return Ok(new { - result = project, + result = ToProjectResponse(project), message = "Received Project" }); } @@ -285,7 +425,7 @@ public async Task GetProjectRange([FromQuery(Name = "id")] List GetProjectList() // Return Ok Request return Ok(new { - result = projects, + result = projects.Select(ToProjectResponse).ToList(), message = "Received Project" }); } @@ -339,7 +479,7 @@ public async Task Search([FromQuery(Name = "term")] List return Ok(new { - result = matchedProject, + result = matchedProject.Select(ToProjectResponse).ToList(), message = "Received Search Result." }); } @@ -451,7 +591,7 @@ public async Task GetNotebook([FromRoute] int notebookID) return Ok(new { message = "Notebook Retrieved", - notebook + notebook = ToNotebookResponse(notebook) }); } catch (Exception e) @@ -474,7 +614,7 @@ public IActionResult GetAllNotebooks() return Ok(new { - result = notebooks, + result = notebooks.Select(ToNotebookResponse).ToList(), message = "All notebooks retrieved" }); } @@ -985,6 +1125,12 @@ public async Task AddPublication([FromForm] CreatePublicationVM f var project = await _dbContext.Projects.FindAsync(formdata.ProjectID); if (project == null) return NotFound(new { message = "Project Not Found" }); + var canManagePublications = await _dbContext.ProjectUsers.AnyAsync(pu => + pu.ProjectID == project.ProjectID && + pu.UserID == user.Id && + (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); + if (!canManagePublications) return Forbid(); + // Validate Fields if (string.IsNullOrWhiteSpace(formdata.Journal)) return BadRequest(new { message = "Publication Journal is required." }); @@ -1059,7 +1205,7 @@ public async Task AddProjectLog([FromForm] CreateProjectLogVM for .AnyAsync(aup => aup.UserID == user.Id && aup.ProjectID == project.ProjectID && - (aup.UserRole == "owner" || aup.UserRole == "member")); + (aup.UserRole == ProjectOwnerRole || aup.UserRole == ProjectMemberRole)); if (!hasProjectLogAccess) { @@ -1239,6 +1385,7 @@ public async Task PostComment([FromForm] CreateProjectCommentVM f // Validate Project var project = await _dbContext.Projects.FindAsync(formdata.ProjectID); if (project == null) return NotFound(new { message = "Project Not Found" }); + if (!await CanCurrentUserViewProjectAsync(project.ProjectID)) return ProjectNotFound(); // Validate Project Log (if present) ProjectLog projectLog = null; @@ -1361,11 +1508,12 @@ public async Task LikeComment([FromRoute] int commentID) if (user == null) return NotFound(new {message = "User Not Found."}); // Validate Comment - var commentExists = await _dbContext.ProjectComments - .AnyAsync(c => c.CommentID == commentID && !c.IsDeleted && !c.IsPendingReview); + var comment = await _dbContext.ProjectComments + .SingleOrDefaultAsync(c => c.CommentID == commentID && !c.IsDeleted && !c.IsPendingReview); - if (!commentExists) + if (comment == null) return NotFound(new { message = "Comment not found." }); + if (!await CanCurrentUserViewProjectAsync(comment.ProjectID)) return ProjectNotFound(); // Prevent duplicate likes var alreadyLiked = await _dbContext.ProjectCommentLikes @@ -1417,6 +1565,7 @@ public async Task ReportComment([FromRoute] int commentID) if (comment == null) return NotFound(new { message = "Comment not found." }); + if (!await CanCurrentUserViewProjectAsync(comment.ProjectID)) return ProjectNotFound(); // Prevent duplicate flags by user var alreadyFlagged = await _dbContext.ProjectCommentFlags @@ -1530,6 +1679,20 @@ public async Task ForkProject([FromForm] ProjectForkVM formdata) if (project == null) return NotFound(new { message = "Project Not Found" }); if (!await CanCurrentUserViewProjectAsync(project.ProjectID)) return ProjectNotFound(); + // Validate all selected files belong to the source project + if (formdata.BlobFilesID != null && formdata.BlobFilesID.Length > 0) + { + var requestedBlobFileIDs = formdata.BlobFilesID.Distinct().ToArray(); + var sourceBlobFileCount = await _dbContext.BlobFiles.CountAsync(file => + requestedBlobFileIDs.Contains(file.BlobFileID) && + file.ProjectID == project.ProjectID); + + if (sourceBlobFileCount != requestedBlobFileIDs.Length) + { + return BadRequest(new { message = "One or more files do not belong to the source project." }); + } + } + // Check if the project already exists bool projectExists = await _dbContext.Projects .AnyAsync(p => p.ProjectUsers.Any(aup => @@ -1694,7 +1857,7 @@ await _dbContext.ObservableNotebookDataset.AddAsync(new ObservableNotebookDatase return Ok(new { - result = newProject, + result = ToProjectResponse(newProject), message = "Project Successfully Forked" }); } @@ -1835,7 +1998,7 @@ await _dbContext.NotebookContent.AddAsync(new NotebookContent return Ok(new { - result = newProject, + result = ToProjectResponse(newProject), message = "Project Successfully Forked" }); } @@ -2005,7 +2168,7 @@ await _dbContext.ProjectTags.AddRangeAsync( // Return Ok Request return Ok(new { - result = newProject, + result = ToProjectResponse(newProject), message = "Project Successfully Created" }); } @@ -2058,7 +2221,7 @@ public async Task FollowProject([FromRoute] int projectID) { return Conflict(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "Project membership already exists and cannot be changed to a follower record." }); } @@ -2067,7 +2230,7 @@ public async Task FollowProject([FromRoute] int projectID) { return Ok(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "Project is already followed." }); } @@ -2078,7 +2241,7 @@ public async Task FollowProject([FromRoute] int projectID) return Ok(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "Project followed successfully." }); } @@ -2099,7 +2262,7 @@ public async Task FollowProject([FromRoute] int projectID) return Ok(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "Project followed successfully." }); } @@ -2153,7 +2316,7 @@ public async Task UnfollowProject([FromRoute] int projectID) { return Conflict(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "Project membership already exists and cannot be changed to an unfollow record." }); } @@ -2162,7 +2325,7 @@ public async Task UnfollowProject([FromRoute] int projectID) { return Ok(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "Project is already unfollowed." }); } @@ -2173,7 +2336,7 @@ public async Task UnfollowProject([FromRoute] int projectID) return Ok(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "Project unfollowed successfully." }); } @@ -2209,7 +2372,7 @@ public async Task GetProjectMembershipRequests([FromRoute] int pr return Ok(new { - result = requests, + result = requests.Select(ToMembershipRequestResponse).ToList(), message = "Project membership requests received." }); } @@ -2236,7 +2399,7 @@ public async Task GetMyMembershipRequests() return Ok(new { - result = requests, + result = requests.Select(ToMembershipRequestResponse).ToList(), message = "Membership requests received." }); } @@ -2267,7 +2430,7 @@ public async Task RequestJoinProject([FromRoute] int projectID) { return Conflict(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "You are already a member of this project." }); } @@ -2283,7 +2446,7 @@ public async Task RequestJoinProject([FromRoute] int projectID) { return Conflict(new { - result = pendingInvitation, + result = ToMembershipRequestResponse(pendingInvitation), message = "You already have a pending invitation for this project." }); } @@ -2299,7 +2462,7 @@ public async Task RequestJoinProject([FromRoute] int projectID) { return Ok(new { - result = existingRequest, + result = ToMembershipRequestResponse(existingRequest), message = "Join request is already pending." }); } @@ -2338,7 +2501,7 @@ await _notificationService.PublishAsync(new NotificationEvent return Ok(new { - result = joinRequest, + result = ToMembershipRequestResponse(joinRequest), message = "Join request submitted successfully." }); } @@ -2376,7 +2539,7 @@ public async Task InviteProjectMember([FromForm] ProjectMembershi { return Conflict(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "User is already a member of this project." }); } @@ -2392,7 +2555,7 @@ public async Task InviteProjectMember([FromForm] ProjectMembershi { return Conflict(new { - result = pendingJoinRequest, + result = ToMembershipRequestResponse(pendingJoinRequest), message = "User already has a pending join request for this project." }); } @@ -2408,7 +2571,7 @@ public async Task InviteProjectMember([FromForm] ProjectMembershi { return Ok(new { - result = existingInvitation, + result = ToMembershipRequestResponse(existingInvitation), message = "Project invitation is already pending." }); } @@ -2449,7 +2612,7 @@ await _notificationService.PublishAsync(new NotificationEvent return Ok(new { - result = invitation, + result = ToMembershipRequestResponse(invitation), message = "Project invitation created successfully." }); } @@ -2474,7 +2637,7 @@ public async Task AcceptProjectMembershipRequest([FromRoute] int if (membershipRequest == null) return NotFound(new { message = "Membership request not found." }); if (membershipRequest.Status != MembershipRequestStatusPending) - return Conflict(new { result = membershipRequest, message = "Membership request is not pending." }); + return Conflict(new { message = "Membership request is not pending." }); if (membershipRequest.Type == MembershipRequestTypeJoin) { @@ -2525,8 +2688,8 @@ await _notificationService.PublishAsync(new NotificationEvent return Ok(new { - result = projectUser, - request = membershipRequest, + result = ToProjectUserResponse(projectUser), + request = ToMembershipRequestResponse(membershipRequest), message = "Project membership request accepted successfully." }); } @@ -2551,7 +2714,7 @@ public async Task RejectProjectMembershipRequest([FromRoute] int if (membershipRequest == null) return NotFound(new { message = "Membership request not found." }); if (membershipRequest.Status != MembershipRequestStatusPending) - return Conflict(new { result = membershipRequest, message = "Membership request is not pending." }); + return Conflict(new { message = "Membership request is not pending." }); if (membershipRequest.Type == MembershipRequestTypeJoin) { @@ -2575,7 +2738,7 @@ public async Task RejectProjectMembershipRequest([FromRoute] int return Ok(new { - result = membershipRequest, + result = ToMembershipRequestResponse(membershipRequest), message = "Project membership request rejected successfully." }); } @@ -2600,7 +2763,7 @@ public async Task CancelProjectMembershipRequest([FromRoute] int if (membershipRequest == null) return NotFound(new { message = "Membership request not found." }); if (membershipRequest.Status != MembershipRequestStatusPending) - return Conflict(new { result = membershipRequest, message = "Membership request is not pending." }); + return Conflict(new { message = "Membership request is not pending." }); if (membershipRequest.Type == MembershipRequestTypeJoin) { @@ -2624,7 +2787,7 @@ public async Task CancelProjectMembershipRequest([FromRoute] int return Ok(new { - result = membershipRequest, + result = ToMembershipRequestResponse(membershipRequest), message = "Project membership request cancelled successfully." }); } @@ -2671,7 +2834,7 @@ public async Task LeaveProject([FromRoute] int projectID) return Ok(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "Project left successfully." }); } @@ -2749,7 +2912,7 @@ public async Task AddTag([FromForm] ProjectTagVM formdata) // Return Ok Status return Ok(new { - result = projectTag, + result = ToProjectTagResponse(projectTag), message = "Project Tag Added" }); } @@ -2858,7 +3021,7 @@ public async Task UploadFile([FromForm] ProjectFileUploadVM formd // Return Ok Status return Ok(new { - result = newBlobFile, + result = ToBlobFileResponse(newBlobFile), message = "File Successfully Uploaded", }); @@ -2958,7 +3121,7 @@ public async Task UploadNotebook([FromForm] ProjectNotebookUpload // Return Ok Status return Ok(new { - result = newNotebook, + result = ToNotebookResponse(newNotebook), message = "Notebook Successfully Uploaded", }); @@ -3190,7 +3353,7 @@ public async Task UploadExistingNotebook([FromForm] ExistingProje return Ok(new { - result = newNotebook, + result = ToNotebookResponse(newNotebook), message = "Notebook Uploaded Successfully" }); } @@ -3264,7 +3427,7 @@ public async Task CreateFolder([FromForm] FolderUploadProfileView // Return Ok Status return Ok(new { - result = newBlobFile, + result = ToBlobFileResponse(newBlobFile), message = "File Successfully Uploaded" }); @@ -3340,7 +3503,7 @@ public async Task CreateNotebookFolder([FromForm] FolderUploadPro // Return Ok Status return Ok(new { - result = newNotebook, + result = ToNotebookResponse(newNotebook), message = "File Successfully Uploaded" }); @@ -3408,7 +3571,7 @@ public async Task AddDatasetToNotebook([FromForm] int notebookID, return Ok(new { - result = newDataset, + result = ToDatasetResponse(newDataset), message = "Dataset added to the notebook successfully." }); } @@ -3453,10 +3616,11 @@ public async Task UpdatePublication([FromRoute] int publicationID if (publication == null) return NotFound(new { message = "Publication Not Found" }); - // Validate Project - var project = await _dbContext.Projects.FindAsync(formdata.ProjectID); - if (project == null) - return NotFound(new { message = "Project Not Found" }); + var canManagePublications = await _dbContext.ProjectUsers.AnyAsync(pu => + pu.ProjectID == publication.ProjectID && + pu.UserID == user.Id && + (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); + if (!canManagePublications) return Forbid(); if (publication.ProjectID != formdata.ProjectID) return BadRequest(new { message = "Publication does not belong to this project." }); @@ -3534,6 +3698,12 @@ public async Task UpdateProjectLog([FromRoute] int logId, [FromFo if (projectLog == null) return NotFound(new { message = "Project Log Not Found." }); + var isProjectMember = await _dbContext.ProjectUsers.AnyAsync(pu => + pu.ProjectID == projectLog.ProjectID && + pu.UserID == user.Id && + (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); + if (!isProjectMember) return Forbid(); + // Validate this log belongs to current user if (projectLog.UserID != user.Id) return Forbid(); @@ -3759,8 +3929,17 @@ public async Task DeleteLog([FromRoute] int projectLogID) if (projectLog.IsDeleted) return BadRequest(new { message = "Cannot delete already deleted project log." }); - if (!isAdmin && projectLog.UserID != user.Id) - return Unauthorized(new { message = "Project log does not belong to current user." }); + if (!isAdmin) + { + var isProjectMember = await _dbContext.ProjectUsers.AnyAsync(pu => + pu.ProjectID == projectLog.ProjectID && + pu.UserID == user.Id && + (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); + if (!isProjectMember) return Forbid(); + + if (projectLog.UserID != user.Id) + return Unauthorized(new { message = "Project log does not belong to current user." }); + } // Update project log projectLog.IsDeleted = true; @@ -3812,8 +3991,17 @@ public async Task RepostLog([FromRoute] int projectLogID) if (!projectLog.IsDeleted) return BadRequest(new { message = "Cannot repost project log that is not deleted." }); - if (!isAdmin && projectLog.UserID != user.Id) - return Unauthorized(new { message = "Project log does not belong to current user." }); + if (!isAdmin) + { + var isProjectMember = await _dbContext.ProjectUsers.AnyAsync(pu => + pu.ProjectID == projectLog.ProjectID && + pu.UserID == user.Id && + (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); + if (!isProjectMember) return Forbid(); + + if (projectLog.UserID != user.Id) + return Unauthorized(new { message = "Project log does not belong to current user." }); + } // Update project log projectLog.IsDeleted = false; @@ -3863,6 +4051,7 @@ public async Task UpdateComment([FromRoute] int commentID, [FromF // Validate Comment var comment = await _dbContext.ProjectComments.SingleOrDefaultAsync(c => c.CommentID == commentID); if (comment == null) return NotFound(new { message = "Comment Not Found" }); + if (!await CanCurrentUserViewProjectAsync(comment.ProjectID)) return ProjectNotFound(); if(comment.IsDeleted) return BadRequest(new {message = "Cannot edit deleted comments."}); if(comment.IsPendingReview) return BadRequest(new {message = "Cannot edit comments under review."}); @@ -3988,7 +4177,7 @@ public async Task UpdateProject([FromRoute] int projectID, [FromF // Return Ok Status return Ok(new { - result = project, + result = ToProjectResponse(project), message = "Project successfully updated." }); @@ -4038,7 +4227,7 @@ public async Task DeleteDatasetFromNotebook([FromForm] int notebo await _dbContext.SaveChangesAsync(); return Ok(new { - result = dataset, + result = ToDatasetResponse(dataset), message = "Dataset deleted from the notebook successfully." }); } @@ -4082,7 +4271,7 @@ public async Task RenameNotebook([FromForm] NotebookNameChangeVM } return Ok(new { - notebook, + notebook = ToNotebookResponse(notebook), message = "Notebook name Successfully Changed" }); @@ -4112,7 +4301,12 @@ public async Task DeletePublication([FromRoute] int publicationId var publication = await _dbContext.Publications .FirstOrDefaultAsync(p => p.PublicationID == publicationId); if (publication == null) return NotFound(new { message = "Publication not found." }); - + + var canManagePublications = await _dbContext.ProjectUsers.AnyAsync(pu => + pu.ProjectID == publication.ProjectID && + pu.UserID == userId && + (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); + if (!canManagePublications) return Forbid(); // Remove publication _dbContext.Publications.Remove(publication); @@ -4532,7 +4726,7 @@ public async Task DeleteProject([FromRoute] int projectID) // Return Ok Status return Ok(new { - result = deleteProject, + result = ToProjectResponse(deleteProject), message = "Project successfully deleted." }); } @@ -4591,7 +4785,7 @@ public async Task RemoveUser([FromRoute] int projectID, int userI // Return Ok Status return Ok(new { - result = projectUser, + result = ToProjectUserResponse(projectUser), message = "User Role successfully deleted." }); } @@ -4655,7 +4849,7 @@ public async Task RemoveTag([FromRoute] int projectID, [FromRoute // Return Ok Status return Ok(new { - result = projectTag, + result = ToProjectTagResponse(projectTag), message = projectTag.Tag.Name + " tag has been removed" }); } @@ -4725,7 +4919,7 @@ public async Task DeleteFile([FromRoute] int fileID, [FromRoute] // Return Ok Status return Ok(new { - result = blobFile, + result = ToBlobFileResponse(blobFile), message = "File Successfully Deleted" }); } @@ -4812,7 +5006,7 @@ public async Task DeleteNotebook([FromRoute] int notebookID, [Fro // Return Ok Status return Ok(new { - result = notebook, + result = ToNotebookResponse(notebook), message = "Notebook Successfully Deleted" }); } @@ -4854,7 +5048,6 @@ public async Task GetUserList([FromRoute] int projectID) .Include(pu => pu.User).ThenInclude(u => u.BlobFiles) .Include(pu => pu.User).ThenInclude(u => u.Followers) .Include(pu => pu.User).ThenInclude(u => u.Following) - .Include(pu => pu.User).ThenInclude(u => u.ProjectUsers) .Where(pu => pu.ProjectID == projectID) .ToList(); if (users.Count() == 0) return NoContent(); @@ -4862,7 +5055,7 @@ public async Task GetUserList([FromRoute] int projectID) // Return Ok Status return Ok(new { - result = users, + result = users.Select(ToProjectUserResponse).ToList(), message = "Received Project User" }); } @@ -4884,7 +5077,7 @@ public async Task GetFileList([FromRoute] int projectID) // Return Ok Status return Ok(new { - result = files, + result = files.Select(ToBlobFileResponse).ToList(), message = "Project File Received" }); } @@ -4903,7 +5096,7 @@ public IActionResult GetAllDatasets() // Return Ok Status return Ok(new { - result = files, + result = files.Select(ToBlobFileResponse).ToList(), message = "Datasets Received" }); } @@ -4921,7 +5114,7 @@ public async Task GetNotebooks([FromRoute] int projectID, [FromRo return Ok(new { - result = notebooks, + result = notebooks.Select(ToNotebookResponse).ToList(), message = "Project Notebooks Received" }); } @@ -4947,7 +5140,7 @@ public async Task GetTagList([FromRoute] int projectID) var query = project .SelectMany(p => p.ProjectTags) - .Select(pt => pt.Tag); + .Select(pt => new { pt.Tag.TagID, pt.Tag.Name }); // Return Ok Status return Ok(new @@ -5030,6 +5223,7 @@ public async Task GetRelated([FromQuery(Name = "directory")] stri * Param : BlobUploadViewModel * Description: Upload Folder To Azure Storage */ + [Authorize] [HttpPut("[action]")] public async Task MoveFile([FromForm] ProjectFileMoveVM formdata) { @@ -5038,9 +5232,21 @@ public async Task MoveFile([FromForm] ProjectFileMoveVM formdata) // Check Model State if (!ModelState.IsValid) return BadRequest(ModelState); - // Find User + if (!TryGetCurrentUserId(out var userId)) + { + return Unauthorized(new { message = "Invalid user identifier." }); + } + + // Find File var blobFile = await _dbContext.BlobFiles.FindAsync(formdata.FileID); if (blobFile == null) return NotFound(new { message = "File Not Found" }); + if (!blobFile.ProjectID.HasValue) + { + return BadRequest(new { message = "File does not belong to a project" }); + } + + var isOwner = await IsProjectOwnerAsync(blobFile.ProjectID.Value, userId); + if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); var filePath = formdata.SubDirectory + blobFile.Name + blobFile.Extension; @@ -5060,7 +5266,7 @@ public async Task MoveFile([FromForm] ProjectFileMoveVM formdata) // Return Ok Status return Ok(new { - result = blobFile, + result = ToBlobFileResponse(blobFile), message = "File Successfully Moved" }); diff --git a/src/Analysim.Web/Services/NotificationService.cs b/src/Analysim.Web/Services/NotificationService.cs index 55842a5e..cb7a39a3 100644 --- a/src/Analysim.Web/Services/NotificationService.cs +++ b/src/Analysim.Web/Services/NotificationService.cs @@ -79,6 +79,12 @@ private async Task PublishCommentReplyAsync(NotificationEvent notificationEvent) if (notificationEvent.ActorUserID.HasValue && recipientUserId == notificationEvent.ActorUserID.Value) return; + var recipients = await FilterProjectRecipientsAsync(comment.Project, new[] { recipientUserId }); + if (recipients.Count == 0) + return; + + notificationEvent.ProjectID = comment.ProjectID; + var actorName = await GetUserDisplayNameAsync(notificationEvent.ActorUserID, "Someone"); var projectName = GetProjectName(comment.Project); var link = BuildProjectLink(comment.Project, comment.ProjectLogID.HasValue ? "log" : "comment"); @@ -92,7 +98,7 @@ private async Task PublishCommentReplyAsync(NotificationEvent notificationEvent) await CreateAndSendAsync( notificationEvent, - new[] { recipientUserId }, + recipients, title, body, link, @@ -240,6 +246,9 @@ private async Task PublishProjectLogUpdateAsync(NotificationEvent notificationEv .Distinct() .ToListAsync(); + recipients = await FilterProjectRecipientsAsync(projectLog.Project, recipients); + notificationEvent.ProjectID = projectLog.ProjectID; + var actorName = await GetUserDisplayNameAsync(actorUserId, "Someone"); var projectName = GetProjectName(projectLog.Project); var logTitle = string.IsNullOrWhiteSpace(projectLog.Title) ? "Project update" : projectLog.Title.Trim(); @@ -265,6 +274,44 @@ await CreateAndSendAsync( ); } + private async Task> FilterProjectRecipientsAsync(Project project, IEnumerable recipientUserIds) + { + var recipientIds = recipientUserIds.Distinct().ToList(); + if (project == null || recipientIds.Count == 0) + return new List(); + + if (string.Equals(project.Visibility, "public", StringComparison.OrdinalIgnoreCase)) + return recipientIds; + + var allowedRecipientIds = await _dbContext.ProjectUsers + .AsNoTracking() + .Where(pu => + pu.ProjectID == project.ProjectID && + recipientIds.Contains(pu.UserID) && + pu.UserRole != null && + pu.UserRole.ToLower() != "follower") + .Select(pu => pu.UserID) + .Distinct() + .ToListAsync(); + + var admins = _configuration.GetSection("AdminUsers").Get>() ?? new List(); + if (admins.Count > 0) + { + var recipientUsers = await _dbContext.Users + .AsNoTracking() + .Where(u => recipientIds.Contains(u.Id)) + .Select(u => new { u.Id, u.UserName }) + .ToListAsync(); + + allowedRecipientIds.AddRange(recipientUsers + .Where(u => admins.Any(admin => + string.Equals(admin, u.UserName, StringComparison.OrdinalIgnoreCase))) + .Select(u => u.Id)); + } + + return allowedRecipientIds.Distinct().ToList(); + } + private async Task CreateAndSendAsync( NotificationEvent notificationEvent, IEnumerable recipientUserIds,