diff --git a/Govor.API.Tests/IntegrationTests/Hubs/Infrastructure/ConnectionStoreTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/Infrastructure/ConnectionStoreTests.cs new file mode 100644 index 0000000..00d05ae --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Hubs/Infrastructure/ConnectionStoreTests.cs @@ -0,0 +1,143 @@ +using Govor.API.Hubs.Infrastructure; + +namespace Govor.API.Tests.IntegrationTests.Hubs.Infrastructure; + +[TestFixture] +public class ConnectionStoreTests +{ + private ConnectionStore _store; + + [SetUp] + public void Setup() + { + _store = new ConnectionStore(); + } + + [Test] + public void AddConnection_AddsConnection() + { + // Arrange + var userId = Guid.NewGuid(); + var connectionId = "conn1"; + + // Act + _store.AddConnection(userId, connectionId); + + var connections = _store.GetConnections(userId); + + // Assert + Assert.That(connections, Contains.Item(connectionId)); + } + + [Test] + public void AddConnection_DoesNotDuplicateConnection() + { + // Arrange + var userId = Guid.NewGuid(); + var connectionId = "conn1"; + + // Act + _store.AddConnection(userId, connectionId); + _store.AddConnection(userId, connectionId); + + var connections = _store.GetConnections(userId).ToList(); + + // Assert + Assert.That(connections.Count, Is.EqualTo(1)); + } + + [Test] + public void RemoveConnection_RemovesConnection() + { + // Arrange + var userId = Guid.NewGuid(); + var connectionId = "conn1"; + + // Act + _store.AddConnection(userId, connectionId); + _store.RemoveConnection(userId, connectionId); + + var connections = _store.GetConnections(userId); + // Assert + Assert.That(connections, Is.Empty); + } + + [Test] + public void RemoveConnection_LastConnection_RemovesUserEntry() + { + // Arrange + var userId = Guid.NewGuid(); + var connectionId = "conn1"; + + // Act + _store.AddConnection(userId, connectionId); + _store.RemoveConnection(userId, connectionId); + + var connections = _store.GetConnections(userId); + + // Assert + Assert.That(connections, Is.Empty); + } + + [Test] + public void RemoveConnection_NonExistingUser_DoesNotThrow() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act & Assert + Assert.DoesNotThrow(() => + _store.RemoveConnection(userId, "conn1")); + } + + [Test] + public void GetConnections_NonExistingUser_ReturnsEmpty() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + var connections = _store.GetConnections(userId); + + // Assert + Assert.That(connections, Is.Empty); + } + + [Test] + public void MultipleUsers_IsolatedConnections() + { + // Arrange + var userA = Guid.NewGuid(); + var userB = Guid.NewGuid(); + + // Act + _store.AddConnection(userA, "A1"); + _store.AddConnection(userB, "B1"); + + var connectionsA = _store.GetConnections(userA); + var connectionsB = _store.GetConnections(userB); + + // Assert + Assert.That(connectionsA, Contains.Item("A1")); + Assert.That(connectionsB, Contains.Item("B1")); + Assert.That(connectionsA, Does.Not.Contain("B1")); + } + + [Test] + public void MultipleConnections_ForSameUser_WorkCorrectly() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + _store.AddConnection(userId, "conn1"); + _store.AddConnection(userId, "conn2"); + + var connections = _store.GetConnections(userId).ToList(); + + // Assert + Assert.That(connections.Count, Is.EqualTo(2)); + Assert.That(connections, Contains.Item("conn1")); + Assert.That(connections, Contains.Item("conn2")); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/Hubs/Infrastructure/PrivateChatGroupManagerTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/Infrastructure/PrivateChatGroupManagerTests.cs new file mode 100644 index 0000000..bf931f4 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Hubs/Infrastructure/PrivateChatGroupManagerTests.cs @@ -0,0 +1,155 @@ +using Govor.API.Hubs; +using Govor.API.Hubs.Infrastructure; +using Govor.Application.Interfaces; +using Govor.Core.Models; +using Microsoft.AspNetCore.SignalR; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Hubs.Infrastructure; + +[TestFixture] +public class PrivateChatGroupManagerTests +{ + private Mock _connectionStoreMock; + private Mock> _hubContextMock; + private Mock _groupsMock; + + private PrivateChatGroupManager _service; + + [SetUp] + public void Setup() + { + _connectionStoreMock = new Mock(); + + _groupsMock = new Mock(); + + _hubContextMock = new Mock>(); + _hubContextMock + .SetupGet(h => h.Groups) + .Returns(_groupsMock.Object); + + _service = new PrivateChatGroupManager( + _connectionStoreMock.Object, + _hubContextMock.Object); + } + + [Test] + public async Task AddUsersToPrivateChatGroupAsync_AddsAllConnectionsToGroup() + { + // Arrange + var chat = new PrivateChat + { + Id = Guid.NewGuid(), + UserAId = Guid.NewGuid(), + UserBId = Guid.NewGuid() + }; + + var connectionsA = new List { "connA1", "connA2" }; + var connectionsB = new List { "connB1" }; + + _connectionStoreMock + .Setup(s => s.GetConnections(chat.UserAId)) + .Returns(connectionsA); + + _connectionStoreMock + .Setup(s => s.GetConnections(chat.UserBId)) + .Returns(connectionsB); + + var expectedGroup = ChatHubConstants.GetPrivateChat(chat.Id); + + // Act + await _service.AddUsersToPrivateChatGroupAsync(chat); + + // Assert + foreach (var conn in connectionsA.Concat(connectionsB)) + { + _groupsMock.Verify( + g => g.AddToGroupAsync(conn, expectedGroup, default), + Times.Once); + } + } + + [Test] + public async Task RemoveUsersFromPrivateChatGroupAsync_RemovesAllConnectionsFromGroup() + { + // Arrange + var chat = new PrivateChat + { + Id = Guid.NewGuid(), + UserAId = Guid.NewGuid(), + UserBId = Guid.NewGuid() + }; + + var connectionsA = new List { "connA1" }; + var connectionsB = new List { "connB1", "connB2" }; + + _connectionStoreMock + .Setup(s => s.GetConnections(chat.UserAId)) + .Returns(connectionsA); + + _connectionStoreMock + .Setup(s => s.GetConnections(chat.UserBId)) + .Returns(connectionsB); + + var expectedGroup = ChatHubConstants.GetPrivateChat(chat.Id); + + // Act + await _service.RemoveUsersFromPrivateChatGroupAsync(chat); + + // Assert + foreach (var conn in connectionsA.Concat(connectionsB)) + { + _groupsMock.Verify( + g => g.RemoveFromGroupAsync(conn, expectedGroup, default), + Times.Once); + } + } + + [Test] + public async Task AddUsersToPrivateChatGroupAsync_NoConnections_DoesNothing() + { + // Arrange + var chat = new PrivateChat + { + Id = Guid.NewGuid(), + UserAId = Guid.NewGuid(), + UserBId = Guid.NewGuid() + }; + + _connectionStoreMock + .Setup(s => s.GetConnections(It.IsAny())) + .Returns(new List()); + + // Act + await _service.AddUsersToPrivateChatGroupAsync(chat); + + // Assert + _groupsMock.Verify( + g => g.AddToGroupAsync(It.IsAny(), It.IsAny(), default), + Times.Never); + } + + [Test] + public async Task RemoveUsersFromPrivateChatGroupAsync_NoConnections_DoesNothing() + { + // Arrange + var chat = new PrivateChat + { + Id = Guid.NewGuid(), + UserAId = Guid.NewGuid(), + UserBId = Guid.NewGuid() + }; + + _connectionStoreMock + .Setup(s => s.GetConnections(It.IsAny())) + .Returns(new List()); + + // Act + await _service.RemoveUsersFromPrivateChatGroupAsync(chat); + + // Assert + _groupsMock.Verify( + g => g.RemoveFromGroupAsync(It.IsAny(), It.IsAny(), default), + Times.Never); + } +} \ No newline at end of file diff --git a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs index b8b91e6..5384488 100644 --- a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -10,6 +10,7 @@ using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Medias; using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.PushNotifications; using Govor.Application.Interfaces.UserOnlineStatus; using Govor.Application.Interfaces.UserSession; using Govor.Application.Interfaces.UserSession.Crypto; @@ -18,6 +19,8 @@ using Govor.Application.Services.Authentication; using Govor.Application.Services.Friends; using Govor.Application.Services.Medias; using Govor.Application.Services.Messages; +using Govor.Application.Services.PushNotifications; +using Govor.Application.Services.PushNotifications.Providers; using Govor.Application.Services.UserOnlineStatus; using Govor.Application.Services.UserSessions; using Govor.Application.Services.UserSessions.Crypto; @@ -33,6 +36,7 @@ using Govor.Core.Repositories.Invaites; using Govor.Core.Repositories.MediasAttachments; using Govor.Core.Repositories.Messages; using Govor.Core.Repositories.PrivateChats; +using Govor.Core.Repositories.PushTokens; using Govor.Core.Repositories.Users; using Govor.Core.Repositories.UserSessionsRepository; using Govor.Data; @@ -45,7 +49,7 @@ public static class ConfigurationProgramExtensions { public static void AddServices(this IServiceCollection services) { - services.AddScoped(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -74,10 +78,12 @@ public static class ConfigurationProgramExtensions services.AddMemoryCache(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -85,15 +91,23 @@ public static class ConfigurationProgramExtensions // UserSession services.AddScoped(); services.AddScoped(); - + services.AddScoped(); services.AddScoped(); services.AddSingleton(); // Hubs Infrastructure + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); + + // Pushs + services.AddScoped(); + services.AddSingleton(); + + // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); @@ -120,6 +134,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // other } @@ -160,6 +175,4 @@ public static class ConfigurationProgramExtensions }); } } - - } \ No newline at end of file diff --git a/Govor.API/Controllers/PrivateChatController.cs b/Govor.API/Controllers/PrivateChatController.cs index a22e22e..7efc26f 100644 --- a/Govor.API/Controllers/PrivateChatController.cs +++ b/Govor.API/Controllers/PrivateChatController.cs @@ -1,5 +1,6 @@ using Govor.Application.Interfaces; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.DTOs; using Govor.Core.Models; using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.Users; @@ -17,7 +18,8 @@ public class PrivateChatController : Controller private readonly ICurrentUserService _currentUser; private readonly IUsersRepository _usersRepository; private readonly IVerifyFriendship _verifyFriendship; - private readonly IPrivateChatsRepository _privateChats; + private readonly IUserPrivateChatsCreator _userPrivateChatsCreator; + private readonly IUserPrivateChatsGetterService _privateChatsGetter; private readonly ILogger _logger; public PrivateChatController( @@ -25,12 +27,15 @@ public class PrivateChatController : Controller IUsersRepository usersRepository, IVerifyFriendship verifyFriendship, IPrivateChatsRepository privateChats, + IUserPrivateChatsCreator userPrivateChatsCreator, + IUserPrivateChatsGetterService userPrivateChatsGetterService, ILogger logger) { _currentUser = currentUser; _usersRepository = usersRepository; _verifyFriendship = verifyFriendship; - _privateChats = privateChats; + _userPrivateChatsCreator = userPrivateChatsCreator; + _privateChatsGetter = userPrivateChatsGetterService; _logger = logger; } @@ -49,9 +54,8 @@ public class PrivateChatController : Controller await _verifyFriendship.VerifyAsync(currentId, friendId); - - var chatId = await GetPrivateChatsIdAsync(currentId, friendId); - return Ok(chatId); + var chat = await _userPrivateChatsCreator.CreateAsync(currentId, friendId); + return Ok(chat.Id); } catch (UnauthorizedAccessException ex) { @@ -74,22 +78,32 @@ public class PrivateChatController : Controller return StatusCode(500, "Unexpected Error! Please try again later."); } } - - private async Task GetPrivateChatsIdAsync(Guid userA, Guid userB) + + [HttpGet("user/private-chats")] + public async Task GetChatsByFriends() { - Guid recipientId; - // Makes new PrivateChat if not exist - if (!_privateChats.Exist(userA, userB)) + try { - recipientId = Guid.NewGuid(); - await _privateChats.AddAsync(new PrivateChat() - { Id = recipientId, UserAId = userA, UserBId = userB }); + var currentId = _currentUser.GetCurrentUserId(); + var chats = await _privateChatsGetter.GetUserChatsAsync(currentId); + + var result = chats.Select(chat => new PrivateChatDto + { + ChatId = chat.Id, + FriendId = chat.UserAId == currentId ? chat.UserBId : chat.UserAId + }).ToList(); + + return Ok(result); } - else + catch (UnauthorizedAccessException ex) { - recipientId = (await _privateChats.GetByMembersAsync(userA, userB)).Id; + _logger.LogWarning(ex.Message); + return Forbid(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "Unexpected Error! Please try again later."); } - - return recipientId; } } \ No newline at end of file diff --git a/Govor.API/Controllers/PushTokensController.cs b/Govor.API/Controllers/PushTokensController.cs new file mode 100644 index 0000000..9577d29 --- /dev/null +++ b/Govor.API/Controllers/PushTokensController.cs @@ -0,0 +1,59 @@ +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.Requests; +using Govor.Core.Repositories.PushTokens; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers; + +[ApiController] +[Authorize(Roles = "Admin, User")] +[Route("api")] +public class PushTokensController : Controller +{ + private readonly ICurrentUserService _currentUser; + private readonly ICurrentUserSessionService _currentSession; + private readonly IPushTokenRepository _pushTokenRepo; + + public PushTokensController( + ICurrentUserService currentUser, + ICurrentUserSessionService currentSession, + IPushTokenRepository pushTokenRepository) + { + _currentUser = currentUser; + _pushTokenRepo = pushTokenRepository; + _currentSession = currentSession; + } + + [HttpPost("pushes/token/register")] + public async Task RegisterToken([FromBody] RegisterPushTokenRequest req) + { + try + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + if (string.IsNullOrEmpty(req.Token) || string.IsNullOrWhiteSpace(req.Platform)) + return BadRequest(ModelState); + + var currentId = _currentUser.GetCurrentUserId(); + var currentSessionId = _currentSession.GetUserSessionId(); + + await _pushTokenRepo.AddOrUpdateTokenAsync( + userId: currentId, + sessionId: currentSessionId, + token: req.Token, + platform: req.Platform); + + return Ok(); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (Exception ex) + { + return StatusCode(500, "Unexpected Error! Please try again later."); + } + } +} \ No newline at end of file diff --git a/Govor.API/Controllers/SessionController.cs b/Govor.API/Controllers/SessionController.cs index 489579f..f68b91b 100644 --- a/Govor.API/Controllers/SessionController.cs +++ b/Govor.API/Controllers/SessionController.cs @@ -2,6 +2,7 @@ using AutoMapper; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.UserSession; using Govor.Contracts.DTOs; +using Govor.Core.Repositories.PushTokens; using Govor.Data.Repositories.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index 0f12b70..76f5525 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -10,6 +10,7 @@ + diff --git a/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs b/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs index 8e42900..ab9bba0 100644 --- a/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs +++ b/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs @@ -1,16 +1,29 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.PushNotifications; using Govor.Contracts.Responses.SignalR; using Govor.Core.Models.Messages; +using Govor.Core.Repositories.PrivateChats; +using Govor.Core.Repositories.PushTokens; using Microsoft.AspNetCore.SignalR; namespace Govor.API.Hubs.Infrastructure; public class ChatNotificationService : IChatNotificationService { -private readonly IHubContext _hubContext; - - public ChatNotificationService(IHubContext hubContext) + private readonly IHubContext _hubContext; + private readonly IPrivateChatsRepository _privateChatsRepository; + private readonly IPushNotificationService _notificationService; + private readonly IProfileService _profileService; + public ChatNotificationService( + IProfileService profileService, + IHubContext hubContext, + IPushNotificationService notificationService, + IPrivateChatsRepository privateChatsRepository) { _hubContext = hubContext; + _profileService = profileService; + _notificationService = notificationService; + _privateChatsRepository = privateChatsRepository; } public async Task NotifyMessageSentAsync(UserMessageResponse message) @@ -19,6 +32,8 @@ private readonly IHubContext _hubContext; { await _hubContext.Clients.Group(ChatHubConstants.GetPrivateChat(message.RecipientId)) .SendAsync(ChatHubConstants.ReceiveMessage, message); + + await NotifyMessageReceivedInPrivateChatAsync(message); } else { @@ -30,6 +45,19 @@ private readonly IHubContext _hubContext; // .SendAsync(ChatHubConstants.MessageSent, message); } + private async Task NotifyMessageReceivedInPrivateChatAsync(UserMessageResponse message) + { + var privateChat = await _privateChatsRepository.GetByIdAsync(message.RecipientId); + + var text = message.EncryptedContent.Substring(0, Math.Min(40, message.EncryptedContent.Length)); + var userId = message.SenderId == privateChat.UserAId ? privateChat.UserAId : privateChat.UserBId; + + var profile = await _profileService.GetUserProfileAsync(userId); + var title = profile.Username; + + await _notificationService.SendToUserAsync(userId, title,text, "chat_messages"); + } + public async Task NotifyMessageRemovedAsync(MessageRemovedResponse response) { await NotifyParticipantsAsync( diff --git a/Govor.API/Hubs/Infrastructure/ConnectionManager.cs b/Govor.API/Hubs/Infrastructure/ConnectionManager.cs index d79af1d..e70b353 100644 --- a/Govor.API/Hubs/Infrastructure/ConnectionManager.cs +++ b/Govor.API/Hubs/Infrastructure/ConnectionManager.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Govor.Application.Interfaces; using Microsoft.AspNetCore.SignalR; @@ -7,11 +8,17 @@ public class ConnectionManager : IConnectionManager { private readonly IUserGroupsGetterService _userGroupsGetterService; private readonly IUserPrivateChatsGetterService _userPrivateChatsGetterService; + private readonly IConnectionStore _connectionStore; private readonly IHubContext _hubContext; - public ConnectionManager(IUserGroupsGetterService userGroupsGetterService, IUserPrivateChatsGetterService userPrivateChatsGetterService, IHubContext hubContext) + public ConnectionManager( + IUserGroupsGetterService userGroupsGetterService, + IConnectionStore connectionStore, + IUserPrivateChatsGetterService userPrivateChatsGetterService, + IHubContext hubContext) { _userGroupsGetterService = userGroupsGetterService; + _connectionStore = connectionStore; _userPrivateChatsGetterService = userPrivateChatsGetterService; _hubContext = hubContext; } @@ -20,7 +27,8 @@ public class ConnectionManager : IConnectionManager { // user await _hubContext.Groups.AddToGroupAsync(connectionId, ChatHubConstants.GetUserGroup(userId)); - + _connectionStore.AddConnection(userId, connectionId); + // groups var userGroups = await _userGroupsGetterService.GetUserGroupsAsync(userId); foreach (var group in userGroups) @@ -41,6 +49,7 @@ public class ConnectionManager : IConnectionManager if (userId != Guid.Empty) { await _hubContext.Groups.RemoveFromGroupAsync(connectionId, ChatHubConstants.GetUserGroup(userId)); + _connectionStore.RemoveConnection(userId, connectionId); } } } \ No newline at end of file diff --git a/Govor.API/Hubs/Infrastructure/ConnectionStore.cs b/Govor.API/Hubs/Infrastructure/ConnectionStore.cs new file mode 100644 index 0000000..3e93fa3 --- /dev/null +++ b/Govor.API/Hubs/Infrastructure/ConnectionStore.cs @@ -0,0 +1,32 @@ +using System.Collections.Concurrent; +using Govor.Application.Interfaces; + +namespace Govor.API.Hubs.Infrastructure; + +public class ConnectionStore : IConnectionStore +{ + private readonly ConcurrentDictionary> _connections = new(); + + public void AddConnection(Guid userId, string connectionId) + { + var conns = _connections.GetOrAdd(userId, _ => new HashSet()); + lock (conns) conns.Add(connectionId); + } + + public void RemoveConnection(Guid userId, string connectionId) + { + if (_connections.TryGetValue(userId, out var conns)) + { + lock (conns) + { + conns.Remove(connectionId); + if (!conns.Any()) _connections.TryRemove(userId, out _); + } + } + } + + public IEnumerable GetConnections(Guid userId) + { + return _connections.TryGetValue(userId, out var conns) ? conns.ToList() : Enumerable.Empty(); + } +} \ No newline at end of file diff --git a/Govor.API/Hubs/Infrastructure/PrivateChatGroupManager.cs b/Govor.API/Hubs/Infrastructure/PrivateChatGroupManager.cs new file mode 100644 index 0000000..16714ab --- /dev/null +++ b/Govor.API/Hubs/Infrastructure/PrivateChatGroupManager.cs @@ -0,0 +1,41 @@ +using Govor.API.Hubs; +using Govor.API.Hubs.Infrastructure; +using Govor.Application.Interfaces; +using Govor.Core.Models; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Hubs.Infrastructure; + +public class PrivateChatGroupManager : IPrivateChatGroupManager +{ + private readonly IConnectionStore _connectionStore; + private readonly IHubContext _hubContext; + + public PrivateChatGroupManager(IConnectionStore connectionStore, IHubContext hubContext) + { + _connectionStore = connectionStore; + _hubContext = hubContext; + } + + public async Task AddUsersToPrivateChatGroupAsync(PrivateChat privateChat) + { + var connectionsA = _connectionStore.GetConnections(privateChat.UserAId); + var connectionsB = _connectionStore.GetConnections(privateChat.UserBId); + + foreach (var conn in connectionsA.Concat(connectionsB)) + { + await _hubContext.Groups.AddToGroupAsync(conn, ChatHubConstants.GetPrivateChat(privateChat.Id)); + } + } + + public async Task RemoveUsersFromPrivateChatGroupAsync(PrivateChat privateChat) + { + var connectionsA = _connectionStore.GetConnections(privateChat.UserAId); + var connectionsB = _connectionStore.GetConnections(privateChat.UserBId); + + foreach (var conn in connectionsA.Concat(connectionsB)) + { + await _hubContext.Groups.RemoveFromGroupAsync(conn, ChatHubConstants.GetPrivateChat(privateChat.Id)); + } + } +} \ No newline at end of file diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 4db6d4d..0b86b87 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -1,4 +1,6 @@ using System.Text; +using FirebaseAdmin; +using Google.Apis.Auth.OAuth2; using Govor.API.Common.Extensions; using Govor.API.Hubs; using Govor.Application.Services.Authentication; @@ -15,11 +17,17 @@ var services = builder.Services; builder.AddLogger();// Serilog #if DEBUG -builder.Configuration.AddJsonFile("appsettings.Development.json", optional: false, reloadOnChange: true); +builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); +//builder.Configuration.AddJsonFile("appsettings.Development.json", optional: false, reloadOnChange: true); #else builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); #endif +FirebaseApp.Create(new AppOptions() +{ + Credential = GoogleCredential.FromFile("secrets/firebase-adminsdk.json") + // или FromStream(File.OpenRead("firebase-adminsdk.json")) +}); builder.Services.AddCors(options => { diff --git a/Govor.API/appsettings.Development.json b/Govor.API/appsettings.Development.json index 61c97cc..741b675 100644 --- a/Govor.API/appsettings.Development.json +++ b/Govor.API/appsettings.Development.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "GovorDbContext": "Host=localhost;Port=5432;Database=GovorDb;Username=postgres;Password=stalcker;" + "GovorDbContext": "Host=46.19.68.182;Port=5432;Database=default_db;Username=main;Password=fP6%,WzF$S?IUI;" }, "UseMySql": false, "AllowedHosts": "*", diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index 2ff8063..741b675 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "GovorDbContext": "Host=46.19.68.182;Port=5432;Database=default_db;Username=gen_user;Password=p_mxIUCl#G6REV;" + "GovorDbContext": "Host=46.19.68.182;Port=5432;Database=default_db;Username=main;Password=fP6%,WzF$S?IUI;" }, "UseMySql": false, "AllowedHosts": "*", diff --git a/Govor.API/secrets/firebase-adminsdk.json b/Govor.API/secrets/firebase-adminsdk.json new file mode 100644 index 0000000..7020bd0 --- /dev/null +++ b/Govor.API/secrets/firebase-adminsdk.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "govor-ad008", + "private_key_id": "c86cedafc5fd26278d812d1eb04281415076b70b", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCxSZEVKcgorEeR\nIMobn6Fa2WDRZTwqm6A/yQ4gMJb673TvOSn7ukAZoV/IoZOR8fz84OdLzoEP0/Gu\nYtCvKdc/rsVdPVnLZZxxpk+cYbrfSstdke55E67tLjtlfF7XZn3OSQ2lRszff80z\n78DHWORaSGUb4UKrB8mnvPFZmM8lDzwmslnskQBB97nIDBxsBdUD2jw2n70kjfM5\nFgNX814PEztSqwC7nNkXl2MZxWSOP7ppjTsJrDfWGhFJ1faCS+sW3KzzI4XHMUyG\nd++Oxs7QK/jcD0/NwDMjMycRv/6WZgn2ItY+EUXza/6NMS+RikXwEiZjkfvte5IU\nkTk3Wb+nAgMBAAECggEAEAQN28QVBXue0YENURaILJ1jrjb/ivfZL7n5kTXihdPZ\nE/1tigSm4sndzdGu86nRSYUF0CbdKPFkLXVW1eQpQmMHCGfQ81uAGRJFseJwT9jB\na3gR+5UoIdwh2Ia2SK5FIjweQ/aT7oLspkr79uZUZsWQgMbGT44aDRG4GfiAih/i\nCye02cBUt6LwYTSyn4toX/Hsnoew60qLsyo6UiyE18eOrrFaK0Rcc8ia8jJ7j4K/\niKywX+PiK9z9dp95G/bpspfPkTh1XE1LEL8ArglnOLSJtbewmOrGK/PODo6EaVIU\nH4uv/0ThIkjDN5PvvZZkBXLdZ/mLyAPa2Xh5hvih0QKBgQDqkSSTAJNQG3UgH3cq\n8So4XuFbsWB4zCDqpBz+WitmKJUzYNhXyTZ3Sx7JbculNFESaX2lmeHTW/7vwa/Z\nZrt6npSlR6Ln3AitCyZRE46tBlaN3kBJvTolRLZvBS+Rq/bYedNyN8Ls18yoQcUj\nsGn1Jetjl3M8QQl7AmnOb2aclwKBgQDBfJJ34ba2LwsQTTwdbCeyCGOAyj59qQaq\nB4oGKb24SDJMbOGa7p17PSwvnRUmdNXmT9QKawBf3H2d3MWBpHW1024Ycg64ZD6Y\nLCpye7wLeNs3OPyFDfpi7uXKbpBA/cjU1uI1eqXC197VmU/BAk6TxlRijY3zK1kU\n9bA0pb2HcQKBgHBV1mg4TFR+8dbSeuWr3YZlmhOpnQP87n6w3dnKISKpqNqUNMfF\n6zmyViotVOvnZDQnJS9bxNTOKAd2gNri4kJVE+cbqZ7Ut6r3vf/yF9AxSt5iY/Ns\nlh4nDB+bIi7nZi5CGcuHfOikaLTj2p++6t+mq1Zkv6FJnFq2yyk8HK49AoGBAJ5N\nADyq4+TsWj3tItjjqxqCuH4feb8vsi8cWfWu4vTJxLU4g+BRh3DT3Lnb5/j9sB9t\ngos/fh/v8qpcfQ8TcebgY/wGHTsJcRjpUZU23OP57kihDCEEDa4xzLmxeb5ipJRW\ndt7QSJxAJ6VUeKbt70ICCvpS3CdueMSoOpDoZUdRAoGBALY0kemgKgLxHDNXZEjI\n3/ndIdea5aI2q2UpZHEdfLcCE1Fb/bVjtx9ZTLsxxm25L7sdqHqxivMxmEMhgxc4\nXzvUtA22i4FIF2g6M/31gWeFtHvJWczeit/FMsw271HxPflVuuf4cxaIcYkccJTd\nQhbyfbGJAhVBR02CeDXM3lOy\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-fbsvc@govor-ad008.iam.gserviceaccount.com", + "client_id": "115697205969744490044", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40govor-ad008.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/Govor.Application.Tests/Services/Authentication/JwtTokenHasherTests.cs b/Govor.Application.Tests/Services/Authentication/JwtTokenHasherTests.cs index 01fa458..fff9b5d 100644 --- a/Govor.Application.Tests/Services/Authentication/JwtTokenHasherTests.cs +++ b/Govor.Application.Tests/Services/Authentication/JwtTokenHasherTests.cs @@ -1,51 +1,103 @@ using AutoFixture; using Govor.Application.Interfaces.Authentication; using Govor.Application.Services.Authentication; - -namespace Govor.Application.Tests.Services.Authentication; +using Microsoft.Extensions.Configuration; [TestFixture] public class JwtTokenHasherTests { private IJwtTokenHasher _jwtTokenHasher; private Fixture _fixture; - + private const string TestSecret = "SuperSecretPepper123!"; + [SetUp] - public void StarUp() + public void StartUp() { _fixture = new Fixture(); - //_jwtTokenHasher = new JwtTokenHasher(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "EncryptionOption:Secret", TestSecret } + }) + .Build(); + + _jwtTokenHasher = new JwtTokenHasher(config); } - + [Test] - public void Given_Token_When_Hash_Then_Hash_And_Verify_Then_Result_Should_Be_True() + public void Given_Token_When_Hash_Then_Verify_Should_Return_True() { // Arrange string token = _fixture.Create(); - - // Act + + // Act string hash = _jwtTokenHasher.HashToken(token); - var result = _jwtTokenHasher.VerifyToken(token, hash); - - // Assert + + // Assert Assert.That(hash, Is.Not.EqualTo(token)); Assert.That(result, Is.True); } [Test] - public void Given_Token_NotToken_When_Hash_Should_Not_Be_True_Then_Result_Should_Be_False() + public void Given_Invalid_Token_When_Verify_Should_Return_False() { - // Arrange + // Arrange string token = _fixture.Create(); string notToken = _fixture.Create(); - - // Act + + // Act string hash = _jwtTokenHasher.HashToken(token); - var result = _jwtTokenHasher.VerifyToken(notToken, hash); - - // Assert + + // Assert Assert.That(result, Is.False); } + + [Test] + public void Given_Same_Token_When_Hash_Multiple_Times_Should_Be_Equal() + { + // Arrange + string token = _fixture.Create(); + + // Act + string hash1 = _jwtTokenHasher.HashToken(token); + string hash2 = _jwtTokenHasher.HashToken(token); + + // Assert + Assert.That(hash1, Is.EqualTo(hash2)); + } + + [Test] + public void Given_Different_Tokens_When_Hash_Should_Be_Different() + { + // Arrange + string token1 = _fixture.Create(); + string token2 = _fixture.Create(); + + // Act + string hash1 = _jwtTokenHasher.HashToken(token1); + string hash2 = _jwtTokenHasher.HashToken(token2); + + // Assert + Assert.That(hash1, Is.Not.EqualTo(hash2)); + } + + [Test] + public void Given_Null_Secret_In_Config_Should_Use_Default_Secret() + { + // Arrange + var emptyConfig = new ConfigurationBuilder().Build(); + var hasherWithDefaultSecret = new JwtTokenHasher(emptyConfig); + + string token = _fixture.Create(); + + // Act + string hash = hasherWithDefaultSecret.HashToken(token); + var result = hasherWithDefaultSecret.VerifyToken(token, hash); + + // Assert + Assert.That(result, Is.True); + } } \ No newline at end of file diff --git a/Govor.Application.Tests/Services/Friends/FriendRequestCommandServiceTests.cs b/Govor.Application.Tests/Services/Friends/FriendRequestCommandServiceTests.cs index 2cfd781..9d7a389 100644 --- a/Govor.Application.Tests/Services/Friends/FriendRequestCommandServiceTests.cs +++ b/Govor.Application.Tests/Services/Friends/FriendRequestCommandServiceTests.cs @@ -1,5 +1,6 @@ using AutoFixture; using Govor.Application.Exceptions.FriendsService; +using Govor.Application.Interfaces; using Govor.Application.Interfaces.Friends; using Govor.Application.Services.Friends; using Govor.Core.Models; @@ -14,8 +15,8 @@ namespace Govor.Application.Tests.Services.Friends; public class FriendRequestCommandServiceTests { private Fixture _fixture; - private Mock _usersRepositoryMock; private Mock _friendshipsRepositoryMock; + private Mock _privateChatsCreatorMock; private IFriendRequestCommandService _service; [SetUp] @@ -27,11 +28,11 @@ public class FriendRequestCommandServiceTests .ToList() .ForEach(b => _fixture.Behaviors.Remove(b)); _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); - - _usersRepositoryMock = new Mock(); + _friendshipsRepositoryMock = new Mock(); - - _service = new FriendRequestCommandService(_friendshipsRepositoryMock.Object); + _privateChatsCreatorMock = new Mock(); + + _service = new FriendRequestCommandService(_friendshipsRepositoryMock.Object, _privateChatsCreatorMock.Object); } // SendFriendRequestAsync @@ -55,9 +56,17 @@ public class FriendRequestCommandServiceTests var fromUserId = Guid.NewGuid(); var toUserId = Guid.NewGuid(); + var existingFriendship = new Friendship + { + Id = Guid.NewGuid(), + RequesterId = fromUserId, + AddresseeId = toUserId, + Status = FriendshipStatus.Pending + }; + _friendshipsRepositoryMock - .Setup(repo => repo.Exist(fromUserId, toUserId)) - .Returns(true); + .Setup(repo => repo.GetFriendshipAsync(fromUserId, toUserId)) + .ReturnsAsync(existingFriendship); // Act & Assert Assert.ThrowsAsync(() => @@ -91,17 +100,30 @@ public class FriendRequestCommandServiceTests // AcceptFriendRequestAsync [Test] - public async Task AcceptFriendRequestAsync_ValidRequest_CallsUpdateAsync() + public async Task AcceptFriendRequestAsync_ValidRequest_CallsUpdateAsync_AndCreatesPrivateChat() { + // Arrange var friendship = _fixture.Build() .With(f => f.Status, FriendshipStatus.Pending) .Create(); + + var privateChat = new PrivateChat + { + Id = Guid.NewGuid(), + UserAId = friendship.AddresseeId, + UserBId = friendship.RequesterId + }; + _friendshipsRepositoryMock .Setup(r => r.GetByIdAsync(friendship.Id)) .ReturnsAsync(friendship); - // Act: вызываем именно Accept, не Send + _privateChatsCreatorMock + .Setup(p => p.CreateAsync(friendship.AddresseeId, friendship.RequesterId)) + .ReturnsAsync(privateChat); + + // Act await _service.AcceptAsync(friendship.Id, friendship.AddresseeId); // Assert @@ -111,6 +133,11 @@ public class FriendRequestCommandServiceTests f.AddresseeId == friendship.AddresseeId && f.Status == FriendshipStatus.Accepted )), Times.Once); + + _privateChatsCreatorMock.Verify(p => p.CreateAsync( + friendship.AddresseeId, + friendship.RequesterId + ), Times.Once); } [Test] diff --git a/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs b/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs index e517287..40a4327 100644 --- a/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs +++ b/Govor.Application.Tests/Services/Messages/MessageCommandServiceTests.cs @@ -21,9 +21,10 @@ public class MessageCommandServiceTests private Mock _mockMessagesRepo; private Mock _mockUsersRepo; private Mock _mockGroupsRepo; + private Mock _mockPrivateChatsRepo; private Mock _mockVerifyFriendship; - private Mock _mockPrivateChats; - private Mock _mockMediaService; + private Mock _mockMediaService; + private Mock _mockUserPrivateChatCreator; private Mock> _mockLogger; private MessageCommandService _messageService; @@ -33,17 +34,19 @@ public class MessageCommandServiceTests _mockMessagesRepo = new Mock(); _mockUsersRepo = new Mock(); _mockGroupsRepo = new Mock(); + _mockPrivateChatsRepo = new Mock(); _mockVerifyFriendship = new Mock(); - _mockPrivateChats = new Mock(); _mockMediaService = new Mock(); + _mockUserPrivateChatCreator = new Mock(); _mockLogger = new Mock>(); _messageService = new MessageCommandService( _mockMessagesRepo.Object, _mockUsersRepo.Object, _mockGroupsRepo.Object, + _mockPrivateChatsRepo.Object, + _mockUserPrivateChatCreator.Object, _mockVerifyFriendship.Object, - _mockPrivateChats.Object, _mockMediaService.Object, _mockLogger.Object); } @@ -56,7 +59,16 @@ public class MessageCommandServiceTests // Arrange var senderId = Guid.NewGuid(); var recipientId = Guid.NewGuid(); - var sendMessageParams = new SendMessage("Hello", + + var privateChat = new PrivateChat + { + Id = recipientId, + UserAId = senderId, + UserBId = Guid.NewGuid() + }; + + var sendParams = new SendMessage( + "Hello", null, recipientId, RecipientType.User, @@ -64,26 +76,28 @@ public class MessageCommandServiceTests DateTime.UtcNow, new List()); - _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(true); - _mockVerifyFriendship.Setup(v => v.VerifyAsync(senderId, recipientId)).Returns(Task.CompletedTask); - _mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); - _mockPrivateChats.Setup(c => c.Exist(senderId, recipientId)).Returns(true); - _mockPrivateChats.Setup(c => c.GetByMembersAsync(senderId, recipientId)).ReturnsAsync(new PrivateChat(){Id = recipientId}); - + _mockPrivateChatsRepo + .Setup(r => r.GetByIdAsync(recipientId)) + .ReturnsAsync(privateChat); + + _mockMessagesRepo + .Setup(r => r.AddAsync(It.IsAny())) + .Returns(Task.CompletedTask); + // Act - var result = await _messageService.SendMessageAsync(sendMessageParams); - // Assert - Assert.That(result, Is.Not.Null); + var result = await _messageService.SendMessageAsync(sendParams); + + // Assert Assert.That(result.IsSuccess, Is.True); Assert.That(result.Exception, Is.Null); - - _mockMessagesRepo.Verify(r => r.AddAsync(It.Is(m => - m.SenderId == senderId && - m.RecipientId == recipientId && + + _mockMessagesRepo.Verify(r => r.AddAsync(It.Is(m => + m.SenderId == senderId && + m.RecipientId == recipientId && m.RecipientType == RecipientType.User && m.EncryptedContent == "Hello")), Times.Once); } - + [Test] public async Task SendMessageAsync_ToUser_When_AttachMediaThrowsException_ReturnsFailure() { @@ -92,44 +106,43 @@ public class MessageCommandServiceTests var recipientId = Guid.NewGuid(); var sendMediaId = Guid.NewGuid(); - var sendMessageParams = new SendMessage( + var privateChat = new PrivateChat + { + Id = recipientId, + UserAId = senderId, + UserBId = recipientId + }; + + var sendParams = new SendMessage( "Hello", null, recipientId, RecipientType.User, senderId, DateTime.UtcNow, - new List { new SendMedia(sendMediaId, string.Empty) }); + new List + { + new SendMedia(sendMediaId, string.Empty) + }); - _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(true); - _mockVerifyFriendship.Setup(v => v.VerifyAsync(senderId, recipientId)).Returns(Task.CompletedTask); - _mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); - _mockPrivateChats.Setup(c => c.Exist(senderId, recipientId)).Returns(true); - _mockPrivateChats.Setup(c => c.GetByMembersAsync(senderId, recipientId)) - .ReturnsAsync(new PrivateChat { Id = recipientId }); + _mockPrivateChatsRepo + .Setup(r => r.GetByIdAsync(recipientId)) + .ReturnsAsync(privateChat); - // ! _mockMediaService .Setup(m => m.AttachToMessageAsync(sendMediaId, It.IsAny())) .ThrowsAsync(new Exception("Unexpected DB error")); // Act - var result = await _messageService.SendMessageAsync(sendMessageParams); + var result = await _messageService.SendMessageAsync(sendParams); // Assert - Assert.That(result, Is.Not.Null); Assert.That(result.IsSuccess, Is.False); - Assert.That(result.Exception, Is.Not.Null); - Assert.That(result.Exception.Message, Is.EqualTo("Unexpected DB error")); + Assert.That(result.Exception!.Message, Is.EqualTo("Unexpected DB error")); - // Проверяем, что сообщение не было сохранено _mockMessagesRepo.Verify(r => r.AddAsync(It.IsAny()), Times.Never); - - // Проверяем, что метод прикрепления медиа вызывался - _mockMediaService.Verify(m => m.AttachToMessageAsync(sendMediaId, It.IsAny()), Times.Once); } - [Test] public async Task SendMessageAsync_ToGroup_Success() { @@ -162,13 +175,14 @@ public class MessageCommandServiceTests } [Test] - public async Task SendMessageAsync_ToUser_RecipientNotFound_ReturnsFailure() + public async Task SendMessageAsync_ToUser_PrivateChatNotFound_ReturnsFailure() { // Arrange var senderId = Guid.NewGuid(); var recipientId = Guid.NewGuid(); - - var sendMessageParams = new SendMessage("Hello", + + var sendParams = new SendMessage( + "Hello", null, recipientId, RecipientType.User, @@ -176,50 +190,18 @@ public class MessageCommandServiceTests DateTime.UtcNow, new List()); - _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(false); + _mockPrivateChatsRepo + .Setup(r => r.GetByIdAsync(recipientId)) + .ReturnsAsync((PrivateChat?)null); // Act - var result = await _messageService.SendMessageAsync(sendMessageParams); + var result = await _messageService.SendMessageAsync(sendParams); // Assert - Assert.That(result, Is.Not.Null); Assert.That(result.IsSuccess, Is.False); - Assert.That(result.Exception, Is.Not.Null); - Assert.That(result.Exception,Is.TypeOf()); + Assert.That(result.Exception, Is.TypeOf()); Assert.That(result.Message, Is.Null); } - - [Test] - public async Task SendMessageAsync_ToUser_FriendshipVerificationFails_ReturnsFailure() - { - // Arrange - var senderId = Guid.NewGuid(); - var recipientId = Guid.NewGuid(); - - var sendMessageParams = new SendMessage("Hello", - null, - recipientId, - RecipientType.User, - senderId, - DateTime.UtcNow, - new List() - ); - - _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(true); - _mockVerifyFriendship.Setup(v => v.VerifyAsync(senderId, recipientId)).ThrowsAsync(new FriendshipException("Not friends")); - - // Act - var result = await _messageService.SendMessageAsync(sendMessageParams); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.IsSuccess, Is.False); - Assert.That(result.Exception, Is.Not.Null); - Assert.That(result.Exception,Is.TypeOf()); - Assert.That(result.Message, Is.Null); - } - - // Test for EditMessageAsync action [Test] diff --git a/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs b/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs index 4bdf983..0cfa9d9 100644 --- a/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs +++ b/Govor.Application.Tests/Services/Messages/MessagesLoaderTests.cs @@ -43,36 +43,51 @@ public class MessagesLoaderTests // Arrange var chatId = Guid.NewGuid(); - _privateChatsRepoMock.Setup(r => r.Exist(_currentUserId, _otherUserId)).Returns(true); - _privateChatsRepoMock.Setup(r => r.GetByMembersAsync(_currentUserId, _otherUserId)) - .ReturnsAsync(new PrivateChat() { Id = chatId }); + _privateChatsRepoMock + .Setup(r => r.Exist(chatId)) + .Returns(true); var messages = new List { - new() { Id = Guid.NewGuid(), RecipientId = chatId, RecipientType = RecipientType.User } + new() + { + Id = Guid.NewGuid(), + RecipientId = chatId, + RecipientType = RecipientType.User, + SentAt = DateTime.UtcNow + } }; await _dbContext.Messages.AddRangeAsync(messages); await _dbContext.SaveChangesAsync(); // Act - var result = await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null); + var result = await _loader.LoadMessagesInUserChat( + chatId, + _currentUserId, + null); // Assert Assert.That(result, Has.Count.EqualTo(1)); } - + [Test] public async Task LoadLastMessagesInUserChat_ReturnsEmpty_WhenNoMessages() { - // Arrange - _privateChatsRepoMock.Setup(r => r.Exist(_currentUserId, _otherUserId)).Returns(true); - _privateChatsRepoMock.Setup(r => r.GetByMembersAsync(_currentUserId, _otherUserId)) - .ReturnsAsync(new PrivateChat { Id = Guid.NewGuid() }); - // Act - var result = await _loader.LoadMessagesInUserChat(_currentUserId, _otherUserId, null); + // Arrange + var chatId = Guid.NewGuid(); - // Assert + _privateChatsRepoMock + .Setup(r => r.Exist(chatId)) + .Returns(true); + + // Act + var result = await _loader.LoadMessagesInUserChat( + chatId, + _currentUserId, + null); + + // Assert Assert.That(result, Is.Empty); } @@ -83,7 +98,7 @@ public class MessagesLoaderTests var ex = Assert.ThrowsAsync(async () => await _loader.LoadMessagesInUserChat(Guid.Empty, _currentUserId, null)); - Assert.That(ex.Message, Does.Contain("User id cannot be empty")); + Assert.That(ex.Message, Does.Contain("PrivateChatId id cannot be empty")); } [Test] diff --git a/Govor.Application.Tests/Services/PushNotifications/PushNotificationServiceTests.cs b/Govor.Application.Tests/Services/PushNotifications/PushNotificationServiceTests.cs new file mode 100644 index 0000000..dcf2f1a --- /dev/null +++ b/Govor.Application.Tests/Services/PushNotifications/PushNotificationServiceTests.cs @@ -0,0 +1,183 @@ +using Govor.Application.Interfaces.PushNotifications; +using Govor.Application.Interfaces.PushNotifications.Models; +using Govor.Application.Services.PushNotifications; +using Govor.Core.Repositories.PushTokens; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.Application.Tests.Services.PushNotifications; + +[TestFixture] +public class PushNotificationServiceTests +{ + private Mock _providerMock; + private Mock _tokenRepoMock; + private Mock> _loggerMock; + + private PushNotificationService _service; + + [SetUp] + public void Setup() + { + _providerMock = new Mock(MockBehavior.Strict); + _tokenRepoMock = new Mock(MockBehavior.Strict); + _loggerMock = new Mock>(); + + _service = new PushNotificationService( + _providerMock.Object, + _tokenRepoMock.Object, + _loggerMock.Object); + } + + #region SendToUserAsync + + [Test] + public async Task SendToUserAsync_NoTokens_DoesNotCallProvider() + { + // Arrange + var userId = Guid.NewGuid(); + + _tokenRepoMock + .Setup(r => r.GetActiveTokensAsync(userId)) + .ReturnsAsync(new List()); + + // Act + await _service.SendToUserAsync(userId, "title", "body", "chat"); + + // Assert + _providerMock.Verify( + p => p.SendMulticastAsync(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task SendToUserAsync_SuccessfulSend_CallsProvider() + { + // Arrange + var userId = Guid.NewGuid(); + var tokens = new List { "t1", "t2" }; + + _tokenRepoMock + .Setup(r => r.GetActiveTokensAsync(userId)) + .ReturnsAsync(tokens); + + _providerMock + .Setup(p => p.SendMulticastAsync(tokens, It.IsAny())) + .ReturnsAsync(new SendPushResult(0, 0, [])); + + // Act + await _service.SendToUserAsync(userId, "title", "body", "chat"); + + // Assert + _providerMock.Verify( + p => p.SendMulticastAsync(tokens, It.IsAny()), + Times.Once); + + _tokenRepoMock.Verify( + r => r.RemoveTokensAsync(It.IsAny>()), + Times.Never); + } + + [Test] + public async Task SendToUserAsync_WithFailures_RemovesInvalidTokens() + { + // Arrange + var userId = Guid.NewGuid(); + var tokens = new List { "t1", "t2" }; + var failed = new List { "t2" }; + + _tokenRepoMock + .Setup(r => r.GetActiveTokensAsync(userId)) + .ReturnsAsync(tokens); + + _providerMock + .Setup(p => p.SendMulticastAsync(tokens, It.IsAny())) + .ReturnsAsync(new SendPushResult(0, 1, failed)); + + _tokenRepoMock + .Setup(r => r.RemoveTokensAsync(failed)) + .Returns(Task.CompletedTask); + + // Act + await _service.SendToUserAsync(userId, "title", "body", "chat"); + + // Assert + _tokenRepoMock.Verify(r => r.RemoveTokensAsync(failed), Times.Once); + } + + #endregion + + #region SendToUsersAsync + + [Test] + public async Task SendToUsersAsync_NoTokens_DoesNotCallProvider() + { + // Arrange + var users = new[] { Guid.NewGuid() }; + + _tokenRepoMock + .Setup(r => r.GetActiveTokensUsersAsync(users)) + .ReturnsAsync(new List()); + + // Act + await _service.SendToUsersAsync(users, "title", "body", "chat"); + + // Assert + _providerMock.Verify( + p => p.SendMulticastAsync(It.IsAny>(), It.IsAny()), + Times.Never); + } + + #endregion + + #region SendToSessionAsync + + [Test] + public async Task SendToSessionAsync_NoToken_DoesNothing() + { + // Arrange + var sessionId = Guid.NewGuid(); + + _tokenRepoMock + .Setup(r => r.GetActiveTokenBySessionAsync(sessionId)) + .ReturnsAsync(""); + + // Act + await _service.SendToSessionAsync(sessionId, "title", "body", "chat"); + + // Assert + _providerMock.Verify( + p => p.SendToTokenAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task SendToSessionAsync_WithFailure_RemovesToken() + { + // Arrange + var sessionId = Guid.NewGuid(); + var token = "t1"; + + _tokenRepoMock + .Setup(r => r.GetActiveTokenBySessionAsync(sessionId)) + .ReturnsAsync(token); + + _providerMock + .Setup(p => p.SendToTokenAsync(token, It.IsAny())) + .ReturnsAsync(new SendPushResult(0, 1, [token])); + + _tokenRepoMock + .Setup(r => r.RemoveTokensAsync(It.IsAny>())) + .Returns(Task.CompletedTask); + + // Act + await _service.SendToSessionAsync(sessionId, "title", "body", "chat"); + + // Assert + _tokenRepoMock.Verify( + r => r.RemoveTokensAsync(It.Is>(x => x.Contains(token))), + Times.Once); + } + + #endregion +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserPrivateChatsCreatorTests.cs b/Govor.Application.Tests/Services/UserPrivateChatsCreatorTests.cs new file mode 100644 index 0000000..147b9ed --- /dev/null +++ b/Govor.Application.Tests/Services/UserPrivateChatsCreatorTests.cs @@ -0,0 +1,108 @@ +using AutoFixture; +using Govor.Application.Interfaces; +using Govor.Application.Services; +using Govor.Core.Models; +using Govor.Core.Repositories.PrivateChats; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.Application.Tests.Services; + +[TestFixture] +public class UserPrivateChatsCreatorTests +{ + private Fixture _fixture; + private Mock> _mockLogger; + private Mock _mockPrivateChats; + private Mock _mockPrivateChatGroupManager; + private IUserPrivateChatsCreator _service; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _mockLogger = new Mock>(); + _mockPrivateChats = new Mock(); + _mockPrivateChatGroupManager = new Mock(); + + _service = new UserPrivateChatsCreator( + _mockPrivateChats.Object, + _mockPrivateChatGroupManager.Object, + _mockLogger.Object); + } + + [Test] + public async Task CreateAsync_ShouldCreateNewChat_WhenChatDoesNotExist() + { + // Arrange + var userA = Guid.NewGuid(); + var userB = Guid.NewGuid(); + + _mockPrivateChats + .Setup(x => x.Exist(userA, userB)) + .Returns(false); + + PrivateChat? addedChat = null; + + _mockPrivateChats + .Setup(x => x.AddAsync(It.IsAny())) + .Callback(chat => addedChat = chat) + .Returns(Task.CompletedTask); + + // Act + var result = await _service.CreateAsync(userA, userB); + + // Assert + _mockPrivateChats.Verify(x => x.AddAsync(It.IsAny()), Times.Once); + _mockPrivateChatGroupManager.Verify(x => x.AddUsersToPrivateChatGroupAsync(It.IsAny()), Times.Once); + + Assert.That(result, Is.Not.Null); + Assert.That(result.UserAId, Is.EqualTo(userA)); + Assert.That(result.UserBId, Is.EqualTo(userB)); + Assert.That(result.Id, Is.Not.EqualTo(Guid.Empty)); + + Assert.That(addedChat, Is.Not.Null); + Assert.That(addedChat!.Id, Is.EqualTo(result.Id)); + } + + [Test] + public async Task CreateAsync_ShouldReturnExistingChat_WhenChatAlreadyExists() + { + // Arrange + var userA = Guid.NewGuid(); + var userB = Guid.NewGuid(); + + var existingChat = _fixture.Build() + .With(x => x.UserAId, userA) + .With(x => x.UserBId, userB) + .Create(); + + _mockPrivateChats + .Setup(x => x.Exist(userA, userB)) + .Returns(true); + + _mockPrivateChats + .Setup(x => x.GetByMembersAsync(userA, userB)) + .ReturnsAsync(existingChat); + + // Act + var result = await _service.CreateAsync(userA, userB); + + // Assert + _mockPrivateChats.Verify(x => x.AddAsync(It.IsAny()), Times.Never); + + _mockPrivateChatGroupManager.Verify( + x => x.AddUsersToPrivateChatGroupAsync(It.IsAny()), + Times.Never); + + _mockPrivateChats.Verify(x => x.GetByMembersAsync(userA, userB), Times.Once); + + Assert.That(result, Is.EqualTo(existingChat)); + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs index cd40008..289a82e 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionRevokerTests.cs @@ -1,6 +1,7 @@ using Govor.Application.Services.UserSessions; using Govor.Core.Models; using Govor.Core.Models.Users; +using Govor.Core.Repositories.PushTokens; using Govor.Core.Repositories.UserSessionsRepository; using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; @@ -14,6 +15,7 @@ public class UserSessionRevokerTests { private Mock _sessionsRepositoryMock; private Mock> _loggerMock; + private Mock _pushTokenRepositoryMock; private UserSessionRevoker _revoker; [SetUp] @@ -21,7 +23,8 @@ public class UserSessionRevokerTests { _sessionsRepositoryMock = new Mock(); _loggerMock = new Mock>(); - _revoker = new UserSessionRevoker(_sessionsRepositoryMock.Object, _loggerMock.Object); + _pushTokenRepositoryMock = new Mock(); + _revoker = new UserSessionRevoker(_sessionsRepositoryMock.Object, _pushTokenRepositoryMock.Object, _loggerMock.Object); } [Test] @@ -39,6 +42,7 @@ public class UserSessionRevokerTests // Assert Assert.That(session.IsRevoked, Is.True); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(session), Times.Once()); + _pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessionId), Times.Once()); _loggerMock.VerifyNoOtherCalls(); } @@ -56,13 +60,16 @@ public class UserSessionRevokerTests var ex = Assert.ThrowsAsync(() => _revoker.CloseSessionByIdAsync(sessionId, differentUserId)); Assert.That(ex.Message, Contains.Substring($"User {differentUserId} does not belong to this session {sessionId}")); + _loggerMock.Verify(l => l.Log( LogLevel.Warning, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>()), Times.Once()); + _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never()); + _pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessionId), Times.Never()); } [Test] @@ -79,6 +86,7 @@ public class UserSessionRevokerTests // Assert _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never()); + _pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessionId), Times.Never()); _loggerMock.VerifyNoOtherCalls(); } @@ -102,6 +110,7 @@ public class UserSessionRevokerTests It.IsAny(), It.IsAny>()), Times.Once()); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never()); + _pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessionId), Times.Never()); } [Test] @@ -126,7 +135,11 @@ public class UserSessionRevokerTests Assert.That(sessions[2].IsRevoked, Is.True); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(sessions[0]), Times.Once()); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(sessions[2]), Times.Once()); + _pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessions[0].Id), Times.Once()); + _pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessions[2].Id), Times.Once()); + _sessionsRepositoryMock.Verify(r => r.UpdateAsync(sessions[1]), Times.Never()); + _pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(sessions[1].Id), Times.Never); _loggerMock.VerifyNoOtherCalls(); } @@ -148,6 +161,8 @@ public class UserSessionRevokerTests It.IsAny(), It.IsAny(), It.IsAny>()), Times.Once()); + + _pushTokenRepositoryMock.Verify(r => r.DeactivateTokenBySessionAsync(It.IsAny()), Times.Never); _sessionsRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never()); } } \ No newline at end of file diff --git a/Govor.Application/Govor.Application.csproj b/Govor.Application/Govor.Application.csproj index 703ef94..4f3953a 100644 --- a/Govor.Application/Govor.Application.csproj +++ b/Govor.Application/Govor.Application.csproj @@ -13,6 +13,7 @@ + diff --git a/Govor.Application/Interfaces/IConnectionStore.cs b/Govor.Application/Interfaces/IConnectionStore.cs new file mode 100644 index 0000000..3548722 --- /dev/null +++ b/Govor.Application/Interfaces/IConnectionStore.cs @@ -0,0 +1,8 @@ +namespace Govor.Application.Interfaces; + +public interface IConnectionStore +{ + void AddConnection(Guid userId, string connectionId); + void RemoveConnection(Guid userId, string connectionId); + IEnumerable GetConnections(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/IPrivateChatGroupManager.cs b/Govor.Application/Interfaces/IPrivateChatGroupManager.cs new file mode 100644 index 0000000..5d09ed7 --- /dev/null +++ b/Govor.Application/Interfaces/IPrivateChatGroupManager.cs @@ -0,0 +1,9 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces; + +public interface IPrivateChatGroupManager +{ + Task AddUsersToPrivateChatGroupAsync(PrivateChat privateChat); + Task RemoveUsersFromPrivateChatGroupAsync(PrivateChat privateChat); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/IUserPrivateChatsCreator.cs b/Govor.Application/Interfaces/IUserPrivateChatsCreator.cs new file mode 100644 index 0000000..674c705 --- /dev/null +++ b/Govor.Application/Interfaces/IUserPrivateChatsCreator.cs @@ -0,0 +1,8 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces; + +public interface IUserPrivateChatsCreator +{ + Task CreateAsync(Guid userIdA, Guid userIdB); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/PushNotifications/IPushNotificationProvider.cs b/Govor.Application/Interfaces/PushNotifications/IPushNotificationProvider.cs new file mode 100644 index 0000000..96dd7fd --- /dev/null +++ b/Govor.Application/Interfaces/PushNotifications/IPushNotificationProvider.cs @@ -0,0 +1,14 @@ +using Govor.Application.Interfaces.PushNotifications.Models; + +namespace Govor.Application.Interfaces.PushNotifications; + +public interface IPushNotificationProvider +{ + string Name { get; } + + Task SendToTokenAsync(string token, PushMessage message); + + Task SendMulticastAsync( + IReadOnlyList tokens, + PushMessage message); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/PushNotifications/IPushNotificationService.cs b/Govor.Application/Interfaces/PushNotifications/IPushNotificationService.cs new file mode 100644 index 0000000..8a7c9b5 --- /dev/null +++ b/Govor.Application/Interfaces/PushNotifications/IPushNotificationService.cs @@ -0,0 +1,10 @@ +namespace Govor.Application.Interfaces.PushNotifications; + +public interface IPushNotificationService +{ + Task SendToUserAsync(Guid userId, string title, string body, string channelId, Dictionary? data = null); + + Task SendToUsersAsync(IEnumerable userIds, string title, string body, string channelId, Dictionary? data = null); + + Task SendToSessionAsync(Guid sessionId, string title, string body, string channelId, Dictionary? data = null); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/PushNotifications/Models/PushMessage.cs b/Govor.Application/Interfaces/PushNotifications/Models/PushMessage.cs new file mode 100644 index 0000000..315bc51 --- /dev/null +++ b/Govor.Application/Interfaces/PushNotifications/Models/PushMessage.cs @@ -0,0 +1,9 @@ +namespace Govor.Application.Interfaces.PushNotifications.Models; + +public class PushMessage +{ + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public Dictionary Data { get; set; } = new(); + public string? ChannelId { get; set; } = "chat_messages"; //for Android +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/PushNotifications/Models/SendPushResult.cs b/Govor.Application/Interfaces/PushNotifications/Models/SendPushResult.cs new file mode 100644 index 0000000..2ab347e --- /dev/null +++ b/Govor.Application/Interfaces/PushNotifications/Models/SendPushResult.cs @@ -0,0 +1,3 @@ +namespace Govor.Application.Interfaces.PushNotifications.Models; + +public record SendPushResult(int SuccessCount, int FailureCount, IEnumerable FailedTokens); \ No newline at end of file diff --git a/Govor.Application/Services/Friends/FriendRequestCommandService.cs b/Govor.Application/Services/Friends/FriendRequestCommandService.cs index ad29313..a6b16f0 100644 --- a/Govor.Application/Services/Friends/FriendRequestCommandService.cs +++ b/Govor.Application/Services/Friends/FriendRequestCommandService.cs @@ -1,4 +1,5 @@ using Govor.Application.Exceptions.FriendsService; +using Govor.Application.Interfaces; using Govor.Application.Interfaces.Friends; using Govor.Core.Models; using Govor.Core.Repositories.Friendships; @@ -9,10 +10,13 @@ namespace Govor.Application.Services.Friends; public class FriendRequestCommandService : IFriendRequestCommandService { private readonly IFriendshipsRepository _friendshipsRepository; - - public FriendRequestCommandService(IFriendshipsRepository friendshipsRepository) + private readonly IUserPrivateChatsCreator _privateChatsCreator; + public FriendRequestCommandService( + IFriendshipsRepository friendshipsRepository, + IUserPrivateChatsCreator privateChatsCreator) { _friendshipsRepository = friendshipsRepository; + _privateChatsCreator = privateChatsCreator; } public async Task SendAsync(Guid fromUserId, Guid toUserId) @@ -56,7 +60,8 @@ public class FriendRequestCommandService : IFriendRequestCommandService try { var friendship = await _friendshipsRepository.GetByIdAsync(requestId); - + + if (friendship.AddresseeId != currentUserId) throw new UnauthorizedAccessException("You cannot accept this request"); @@ -66,6 +71,8 @@ public class FriendRequestCommandService : IFriendRequestCommandService friendship.Status = FriendshipStatus.Accepted; await _friendshipsRepository.UpdateAsync(friendship); + await _privateChatsCreator.CreateAsync(friendship.AddresseeId, friendship.RequesterId); + return friendship; } catch (NotFoundByKeyException ex) diff --git a/Govor.Application/Services/Messages/MessageCommandService.cs b/Govor.Application/Services/Messages/MessageCommandService.cs index 1f0a4e6..884c03e 100644 --- a/Govor.Application/Services/Messages/MessageCommandService.cs +++ b/Govor.Application/Services/Messages/MessageCommandService.cs @@ -16,9 +16,10 @@ namespace Govor.Application.Services.Messages; public class MessageCommandService : IMessageCommandService { private readonly IMessagesRepository _messagesRepository; - private readonly IUsersRepository _usersRepository; - private readonly IGroupsRepository _groupsRepository; - private readonly IPrivateChatsRepository _privateChats; + private readonly IPrivateChatsRepository _privateChatsRepository; + private readonly IUsersRepository _usersRepository; + private readonly IGroupsRepository _groupsRepository; + private readonly IUserPrivateChatsCreator _privateChatsCreator; private readonly IVerifyFriendship _verifyFriendship; private readonly IMediaService _mediaService; private readonly ILogger _logger; @@ -27,77 +28,39 @@ public class MessageCommandService : IMessageCommandService IMessagesRepository messagesRepository, IUsersRepository usersRepository, IGroupsRepository groupsRepository, + IPrivateChatsRepository privateChatsRepository, + IUserPrivateChatsCreator privateChatsCreator, IVerifyFriendship verifyFriendship, - IPrivateChatsRepository privateChats, IMediaService mediaService, ILogger logger) { _messagesRepository = messagesRepository; _usersRepository = usersRepository; _groupsRepository = groupsRepository; + _privateChatsRepository = privateChatsRepository; + _privateChatsCreator = privateChatsCreator; _verifyFriendship = verifyFriendship; - _privateChats = privateChats; _mediaService = mediaService; _logger = logger; } - + public async Task SendMessageAsync(SendMessage sendParams) { - Guid recipientId; try { - // Validate recipient - if (sendParams.RecipientType == RecipientType.User) + var recipientId = sendParams.RecipientType switch { - if (!await _usersRepository.ExistsByIdAsync(sendParams.RecipientId)) - { - if (!_privateChats.Exist(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); - } - else - { - recipientId = sendParams.RecipientId; - } - } - else - { - // Verify friendship for private messages - await _verifyFriendship.VerifyAsync(sendParams.FromUserId, sendParams.RecipientId); - recipientId = await GetPrivateChatsIdAsync(sendParams.FromUserId, sendParams.RecipientId); - } - } - else if (sendParams.RecipientType == RecipientType.Group) - { - if (!_groupsRepository.Exist(sendParams.RecipientId)) - { - _logger.LogWarning("Attempt to send message to non-existent group {GroupId}", sendParams.RecipientId); - return new SendMessageResult(false, new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found."), default); - } - // TODO: Optionally, verify if sender is a member of the group - bool isMember = await _groupsRepository.IsUserMemberOfGroupAsync(sendParams.FromUserId, sendParams.RecipientId); - if (!isMember) - { - _logger.LogWarning("User {UserId} attempted to send message to group {GroupId} but is not a member", sendParams.FromUserId, sendParams.RecipientId); - return new SendMessageResult(false, new UnauthorizedAccessException("Sender is not a member of the group."), default); - } - - recipientId = sendParams.RecipientId; - } - else - { - _logger.LogError("Invalid recipient type specified: {RecipientType}", sendParams.RecipientType); - return new SendMessageResult(false, new ArgumentException("Invalid recipient type."), default); - } - - + RecipientType.User => await HandleUserRecipientAsync(sendParams), + RecipientType.Group => await HandleGroupRecipientAsync(sendParams), + _ => throw new ArgumentException("Invalid recipient type.") + }; + var messageId = Guid.NewGuid(); var message = new Message { Id = messageId, SenderId = sendParams.FromUserId, - RecipientId = recipientId, + RecipientId = recipientId, RecipientType = sendParams.RecipientType, EncryptedContent = sendParams.EncryptContent, SentAt = sendParams.SendAt, @@ -111,44 +74,61 @@ public class MessageCommandService : IMessageCommandService }).ToList() ?? new List() }; - // If there is media, we link them. - if (sendParams.Media != null && sendParams.Media.Any()) - { + // Attach media + if (sendParams.Media?.Any() == true) foreach (var media in sendParams.Media) - { await _mediaService.AttachToMessageAsync(media.MediaId, messageId); - } - } await _messagesRepository.AddAsync(message); - _logger.LogInformation("Message {MessageId} from {SenderId} to {RecipientId} ({RecipientType}) saved successfully.", messageId, sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); + _logger.LogInformation( + "Message {MessageId} from {SenderId} to {RecipientId} ({RecipientType}) saved successfully.", + messageId, sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); + return new SendMessageResult(true, null, message); } catch (Exception ex) { - _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId} ({RecipientType})", sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); + _logger.LogError( + ex, + "Error sending message from {SenderId} to {RecipientId} ({RecipientType})", + sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); return new SendMessageResult(false, ex, default); } } - private async Task GetPrivateChatsIdAsync(Guid userA, Guid userB) + private async Task HandleUserRecipientAsync(SendMessage sendParams) { - Guid recipientId; - // Makes new PrivateChat if not exist - if (!_privateChats.Exist(userA, userB)) + var privateChat = await _privateChatsRepository.GetByIdAsync(sendParams.RecipientId); + + if (privateChat == null) { - recipientId = Guid.NewGuid(); - await _privateChats.AddAsync(new PrivateChat() - { Id = recipientId, UserAId = userA, UserBId = userB }); - } - else - { - recipientId = (await _privateChats.GetByMembersAsync(userA, userB)).Id; + _logger.LogWarning("Private chat {ChatId} not found", sendParams.RecipientId); + throw new KeyNotFoundException($"Private chat {sendParams.RecipientId} not found."); } - return recipientId; + return privateChat.Id; } + private async Task HandleGroupRecipientAsync(SendMessage sendParams) + { + if (!_groupsRepository.Exist(sendParams.RecipientId)) + { + _logger.LogWarning("Attempt to send message to non-existent group {GroupId}", sendParams.RecipientId); + throw new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found."); + } + + var isMember = await _groupsRepository.IsUserMemberOfGroupAsync(sendParams.FromUserId, sendParams.RecipientId); + if (!isMember) + { + _logger.LogWarning("User {UserId} attempted to send message to group {GroupId} but is not a member", + sendParams.FromUserId, sendParams.RecipientId); + throw new UnauthorizedAccessException("Sender is not a member of the group."); + } + + return sendParams.RecipientId; + } + + //TODO: Full Cleanup all them: public async Task EditMessageAsync(EditMessage editParams) { try @@ -157,13 +137,16 @@ public class MessageCommandService : IMessageCommandService if (message.SenderId != editParams.EditorId) { - _logger.LogWarning("User {EditorId} attempted to edit message {MessageId} not sent by them (sender was {SenderId})", editParams.EditorId, editParams.MessageId, message.SenderId); - return new EditMessageResult(false, new UnauthorizedAccessException("User is not authorized to edit this message."), null); + _logger.LogWarning( + "User {EditorId} attempted to edit message {MessageId} not sent by them (sender was {SenderId})", + editParams.EditorId, editParams.MessageId, message.SenderId); + return new EditMessageResult(false, + new UnauthorizedAccessException("User is not authorized to edit this message."), null); } /*if (message.SentAt < DateTime.UtcNow.AddMinutes(-15)) throw new Exception("Edit time limit exceeded");*/ - + var originalMessageForNotification = new Message { Id = message.Id, @@ -174,7 +157,7 @@ public class MessageCommandService : IMessageCommandService ReplyToMessageId = message.ReplyToMessageId, Reactions = message.Reactions, MediaAttachments = message.MediaAttachments, - MessageViews = message.MessageViews, + MessageViews = message.MessageViews }; message.EncryptedContent = editParams.NewContent; @@ -182,12 +165,14 @@ public class MessageCommandService : IMessageCommandService message.EditedAt = editParams.EditedAt; await _messagesRepository.UpdateAsync(message); - _logger.LogInformation("Message {MessageId} edited successfully by user {EditorId}", editParams.MessageId, editParams.EditorId); + _logger.LogInformation("Message {MessageId} edited successfully by user {EditorId}", editParams.MessageId, + editParams.EditorId); return new EditMessageResult(true, default, originalMessageForNotification); } catch (Exception ex) { - _logger.LogError(ex, "Error editing message {MessageId} by user {EditorId}", editParams.MessageId, editParams.EditorId); + _logger.LogError(ex, "Error editing message {MessageId} by user {EditorId}", editParams.MessageId, + editParams.EditorId); return new EditMessageResult(false, ex, default); } } @@ -221,7 +206,7 @@ public class MessageCommandService : IMessageCommandService Id = message.Id, SenderId = message.SenderId, RecipientId = message.RecipientId, - RecipientType = message.RecipientType, + RecipientType = message.RecipientType }; await _messagesRepository.RemoveAsync(deleteParams.MessageId); @@ -236,7 +221,8 @@ public class MessageCommandService : IMessageCommandService } catch (Exception ex) { - _logger.LogError(ex, "Error deleting message {MessageId} by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); + _logger.LogError(ex, "Error deleting message {MessageId} by user {DeleterId}", deleteParams.MessageId, + deleteParams.DeleterId); return new DeleteMessageResult(false, ex, default); } } diff --git a/Govor.Application/Services/ProfileService.cs b/Govor.Application/Services/ProfileService.cs index 0ec2073..ab07ce8 100644 --- a/Govor.Application/Services/ProfileService.cs +++ b/Govor.Application/Services/ProfileService.cs @@ -36,7 +36,6 @@ public class ProfileService : IProfileService { throw new NotFoundException("User's profile cant be found with given id", ex); } - } public async Task SetDescription(string description, Guid userId) diff --git a/Govor.Application/Services/PushNotifications/Providers/FirebasePushProvider.cs b/Govor.Application/Services/PushNotifications/Providers/FirebasePushProvider.cs new file mode 100644 index 0000000..d6d8f86 --- /dev/null +++ b/Govor.Application/Services/PushNotifications/Providers/FirebasePushProvider.cs @@ -0,0 +1,107 @@ +using FirebaseAdmin.Messaging; +using Govor.Application.Interfaces.PushNotifications; +using Govor.Application.Interfaces.PushNotifications.Models; + +namespace Govor.Application.Services.PushNotifications.Providers; + +public class FirebasePushProvider : IPushNotificationProvider +{ + public string Name => "FCM"; + + public async Task SendToTokenAsync(string token, PushMessage message) + { + var msg = BuildFcmMessage(message, token: token); + + try + { + string response = await FirebaseMessaging.DefaultInstance.SendAsync(msg); + return new SendPushResult(1, 0, []); + } + catch (FirebaseMessagingException ex) + { + if (IsInvalidTokenError(ex)) + { + return new SendPushResult(0, 1, [token]); + } + + throw; // return failed + } + } + + public async Task SendMulticastAsync(IReadOnlyList tokens, PushMessage message) + { + if (tokens.Count == 0) + return new SendPushResult(0, 0, []); + + var multicastMsg = new MulticastMessage + { + Tokens = tokens.ToList(), + Notification = new Notification + { + Title = message.Title, + Body = message.Body + }, + Data = message.Data, + Android = message.ChannelId != null ? new AndroidConfig + { + Notification = new AndroidNotification + { + ChannelId = message.ChannelId, + Priority = NotificationPriority.HIGH + } + } : null + }; + + try + { + var response = await FirebaseMessaging.DefaultInstance.SendEachForMulticastAsync(multicastMsg); + + var failedTokens = new List(); + + for (int i = 0; i < response.Responses.Count; i++) + { + if (!response.Responses[i].IsSuccess) + { + failedTokens.Add(tokens[i]); + } + } + + return new SendPushResult( + response.SuccessCount, + response.FailureCount, + failedTokens + ); + } + catch (Exception ex) + { + return new SendPushResult(0, tokens.Count, tokens.ToList()); + } + } + + private Message BuildFcmMessage(PushMessage pm, string? token = null) + { + var msg = new Message + { + Notification = new Notification { Title = pm.Title, Body = pm.Body }, + Data = pm.Data, + Token = token + }; + + if (pm.ChannelId is not null) + { + msg.Android = new AndroidConfig + { + Notification = new AndroidNotification { ChannelId = pm.ChannelId } + }; + } + + return msg; + } + + private static bool IsInvalidTokenError(FirebaseMessagingException ex) + { + return ex.MessagingErrorCode is MessagingErrorCode.Unregistered + or MessagingErrorCode.InvalidArgument + or MessagingErrorCode.SenderIdMismatch; + } +} \ No newline at end of file diff --git a/Govor.Application/Services/PushNotifications/Providers/NullPushProvider.cs b/Govor.Application/Services/PushNotifications/Providers/NullPushProvider.cs new file mode 100644 index 0000000..a7a059f --- /dev/null +++ b/Govor.Application/Services/PushNotifications/Providers/NullPushProvider.cs @@ -0,0 +1,19 @@ +using Govor.Application.Interfaces.PushNotifications; +using Govor.Application.Interfaces.PushNotifications.Models; + +namespace Govor.Application.Services.PushNotifications.Providers; + +public class NullPushProvider : IPushNotificationProvider +{ + public string Name => "NULL"; + + public Task SendToTokenAsync(string token, PushMessage message) + { + throw new NotImplementedException(); + } + + public Task SendMulticastAsync(IReadOnlyList tokens, PushMessage message) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/PushNotifications/PushNotificationService.cs b/Govor.Application/Services/PushNotifications/PushNotificationService.cs new file mode 100644 index 0000000..200732c --- /dev/null +++ b/Govor.Application/Services/PushNotifications/PushNotificationService.cs @@ -0,0 +1,95 @@ +using FirebaseAdmin.Messaging; +using Govor.Application.Interfaces.PushNotifications; +using Govor.Application.Interfaces.PushNotifications.Models; +using Govor.Core.Repositories.PushTokens; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services.PushNotifications; + +public class PushNotificationService : IPushNotificationService +{ + private readonly IPushNotificationProvider _provider; + private readonly IPushTokenRepository _tokenRepo; + private readonly ILogger _logger; + + public PushNotificationService( + IPushNotificationProvider provider, + IPushTokenRepository tokenRepo, + ILogger logger) + { + _provider = provider; + _tokenRepo = tokenRepo; + _logger = logger; + } + + public async Task SendToUserAsync(Guid userId, string title, string body, string channelId, Dictionary? data = null) + { + var tokens = await _tokenRepo.GetActiveTokensAsync(userId); + if (tokens.Count == 0) + { + _logger.LogInformation("No active push tokens for user {UserId}", userId); + return; + } + + var message = new PushMessage + { + Title = title, + Body = body, + Data = data ?? new(), + ChannelId = channelId + }; + + var result = await _provider.SendMulticastAsync(tokens, message); + + if (result.FailureCount > 0) + { + await _tokenRepo.RemoveTokensAsync(result.FailedTokens); + _logger.LogWarning("Removed {Count} invalid FCM tokens for user {UserId}", result.FailureCount, userId); + } + } + + public async Task SendToUsersAsync(IEnumerable userIds, string title, string body, string channelId, Dictionary? data = null) + { + if (userIds is null) + throw new ArgumentNullException(nameof(userIds)); + + var tokens = (await _tokenRepo.GetActiveTokensUsersAsync(userIds)).AsReadOnly(); + if (tokens.Count == 0) + return; + + var result = await _provider.SendMulticastAsync(tokens, new PushMessage() + { + Title = title, + Body = body, + Data = data ?? new(), + ChannelId = channelId + }); + + if (result.FailureCount > 0) + { + await _tokenRepo.RemoveTokensAsync(result.FailedTokens); + _logger.LogWarning("Removed {Count} invalid FCM tokens for users {Users}...", result.FailureCount, userIds); + } + } + + public async Task SendToSessionAsync(Guid sessionId, string title, string body, string channelId, Dictionary? data = null) + { + var token = await _tokenRepo.GetActiveTokenBySessionAsync(sessionId); + if(string.IsNullOrEmpty(token)) + return; + + var result = await _provider.SendToTokenAsync(token, new PushMessage() + { + Title = title, + Body = body, + Data = data ?? new(), + ChannelId = channelId + }); + + if (result.FailureCount > 0) + { + await _tokenRepo.RemoveTokensAsync(result.FailedTokens); + _logger.LogWarning("Removed {Count} invalid FCM tokens for session {Session}...", result.FailureCount, sessionId); + } + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserPrivateChatsCreator.cs b/Govor.Application/Services/UserPrivateChatsCreator.cs new file mode 100644 index 0000000..2e2d8c9 --- /dev/null +++ b/Govor.Application/Services/UserPrivateChatsCreator.cs @@ -0,0 +1,46 @@ +using Govor.Application.Interfaces; +using Govor.Core.Models; +using Govor.Core.Repositories.PrivateChats; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services; + +public class UserPrivateChatsCreator : IUserPrivateChatsCreator +{ + private readonly IPrivateChatsRepository _privateChats; + private readonly IPrivateChatGroupManager _privateChatGroupManager; + private readonly ILogger _logger; + + public UserPrivateChatsCreator( + IPrivateChatsRepository privateChats, + IPrivateChatGroupManager privateChatGroupManager, + ILogger logger) + { + _privateChats = privateChats; + _privateChatGroupManager = privateChatGroupManager; + _logger = logger; + } + + public async Task CreateAsync(Guid userIdA, Guid userIdB) + { + if (!_privateChats.Exist(userIdA, userIdB)) + { + var recipientId = Guid.NewGuid(); + var privateChat = new PrivateChat() + { + Id = recipientId, + UserAId = userIdA, + UserBId = userIdB + }; + + await _privateChats.AddAsync(privateChat); + await _privateChatGroupManager.AddUsersToPrivateChatGroupAsync(privateChat); // Notifying + + _logger.LogInformation($"User for {userIdA} and {userIdB} was created private chat with Id: {recipientId}"); + return privateChat; + } + + _logger.LogInformation($"Private chat already exists for {userIdA} and {userIdB}; Returning already created chat..."); + return await _privateChats.GetByMembersAsync(userIdA, userIdB); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserSessions/UserSessionRevoker.cs b/Govor.Application/Services/UserSessions/UserSessionRevoker.cs index 6eb6450..a889379 100644 --- a/Govor.Application/Services/UserSessions/UserSessionRevoker.cs +++ b/Govor.Application/Services/UserSessions/UserSessionRevoker.cs @@ -1,4 +1,5 @@ using Govor.Application.Interfaces.UserSession; +using Govor.Core.Repositories.PushTokens; using Govor.Core.Repositories.UserSessionsRepository; using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; @@ -8,11 +9,16 @@ namespace Govor.Application.Services.UserSessions; public class UserSessionRevoker : IUserSessionRevoker { private readonly IUserSessionsRepository _sessionsRepository; + private readonly IPushTokenRepository _pushTokenRepository; private readonly ILogger _logger; - public UserSessionRevoker(IUserSessionsRepository sessionsRepository, ILogger logger) + public UserSessionRevoker( + IUserSessionsRepository sessionsRepository, + IPushTokenRepository pushTokenRepository, + ILogger logger) { _sessionsRepository = sessionsRepository; + _pushTokenRepository = pushTokenRepository; _logger = logger; } @@ -32,6 +38,8 @@ public class UserSessionRevoker : IUserSessionRevoker session.IsRevoked = true; await _sessionsRepository.UpdateAsync(session); + + await _pushTokenRepository.DeactivateTokenBySessionAsync(sessionId); // pushes deactivate } catch (NotFoundByKeyException ex) { @@ -52,6 +60,8 @@ public class UserSessionRevoker : IUserSessionRevoker session.IsRevoked = true; await _sessionsRepository.UpdateAsync(session); + + await _pushTokenRepository.DeactivateTokenBySessionAsync(session.Id); // pushes deactivate } } catch (NotFoundByKeyException ex) diff --git a/Govor.Contracts/DTOs/PrivateChatDto.cs b/Govor.Contracts/DTOs/PrivateChatDto.cs new file mode 100644 index 0000000..bea3b0f --- /dev/null +++ b/Govor.Contracts/DTOs/PrivateChatDto.cs @@ -0,0 +1,7 @@ +namespace Govor.Contracts.DTOs; + +public class PrivateChatDto +{ + public Guid ChatId { get; set; } + public Guid FriendId { get; set; } +} \ No newline at end of file diff --git a/Govor.Contracts/Requests/RegisterPushTokenRequest.cs b/Govor.Contracts/Requests/RegisterPushTokenRequest.cs new file mode 100644 index 0000000..c03a053 --- /dev/null +++ b/Govor.Contracts/Requests/RegisterPushTokenRequest.cs @@ -0,0 +1,7 @@ +namespace Govor.Contracts.Requests; + +public class RegisterPushTokenRequest +{ + public string Token { get; set; } + public string Platform { get; set; } +} \ No newline at end of file diff --git a/Govor.Core/Models/Users/UserPushToken.cs b/Govor.Core/Models/Users/UserPushToken.cs new file mode 100644 index 0000000..1ff716a --- /dev/null +++ b/Govor.Core/Models/Users/UserPushToken.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; + +namespace Govor.Core.Models.Users; +public class UserPushToken +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid UserId { get; set; } + public Guid? UserSessionId { get; set; } + + public string Token { get; set; } = string.Empty; // FCM/APNs + public string Provider { get; set; } = "FCM"; // FCM | APNs | Web | Huawei + public string Platform { get; set; } = string.Empty; // android | ios | web + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastUsedAt { get; set; } + + public bool IsActive { get; set; } = true; +} \ No newline at end of file diff --git a/Govor.Core/Repositories/PushTokens/IPushTokenReader.cs b/Govor.Core/Repositories/PushTokens/IPushTokenReader.cs new file mode 100644 index 0000000..17ee1f2 --- /dev/null +++ b/Govor.Core/Repositories/PushTokens/IPushTokenReader.cs @@ -0,0 +1,10 @@ +namespace Govor.Core.Repositories.PushTokens; + +public interface IPushTokenReader +{ + Task> GetActiveTokensAsync(Guid userId); + + Task> GetActiveTokensUsersAsync(IEnumerable userIds); + + Task GetActiveTokenBySessionAsync(Guid sessionId); +} \ No newline at end of file diff --git a/Govor.Core/Repositories/PushTokens/IPushTokenRepository.cs b/Govor.Core/Repositories/PushTokens/IPushTokenRepository.cs new file mode 100644 index 0000000..9f2a841 --- /dev/null +++ b/Govor.Core/Repositories/PushTokens/IPushTokenRepository.cs @@ -0,0 +1,6 @@ +namespace Govor.Core.Repositories.PushTokens; + +public interface IPushTokenRepository : IPushTokenReader, IPushTokenWriter +{ + +} \ No newline at end of file diff --git a/Govor.Core/Repositories/PushTokens/IPushTokenWriter.cs b/Govor.Core/Repositories/PushTokens/IPushTokenWriter.cs new file mode 100644 index 0000000..6847af9 --- /dev/null +++ b/Govor.Core/Repositories/PushTokens/IPushTokenWriter.cs @@ -0,0 +1,11 @@ +namespace Govor.Core.Repositories.PushTokens; + +public interface IPushTokenWriter +{ + Task AddOrUpdateTokenAsync(Guid userId, Guid? sessionId, string token, + string platform, string provider = "FCM"); + + Task RemoveTokensAsync(IEnumerable tokens); + + Task DeactivateTokenBySessionAsync(Guid sessionId); // logout +} \ No newline at end of file diff --git a/Govor.Data.Tests/Repositories/PushTokenRepository.cs b/Govor.Data.Tests/Repositories/PushTokenRepository.cs new file mode 100644 index 0000000..258f092 --- /dev/null +++ b/Govor.Data.Tests/Repositories/PushTokenRepository.cs @@ -0,0 +1,233 @@ +using Govor.Core.Models.Users; +using Govor.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Data.Tests.Repositories; + +[TestFixture] +public class PushTokenRepositoryTests +{ + private GovorDbContext _context; + private PushTokenRepository _repository; + + [SetUp] + public void Setup() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _context = new GovorDbContext(options); + _repository = new PushTokenRepository(_context); + } + + [TearDown] + public void TearDown() + { + _context.Dispose(); + } + + #region GetActiveTokensAsync + + [Test] + public async Task GetActiveTokensAsync_ReturnsUserTokens() + { + var userId = Guid.NewGuid(); + + _context.UserPushTokens.Add(new UserPushToken + { + UserId = userId, + Token = "token1", + IsActive = true + }); + + await _context.SaveChangesAsync(); + + var result = await _repository.GetActiveTokensAsync(userId); + + Assert.That(result, Contains.Item("token1")); + } + + #endregion + + #region GetActiveTokensUsersAsync + + [Test] + public async Task GetActiveTokensUsersAsync_ReturnsDistinctActiveTokens() + { + var user1 = Guid.NewGuid(); + var user2 = Guid.NewGuid(); + + _context.UserPushTokens.AddRange( + new UserPushToken { UserId = user1, Token = "t1", IsActive = true }, + new UserPushToken { UserId = user2, Token = "t2", IsActive = true }, + new UserPushToken { UserId = user2, Token = "inactive", IsActive = false } + ); + + await _context.SaveChangesAsync(); + + var result = await _repository.GetActiveTokensUsersAsync([user1, user2, user1]); + + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result, Does.Not.Contain("inactive")); + } + + [Test] + public async Task GetActiveTokensUsersAsync_EmptyInput_ReturnsEmpty() + { + var result = await _repository.GetActiveTokensUsersAsync([]); + + Assert.That(result, Is.Empty); + } + + #endregion + + #region GetActiveTokenBySessionAsync + + [Test] + public async Task GetActiveTokenBySessionAsync_ReturnsToken() + { + var sessionId = Guid.NewGuid(); + + _context.UserPushTokens.Add(new UserPushToken + { + UserSessionId = sessionId, + Token = "sessionToken", + IsActive = true + }); + + await _context.SaveChangesAsync(); + + var result = await _repository.GetActiveTokenBySessionAsync(sessionId); + + Assert.That(result, Is.EqualTo("sessionToken")); + } + + [Test] + public async Task GetActiveTokenBySessionAsync_EmptySession_ReturnsEmptyString() + { + var result = await _repository.GetActiveTokenBySessionAsync(Guid.Empty); + + Assert.That(result, Is.EqualTo("")); + } + + #endregion + + #region AddOrUpdateTokenAsync + + [Test] + public void AddOrUpdateTokenAsync_InvalidUserId_Throws() + { + Assert.ThrowsAsync(() => + _repository.AddOrUpdateTokenAsync(Guid.Empty, null, "token", "android")); + } + + [Test] + public void AddOrUpdateTokenAsync_EmptyToken_Throws() + { + Assert.ThrowsAsync(() => + _repository.AddOrUpdateTokenAsync(Guid.NewGuid(), null, "", "android")); + } + + [Test] + public async Task AddOrUpdateTokenAsync_AddsNewToken() + { + var userId = Guid.NewGuid(); + + await _repository.AddOrUpdateTokenAsync(userId, null, "newToken", "android"); + + var token = await _context.UserPushTokens.FirstOrDefaultAsync(); + + Assert.That(token, Is.Not.Null); + Assert.That(token!.UserId, Is.EqualTo(userId)); + Assert.That(token.IsActive, Is.True); + } + + [Test] + public async Task AddOrUpdateTokenAsync_UpdatesExistingToken() + { + var userId = Guid.NewGuid(); + + var existing = new UserPushToken + { + UserId = Guid.NewGuid(), + Token = "sameToken", + Platform = "ios", + IsActive = false + }; + + _context.UserPushTokens.Add(existing); + await _context.SaveChangesAsync(); + + await _repository.AddOrUpdateTokenAsync(userId, null, "sameToken", "android"); + + var updated = await _context.UserPushTokens.FirstAsync(); + + Assert.That(updated.UserId, Is.EqualTo(userId)); + Assert.That(updated.Platform, Is.EqualTo("android")); + Assert.That(updated.IsActive, Is.True); + } + + #endregion + + #region RemoveTokensAsync + + [Test] + public async Task RemoveTokensAsync_RemovesTokens() + { + _context.UserPushTokens.Add(new UserPushToken + { + Token = "t1", + UserId = Guid.NewGuid() + }); + + await _context.SaveChangesAsync(); + + await _repository.RemoveTokensAsync(["t1"]); + + Assert.That(_context.UserPushTokens.Count(), Is.EqualTo(0)); + } + + [Test] + public async Task RemoveTokensAsync_EmptyInput_DoesNothing() + { + await _repository.RemoveTokensAsync([]); + + Assert.Pass(); + } + + #endregion + + #region DeactivateTokenBySessionAsync + + [Test] + public async Task DeactivateTokenBySessionAsync_DeactivatesTokens() + { + var sessionId = Guid.NewGuid(); + + _context.UserPushTokens.Add(new UserPushToken + { + UserSessionId = sessionId, + Token = "t1", + IsActive = true + }); + + await _context.SaveChangesAsync(); + + await _repository.DeactivateTokenBySessionAsync(sessionId); + + var token = await _context.UserPushTokens.FirstAsync(); + + Assert.That(token.IsActive, Is.False); + } + + [Test] + public async Task DeactivateTokenBySessionAsync_EmptySession_DoesNothing() + { + await _repository.DeactivateTokenBySessionAsync(Guid.Empty); + + Assert.Pass(); + } + + #endregion +} \ No newline at end of file diff --git a/Govor.Data/Configurations/FriendshipConfiguration.cs b/Govor.Data/Configurations/FriendshipConfiguration.cs index 1d829cd..bd2c17b 100644 --- a/Govor.Data/Configurations/FriendshipConfiguration.cs +++ b/Govor.Data/Configurations/FriendshipConfiguration.cs @@ -9,7 +9,7 @@ public class FriendshipConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey(f => f.Id); - + builder.HasOne(f => f.Requester) .WithMany(u => u.SentFriendRequests) .HasForeignKey(f => f.RequesterId) diff --git a/Govor.Data/Configurations/MessagesConfiguration.cs b/Govor.Data/Configurations/MessagesConfiguration.cs index 5eee870..5693296 100644 --- a/Govor.Data/Configurations/MessagesConfiguration.cs +++ b/Govor.Data/Configurations/MessagesConfiguration.cs @@ -9,6 +9,9 @@ public class MessagesConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey(m => m.Id); + + // Просто индекс, без unique + builder.HasIndex(m => m.RecipientId); builder.HasMany(m => m.Reactions) .WithOne(r => r.Message) diff --git a/Govor.Data/Configurations/PrivateChatsConfiguration.cs b/Govor.Data/Configurations/PrivateChatsConfiguration.cs new file mode 100644 index 0000000..7c42412 --- /dev/null +++ b/Govor.Data/Configurations/PrivateChatsConfiguration.cs @@ -0,0 +1,13 @@ +using Govor.Core.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class PrivateChatsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(us => us.Id); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/UserPushTokenConfiguration.cs b/Govor.Data/Configurations/UserPushTokenConfiguration.cs new file mode 100644 index 0000000..029263e --- /dev/null +++ b/Govor.Data/Configurations/UserPushTokenConfiguration.cs @@ -0,0 +1,18 @@ +using Govor.Core.Models.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class UserPushTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(t => t.Id); + + builder.HasIndex(x => x.Token).IsUnique(); + builder.HasIndex(x => x.UserSessionId).IsUnique(); + builder.HasIndex(x => x.UserId).IsUnique(); + builder.HasIndex(x => new { x.UserId, x.IsActive }); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/UserSessionConfiguration.cs b/Govor.Data/Configurations/UserSessionConfiguration.cs index bb5cf69..2dd82d2 100644 --- a/Govor.Data/Configurations/UserSessionConfiguration.cs +++ b/Govor.Data/Configurations/UserSessionConfiguration.cs @@ -12,6 +12,14 @@ public class UserSessionConfiguration : IEntityTypeConfiguration { builder.HasKey(us => us.Id); + builder.HasIndex(us => us.Id) + .IsUnique(); + + builder.HasIndex(us => us.UserId); + + builder.HasIndex(s => s.RefreshTokenHash) + .IsUnique(); + builder.Property(us => us.RefreshTokenHash) .IsRequired(); diff --git a/Govor.Data/GovorDbContext.cs b/Govor.Data/GovorDbContext.cs index 1593fe2..d811a88 100644 --- a/Govor.Data/GovorDbContext.cs +++ b/Govor.Data/GovorDbContext.cs @@ -11,6 +11,7 @@ public class GovorDbContext(DbContextOptions options) : DbContex { public virtual DbSet Users { get; set; } public virtual DbSet UserSessions { get; set; } + public virtual DbSet UserPushTokens { get; set; } public virtual DbSet UserCryptoSessions { get; set; } public virtual DbSet SignedPreKeys { get; set; } public virtual DbSet OneTimePreKeys { get; set; } @@ -51,6 +52,8 @@ public class GovorDbContext(DbContextOptions options) : DbContex modelBuilder.ApplyConfiguration(new OneTimePreKeyConfiguration()); modelBuilder.ApplyConfiguration(new UserCryptoSessionConfiguration()); modelBuilder.ApplyConfiguration(new SignedPreKeyConfiguration()); + modelBuilder.ApplyConfiguration(new PrivateChatsConfiguration()); + modelBuilder.ApplyConfiguration(new UserPushTokenConfiguration()); base.OnModelCreating(modelBuilder); } } \ No newline at end of file diff --git a/Govor.Data/Migrations/20251019125424_InitialCreate.Designer.cs b/Govor.Data/Migrations/20251019125424_InitialCreate.Designer.cs deleted file mode 100644 index fa346ae..0000000 --- a/Govor.Data/Migrations/20251019125424_InitialCreate.Designer.cs +++ /dev/null @@ -1,763 +0,0 @@ -// -using System; -using Govor.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Govor.Data.Migrations -{ - [DbContext(typeof(GovorDbContext))] - [Migration("20251019125424_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("ImageId") - .HasColumnType("uuid"); - - b.Property("IsChannel") - .HasColumnType("boolean"); - - b.Property("IsPrivate") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.ToTable("ChatGroups"); - }); - - modelBuilder.Entity("Govor.Core.Models.Friendship", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AddresseeId") - .HasColumnType("uuid"); - - b.Property("RequesterId") - .HasColumnType("uuid"); - - b.Property("Status") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("AddresseeId"); - - b.HasIndex("RequesterId"); - - b.ToTable("Friendships"); - }); - - modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("GroupId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("GroupId"); - - b.ToTable("GroupAdmins"); - }); - - modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("EndDate") - .HasColumnType("timestamp with time zone"); - - b.Property("GroupId") - .HasColumnType("uuid"); - - b.Property("InvitationCode") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("MaxParticipants") - .HasColumnType("integer"); - - b.Property("UserMakerId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("GroupId"); - - b.HasIndex("UserMakerId"); - - b.ToTable("GroupInvitations"); - }); - - modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("GroupId") - .HasColumnType("uuid"); - - b.Property("InvitationId") - .HasColumnType("uuid"); - - b.Property("IsBanned") - .HasColumnType("boolean"); - - b.Property("MemberSince") - .HasColumnType("timestamp with time zone"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("GroupId"); - - b.HasIndex("InvitationId"); - - b.ToTable("GroupMemberships"); - }); - - modelBuilder.Entity("Govor.Core.Models.Invitation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Code") - .IsRequired() - .HasColumnType("text"); - - b.Property("DateCreated") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("EndDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsActive") - .HasColumnType("boolean"); - - b.Property("IsAdmin") - .HasColumnType("boolean"); - - b.Property("MaxParticipants") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("Invitations"); - }); - - modelBuilder.Entity("Govor.Core.Models.MediaFile", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DateCreated") - .HasColumnType("timestamp with time zone"); - - b.Property("MediaType") - .IsRequired() - .HasColumnType("text"); - - b.Property("MineType") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("UploaderId") - .HasColumnType("uuid"); - - b.Property("Url") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("MediaFiles"); - }); - - modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("MediaFileId") - .HasColumnType("uuid"); - - b.Property("MessageId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("MediaFileId"); - - b.HasIndex("MessageId"); - - b.ToTable("MediaAttachments"); - }); - - modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("ChatGroupId") - .HasColumnType("uuid"); - - b.Property("EditedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EncryptedContent") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsEdited") - .HasColumnType("boolean"); - - b.Property("PrivateChatId") - .HasColumnType("uuid"); - - b.Property("RecipientId") - .HasColumnType("uuid"); - - b.Property("RecipientType") - .HasColumnType("integer"); - - b.Property("ReplyToMessageId") - .HasColumnType("uuid"); - - b.Property("SenderId") - .HasColumnType("uuid"); - - b.Property("SentAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("ChatGroupId"); - - b.HasIndex("PrivateChatId"); - - b.ToTable("Messages"); - }); - - modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("MessageId") - .HasColumnType("uuid"); - - b.Property("ReactedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReactionCode") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.HasIndex("MessageId", "UserId") - .IsUnique(); - - b.ToTable("MessageReactions"); - }); - - modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("MessageId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("ViewedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("MessageId", "UserId") - .IsUnique(); - - b.ToTable("MessageViews"); - }); - - modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("UserAId") - .HasColumnType("uuid"); - - b.Property("UserBId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.ToTable("PrivateChats"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => - { - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("UserId"); - - b.ToTable("Admins"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("IsUsed") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("PublicKey") - .IsRequired() - .HasColumnType("bytea"); - - b.Property("UploadedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UserCryptoSessionId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("UploadedAt"); - - b.HasIndex("UserCryptoSessionId", "IsUsed"); - - b.ToTable("OneTimePreKeys"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.Crypto.SignedPreKey", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("PublicSignedPreKey") - .IsRequired() - .HasColumnType("bytea"); - - b.Property("SignedPreKeySignature") - .IsRequired() - .HasColumnType("bytea"); - - b.Property("UserCryptoSessionId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("UserCryptoSessionId") - .IsUnique(); - - b.ToTable("SignedPreKeys"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("PublicIdentityKey") - .IsRequired() - .HasColumnType("bytea"); - - b.Property("UserSessionId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("UserSessionId") - .IsUnique(); - - b.ToTable("UserCryptoSessions"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedOn") - .HasColumnType("date"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("IconId") - .HasColumnType("uuid"); - - b.Property("InviteId") - .HasColumnType("uuid"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Username") - .IsRequired() - .HasColumnType("text"); - - b.Property("WasOnline") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("InviteId"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeviceInfo") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsRevoked") - .HasColumnType("boolean"); - - b.Property("RefreshToken") - .IsRequired() - .HasColumnType("text"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("UserSessions"); - }); - - modelBuilder.Entity("Govor.Core.Models.Friendship", b => - { - b.HasOne("Govor.Core.Models.Users.User", "Addressee") - .WithMany("ReceivedFriendRequests") - .HasForeignKey("AddresseeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Govor.Core.Models.Users.User", "Requester") - .WithMany("SentFriendRequests") - .HasForeignKey("RequesterId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Addressee"); - - b.Navigation("Requester"); - }); - - modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => - { - b.HasOne("Govor.Core.Models.ChatGroup", null) - .WithMany("Admins") - .HasForeignKey("GroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => - { - b.HasOne("Govor.Core.Models.ChatGroup", null) - .WithMany("InviteCodes") - .HasForeignKey("GroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Govor.Core.Models.Users.User", "UserMaker") - .WithMany() - .HasForeignKey("UserMakerId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("UserMaker"); - }); - - modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => - { - b.HasOne("Govor.Core.Models.ChatGroup", null) - .WithMany("Members") - .HasForeignKey("GroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Govor.Core.Models.GroupInvitation", null) - .WithMany() - .HasForeignKey("InvitationId") - .OnDelete(DeleteBehavior.SetNull); - }); - - modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => - { - b.HasOne("Govor.Core.Models.MediaFile", "MediaFile") - .WithMany() - .HasForeignKey("MediaFileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Govor.Core.Models.Messages.Message", "Message") - .WithMany("MediaAttachments") - .HasForeignKey("MessageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("MediaFile"); - - b.Navigation("Message"); - }); - - modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => - { - b.HasOne("Govor.Core.Models.ChatGroup", null) - .WithMany("Messages") - .HasForeignKey("ChatGroupId"); - - b.HasOne("Govor.Core.Models.PrivateChat", null) - .WithMany("Messages") - .HasForeignKey("PrivateChatId"); - }); - - modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => - { - b.HasOne("Govor.Core.Models.Messages.Message", "Message") - .WithMany("Reactions") - .HasForeignKey("MessageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Govor.Core.Models.Users.User", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Message"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => - { - b.HasOne("Govor.Core.Models.Messages.Message", null) - .WithMany("MessageViews") - .HasForeignKey("MessageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => - { - b.HasOne("Govor.Core.Models.Users.User", "User") - .WithOne() - .HasForeignKey("Govor.Core.Models.Users.Admin", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.Crypto.OneTimePreKey", b => - { - b.HasOne("Govor.Core.Models.Users.Crypto.UserCryptoSession", "UserCryptoSession") - .WithMany("OneTimePreKeys") - .HasForeignKey("UserCryptoSessionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("UserCryptoSession"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.Crypto.SignedPreKey", b => - { - b.HasOne("Govor.Core.Models.Users.Crypto.UserCryptoSession", "UserCryptoSession") - .WithOne("SignedPreKey") - .HasForeignKey("Govor.Core.Models.Users.Crypto.SignedPreKey", "UserCryptoSessionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("UserCryptoSession"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => - { - b.HasOne("Govor.Core.Models.Users.UserSession", "UserSession") - .WithOne("CryptoSession") - .HasForeignKey("Govor.Core.Models.Users.Crypto.UserCryptoSession", "UserSessionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("UserSession"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.User", b => - { - b.HasOne("Govor.Core.Models.Invitation", "Invite") - .WithMany("Users") - .HasForeignKey("InviteId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Invite"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => - { - b.HasOne("Govor.Core.Models.Users.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => - { - b.Navigation("Admins"); - - b.Navigation("InviteCodes"); - - b.Navigation("Members"); - - b.Navigation("Messages"); - }); - - modelBuilder.Entity("Govor.Core.Models.Invitation", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => - { - b.Navigation("MediaAttachments"); - - b.Navigation("MessageViews"); - - b.Navigation("Reactions"); - }); - - modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => - { - b.Navigation("Messages"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.Crypto.UserCryptoSession", b => - { - b.Navigation("OneTimePreKeys"); - - b.Navigation("SignedPreKey") - .IsRequired(); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.User", b => - { - b.Navigation("ReceivedFriendRequests"); - - b.Navigation("SentFriendRequests"); - }); - - modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => - { - b.Navigation("CryptoSession") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.cs b/Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.cs deleted file mode 100644 index c9bce75..0000000 --- a/Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Govor.Data.Migrations -{ - /// - public partial class MediaOwnerTypeAdded : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "OwnerId", - table: "MediaFiles", - type: "uuid", - nullable: true); - - migrationBuilder.AddColumn( - name: "OwnerType", - table: "MediaFiles", - type: "integer", - nullable: false, - defaultValue: 0); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "OwnerId", - table: "MediaFiles"); - - migrationBuilder.DropColumn( - name: "OwnerType", - table: "MediaFiles"); - } - } -} diff --git a/Govor.Data/Migrations/20251214065852_update_sessions_model.cs b/Govor.Data/Migrations/20251214065852_update_sessions_model.cs deleted file mode 100644 index 9e7a7f9..0000000 --- a/Govor.Data/Migrations/20251214065852_update_sessions_model.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Govor.Data.Migrations -{ - /// - public partial class update_sessions_model : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "RefreshToken", - table: "UserSessions", - newName: "RefreshTokenHash"); - - migrationBuilder.AlterColumn( - name: "Username", - table: "Users", - type: "character varying(50)", - maxLength: 50, - nullable: false, - oldClrType: typeof(string), - oldType: "text"); - - migrationBuilder.AlterColumn( - name: "PasswordHash", - table: "Users", - type: "character varying(128)", - maxLength: 128, - nullable: false, - oldClrType: typeof(string), - oldType: "text"); - - migrationBuilder.AlterColumn( - name: "Description", - table: "Users", - type: "character varying(500)", - maxLength: 500, - nullable: true, - oldClrType: typeof(string), - oldType: "text"); - - migrationBuilder.CreateIndex( - name: "IX_Users_Username", - table: "Users", - column: "Username", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Users_Username", - table: "Users"); - - migrationBuilder.RenameColumn( - name: "RefreshTokenHash", - table: "UserSessions", - newName: "RefreshToken"); - - migrationBuilder.AlterColumn( - name: "Username", - table: "Users", - type: "text", - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(50)", - oldMaxLength: 50); - - migrationBuilder.AlterColumn( - name: "PasswordHash", - table: "Users", - type: "text", - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(128)", - oldMaxLength: 128); - - migrationBuilder.AlterColumn( - name: "Description", - table: "Users", - type: "text", - nullable: false, - defaultValue: "", - oldClrType: typeof(string), - oldType: "character varying(500)", - oldMaxLength: 500, - oldNullable: true); - } - } -} diff --git a/Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.Designer.cs b/Govor.Data/Migrations/20260301080331_Init.Designer.cs similarity index 90% rename from Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.Designer.cs rename to Govor.Data/Migrations/20260301080331_Init.Designer.cs index 93a2188..ec91fae 100644 --- a/Govor.Data/Migrations/20251103060801_MediaOwnerTypeAdded.Designer.cs +++ b/Govor.Data/Migrations/20260301080331_Init.Designer.cs @@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Govor.Data.Migrations { [DbContext(typeof(GovorDbContext))] - [Migration("20251103060801_MediaOwnerTypeAdded")] - partial class MediaOwnerTypeAdded + [Migration("20260301080331_Init")] + partial class Init { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -74,6 +74,9 @@ namespace Govor.Data.Migrations b.HasIndex("AddresseeId"); + b.HasIndex("Id") + .IsUnique(); + b.HasIndex("RequesterId"); b.ToTable("Friendships"); @@ -300,8 +303,14 @@ namespace Govor.Data.Migrations b.HasIndex("ChatGroupId"); + b.HasIndex("Id") + .IsUnique(); + b.HasIndex("PrivateChatId"); + b.HasIndex("RecipientId") + .IsUnique(); + b.ToTable("Messages"); }); @@ -372,6 +381,9 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("Id") + .IsUnique(); + b.ToTable("PrivateChats"); }); @@ -471,8 +483,8 @@ namespace Govor.Data.Migrations .HasColumnType("date"); b.Property("Description") - .IsRequired() - .HasColumnType("text"); + .HasMaxLength(500) + .HasColumnType("character varying(500)"); b.Property("IconId") .HasColumnType("uuid"); @@ -482,22 +494,83 @@ namespace Govor.Data.Migrations b.Property("PasswordHash") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("Username") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(50) + .HasColumnType("character varying(50)"); b.Property("WasOnline") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); + b.HasIndex("CreatedOn"); + b.HasIndex("InviteId"); + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("WasOnline"); + b.ToTable("Users"); }); + modelBuilder.Entity("Govor.Core.Models.Users.UserPushToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("UserSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("UserSessionId") + .IsUnique(); + + b.HasIndex("UserId", "IsActive"); + + b.ToTable("UserPushTokens"); + }); + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => { b.Property("Id") @@ -518,7 +591,7 @@ namespace Govor.Data.Migrations b.Property("IsRevoked") .HasColumnType("boolean"); - b.Property("RefreshToken") + b.Property("RefreshTokenHash") .IsRequired() .HasColumnType("text"); @@ -527,6 +600,12 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + b.HasIndex("UserId"); b.ToTable("UserSessions"); diff --git a/Govor.Data/Migrations/20251019125424_InitialCreate.cs b/Govor.Data/Migrations/20260301080331_Init.cs similarity index 85% rename from Govor.Data/Migrations/20251019125424_InitialCreate.cs rename to Govor.Data/Migrations/20260301080331_Init.cs index d77b539..da67b0a 100644 --- a/Govor.Data/Migrations/20251019125424_InitialCreate.cs +++ b/Govor.Data/Migrations/20260301080331_Init.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Govor.Data.Migrations { /// - public partial class InitialCreate : Migration + public partial class Init : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -54,7 +54,9 @@ namespace Govor.Data.Migrations Url = table.Column(type: "text", nullable: false), MediaType = table.Column(type: "text", nullable: false), MineType = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), - DateCreated = table.Column(type: "timestamp with time zone", nullable: false) + DateCreated = table.Column(type: "timestamp with time zone", nullable: false), + OwnerType = table.Column(type: "integer", nullable: false), + OwnerId = table.Column(type: "uuid", nullable: true) }, constraints: table => { @@ -74,6 +76,26 @@ namespace Govor.Data.Migrations table.PrimaryKey("PK_PrivateChats", x => x.Id); }); + migrationBuilder.CreateTable( + name: "UserPushTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + UserSessionId = table.Column(type: "uuid", nullable: true), + Token = table.Column(type: "text", nullable: false), + Provider = table.Column(type: "text", nullable: false), + Platform = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastUsedAt = table.Column(type: "timestamp with time zone", nullable: true), + IsActive = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserPushTokens", x => x.Id); + }); + migrationBuilder.CreateTable( name: "GroupAdmins", columns: table => new @@ -98,9 +120,9 @@ namespace Govor.Data.Migrations columns: table => new { Id = table.Column(type: "uuid", nullable: false), - Username = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - PasswordHash = table.Column(type: "text", nullable: false), + Username = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + PasswordHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), IconId = table.Column(type: "uuid", nullable: false), CreatedOn = table.Column(type: "date", nullable: false), WasOnline = table.Column(type: "timestamp with time zone", nullable: false), @@ -227,7 +249,7 @@ namespace Govor.Data.Migrations { Id = table.Column(type: "uuid", nullable: false), UserId = table.Column(type: "uuid", nullable: false), - RefreshToken = table.Column(type: "text", nullable: false), + RefreshTokenHash = table.Column(type: "text", nullable: false), DeviceInfo = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), @@ -409,6 +431,12 @@ namespace Govor.Data.Migrations table: "Friendships", column: "AddresseeId"); + migrationBuilder.CreateIndex( + name: "IX_Friendships_Id", + table: "Friendships", + column: "Id", + unique: true); + migrationBuilder.CreateIndex( name: "IX_Friendships_RequesterId", table: "Friendships", @@ -465,11 +493,23 @@ namespace Govor.Data.Migrations table: "Messages", column: "ChatGroupId"); + migrationBuilder.CreateIndex( + name: "IX_Messages_Id", + table: "Messages", + column: "Id", + unique: true); + migrationBuilder.CreateIndex( name: "IX_Messages_PrivateChatId", table: "Messages", column: "PrivateChatId"); + migrationBuilder.CreateIndex( + name: "IX_Messages_RecipientId", + table: "Messages", + column: "RecipientId", + unique: true); + migrationBuilder.CreateIndex( name: "IX_MessageViews_MessageId_UserId", table: "MessageViews", @@ -486,6 +526,12 @@ namespace Govor.Data.Migrations table: "OneTimePreKeys", columns: new[] { "UserCryptoSessionId", "IsUsed" }); + migrationBuilder.CreateIndex( + name: "IX_PrivateChats_Id", + table: "PrivateChats", + column: "Id", + unique: true); + migrationBuilder.CreateIndex( name: "IX_SignedPreKeys_UserCryptoSessionId", table: "SignedPreKeys", @@ -498,11 +544,62 @@ namespace Govor.Data.Migrations column: "UserSessionId", unique: true); + migrationBuilder.CreateIndex( + name: "IX_UserPushTokens_Token", + table: "UserPushTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserPushTokens_UserId", + table: "UserPushTokens", + column: "UserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserPushTokens_UserId_IsActive", + table: "UserPushTokens", + columns: new[] { "UserId", "IsActive" }); + + migrationBuilder.CreateIndex( + name: "IX_UserPushTokens_UserSessionId", + table: "UserPushTokens", + column: "UserSessionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_CreatedOn", + table: "Users", + column: "CreatedOn"); + migrationBuilder.CreateIndex( name: "IX_Users_InviteId", table: "Users", column: "InviteId"); + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_WasOnline", + table: "Users", + column: "WasOnline"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_Id", + table: "UserSessions", + column: "Id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_RefreshTokenHash", + table: "UserSessions", + column: "RefreshTokenHash", + unique: true); + migrationBuilder.CreateIndex( name: "IX_UserSessions_UserId", table: "UserSessions", @@ -539,6 +636,9 @@ namespace Govor.Data.Migrations migrationBuilder.DropTable( name: "SignedPreKeys"); + migrationBuilder.DropTable( + name: "UserPushTokens"); + migrationBuilder.DropTable( name: "GroupInvitations"); diff --git a/Govor.Data/Migrations/20251214065852_update_sessions_model.Designer.cs b/Govor.Data/Migrations/20260301091506_FixRecipientIndex.Designer.cs similarity index 92% rename from Govor.Data/Migrations/20251214065852_update_sessions_model.Designer.cs rename to Govor.Data/Migrations/20260301091506_FixRecipientIndex.Designer.cs index 7a52bfb..9327098 100644 --- a/Govor.Data/Migrations/20251214065852_update_sessions_model.Designer.cs +++ b/Govor.Data/Migrations/20260301091506_FixRecipientIndex.Designer.cs @@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Govor.Data.Migrations { [DbContext(typeof(GovorDbContext))] - [Migration("20251214065852_update_sessions_model")] - partial class update_sessions_model + [Migration("20260301091506_FixRecipientIndex")] + partial class FixRecipientIndex { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -302,6 +302,8 @@ namespace Govor.Data.Migrations b.HasIndex("PrivateChatId"); + b.HasIndex("RecipientId"); + b.ToTable("Messages"); }); @@ -495,14 +497,70 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("CreatedOn"); + b.HasIndex("InviteId"); b.HasIndex("Username") .IsUnique(); + b.HasIndex("WasOnline"); + b.ToTable("Users"); }); + modelBuilder.Entity("Govor.Core.Models.Users.UserPushToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("UserSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("UserSessionId") + .IsUnique(); + + b.HasIndex("UserId", "IsActive"); + + b.ToTable("UserPushTokens"); + }); + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => { b.Property("Id") @@ -532,6 +590,12 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + b.HasIndex("UserId"); b.ToTable("UserSessions"); diff --git a/Govor.Data/Migrations/20260301091506_FixRecipientIndex.cs b/Govor.Data/Migrations/20260301091506_FixRecipientIndex.cs new file mode 100644 index 0000000..5e43c86 --- /dev/null +++ b/Govor.Data/Migrations/20260301091506_FixRecipientIndex.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Govor.Data.Migrations +{ + /// + public partial class FixRecipientIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PrivateChats_Id", + table: "PrivateChats"); + + migrationBuilder.DropIndex( + name: "IX_Messages_Id", + table: "Messages"); + + migrationBuilder.DropIndex( + name: "IX_Messages_RecipientId", + table: "Messages"); + + migrationBuilder.DropIndex( + name: "IX_Friendships_Id", + table: "Friendships"); + + migrationBuilder.CreateIndex( + name: "IX_Messages_RecipientId", + table: "Messages", + column: "RecipientId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Messages_RecipientId", + table: "Messages"); + + migrationBuilder.CreateIndex( + name: "IX_PrivateChats_Id", + table: "PrivateChats", + column: "Id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Messages_Id", + table: "Messages", + column: "Id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Messages_RecipientId", + table: "Messages", + column: "RecipientId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Friendships_Id", + table: "Friendships", + column: "Id", + unique: true); + } + } +} diff --git a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs index 6217647..3e7a42b 100644 --- a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs +++ b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs @@ -299,6 +299,8 @@ namespace Govor.Data.Migrations b.HasIndex("PrivateChatId"); + b.HasIndex("RecipientId"); + b.ToTable("Messages"); }); @@ -492,14 +494,70 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("CreatedOn"); + b.HasIndex("InviteId"); b.HasIndex("Username") .IsUnique(); + b.HasIndex("WasOnline"); + b.ToTable("Users"); }); + modelBuilder.Entity("Govor.Core.Models.Users.UserPushToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("UserSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("UserSessionId") + .IsUnique(); + + b.HasIndex("UserId", "IsActive"); + + b.ToTable("UserPushTokens"); + }); + modelBuilder.Entity("Govor.Core.Models.Users.UserSession", b => { b.Property("Id") @@ -529,6 +587,12 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + b.HasIndex("UserId"); b.ToTable("UserSessions"); diff --git a/Govor.Data/Repositories/PushTokenRepository.cs b/Govor.Data/Repositories/PushTokenRepository.cs new file mode 100644 index 0000000..db60747 --- /dev/null +++ b/Govor.Data/Repositories/PushTokenRepository.cs @@ -0,0 +1,140 @@ +using Govor.Core.Infrastructure.Extensions; +using Govor.Core.Models.Users; +using Govor.Core.Repositories.PushTokens; +using Govor.Data.Repositories.Exceptions; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Data.Repositories; + +public class PushTokenRepository : IPushTokenRepository +{ + private GovorDbContext _context; + + public PushTokenRepository(GovorDbContext context) + { + _context = context; + } + + public async Task> GetActiveTokensAsync(Guid userId) + { + return await _context.UserPushTokens + .AsNoTracking() + .Where(t => t.UserId == userId && t.IsActive) + .Select(t => t.Token) + .ToListAsync(); + } + + public async Task> GetActiveTokensUsersAsync(IEnumerable userIds) + { + if (userIds is null || !userIds.Any()) + return []; + + var distinctIds = userIds.Distinct().ToList(); + + return await _context.UserPushTokens + .AsNoTracking() + .Where(t => distinctIds.Contains(t.UserId) && t.IsActive) + .Select(t => t.Token) + .ToListAsync(); + } + + public async Task GetActiveTokenBySessionAsync(Guid sessionId) + { + if (sessionId == Guid.Empty) + return ""; + + return await _context.UserPushTokens + .AsNoTracking() + .Where(t => t.UserSessionId == sessionId && t.IsActive) + .Select(t => t.Token) + .FirstOrDefaultAsync() ?? ""; + } + + public async Task AddOrUpdateTokenAsync(Guid userId, Guid? sessionId, string token, string platform, string provider = "FCM") + { + if (userId == Guid.Empty) + throw new ArgumentException("UserId cannot be empty", nameof(userId)); + + if (string.IsNullOrWhiteSpace(token)) + throw new ArgumentException("Token cannot be empty", nameof(token)); + + if (string.IsNullOrWhiteSpace(platform)) + throw new ArgumentException("Platform cannot be empty", nameof(platform)); + + var existing = await _context.UserPushTokens + .FirstOrDefaultAsync(t => t.Token == token); + + if (existing != null) + { + // Updates + existing.UserId = userId; + existing.UserSessionId = sessionId; + existing.Platform = platform; + existing.Provider = provider; + existing.UpdatedAt = DateTime.UtcNow; + existing.LastUsedAt = DateTime.UtcNow; + existing.IsActive = true; + + _context.UserPushTokens.Update(existing); + } + else + { + // Add new + var newToken = new UserPushToken + { + UserId = userId, + UserSessionId = sessionId, + Token = token, + Platform = platform, + Provider = provider, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + LastUsedAt = DateTime.UtcNow, + IsActive = true, + }; + + _context.UserPushTokens.Add(newToken); + } + + await _context.SaveChangesAsync(); + } + + public async Task RemoveTokensAsync(IEnumerable tokens) + { + if (tokens is null || !tokens.Any()) + return; + + var tokenList = tokens.Distinct().ToList(); + + var toRemove = await _context.UserPushTokens + .Where(t => tokenList.Contains(t.Token)) + .ToListAsync(); + + if (toRemove.Any()) + { + _context.UserPushTokens.RemoveRange(toRemove); + await _context.SaveChangesAsync(); + } + } + + public async Task DeactivateTokenBySessionAsync(Guid sessionId) + { + if (sessionId == Guid.Empty) + return; + + var tokens = await _context.UserPushTokens + .Where(t => t.UserSessionId == sessionId && t.IsActive) + .ToListAsync(); + + if (tokens.Any()) + { + foreach (var token in tokens) + { + token.IsActive = false; + token.UpdatedAt = DateTime.UtcNow; + } + + await _context.SaveChangesAsync(); + } + } +} \ No newline at end of file