test to make server

This commit is contained in:
Artemy
2026-02-08 22:30:29 +07:00
parent cc2921d257
commit 0a43e35797
56 changed files with 949 additions and 442 deletions
@@ -14,7 +14,7 @@ public class ChatsHubTests
{ {
private Mock<ILogger<ChatsHub>> _loggerMock; private Mock<ILogger<ChatsHub>> _loggerMock;
private Mock<IMessageCommandService> _messageServiceMock; private Mock<IMessageCommandService> _messageServiceMock;
private Mock<IUserGroupsService> _userGroupsServiceMock; private Mock<IUserGroupsGetterService> _userGroupsServiceMock;
private Mock<IHubUserAccessor> _hubUserAccessorMock; private Mock<IHubUserAccessor> _hubUserAccessorMock;
private Fixture _fixture; private Fixture _fixture;
private ChatsHub _chatsHub; private ChatsHub _chatsHub;
@@ -27,7 +27,7 @@ public class ChatsHubTests
_fixture.Behaviors.Add(new OmitOnRecursionBehavior()); _fixture.Behaviors.Add(new OmitOnRecursionBehavior());
_messageServiceMock = new Mock<IMessageCommandService>(); _messageServiceMock = new Mock<IMessageCommandService>();
_userGroupsServiceMock = new Mock<IUserGroupsService>(); _userGroupsServiceMock = new Mock<IUserGroupsGetterService>();
_loggerMock = new Mock<ILogger<ChatsHub>>(); _loggerMock = new Mock<ILogger<ChatsHub>>();
_hubUserAccessorMock = new Mock<IHubUserAccessor>(); _hubUserAccessorMock = new Mock<IHubUserAccessor>();
@@ -1,5 +1,6 @@
using Govor.API.Common.Mapping; using Govor.API.Common.Mapping;
using Govor.API.Common.SignalR.Helpers; using Govor.API.Common.SignalR.Helpers;
using Govor.API.Hubs.Infrastructure;
using Govor.Application.Infrastructure.AdminsStuff; using Govor.Application.Infrastructure.AdminsStuff;
using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Extensions;
using Govor.Application.Infrastructure.Validators; using Govor.Application.Infrastructure.Validators;
@@ -52,6 +53,7 @@ public static class ConfigurationProgramExtensions
services.AddScoped<IInvitesService, InvitesService>(); services.AddScoped<IInvitesService, InvitesService>();
services.AddScoped<IInvitationGenerator, InvitationGenerator>(); services.AddScoped<IInvitationGenerator, InvitationGenerator>();
services.AddScoped<IUsernameValidator, UsernameValidator>(); services.AddScoped<IUsernameValidator, UsernameValidator>();
services.AddScoped<ISynchingService, SynchingService>();
// Friends services // Friends services
services.AddScoped<IFriendshipService, FriendshipService>(); services.AddScoped<IFriendshipService, FriendshipService>();
@@ -74,7 +76,8 @@ public static class ConfigurationProgramExtensions
services.AddScoped<IMessageCommandService, MessageCommandService>(); services.AddScoped<IMessageCommandService, MessageCommandService>();
services.AddScoped<IVerifyFriendship, VerifyFriendship>(); services.AddScoped<IVerifyFriendship, VerifyFriendship>();
services.AddScoped<IUserGroupsService, UserGroupsService>(); services.AddScoped<IUserGroupsGetterService, UserGroupsGetterService>();
services.AddScoped<IUserPrivateChatsGetterService, UserPrivateChatsGetter>();
services.AddScoped<IMessagesLoader, MessagesLoader>(); services.AddScoped<IMessagesLoader, MessagesLoader>();
services.AddScoped<IMediaService, MediaService>(); services.AddScoped<IMediaService, MediaService>();
services.AddScoped<IAccesserToDownloadMedia, AccesserToDownloadMediaService>(); services.AddScoped<IAccesserToDownloadMedia, AccesserToDownloadMediaService>();
@@ -87,6 +90,10 @@ public static class ConfigurationProgramExtensions
services.AddScoped<IUserPresenceReader, UserPresenceReader>(); services.AddScoped<IUserPresenceReader, UserPresenceReader>();
services.AddSingleton<IOnlineUserStore, OnlineUserStore>(); services.AddSingleton<IOnlineUserStore, OnlineUserStore>();
// Hubs Infrastructure
services.AddScoped<IChatNotificationService, ChatNotificationService>();
services.AddScoped<IConnectionManager, ConnectionManager>();
// Auto Mapper // Auto Mapper
services.AddAutoMapper(typeof(MappingProfile)); services.AddAutoMapper(typeof(MappingProfile));
@@ -21,6 +21,9 @@ public class MappingProfile : Profile
CreateMap<User, UserDto>() CreateMap<User, UserDto>()
.AfterMap<UserToUserDtoMappingAction>(); .AfterMap<UserToUserDtoMappingAction>();
CreateMap<UserProfile, UserProfileDto>()
.AfterMap<UserProfileToUserProfileDtoMappingAction>();
CreateMap<Friendship, FriendshipDto>(); CreateMap<Friendship, FriendshipDto>();
CreateMap<UserSession, SessionDto>(); CreateMap<UserSession, SessionDto>();
@@ -0,0 +1,22 @@
using AutoMapper;
using Govor.Application.Interfaces.UserOnlineStatus;
using Govor.Application.Profiles;
using Govor.Contracts.DTOs;
using Govor.Core.Models.Users;
namespace Govor.API.Common.Mapping;
public class UserProfileToUserProfileDtoMappingAction : IMappingAction<UserProfile, UserProfileDto>
{
private readonly IOnlineUserStore _onlineUserStore;
public UserProfileToUserProfileDtoMappingAction(IOnlineUserStore onlineUserStore)
{
_onlineUserStore = onlineUserStore;
}
public void Process(UserProfile source, UserProfileDto destination, ResolutionContext context)
{
destination.IsOnline = _onlineUserStore.IsOnline(source.Id);
}
}
+5 -5
View File
@@ -11,7 +11,7 @@ namespace Govor.API.Controllers;
[ApiController] [ApiController]
[Authorize(Roles = "Admin, User")] [Authorize(Roles = "Admin, User")]
[Route("api/chats")] [Route("api")]
public class ChatLoadController : Controller public class ChatLoadController : Controller
{ {
private readonly ICurrentUserService _currentUser; private readonly ICurrentUserService _currentUser;
@@ -31,9 +31,9 @@ public class ChatLoadController : Controller
_mapper = mapper; _mapper = mapper;
} }
[HttpGet("group-messages")] [HttpGet("groups/{groupId:guid}/messages")]
public async Task<IActionResult> GetGroupMessages( public async Task<IActionResult> GetGroupMessages(
Guid chatId, Guid groupId,
[FromQuery] MessageQuery query) [FromQuery] MessageQuery query)
{ {
try try
@@ -42,7 +42,7 @@ public class ChatLoadController : Controller
return BadRequest("Values must be non-negative and total must not exceed 100."); return BadRequest("Values must be non-negative and total must not exceed 100.");
var result = await _messagesLoader.LoadMessagesInChatGroup( var result = await _messagesLoader.LoadMessagesInChatGroup(
chatId, groupId,
_currentUser.GetCurrentUserId(), _currentUser.GetCurrentUserId(),
query.StartMessageId, query.StartMessageId,
query.Before, query.Before,
@@ -74,7 +74,7 @@ public class ChatLoadController : Controller
} }
} }
[HttpGet("user-messages")] [HttpGet("user/{userId:guid}/messages")]
public async Task<IActionResult> GetUserMessages( public async Task<IActionResult> GetUserMessages(
Guid userId, Guid userId,
[FromQuery] MessageQuery query) [FromQuery] MessageQuery query)
@@ -0,0 +1,95 @@
using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Infrastructure.Extensions;
using Govor.Core.Models;
using Govor.Core.Repositories.PrivateChats;
using Govor.Core.Repositories.Users;
using Govor.Data.Repositories.Exceptions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Govor.API.Controllers;
[ApiController]
[Authorize(Roles = "Admin, User")]
[Route("api")]
public class PrivateChatController : Controller
{
private readonly ICurrentUserService _currentUser;
private readonly IUsersRepository _usersRepository;
private readonly IVerifyFriendship _verifyFriendship;
private readonly IPrivateChatsRepository _privateChats;
private readonly ILogger<ChatLoadController> _logger;
public PrivateChatController(
ICurrentUserService currentUser,
IUsersRepository usersRepository,
IVerifyFriendship verifyFriendship,
IPrivateChatsRepository privateChats,
ILogger<ChatLoadController> logger)
{
_currentUser = currentUser;
_usersRepository = usersRepository;
_verifyFriendship = verifyFriendship;
_privateChats = privateChats;
_logger = logger;
}
[HttpGet("user/{friendId:guid}/private-chat")]
public async Task<IActionResult> GetChatByFriendId(Guid friendId)
{
try
{
var currentId = _currentUser.GetCurrentUserId();
if (!await _usersRepository.ExistsByIdAsync(friendId))
{
_logger.LogWarning("User not exist {0}", friendId);
return NotFound("User not exist.");
}
await _verifyFriendship.VerifyAsync(currentId, friendId);
var chatId = await GetPrivateChatsIdAsync(currentId, friendId);
return Ok(chatId);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex.Message);
return Forbid(ex.Message);
}
catch (NotFoundException ex)
{
_logger.LogWarning(ex, ex.Message);
return BadRequest(ex.Message);
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, ex.Message);
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, ex.Message);
return StatusCode(500, "Unexpected Error! Please try again later.");
}
}
private async Task<Guid> GetPrivateChatsIdAsync(Guid userA, Guid userB)
{
Guid recipientId;
// Makes new PrivateChat if not exist
if (!_privateChats.Exist(userA, userB))
{
recipientId = Guid.NewGuid();
await _privateChats.AddAsync(new PrivateChat()
{ Id = recipientId, UserAId = userA, UserBId = userB });
}
else
{
recipientId = (await _privateChats.GetByMembersAsync(userA, userB)).Id;
}
return recipientId;
}
}
@@ -72,14 +72,6 @@ public class ProfileController : ControllerBase
await _profileService.SetNewIcon(userId, mediaInfo.MediaId); await _profileService.SetNewIcon(userId, mediaInfo.MediaId);
var iconId = mediaInfo.MediaId; var iconId = mediaInfo.MediaId;
var payload = new { userId, iconId };
await _profileHubContext.Clients.All.SendAsync(
"AvatarUpdated",
payload
);
return Ok(mediaInfo); return Ok(mediaInfo);
} }
catch (System.Exception ex) catch (System.Exception ex)
+93 -200
View File
@@ -1,6 +1,6 @@
using Govor.API.Common.SignalR.Helpers; using Govor.API.Common.SignalR.Helpers;
using Govor.API.Hubs.Infrastructure;
using Govor.Application.Exceptions.VerifyFriendship; using Govor.Application.Exceptions.VerifyFriendship;
using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.Messages;
using Govor.Application.Interfaces.Messages.Parameters; using Govor.Application.Interfaces.Messages.Parameters;
using Govor.Contracts.Requests.SignalR; using Govor.Contracts.Requests.SignalR;
@@ -15,20 +15,23 @@ namespace Govor.API.Hubs;
public class ChatsHub : Hub public class ChatsHub : Hub
{ {
private readonly ILogger<ChatsHub> _logger; private readonly ILogger<ChatsHub> _logger;
private readonly IMessageCommandService _messageCommandService; private readonly IMessageCommandService _commandService;
private readonly IUserGroupsService _userService;
private readonly IHubUserAccessor _userAccessor; private readonly IHubUserAccessor _userAccessor;
private readonly IChatNotificationService _notifier;
private readonly IConnectionManager _connectionManager;
public ChatsHub( public ChatsHub(
ILogger<ChatsHub> logger, ILogger<ChatsHub> logger,
IMessageCommandService messageCommandService, IMessageCommandService commandService,
IUserGroupsService userService, IHubUserAccessor userAccessor,
IHubUserAccessor userAccessor) IChatNotificationService notifier,
IConnectionManager connectionManager)
{ {
_logger = logger; _logger = logger;
_messageCommandService = messageCommandService; _commandService = commandService;
_userService = userService;
_userAccessor = userAccessor; _userAccessor = userAccessor;
_notifier = notifier;
_connectionManager = connectionManager;
} }
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
@@ -36,132 +39,58 @@ public class ChatsHub : Hub
var userId = _userAccessor.GetUserId(Context); var userId = _userAccessor.GetUserId(Context);
if (userId == Guid.Empty) if (userId == Guid.Empty)
{ {
_logger.LogWarning("User connected with invalid UserID claim.");
Context.Abort(); Context.Abort();
return; return;
} }
await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); await _connectionManager.OnConnectedAsync(Context.ConnectionId, userId);
_logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", _logger.LogInformation("User {UserId} connected", userId);
userId, Context.ConnectionId);
var userGroups = await _userService.GetUserGroupsAsync(userId);
foreach (var group in userGroups)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}");
}
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
public override async Task OnDisconnectedAsync(Exception exception) public override async Task OnDisconnectedAsync(Exception? exception)
{ {
var userId = _userAccessor.GetUserId(Context, true); var userId = _userAccessor.GetUserId(Context, true);
if (userId != Guid.Empty) await _connectionManager.OnDisconnectedAsync(Context.ConnectionId, userId);
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString());
_logger.LogInformation("User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group",
userId, Context.ConnectionId);
var userGroups = await _userService.GetUserGroupsAsync(userId); if (exception != null)
_logger.LogWarning(exception, "User {UserId} disconnected with error", userId);
foreach (var group in userGroups)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group_{group.Id}");
}
}
else if (exception != null)
{
_logger.LogWarning(exception,
"User disconnected with an exception and invalid UserID claim. ConnectionId: {ConnectionId}",
Context.ConnectionId);
}
else else
{ _logger.LogInformation("User {UserId} disconnected", userId);
_logger.LogInformation(
"User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}",
Context.ConnectionId);
}
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
} }
// --- SEND ---
public async Task<HubResult<UserMessageResponse>> Send(MessageRequest request) public async Task<HubResult<UserMessageResponse>> Send(MessageRequest request)
{ {
var senderId = _userAccessor.GetUserId(Context); return await SafeExecute(async (userId) =>
if (string.IsNullOrWhiteSpace(request.EncryptedContent) &&
(request.MediaAttachments == null || !request.MediaAttachments.Any()))
{ {
_logger.LogWarning("Empty message received from user {UserId}", senderId); ValidateMessageRequest(request);
return HubResult<UserMessageResponse>.BadRequest("Message must contain content or media.");
}
if (request.EncryptedContent.Length > 50_000) var sendParams = MapToSendMessage(request, userId);
{ var result = await _commandService.SendMessageAsync(sendParams);
_logger.LogWarning("User {SenderId} tried to send a too long message (length: {Length})", senderId, request.EncryptedContent.Length);
return HubResult<UserMessageResponse>.BadRequest("Message cannot exceed 50,000 characters.");
}
_logger.LogInformation("Sending message from {SenderId} to {RecipientId} ({RecipientType})", if (!result.IsSuccess)
senderId, request.RecipientId, request.RecipientType); throw new InvalidOperationException(result.Exception.Message ?? "Failed to send message");
var sendMessageParams = new SendMessage( var response = MapToResponse(result.Message, request.ReplyToMessageId);
EncryptContent: request.EncryptedContent,
ReplyToMessageId: request.ReplyToMessageId,
FromUserId: senderId,
RecipientId: request.RecipientId,
RecipientType: request.RecipientType,
SendAt: DateTime.UtcNow,
Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey)) ??
Array.Empty<SendMedia>()
);
try await _notifier.NotifyMessageSentAsync(response);
{
var result = await _messageCommandService.SendMessageAsync(sendMessageParams);
if (!result.IsSuccess || result.Message.Id == Guid.Empty)
return LogAndError<UserMessageResponse>(senderId, request.RecipientId, "Failed to send message", result.Exception);
var response = BuildUserMessageResponse(result.Message, request.ReplyToMessageId);
await NotifyClientsAboutMessage(response);
return HubResult<UserMessageResponse>.Ok(response); return HubResult<UserMessageResponse>.Ok(response);
} }, request.RecipientId);
catch (UnauthorizedAccessException ex)
{
return LogAndUnauthorized<UserMessageResponse>(ex, "Unauthorized sending attempt", senderId,
request.RecipientId);
}
catch (FriendshipException)
{
return HubResult<UserMessageResponse>.Unauthorized(
"You cannot send this message because you are not friends.");
}
catch (ArgumentException)
{
return HubResult<UserMessageResponse>.BadRequest("Invalid message content.");
}
catch (Exception ex)
{
return LogAndError<UserMessageResponse>(senderId, request.RecipientId, "Unhandled exception", ex);
}
} }
// --- REMOVE ---
public async Task<HubResult<MessageRemovedResponse>> Remove(RemoveMessageRequest request) public async Task<HubResult<MessageRemovedResponse>> Remove(RemoveMessageRequest request)
{ {
var removerId = _userAccessor.GetUserId(Context); return await SafeExecute(async (userId) =>
_logger.LogInformation("Removing message {MessageId} by user {RemoverId}", request.MessageId, removerId);
try
{ {
var result = await _messageCommandService.DeleteMessageAsync(new DeleteMessage(removerId, request.MessageId)); var result = await _commandService.DeleteMessageAsync(new DeleteMessage(userId, request.MessageId));
if (!result.IsSuccess || result.OriginalMessage == null) if (!result.IsSuccess || result.OriginalMessage == null)
return LogAndError<MessageRemovedResponse>(removerId, request.MessageId, "Message deletion failed", result.Exception); throw new InvalidOperationException("Message deletion failed");
var notification = new MessageRemovedResponse var notification = new MessageRemovedResponse
{ {
@@ -171,76 +100,98 @@ public class ChatsHub : Hub
RecipientType = result.OriginalMessage.RecipientType RecipientType = result.OriginalMessage.RecipientType
}; };
await NotifyClientsAboutRemoval(notification); await _notifier.NotifyMessageRemovedAsync(notification);
_logger.LogInformation("Message {MessageId} removed successfully by {RemoverId}", request.MessageId, removerId);
return HubResult<MessageRemovedResponse>.Ok(notification); return HubResult<MessageRemovedResponse>.Ok(notification);
} }, request.MessageId);
catch (UnauthorizedAccessException ex)
{
return LogAndUnauthorized<MessageRemovedResponse>(ex, "Unauthorized removal", removerId, request.MessageId);
}
catch (KeyNotFoundException ex)
{
return LogAndNotFound<MessageRemovedResponse>(ex, "Message not found", removerId, request.MessageId);
}
catch (Exception ex)
{
return LogAndError<MessageRemovedResponse>(removerId, request.MessageId, "Unhandled deletion error", ex);
}
} }
// --- EDIT ---
public async Task<HubResult<MessageEditResponse>> Edit(EditMessageRequest request) public async Task<HubResult<MessageEditResponse>> Edit(EditMessageRequest request)
{ {
var editor = _userAccessor.GetUserId(Context); return await SafeExecute(async (userId) =>
_logger.LogInformation("Editing message {MessageId} by user {EditorId}", request.MessageId, editor);
var editMessageParam = new EditMessage(editor,
request.MessageId,
request.NewEncryptedContent,
DateTime.UtcNow);
try
{ {
var result = await _messageCommandService.EditMessageAsync(editMessageParam); var editParams = new EditMessage(userId, request.MessageId, request.NewEncryptedContent, DateTime.UtcNow);
var result = await _commandService.EditMessageAsync(editParams);
if (!result.IsSuccess || result.OriginalMessage == null) if (!result.IsSuccess || result.OriginalMessage == null)
return LogAndError<MessageEditResponse>(editor, request.MessageId, "Edit message error", throw new InvalidOperationException("Edit message error");
result.Exception);
var response = new MessageEditResponse() var response = new MessageEditResponse
{ {
MessageId = result.messageId, MessageId = result.messageId,
EditorId = editor, EditorId = userId,
RecipientId = result.OriginalMessage.RecipientId, RecipientId = result.OriginalMessage.RecipientId,
RecipientType = result.OriginalMessage.RecipientType, RecipientType = result.OriginalMessage.RecipientType,
NewEncryptedContent = request.NewEncryptedContent, NewEncryptedContent = request.NewEncryptedContent,
EditedAt = editMessageParam.EditedAt, EditedAt = editParams.EditedAt,
}; };
await NotifyClientsAboutEdit(response); await _notifier.NotifyMessageEditedAsync(response);
_logger.LogInformation("Message {MessageId} edited successfully by {editor}", request.MessageId, editor);
return HubResult<MessageEditResponse>.Ok(response); return HubResult<MessageEditResponse>.Ok(response);
}, request.MessageId);
}
private async Task<HubResult<T>> SafeExecute<T>(Func<Guid, Task<HubResult<T>>> action, Guid targetIdForLog)
{
var userId = _userAccessor.GetUserId(Context);
try
{
return await action(userId);
} }
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException ex)
{ {
return LogAndUnauthorized<MessageEditResponse>(ex, "Unauthorized editing", editor, request.MessageId); _logger.LogWarning(ex, "Unauthorized: {UserId} -> {TargetId}", userId, targetIdForLog);
return HubResult<T>.Unauthorized("You are not authorized.");
} }
catch (KeyNotFoundException ex) catch (FriendshipException)
{ {
return LogAndNotFound<MessageEditResponse>(ex, "Message not found", editor, request.MessageId); return HubResult<T>.Unauthorized("You cannot perform this action due to friendship status.");
}
catch (ArgumentException ex)
{
return HubResult<T>.BadRequest(ex.Message);
}
catch (KeyNotFoundException)
{
return HubResult<T>.NotFound("Resource not found.");
} }
catch (Exception ex) catch (Exception ex)
{ {
return LogAndError<MessageEditResponse>(editor, request.MessageId, "Unhandled exception error", ex); _logger.LogError(ex, "Error executing hub method for {UserId}", userId);
return HubResult<T>.Error("Internal server error");
} }
} }
private void ValidateMessageRequest(MessageRequest request)
{
if (string.IsNullOrWhiteSpace(request.EncryptedContent) &&
(request.MediaAttachments == null || !request.MediaAttachments.Any()))
{
throw new ArgumentException("Message must contain content or media.");
}
if (request.EncryptedContent.Length > 50_000)
{
throw new ArgumentException("Message is too long.");
}
}
#region common private SendMessage MapToSendMessage(MessageRequest request, Guid senderId)
private UserMessageResponse BuildUserMessageResponse(Message message, Guid? replyToId) {
return new SendMessage(
EncryptContent: request.EncryptedContent,
ReplyToMessageId: request.ReplyToMessageId,
FromUserId: senderId,
RecipientId: request.RecipientId,
RecipientType: request.RecipientType,
SendAt: DateTime.UtcNow,
Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey))
?? Array.Empty<SendMedia>()
);
}
private UserMessageResponse MapToResponse(Message message, Guid? replyToId)
{ {
return new UserMessageResponse return new UserMessageResponse
{ {
@@ -255,62 +206,4 @@ public class ChatsHub : Hub
ReplyToMessageId = replyToId ReplyToMessageId = replyToId
}; };
} }
private async Task NotifyClientsAboutMessage(UserMessageResponse response)
{
string group = response.RecipientType == RecipientType.User
? response.RecipientId.ToString()
: $"group_{response.RecipientId}";
await Clients.Group(group).SendAsync("ReceiveMessage", response);
await Clients.Caller.SendAsync("MessageSent", response);
}
private async Task NotifyClientsAboutRemoval(MessageRemovedResponse response)
{
if (response.RecipientType == RecipientType.User)
{
await Clients.Group(response.SenderId.ToString()).SendAsync("MessageRemoved", response);
if (response.SenderId != response.RecipientId)
await Clients.Group(response.RecipientId.ToString()).SendAsync("MessageRemoved", response);
}
else
{
await Clients.Group($"group_{response.RecipientId}").SendAsync("MessageRemoved", response);
}
}
private async Task NotifyClientsAboutEdit(MessageEditResponse response)
{
if (response.RecipientType == RecipientType.User)
{
await Clients.Group(response.EditorId.ToString()).SendAsync("MessageEdit", response);
if (response.EditorId != response.RecipientId)
await Clients.Group(response.RecipientId.ToString()).SendAsync("MessageEdit", response);
}
else
{
await Clients.Group($"group_{response.RecipientId}").SendAsync("MessageEdit", response);
}
}
// Logging helpers
private HubResult<T> LogAndError<T>(Guid userId, Guid targetId, string message, Exception ex)
{
_logger.LogError(ex, "{Message} from {UserId} to {TargetId}", message, userId, targetId);
return HubResult<T>.Error(ex?.Message ?? "Internal server error");
}
private HubResult<T> LogAndUnauthorized<T>(Exception ex, string msg, Guid userId, Guid targetId)
{
_logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId);
return HubResult<T>.Unauthorized("You are not authorized to perform this action.");
}
private HubResult<T> LogAndNotFound<T>(Exception ex, string msg, Guid userId, Guid targetId)
{
_logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId);
return HubResult<T>.NotFound("Message not found.");
}
#endregion
} }
+9 -3
View File
@@ -44,6 +44,7 @@ public class FriendsHub : Hub
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
public override async Task OnDisconnectedAsync(Exception? exception) public override async Task OnDisconnectedAsync(Exception? exception)
{ {
var userId = _userAccessor.GetUserId(Context, true); var userId = _userAccessor.GetUserId(Context, true);
@@ -76,9 +77,13 @@ public class FriendsHub : Hub
{ {
var userId = _userAccessor.GetUserId(Context); var userId = _userAccessor.GetUserId(Context);
var friendship = await _friendRequestService.SendAsync(userId, targetUserId); var friendship = await _friendRequestService.SendAsync(userId, targetUserId);
var dto = _mapper.Map<FriendshipDto>(friendship);
await Clients.Group(targetUserId.ToString()) await Clients.Group(targetUserId.ToString())
.SendAsync("FriendRequestReceived", _mapper.Map<FriendshipDto>(friendship)); .SendAsync("FriendRequestReceived", dto);
await Clients.Group(userId.ToString())
.SendAsync("YourFriendRequestReceived", dto);
_logger.LogInformation($"Friend request received for user {targetUserId} from {userId}."); _logger.LogInformation($"Friend request received for user {targetUserId} from {userId}.");
return HubResult<object>.Created(); return HubResult<object>.Created();
@@ -111,12 +116,13 @@ public class FriendsHub : Hub
{ {
var userId = _userAccessor.GetUserId(Context); var userId = _userAccessor.GetUserId(Context);
var friendship = await _friendRequestService.AcceptAsync(friendshipId, userId); var friendship = await _friendRequestService.AcceptAsync(friendshipId, userId);
var dto = _mapper.Map<FriendshipDto>(friendship);
await Clients.Group(userId.ToString()) await Clients.Group(userId.ToString())
.SendAsync("FriendRequestAccepted", _mapper.Map<FriendshipDto>(friendship)); .SendAsync("FriendRequestAccepted", dto);
await Clients.Group(friendship.RequesterId.ToString()) await Clients.Group(friendship.RequesterId.ToString())
.SendAsync( "YourFriendRequestAccepted", _mapper.Map<UserDto>(friendship.Addressee)); .SendAsync( "YourFriendRequestAccepted", dto);
_logger.LogInformation($"Friend request accepted for user {userId} from {userId}."); _logger.LogInformation($"Friend request accepted for user {userId} from {userId}.");
return HubResult<object>.Ok(); return HubResult<object>.Ok();
@@ -0,0 +1,13 @@
namespace Govor.API.Hubs.Infrastructure;
public static class ChatHubConstants
{
public const string ReceiveMessage = "ReceiveMessage";
public const string MessageSent = "MessageSent";
public const string MessageRemoved = "MessageRemoved";
public const string MessageEdited = "MessageEdit";
public static string GetUserGroup(Guid userId) => userId.ToString();
public static string GetChatGroup(Guid groupId) => $"group_{groupId}";
public static string GetPrivateChat(Guid groupId) => $"private_{groupId}";
}
@@ -0,0 +1,73 @@
using Govor.Contracts.Responses.SignalR;
using Govor.Core.Models.Messages;
using Microsoft.AspNetCore.SignalR;
namespace Govor.API.Hubs.Infrastructure;
public class ChatNotificationService : IChatNotificationService
{
private readonly IHubContext<ChatsHub> _hubContext;
public ChatNotificationService(IHubContext<ChatsHub> hubContext)
{
_hubContext = hubContext;
}
public async Task NotifyMessageSentAsync(UserMessageResponse message)
{
if (message.RecipientType == RecipientType.User)
{
await _hubContext.Clients.Group(ChatHubConstants.GetPrivateChat(message.RecipientId))
.SendAsync(ChatHubConstants.ReceiveMessage, message);
}
else
{
await _hubContext.Clients.Group(ChatHubConstants.GetChatGroup(message.RecipientId))
.SendAsync(ChatHubConstants.ReceiveMessage, message);
}
// await _hubContext.Clients.Group(ChatHubConstants.GetUserGroup(message.SenderId))
// .SendAsync(ChatHubConstants.MessageSent, message);
}
public async Task NotifyMessageRemovedAsync(MessageRemovedResponse response)
{
await NotifyParticipantsAsync(
response.SenderId,
response.RecipientId,
response.RecipientType,
ChatHubConstants.MessageRemoved,
response);
}
public async Task NotifyMessageEditedAsync(MessageEditResponse response)
{
await NotifyParticipantsAsync(
response.EditorId,
response.RecipientId,
response.RecipientType,
ChatHubConstants.MessageEdited,
response);
}
private async Task NotifyParticipantsAsync(Guid initiatorId, Guid targetId, RecipientType type, string method, object payload)
{
if (type == RecipientType.User)
{
await _hubContext.Clients.Group(ChatHubConstants.GetUserGroup(initiatorId))
.SendAsync(method, payload);
if (initiatorId != targetId)
{
await _hubContext.Clients.Group(ChatHubConstants.GetUserGroup(targetId))
.SendAsync(method, payload);
}
}
else
{
// to groups
await _hubContext.Clients.Group(ChatHubConstants.GetChatGroup(targetId))
.SendAsync(method, payload);
}
}
}
@@ -0,0 +1,46 @@
using Govor.Application.Interfaces;
using Microsoft.AspNetCore.SignalR;
namespace Govor.API.Hubs.Infrastructure;
public class ConnectionManager : IConnectionManager
{
private readonly IUserGroupsGetterService _userGroupsGetterService;
private readonly IUserPrivateChatsGetterService _userPrivateChatsGetterService;
private readonly IHubContext<ChatsHub> _hubContext;
public ConnectionManager(IUserGroupsGetterService userGroupsGetterService, IUserPrivateChatsGetterService userPrivateChatsGetterService, IHubContext<ChatsHub> hubContext)
{
_userGroupsGetterService = userGroupsGetterService;
_userPrivateChatsGetterService = userPrivateChatsGetterService;
_hubContext = hubContext;
}
public async Task OnConnectedAsync(string connectionId, Guid userId)
{
// user
await _hubContext.Groups.AddToGroupAsync(connectionId, ChatHubConstants.GetUserGroup(userId));
// groups
var userGroups = await _userGroupsGetterService.GetUserGroupsAsync(userId);
foreach (var group in userGroups)
{
await _hubContext.Groups.AddToGroupAsync(connectionId, ChatHubConstants.GetChatGroup(group.Id));
}
// private chats
var chats = await _userPrivateChatsGetterService.GetUserChatsAsync(userId);
foreach (var group in chats)
{
await _hubContext.Groups.AddToGroupAsync(connectionId, ChatHubConstants.GetPrivateChat(group.Id));
}
}
public async Task OnDisconnectedAsync(string connectionId, Guid userId)
{
if (userId != Guid.Empty)
{
await _hubContext.Groups.RemoveFromGroupAsync(connectionId, ChatHubConstants.GetUserGroup(userId));
}
}
}
@@ -0,0 +1,10 @@
using Govor.Contracts.Responses.SignalR;
namespace Govor.API.Hubs.Infrastructure;
public interface IChatNotificationService
{
Task NotifyMessageSentAsync(UserMessageResponse message);
Task NotifyMessageRemovedAsync(MessageRemovedResponse response);
Task NotifyMessageEditedAsync(MessageEditResponse response);
}
@@ -0,0 +1,7 @@
namespace Govor.API.Hubs.Infrastructure;
public interface IConnectionManager
{
Task OnConnectedAsync(string connectionId, Guid userId);
Task OnDisconnectedAsync(string connectionId, Guid userId);
}
+32 -14
View File
@@ -34,21 +34,22 @@ public class PresenceHub : Hub
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
var userId = _userAccessor.GetUserId(Context); var userId = _userAccessor.GetUserId(Context);
if (userId == Guid.Empty || await _users.ExistsByIdAsync(userId) == false)
if (userId == Guid.Empty)
{ {
_logger.LogWarning("User connected with invalid UserId claim."); _logger.LogWarning("User connected with invalid UserId claim.");
Context.Abort(); Context.Abort();
return; return;
} }
_onlineUserStore.SetOnlineUser(userId); var isFirstConnection = _onlineUserStore.AddConnection(userId, Context.ConnectionId);
await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString());
var friends = await _scopeService.GetNotifiedUsers(userId); if (isFirstConnection)
foreach (var recipient in friends)
{ {
await Clients.Group(recipient.ToString()) var friends = await _scopeService.GetNotifiedUsers(userId);
await Clients.Groups(friends.Select(f => f.ToString()).ToList())
.SendAsync("UserOnline", userId); .SendAsync("UserOnline", userId);
} }
@@ -74,19 +75,36 @@ public class PresenceHub : Hub
return; return;
} }
_onlineUserStore.SetOfflineUser(userId); var isLastConnection = _onlineUserStore.RemoveConnection(userId, Context.ConnectionId);
// Updating was online if (isLastConnection)
{
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(5));
var currentConnections = _onlineUserStore.GetConnections(userId);
if (currentConnections == null || !currentConnections.Any())
{
var friends = await _scopeService.GetNotifiedUsers(userId);
await Clients.Groups(friends.Select(f => f.ToString()).ToList())
.SendAsync("UserOffline", userId);
try
{
var user = await _users.FindByIdAsync(userId); var user = await _users.FindByIdAsync(userId);
if (user != null)
{
user.WasOnline = DateTime.UtcNow; user.WasOnline = DateTime.UtcNow;
await _users.UpdateAsync(user); await _users.UpdateAsync(user);
}
var friends = await _scopeService.GetNotifiedUsers(userId); }
catch (Exception ex)
foreach (var recipient in friends)
{ {
await Clients.Group(recipient.ToString()) _logger.LogError(ex, "Error updating WasOnline for {UserId}", userId);
.SendAsync("UserOffline", userId); }
}
});
} }
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
+65 -89
View File
@@ -1,11 +1,9 @@
using Govor.API.Common.SignalR.Helpers; using Govor.API.Common.SignalR.Helpers;
using Govor.Application.Interfaces; using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Friends;
using Govor.Application.Interfaces.Infrastructure.Extensions;
using Govor.Application.Interfaces.Medias; using Govor.Application.Interfaces.Medias;
using Govor.Contracts.DTOs;
using Govor.Contracts.Responses.SignalR; using Govor.Contracts.Responses.SignalR;
using Govor.Core.Models;
using Govor.Core.Models.Users;
using Govor.Core.Repositories.Groups; using Govor.Core.Repositories.Groups;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
@@ -19,6 +17,7 @@ public class ProfileHub : Hub
private readonly IFriendshipService _friendsService; private readonly IFriendshipService _friendsService;
private readonly IProfileService _profileService; private readonly IProfileService _profileService;
private readonly IHubUserAccessor _userAccessor; private readonly IHubUserAccessor _userAccessor;
private readonly ISynchingService _synchingService;
private readonly ILogger<ProfileHub> _logger; private readonly ILogger<ProfileHub> _logger;
private readonly IMediaService _mediaService; private readonly IMediaService _mediaService;
@@ -27,12 +26,16 @@ public class ProfileHub : Hub
IFriendshipService friendsService, IFriendshipService friendsService,
IProfileService profileService, IProfileService profileService,
IHubUserAccessor userAccessor, IHubUserAccessor userAccessor,
ISynchingService synchingService,
IMediaService mediaService,
ILogger<ProfileHub> logger) ILogger<ProfileHub> logger)
{ {
_groupsRepository = groupsRepository; _groupsRepository = groupsRepository;
_friendsService = friendsService; _friendsService = friendsService;
_profileService = profileService; _profileService = profileService;
_userAccessor = userAccessor; _userAccessor = userAccessor;
_synchingService = synchingService;
_mediaService = mediaService;
_logger = logger; _logger = logger;
} }
@@ -40,33 +43,7 @@ public class ProfileHub : Hub
{ {
var userId = _userAccessor.GetUserId(Context); var userId = _userAccessor.GetUserId(Context);
await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}"); await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
// Friends
try
{
var friendships = await _friendsService.GetFriendsAsync(userId);
foreach (var friends in friendships)
await Groups.AddToGroupAsync(Context.ConnectionId, $"friends-{friends.Id}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get friends for user {userId}", userId);
}
// Groups
try
{
var groups = await _groupsRepository.GetByUserIdAsync(userId);
foreach (var group in groups)
await Groups.AddToGroupAsync(Context.ConnectionId, $"group-{group.Id}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get groups for user {userId}", userId);
}
_logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId}", userId, Context.ConnectionId);
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
@@ -77,56 +54,35 @@ public class ProfileHub : Hub
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user-{userId}"); await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user-{userId}");
// Friends
try
{
var friendships = await _friendsService.GetFriendsAsync(userId);
foreach (var friendship in friendships)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"friends-{friendship.Id}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove user {userId} from friend groups", userId);
}
// Groups
try
{
var groups = await _groupsRepository.GetByUserIdAsync(userId);
foreach (var group in groups)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group-{group.Id}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove user {userId} from groups", userId);
}
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
} }
public async Task<HubResult<bool>> SetDescription(string description) public async Task<HubResult<bool>> SetDescription(string description)
{ {
if (description.Length > 500) if (description.Length > 500)
return HubResult<bool>.Error("The description cannot be longer than 500 characters."); return HubResult<bool>.Error("Description length exceeded.");
var userId = _userAccessor.GetUserId(Context); var userId = _userAccessor.GetUserId(Context);
try try
{ {
description = _synchingService.NormalizeNewlines(description);
await _profileService.SetDescription(description, userId); await _profileService.SetDescription(description, userId);
var payload = new { userId, description }; var payload = new DescriptionUpdatePayload
{
UserId = userId,
Description = description
};
await NotifyFriendsAndGroupsAsync(userId, "DescriptionUpdated", payload); await NotifyProfileUpdatedAsync(userId, "DescriptionUpdated", payload);
return HubResult<bool>.Ok(true); return HubResult<bool>.Ok(true);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "An error occurred while updating the user's {userId} descripton!", userId); _logger.LogError(ex, "Failed to update description for user {UserId}", userId);
return HubResult<bool>.Error("An unaccounted error on the server!"); return HubResult<bool>.Error("Server error.");
} }
} }
@@ -136,14 +92,14 @@ public class ProfileHub : Hub
try try
{ {
if (iconId == Guid.Empty) if (iconId == Guid.Empty || !await _mediaService.HasMediaAsync(iconId))
return HubResult<bool>.BadRequest("IconId can't be empty!"); return HubResult<bool>.BadRequest("Invalid icon id.");
await _profileService.SetNewIcon(userId, iconId); await _profileService.SetNewIcon(userId, iconId);
var payload = new { userId, iconId }; var payload = new AvatarUpdatePayload(){UserId = userId, IconId = iconId};
await NotifyFriendsAndGroupsAsync(userId, "AvatarUpdated", payload); await NotifyProfileUpdatedAsync(userId, "AvatarUpdated", payload);
return HubResult<bool>.Ok(true); return HubResult<bool>.Ok(true);
} }
@@ -154,50 +110,70 @@ public class ProfileHub : Hub
} }
} }
private async Task NotifyFriendsAndGroupsAsync(Guid userId, string eventName, object payload) private async Task NotifyProfileUpdatedAsync(
Guid UserId,
string eventName,
object payload)
{ {
var friendIds = Enumerable.Empty<User>();
var groups = Enumerable.Empty<ChatGroup>();
try try
{ {
friendIds = await _friendsService.GetFriendsAsync(userId); var recipients = new HashSet<Guid>();
// 1. Friends
try
{
var friends = await _friendsService.GetFriendsAsync(UserId);
recipients.UnionWith(friends.Select(f => f.Id));
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to load friend list for notifications. userId: {userId}", userId); _logger.LogWarning(ex, "User has no friends for user {userId}", UserId);
} }
// 2. (pending)
try try
{ {
groups = await _groupsRepository.GetByUserIdAsync(userId); var potentialFriends = await _friendsService.GetPotentialFriendsAsync(UserId);
recipients.UnionWith(potentialFriends.Select(p => p.Id));
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to load group list for notifications. userId: {userId}", userId); _logger.LogWarning(ex, "User has potential friends for user {userId}", UserId);
} }
var groupIds = groups.Select(g => g.Id).ToList();
// Current user // 3. groups
await Clients.Group($"user-{userId}") /*var groups = await _groupsRepository.GetByUserIdAsync(authorUserId);
.SendAsync(eventName, payload); foreach (var group in groups)
// Friends
foreach (var friend in friendIds)
{ {
await Clients.Group($"friends-{friend.Id}") var members =
.SendAsync(eventName, payload); await _groupsRepository.Get(group.Id);
}
// Groups recipients.UnionWith(members.Select(m => m.Id));
foreach (var groupId in groupIds) }*/
recipients.Add(UserId);
// 5.
// recipients.RemoveWhere(id =>
// !_profileVisibilityService.CanViewProfile(id, authorUserId));
// 6. 1 user = 1 event
var recipientsStrings =
recipients.Select(r => $"user:{r}").ToList();
foreach (var recipientId in recipients)
{ {
await Clients.Group($"group-{groupId}") await Clients.Groups(recipientsStrings)
.SendAsync(eventName, payload); .SendAsync(eventName, payload);
} }
}
_logger.LogInformation("Sent {EventName} for {UserId} to {Friends} friends and {Groups} groups", catch (Exception ex)
eventName, userId, friendIds.Count(), groupIds.Count); {
_logger.LogError(ex,
"Failed to notify profile update for user {UserId}",
UserId);
}
} }
} }
+4
View File
@@ -22,6 +22,8 @@ builder.Services.AddCors(options =>
policy.WithOrigins( policy.WithOrigins(
"https://localhost:7155", "https://localhost:7155",
"http://localhost:7155", "http://localhost:7155",
"http://192.168.1.107:8080",
"http://0.0.0.0:8080",
"https://govor-team-govor-8ce1.twc1.net", "https://govor-team-govor-8ce1.twc1.net",
"http://govor-team-govor-8ce1.twc1.net" "http://govor-team-govor-8ce1.twc1.net"
) )
@@ -113,6 +115,7 @@ if (!app.Environment.IsDevelopment())
{ {
//app.MapOpenApi(); //app.MapOpenApi();
builder.WebHost.UseUrls("http://0.0.0.0:8080"); builder.WebHost.UseUrls("http://0.0.0.0:8080");
builder.WebHost.UseUrls("http://192.168.1.107:8080");
} }
app.UseSwagger(); app.UseSwagger();
@@ -132,6 +135,7 @@ app.MapControllers();
app.MapHub<ChatsHub>("/hubs/chats"); app.MapHub<ChatsHub>("/hubs/chats");
app.MapHub<FriendsHub>("/hubs/friends"); app.MapHub<FriendsHub>("/hubs/friends");
app.MapHub<ProfileHub>("/hubs/profiles"); app.MapHub<ProfileHub>("/hubs/profiles");
app.MapHub<PresenceHub>("/hubs/presence");
app.MapSwagger() app.MapSwagger()
.RequireAuthorization(); .RequireAuthorization();
+2 -2
View File
@@ -5,7 +5,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "http://0.0.0.0:8080;", "applicationUrl": "http://0.0.0.0:8080;http://localhost:7155;http://192.168.1.107:8080;72.56.93.242:8080;",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
@@ -14,7 +14,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"applicationUrl": "https://0.0.0.0:8080;", "applicationUrl": "https://0.0.0.0:8080; https://localhost:7155",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
+2 -2
View File
@@ -6,7 +6,7 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"GovorDbContext": "Host=localhost;Port=5432;Database=GovorDb;Username=postgres;Password=stalcker;" "GovorDbContext": "Host=72.56.93.203;Port=5432;Database=default_db;Username=gen_user;Password=9_-L%OLk+v(rQj;"
}, },
"UseMySql": false, "UseMySql": false,
"AllowedHosts": "*", "AllowedHosts": "*",
@@ -18,6 +18,6 @@
"RefreshTokenLifetimeDays": 30 "RefreshTokenLifetimeDays": 30
}, },
"EncryptionOption": { "EncryptionOption": {
"Secret": "4r8B2j9kP5mX7nQwE2zY3A==" "Secret": "8B2j9kkw9xP5m7nQwE2zY3A-=Q8zP7C4+TqLZpg"
} }
} }
@@ -0,0 +1,105 @@
using Govor.Application.Services;
namespace Govor.Application.Tests.Services;
[TestFixture]
public class SynchingServiceTests
{
private readonly SynchingService _sut; // System Under Test
public SynchingServiceTests()
{
_sut = new SynchingService();
}
[Test]
public void NormalizeNewlines_ShouldReturnEmptyString_WhenInputIsEmpty()
{
// Act
var result = _sut.NormalizeNewlines(string.Empty);
// Assert
Assert.That(result, Is.EqualTo(string.Empty));
}
[Test]
public void NormalizeNewlines_ShouldReturnNull_WhenInputIsNull()
{
// Arrange
string input = null;
// Act
var result = _sut.NormalizeNewlines(input);
// Assert
Assert.That(result, Is.Null);
}
[Test]
public void NormalizeNewlines_ShouldNotChangeUnixStyleNewlines_WhenInputIsLF()
{
// Arrange
const string input = "Line 1\nLine 2\nLine 3";
// Act
var result = _sut.NormalizeNewlines(input);
// Assert
Assert.That(result, Is.EqualTo(input));
}
[Test]
public void NormalizeNewlines_ShouldConvertWindowsStyleNewlines_WhenInputIsCRLF()
{
// Arrange
const string input = "Line 1\r\nLine 2\r\nLine 3";
const string expected = "Line 1\nLine 2\nLine 3";
// Act
var result = _sut.NormalizeNewlines(input);
// Assert
Assert.That(result, Is.EqualTo(expected));
}
[Test]
public void NormalizeNewlines_ShouldConvertMacStyleNewlines_WhenInputIsCR()
{
// Arrange
const string input = "Line 1\rLine 2\rLine 3";
const string expected = "Line 1\nLine 2\nLine 3";
// Act
var result = _sut.NormalizeNewlines(input);
// Assert
Assert.That(result, Is.EqualTo(expected));
}
[Test]
public void NormalizeNewlines_ShouldHandleMixedNewlines_WhenInputIsMixed()
{
// Arrange
const string input = "Line 1\r\nLine 2\rLine 3\nLine 4";
const string expected = "Line 1\nLine 2\nLine 3\nLine 4";
// Act
var result = _sut.NormalizeNewlines(input);
// Assert
Assert.That(result, Is.EqualTo(expected));
}
[Test]
public void NormalizeNewlines_ShouldHandleTextWithoutNewlines()
{
// Arrange
const string input = "This is a single line of text.";
// Act
var result = _sut.NormalizeNewlines(input);
// Assert
Assert.That(result, Is.EqualTo(input));
}
}
@@ -9,11 +9,11 @@ using Moq;
namespace Govor.Application.Tests.Services; namespace Govor.Application.Tests.Services;
[TestFixture] [TestFixture]
public class UserGroupsServiceTests public class UserGroupsGetterServiceTests
{ {
private Fixture _fixture; private Fixture _fixture;
private Mock<IGroupsRepository> _repositoryMock; private Mock<IGroupsRepository> _repositoryMock;
private IUserGroupsService _service; private IUserGroupsGetterService _getterService;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
@@ -29,7 +29,7 @@ public class UserGroupsServiceTests
_repositoryMock = new Mock<IGroupsRepository>(); _repositoryMock = new Mock<IGroupsRepository>();
_service = new UserGroupsService(_repositoryMock.Object); _getterService = new UserGroupsGetterService(_repositoryMock.Object);
} }
[Test] [Test]
@@ -43,7 +43,7 @@ public class UserGroupsServiceTests
.ReturnsAsync([chats.First()]); .ReturnsAsync([chats.First()]);
// Act // Act
var result = await _service.GetUserGroupsAsync(userId); var result = await _getterService.GetUserGroupsAsync(userId);
// Assert // Assert
Assert.That(result, Is.Not.Null); Assert.That(result, Is.Not.Null);
@@ -61,7 +61,7 @@ public class UserGroupsServiceTests
.ThrowsAsync(new NotFoundByKeyException<Guid>(userId)); .ThrowsAsync(new NotFoundByKeyException<Guid>(userId));
// Act // Act
var result = await _service.GetUserGroupsAsync(userId); var result = await _getterService.GetUserGroupsAsync(userId);
// Assert // Assert
Assert.That(result, Is.Not.Null); Assert.That(result, Is.Not.Null);
@@ -20,6 +20,7 @@ public class UserSessionRefresherTests
private Mock<IJwtService> _jwtServiceMock; private Mock<IJwtService> _jwtServiceMock;
private Mock<ILogger<UserSessionRefresher>> _loggerMock; private Mock<ILogger<UserSessionRefresher>> _loggerMock;
private Mock<IOptions<JwtRefreshOption>> _optionsMock; private Mock<IOptions<JwtRefreshOption>> _optionsMock;
private Mock<IJwtTokenHasher> _jwtTokenHasherMock;
private JwtRefreshOption _options; private JwtRefreshOption _options;
private UserSessionRefresher _refresher; private UserSessionRefresher _refresher;
private const string OldRefreshToken = "old-refresh-token"; private const string OldRefreshToken = "old-refresh-token";
@@ -36,6 +37,7 @@ public class UserSessionRefresherTests
_jwtServiceMock = new Mock<IJwtService>(); _jwtServiceMock = new Mock<IJwtService>();
_loggerMock = new Mock<ILogger<UserSessionRefresher>>(); _loggerMock = new Mock<ILogger<UserSessionRefresher>>();
_optionsMock = new Mock<IOptions<JwtRefreshOption>>(); _optionsMock = new Mock<IOptions<JwtRefreshOption>>();
_jwtTokenHasherMock = new Mock<IJwtTokenHasher>();
_options = new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }; _options = new JwtRefreshOption { RefreshTokenLifetimeDays = 30 };
@@ -46,6 +48,7 @@ public class UserSessionRefresherTests
_loggerMock.Object, _loggerMock.Object,
_usersRepoMock.Object, _usersRepoMock.Object,
_optionsMock.Object, _optionsMock.Object,
_jwtTokenHasherMock.Object,
_jwtServiceMock.Object); _jwtServiceMock.Object);
_user = new User _user = new User
@@ -5,5 +5,6 @@ namespace Govor.Application.Interfaces.Friends;
public interface IFriendshipService public interface IFriendshipService
{ {
Task<List<User>> GetFriendsAsync(Guid userId); Task<List<User>> GetFriendsAsync(Guid userId);
Task<List<User>> GetPotentialFriendsAsync(Guid userId);
Task<List<User>> SearchUsersAsync(string query, Guid currentId); Task<List<User>> SearchUsersAsync(string query, Guid currentId);
} }
@@ -4,6 +4,6 @@ namespace Govor.Application.Interfaces;
public interface IMessagesLoader public interface IMessagesLoader
{ {
Task<List<Message>> LoadMessagesInUserChat(Guid userId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2); Task<List<Message>> LoadMessagesInUserChat(Guid privateChatId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2);
Task<List<Message>> LoadMessagesInChatGroup(Guid chatId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2); Task<List<Message>> LoadMessagesInChatGroup(Guid chatId,Guid currentId, Guid? startMessageId, int before = 20, int after = 2);
} }
@@ -0,0 +1,11 @@
namespace Govor.Application.Interfaces;
public interface ISynchingService
{
/// <summary>
/// Brings all line breaks (CRLF, CR) to a single LF(\n) standard.
/// </summary>
/// <param name="input">The original line containing the line break.</param>
/// <returns>A string normalized using only \n.</returns>
string NormalizeNewlines(string input);
}
@@ -2,7 +2,7 @@ using Govor.Core.Models;
namespace Govor.Application.Interfaces; namespace Govor.Application.Interfaces;
public interface IUserGroupsService public interface IUserGroupsGetterService
{ {
Task<List<ChatGroup>> GetUserGroupsAsync(Guid userId); Task<List<ChatGroup>> GetUserGroupsAsync(Guid userId);
} }
@@ -0,0 +1,8 @@
using Govor.Core.Models;
namespace Govor.Application.Interfaces;
public interface IUserPrivateChatsGetterService
{
Task<List<PrivateChat>> GetUserChatsAsync(Guid userId);
}
@@ -9,6 +9,8 @@ public interface IMediaService
public Task DeleteMediaAsync(Guid fileId); public Task DeleteMediaAsync(Guid fileId);
public Task<Media> GetMediaByUrlAsync(string url); public Task<Media> GetMediaByUrlAsync(string url);
public Task<Media> GetMediaByIdAsync(Guid mediaId); public Task<Media> GetMediaByIdAsync(Guid mediaId);
public Task<bool> HasMediaAsync(Guid mediaId);
public Task<bool> HasMediaByUrlAsync(string url);
Task AttachToMessageAsync(Guid mediaId, Guid messageId); Task AttachToMessageAsync(Guid mediaId, Guid messageId);
} }
@@ -2,8 +2,9 @@ namespace Govor.Application.Interfaces.UserOnlineStatus;
public interface IOnlineUserStore public interface IOnlineUserStore
{ {
void SetOnlineUser(Guid userId); bool AddConnection(Guid userId, string connectionId);
void SetOfflineUser(Guid userId); bool RemoveConnection(Guid userId, string connectionId);
bool IsOnline(Guid userId); bool IsOnline(Guid userId);
IEnumerable<string> GetConnections(Guid userId);
IReadOnlyCollection<Guid> GetAllOnlineUsers(); IReadOnlyCollection<Guid> GetAllOnlineUsers();
} }
@@ -1,16 +1,32 @@
using System.Security.Cryptography;
using System.Text;
using Govor.Application.Interfaces.Authentication; using Govor.Application.Interfaces.Authentication;
using Microsoft.Extensions.Configuration;
namespace Govor.Application.Services.Authentication; namespace Govor.Application.Services.Authentication;
public class JwtTokenHasher : IJwtTokenHasher public class JwtTokenHasher : IJwtTokenHasher
{ {
private readonly string _pepper;
public JwtTokenHasher(IConfiguration config)
{
_pepper = config["EncryptionOption:Secret"] ?? "D1fault%Lxng%Randxm^Secret^Key(123!";
}
public string HashToken(string token) public string HashToken(string token)
{ {
return BCrypt.Net.BCrypt.HashPassword(token, workFactor: 13); using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_pepper));
var bytes = Encoding.UTF8.GetBytes(token);
var hash = hmac.ComputeHash(bytes);
return Convert.ToBase64String(hash);
} }
public bool VerifyToken(string token, string storedHash) public bool VerifyToken(string token, string storedHash)
{ {
return BCrypt.Net.BCrypt.Verify(token, storedHash); var currentHashBytes = Encoding.UTF8.GetBytes(HashToken(token));
var storedHashBytes = Encoding.UTF8.GetBytes(storedHash);
return CryptographicOperations.FixedTimeEquals(currentHashBytes, storedHashBytes);
} }
} }
@@ -20,18 +20,33 @@ public class FriendRequestCommandService : IFriendRequestCommandService
if (fromUserId == toUserId) if (fromUserId == toUserId)
throw new InvalidOperationException("Cannot send a request to self user"); throw new InvalidOperationException("Cannot send a request to self user");
if (_friendshipsRepository.Exist(fromUserId, toUserId)) var friendship = await _friendshipsRepository.GetFriendshipAsync(fromUserId, toUserId);
throw new RequestAlreadySentException(fromUserId, toUserId);
var friendship = new Friendship if (friendship is null)
{
friendship = new Friendship
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
RequesterId = fromUserId, RequesterId = fromUserId,
AddresseeId = toUserId, AddresseeId = toUserId,
Status = FriendshipStatus.Pending Status = FriendshipStatus.Pending
}; };
await _friendshipsRepository.AddAsync(friendship); await _friendshipsRepository.AddAsync(friendship);
}
else
{
if (friendship.Status == FriendshipStatus.Pending || friendship.Status == FriendshipStatus.Accepted
|| friendship.Status == FriendshipStatus.Blocked)
{
throw new RequestAlreadySentException(fromUserId, toUserId);
}
friendship.RequesterId = fromUserId;
friendship.AddresseeId = toUserId;
friendship.Status = FriendshipStatus.Pending;
await _friendshipsRepository.UpdateAsync(friendship);
}
return friendship; return friendship;
} }
@@ -19,6 +19,7 @@ public class FriendshipService : IFriendshipService
_friendshipsRepository = friendshipsRepository; _friendshipsRepository = friendshipsRepository;
} }
public async Task<List<User>> SearchUsersAsync(string query, Guid currentId) public async Task<List<User>> SearchUsersAsync(string query, Guid currentId)
{ {
List<User> all = new List<User>(); List<User> all = new List<User>();
@@ -43,6 +44,23 @@ public class FriendshipService : IFriendshipService
} }
} }
public async Task<List<User>> GetPotentialFriendsAsync(Guid userId)
{
try
{
var friendships = await _friendshipsRepository.FindByUserIdAsync(userId);
return friendships
.Where(f => f.Status == FriendshipStatus.Pending)
.Select(f => f.RequesterId == userId ? f.Addressee : f.Requester)
.ToList();
}
catch (NotFoundByKeyException<Guid> ex)
{
throw new InvalidOperationException("Nothing was found for the specified id.", ex);
}
}
public async Task<List<User>> GetFriendsAsync(Guid userId) public async Task<List<User>> GetFriendsAsync(Guid userId)
{ {
try try
@@ -100,6 +100,19 @@ public class MediaService : IMediaService
throw; throw;
} }
} }
public async Task<bool> HasMediaAsync(Guid mediaId)
{
return await _dbContext.MediaFiles.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == mediaId) is not null;
}
public async Task<bool> HasMediaByUrlAsync(string url)
{
return await _dbContext.MediaFiles.AsNoTracking()
.FirstOrDefaultAsync(x => x.Url == url) is not null;
}
public async Task AttachToMessageAsync(Guid mediaId, Guid messageId) public async Task AttachToMessageAsync(Guid mediaId, Guid messageId)
{ {
var mediaFile = await _dbContext.MediaFiles var mediaFile = await _dbContext.MediaFiles
@@ -50,14 +50,24 @@ public class MessageCommandService : IMessageCommandService
if (sendParams.RecipientType == RecipientType.User) if (sendParams.RecipientType == RecipientType.User)
{ {
if (!await _usersRepository.ExistsByIdAsync(sendParams.RecipientId)) if (!await _usersRepository.ExistsByIdAsync(sendParams.RecipientId))
{
if (!_privateChats.Exist(sendParams.RecipientId))
{ {
_logger.LogWarning("Attempt to send message to non-existent user {RecipientId}", sendParams.RecipientId); _logger.LogWarning("Attempt to send message to non-existent user {RecipientId}", sendParams.RecipientId);
return new SendMessageResult(false, new KeyNotFoundException($"Recipient user {sendParams.RecipientId} not found."), default); return new SendMessageResult(false, new KeyNotFoundException($"Recipient user {sendParams.RecipientId} not found."), default);
} }
else
{
recipientId = sendParams.RecipientId;
}
}
else
{
// Verify friendship for private messages // Verify friendship for private messages
await _verifyFriendship.VerifyAsync(sendParams.FromUserId, sendParams.RecipientId); await _verifyFriendship.VerifyAsync(sendParams.FromUserId, sendParams.RecipientId);
recipientId = await GetPrivateChatsIdAsync(sendParams.FromUserId, sendParams.RecipientId); recipientId = await GetPrivateChatsIdAsync(sendParams.FromUserId, sendParams.RecipientId);
} }
}
else if (sendParams.RecipientType == RecipientType.Group) else if (sendParams.RecipientType == RecipientType.Group)
{ {
if (!_groupsRepository.Exist(sendParams.RecipientId)) if (!_groupsRepository.Exist(sendParams.RecipientId))
@@ -25,26 +25,24 @@ public class MessagesLoader : IMessagesLoader
} }
public async Task<List<Message>> LoadMessagesInUserChat( public async Task<List<Message>> LoadMessagesInUserChat(
Guid userId, Guid privateChatId,
Guid currentUser, Guid currentUser,
Guid? startMessageId, Guid? startMessageId,
int before = 20, int before = 20,
int after = 2) int after = 2)
{ {
if (userId == Guid.Empty) if (privateChatId == Guid.Empty)
throw new ArgumentException("User id cannot be empty"); throw new ArgumentException("PrivateChatId id cannot be empty");
if (!_privateChatsRepository.Exist(userId, currentUser)) if (!_privateChatsRepository.Exist(privateChatId))
throw new InvalidOperationException("Private chat not found"); throw new InvalidOperationException("Private chat not found");
var chat = await _privateChatsRepository.GetByMembersAsync(userId, currentUser);
var query = _dbContext.Messages var query = _dbContext.Messages
.AsNoTracking() .AsNoTracking()
.Include(m => m.MediaAttachments) .Include(m => m.MediaAttachments)
.ThenInclude(m => m.MediaFile) .ThenInclude(m => m.MediaFile)
.Where(m => m.RecipientType == RecipientType.User && .Where(m => m.RecipientType == RecipientType.User &&
m.RecipientId == chat.Id); m.RecipientId == privateChatId);
if (startMessageId is null) if (startMessageId is null)
{ {
@@ -0,0 +1,20 @@
using Govor.Application.Interfaces;
namespace Govor.Application.Services;
public class SynchingService : ISynchingService
{
public string NormalizeNewlines(string input)
{
if (string.IsNullOrEmpty(input))
{
return input;
}
string normalized = input.Replace("\r\n", "\n");
normalized = normalized.Replace('\r', '\n');
return normalized;
}
}
@@ -5,11 +5,11 @@ using Govor.Data.Repositories.Exceptions;
namespace Govor.Application.Services; namespace Govor.Application.Services;
public class UserGroupsService : IUserGroupsService public class UserGroupsGetterService : IUserGroupsGetterService
{ {
private readonly IGroupsRepository _groupRep; private readonly IGroupsRepository _groupRep;
public UserGroupsService(IGroupsRepository groupsRepository) public UserGroupsGetterService(IGroupsRepository groupsRepository)
{ {
_groupRep = groupsRepository; _groupRep = groupsRepository;
} }
@@ -5,25 +5,68 @@ namespace Govor.Application.Services.UserOnlineStatus;
public class OnlineUserStore : IOnlineUserStore public class OnlineUserStore : IOnlineUserStore
{ {
private readonly ConcurrentDictionary<Guid, DateTime> _onlineUsers = new(); private readonly ConcurrentDictionary<Guid, HashSet<string>> _userConnections = new();
public void SetOnlineUser(Guid userId) public bool AddConnection(Guid userId, string connectionId)
{ {
_onlineUsers[userId] = DateTime.UtcNow; bool isFirstConnection = false;
_userConnections.AddOrUpdate(userId,
_ => {
isFirstConnection = true;
return new HashSet<string> { connectionId };
},
(_, connections) => {
lock (connections)
{
if (connections.Count == 0) isFirstConnection = true;
connections.Add(connectionId);
}
return connections;
});
return isFirstConnection;
} }
public void SetOfflineUser(Guid userId) public bool RemoveConnection(Guid userId, string connectionId)
{ {
_onlineUsers.TryRemove(userId, out _); bool isLastConnection = false;
if (_userConnections.TryGetValue(userId, out var connections))
{
lock (connections)
{
connections.Remove(connectionId);
if (connections.Count == 0)
{
isLastConnection = true;
_userConnections.TryRemove(userId, out _);
}
}
}
return isLastConnection;
}
public IEnumerable<string> GetConnections(Guid userId)
{
if (_userConnections.TryGetValue(userId, out var connections))
{
lock (connections)
{
return connections.ToList();
}
}
return Enumerable.Empty<string>();
} }
public bool IsOnline(Guid userId) public bool IsOnline(Guid userId)
{ {
return _onlineUsers.ContainsKey(userId); return _userConnections.TryGetValue(userId, out var connections) && connections.Count > 0;
} }
public IReadOnlyCollection<Guid> GetAllOnlineUsers() public IReadOnlyCollection<Guid> GetAllOnlineUsers()
{ {
return _onlineUsers.Keys.ToList(); return _userConnections.Keys.ToList();
} }
} }
@@ -26,7 +26,7 @@ public class UserNotificationScopeService : IUserNotificationScopeService
} }
catch (NotFoundByKeyException<Guid> ex) catch (NotFoundByKeyException<Guid> ex)
{ {
_logger.LogError(ex, ex.Message); _logger.LogWarning(ex, ex.Message);
throw new InvalidOperationException("User not found"); throw new InvalidOperationException("User not found");
} }
} }
@@ -0,0 +1,28 @@
using Govor.Application.Interfaces;
using Govor.Core.Models;
using Govor.Core.Repositories.PrivateChats;
using Govor.Data.Repositories.Exceptions;
namespace Govor.Application.Services;
public class UserPrivateChatsGetter : IUserPrivateChatsGetterService
{
private readonly IPrivateChatsRepository _groupRep;
public UserPrivateChatsGetter(IPrivateChatsRepository groupsRepository)
{
_groupRep = groupsRepository;
}
public async Task<List<PrivateChat>> GetUserChatsAsync(Guid userId)
{
try
{
return await _groupRep.GetAllOfUser(userId);
}
catch (NotFoundByKeyException<Guid> ex)
{
return [];
}
}
}
@@ -16,6 +16,7 @@ public class UserSessionRefresher : IUserSessionRefresher
private readonly ILogger<UserSessionRefresher> _logger; private readonly ILogger<UserSessionRefresher> _logger;
private readonly IUsersRepository _usersRepository; private readonly IUsersRepository _usersRepository;
private readonly JwtRefreshOption _options; private readonly JwtRefreshOption _options;
private readonly IJwtTokenHasher _jwtTokenHasher;
private readonly IJwtService _jwtService; private readonly IJwtService _jwtService;
public UserSessionRefresher( public UserSessionRefresher(
@@ -23,12 +24,14 @@ public class UserSessionRefresher : IUserSessionRefresher
ILogger<UserSessionRefresher> logger, ILogger<UserSessionRefresher> logger,
IUsersRepository usersRepository, IUsersRepository usersRepository,
IOptions<JwtRefreshOption> options, IOptions<JwtRefreshOption> options,
IJwtTokenHasher jwtTokenHasher,
IJwtService jwtService) IJwtService jwtService)
{ {
_sessionsRepository = sessionsRepository; _sessionsRepository = sessionsRepository;
_logger = logger; _logger = logger;
_usersRepository = usersRepository; _usersRepository = usersRepository;
_options = options.Value; _options = options.Value;
_jwtTokenHasher = jwtTokenHasher;
_jwtService = jwtService; _jwtService = jwtService;
} }
@@ -36,7 +39,8 @@ public class UserSessionRefresher : IUserSessionRefresher
{ {
try try
{ {
var session = await _sessionsRepository.GetByHashedRefreshTokenAsync(refreshToken);
var session = await _sessionsRepository.GetByHashedRefreshTokenAsync(_jwtTokenHasher.HashToken(refreshToken));
if (session.IsRevoked || session.ExpiresAt <= DateTime.UtcNow) if (session.IsRevoked || session.ExpiresAt <= DateTime.UtcNow)
throw new UnauthorizedAccessException("Refresh token is invalid or expired"); throw new UnauthorizedAccessException("Refresh token is invalid or expired");
@@ -51,11 +55,13 @@ public class UserSessionRefresher : IUserSessionRefresher
var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user, session.Id); var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user, session.Id);
var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user);
var newRefreshTokenHash = _jwtTokenHasher.HashToken(newRefreshToken);
// Opening new session // Opening new session
var newSession = new UserSession var newSession = new UserSession
{ {
UserId = user.Id, UserId = user.Id,
RefreshTokenHash = newRefreshToken, RefreshTokenHash = newRefreshTokenHash,
DeviceInfo = session.DeviceInfo, DeviceInfo = session.DeviceInfo,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays) ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays)
@@ -14,7 +14,10 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="4.3.2" /> <PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit.Analyzers" Version="3.9.0" /> <PackageReference Include="NUnit.Analyzers" Version="4.4.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" /> <PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
</ItemGroup> </ItemGroup>
@@ -34,11 +34,11 @@ public class SendMessageCommand : IInteractiveCommand
public string LongHelp() public string LongHelp()
{ {
return "Отпарвка тестовых сообщений не существующему юзеру 2"; return "Отпарвка тестовых сообщений существующему юзеру 2";
} }
public string ShortHelp() public string ShortHelp()
{ {
return "Отпарвка тестовых сообщений не существующему юзеру"; return "Отпарвка тестовых сообщений существующему юзеру";
} }
} }
@@ -0,0 +1,7 @@
namespace Govor.Contracts.DTOs;
public class AvatarUpdatePayload
{
public Guid UserId { get; set; }
public Guid IconId { get; set; }
}
@@ -0,0 +1,7 @@
namespace Govor.Contracts.DTOs;
public class DescriptionUpdatePayload
{
public Guid UserId { get; set; }
public string? Description { get; set; }
}
+1
View File
@@ -6,4 +6,5 @@ public class UserProfileDto
public string Username { get; set; } = string.Empty; public string Username { get; set; } = string.Empty;
public string? Description { get; set; } public string? Description { get; set; }
public Guid? IconId { get; set; } public Guid? IconId { get; set; }
public bool IsOnline { get; set; }
} }
@@ -3,15 +3,15 @@ using Govor.Core.Models.Messages;
namespace Govor.Contracts.Responses.SignalR; namespace Govor.Contracts.Responses.SignalR;
public record UserMessageResponse public class UserMessageResponse
{ {
public Guid MessageId { get; init; } public Guid MessageId { get; set; }
public Guid SenderId { get; init; } public Guid SenderId { get; set; }
public Guid RecipientId { get; init; } public Guid RecipientId { get; set; }
public RecipientType RecipientType{get; init; } public RecipientType RecipientType{get; set; }
public string EncryptedContent { get; init; } = string.Empty; public string EncryptedContent { get; set; } = string.Empty;
public Guid? ReplyToMessageId { get; init; } public Guid? ReplyToMessageId { get; set; }
public DateTime SentAt { get; init; } public DateTime SentAt { get; set; }
public bool IsEdited { get; init; } = false; public bool IsEdited { get; set; } = false;
public List<MediaFile> MediaAttachments { get; init; } = new List<MediaFile>(); public List<MediaFile> MediaAttachments { get; set; } = new List<MediaFile>();
} }
+1 -11
View File
@@ -20,16 +20,6 @@ public class User
{ {
var user = obj as User; var user = obj as User;
return Id == user.Id && return Id == user.Id;
Username == user.Username &&
Description == user.Description &&
PasswordHash == user.PasswordHash &&
IconId == user.IconId &&
CreatedOn == user.CreatedOn &&
WasOnline == user.WasOnline &&
InviteId == user.InviteId &&
Invite == user.Invite &&
SentFriendRequests == user.SentFriendRequests &&
ReceivedFriendRequests == user.ReceivedFriendRequests;
} }
} }
@@ -3,4 +3,5 @@ namespace Govor.Core.Repositories.Friendships;
public interface IFriendshipsExist public interface IFriendshipsExist
{ {
bool Exist(Guid requesterId, Guid addresseeId); bool Exist(Guid requesterId, Guid addresseeId);
bool IsPossibilityOfCreatingFriendship(Guid requesterId, Guid addresseeId);
} }
@@ -6,5 +6,6 @@ public interface IFriendshipsReader
{ {
Task<List<Friendship>> GetAllAsync(); Task<List<Friendship>> GetAllAsync();
Task<Friendship> GetByIdAsync(Guid id); Task<Friendship> GetByIdAsync(Guid id);
Task<Friendship> GetFriendshipAsync(Guid fromUserId, Guid toUserId);
Task<List<Friendship>> FindByUserIdAsync(Guid userId); Task<List<Friendship>> FindByUserIdAsync(Guid userId);
} }
@@ -7,4 +7,5 @@ public interface IPrivateChatsReader
Task<List<PrivateChat>> GetAllAsync(); Task<List<PrivateChat>> GetAllAsync();
Task<PrivateChat> GetByIdAsync(Guid id); Task<PrivateChat> GetByIdAsync(Guid id);
Task<PrivateChat> GetByMembersAsync(Guid memberAId, Guid memberBId); Task<PrivateChat> GetByMembersAsync(Guid memberAId, Guid memberBId);
Task<List<PrivateChat>> GetAllOfUser(Guid userId);
} }
@@ -10,6 +10,10 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
{ {
builder.HasKey(x => x.Id); builder.HasKey(x => x.Id);
builder.HasIndex(u => u.Username).IsUnique();
builder.HasIndex(u => u.CreatedOn);
builder.HasIndex(u => u.WasOnline);
builder.HasOne(u => u.Invite) builder.HasOne(u => u.Invite)
.WithMany(i => i.Users) .WithMany(i => i.Users)
.HasForeignKey(u => u.InviteId); .HasForeignKey(u => u.InviteId);
@@ -40,6 +40,14 @@ public class FriendshipsRepository : IFriendshipsRepository
?? throw new NotFoundByKeyException<Guid>(id, "Friendship with given id was not found"); ?? throw new NotFoundByKeyException<Guid>(id, "Friendship with given id was not found");
} }
public async Task<Friendship> GetFriendshipAsync(Guid fromUserId, Guid toUserId)
{
return await _context.Friendships
.FirstOrDefaultAsync(x =>
(x.RequesterId == fromUserId && x.AddresseeId == toUserId) ||
(x.RequesterId == toUserId && x.AddresseeId == fromUserId));
}
public async Task<List<Friendship>> FindByUserIdAsync(Guid userId) public async Task<List<Friendship>> FindByUserIdAsync(Guid userId)
{ {
return await _context.Friendships return await _context.Friendships
@@ -120,4 +128,23 @@ public class FriendshipsRepository : IFriendshipsRepository
return _context.Friendships.AsNoTracking().Any(x => (x.RequesterId == requesterId && x.AddresseeId == addresseeId) || return _context.Friendships.AsNoTracking().Any(x => (x.RequesterId == requesterId && x.AddresseeId == addresseeId) ||
x.RequesterId == addresseeId && x.AddresseeId == requesterId); x.RequesterId == addresseeId && x.AddresseeId == requesterId);
} }
public bool IsPossibilityOfCreatingFriendship(Guid requesterId, Guid addresseeId)
{
// Ищем любую существующую связь между пользователями
var existingFriendship = _context.Friendships
.AsNoTracking()
.FirstOrDefault(x =>
(x.RequesterId == requesterId && x.AddresseeId == addresseeId) ||
(x.RequesterId == addresseeId && x.AddresseeId == requesterId));
// Если записи нет — создавать можно
if (existingFriendship == null) return true;
// Если запись есть, создавать новую НЕЛЬЗЯ (нужно обновлять старую),
// за исключением случаев, когда статус "Rejected" или "Blocked" (смотря какая логика)
// Но обычно метод "IsPossibility" подразумевает именно "можно ли инициировать процесс"
return existingFriendship.Status == FriendshipStatus.Rejected;
}
} }
@@ -42,6 +42,14 @@ public class PrivateChatsRepository : IPrivateChatsRepository
?? throw new NotFoundByKeyException<(Guid, Guid)>((memberAId, memberBId), "Private Chat with given members Id's does not exist"); ?? throw new NotFoundByKeyException<(Guid, Guid)>((memberAId, memberBId), "Private Chat with given members Id's does not exist");
} }
public async Task<List<PrivateChat>> GetAllOfUser(Guid userId)
{
return await _context.PrivateChats
.AsNoTracking()
.Where(f => f.UserAId == userId || f.UserBId == userId)
.ToListOrThrowIfEmpty(new NotFoundByKeyException<Guid>(userId, "Private Chat with given member Id's does not exist"));
}
public async Task AddAsync(PrivateChat chat) public async Task AddAsync(PrivateChat chat)
{ {
try try
@@ -102,8 +102,6 @@ public class UserSessionsRepository : IUserSessionsRepository
if (rowsAffected == 0) if (rowsAffected == 0)
throw new UpdateException($"Not found user session by given id {userSession.Id}"); throw new UpdateException($"Not found user session by given id {userSession.Id}");
await _context.SaveChangesAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
+4 -8
View File
@@ -1,5 +1,6 @@
using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Extensions;
using Govor.Core.Infrastructure.Validators; using Govor.Core.Infrastructure.Validators;
using Govor.Core.Models;
using Govor.Core.Models.Users; using Govor.Core.Models.Users;
using Govor.Core.Repositories.Users; using Govor.Core.Repositories.Users;
using Govor.Data.Repositories.Exceptions; using Govor.Data.Repositories.Exceptions;
@@ -77,16 +78,13 @@ public class UsersRepository : IUsersRepository
{ {
return await _context.Users return await _context.Users
.AsNoTracking() .AsNoTracking()
.Include(u => u.Invite)
.Include(u => u.ReceivedFriendRequests)
.Include(u => u.SentFriendRequests)
.AsSplitQuery() .AsSplitQuery()
.Where(u => u.Id != currentUserId && .Where(u => u.Id != currentUserId &&
u.Username.ToLower().Contains(query.ToLower()) && u.Username.ToLower().Contains(query.ToLower()) &&
!_context.Friendships.Any(f => !_context.Friendships.Any(f =>
(f.RequesterId == currentUserId && f.AddresseeId == u.Id) || ((f.RequesterId == currentUserId && f.AddresseeId == u.Id) ||
(f.RequesterId == u.Id && f.AddresseeId == currentUserId))) (f.RequesterId == u.Id && f.AddresseeId == currentUserId)) && f.Status != FriendshipStatus.Rejected))
.Take(20) .Take(10)
.OrderBy(u => u.Username) .OrderBy(u => u.Username)
.ToListOrThrowIfEmpty(new NotFoundByKeyException<(string, Guid)>((query, currentUserId), $"Users with given query for user {currentUserId} not found")); .ToListOrThrowIfEmpty(new NotFoundByKeyException<(string, Guid)>((query, currentUserId), $"Users with given query for user {currentUserId} not found"));
} }
@@ -162,8 +160,6 @@ public class UsersRepository : IUsersRepository
if (rowsAffected == 0) if (rowsAffected == 0)
throw new UpdateException($"Not found user by given id {user.Id}"); throw new UpdateException($"Not found user by given id {user.Id}");
await _context.SaveChangesAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {