From 31fdf4cb37428ac5ee2dd58f7cf7ee0685d4d59c Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:07:29 +0700 Subject: [PATCH] Add user online presence tracking and notification Introduced interfaces and services for tracking user online status, including IOnlineUserStore, IUserNotificationScopeService, and IUserPresenceReader. Added PresenceHub for real-time presence updates via SignalR. Updated OnlinePingingController to use new services and return online status and last seen. Extended UserDto with IsOnline property. Updated dependency injection and privacy settings enum. Removed obsolete IUserPresenceService. --- .../Controllers/OnlinePingingController.cs | 15 +++- .../ConfigurationProgramExtensions.cs | 7 ++ Govor.API/Hubs/PresenceHub.cs | 83 +++++++++++++++++++ .../UserNotificationScopeServiceTests.cs | 15 ++++ .../Interfaces/IUserPresenceService.cs | 6 -- .../UserOnlineStatus/IOnlineUserStore.cs | 9 ++ .../IUserNotificationScopeService.cs | 6 ++ .../UserOnlineStatus/IUserPresenceReader.cs | 6 ++ .../UserOnlineStatus/OnlineUserStore.cs | 26 ++++++ .../UserNotificationScopeService.cs | 54 ++++++++++++ .../UserOnlineStatus/UserPresenceReader.cs | 11 +++ Govor.Contracts/DTOs/UserDto.cs | 1 + .../Models/Users/PrivacyUserSettings.cs | 3 +- 13 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 Govor.API/Hubs/PresenceHub.cs create mode 100644 Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs delete mode 100644 Govor.Application/Interfaces/IUserPresenceService.cs create mode 100644 Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs create mode 100644 Govor.Application/Interfaces/UserOnlineStatus/IUserNotificationScopeService.cs create mode 100644 Govor.Application/Interfaces/UserOnlineStatus/IUserPresenceReader.cs create mode 100644 Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs create mode 100644 Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs create mode 100644 Govor.Application/Services/UserOnlineStatus/UserPresenceReader.cs diff --git a/Govor.API/Controllers/OnlinePingingController.cs b/Govor.API/Controllers/OnlinePingingController.cs index 3f3a026..0a58dfc 100644 --- a/Govor.API/Controllers/OnlinePingingController.cs +++ b/Govor.API/Controllers/OnlinePingingController.cs @@ -1,6 +1,6 @@ using Govor.Application.Interfaces; using Govor.Application.Interfaces.Infrastructure.Extensions; -using Govor.Core.Repositories.Users; +using Govor.Application.Interfaces.UserOnlineStatus; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,7 +13,8 @@ public class OnlinePingingController : Controller { private readonly ILogger _logger; private readonly IPingHandlerService _ping; - private readonly IUserPresenceService _presenceService; + private readonly IUserPresenceReader _presenceReader; + private readonly IOnlineUserStore _userOnlineStore; private readonly ICurrentUserService _currentUserService; public OnlinePingingController(ILogger logger, @@ -57,11 +58,17 @@ public class OnlinePingingController : Controller } [HttpGet("status/{userId}")] - public IActionResult GetStatus(Guid userId) + public async Task GetStatus(Guid userId) { try { - return Ok(_presenceService.WhenUserWasOnline(userId)); + var isOnline = _userOnlineStore.IsOnline(userId); + var lastSeen = await _presenceReader.GetLastSeenAsync(userId); + + return Ok(new { + isOnline, + lastSeen + }); } catch (Exception e) { diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index d169afb..be0667d 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -7,12 +7,14 @@ 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.UserOnlineStatus; using Govor.Application.Interfaces.UserSession; using Govor.Application.Services; using Govor.Application.Services.Authentication; using Govor.Application.Services.Friends; using Govor.Application.Services.Medias; using Govor.Application.Services.Messages; +using Govor.Application.Services.UserOnlineStatus; using Govor.Application.Services.UserSessions; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; @@ -74,6 +76,11 @@ public static class ConfigurationProgramExtensions // UserSession services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); } diff --git a/Govor.API/Hubs/PresenceHub.cs b/Govor.API/Hubs/PresenceHub.cs new file mode 100644 index 0000000..fc23170 --- /dev/null +++ b/Govor.API/Hubs/PresenceHub.cs @@ -0,0 +1,83 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Application.Interfaces.UserOnlineStatus; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Hubs; + +[Authorize(Roles = "Admin, User")] +[Route("hubs/presence")] +public class PresenceHub : Hub +{ + private readonly ILogger _logger; + private readonly IUserNotificationScopeService _notificationScopeService; + private readonly IOnlineUserStore _onlineUserStore; + + public PresenceHub(ILogger logger, IUserNotificationScopeService notificationScopeService, IOnlineUserStore onlineUserStore) + { + _logger = logger; + _notificationScopeService = notificationScopeService; + _onlineUserStore = onlineUserStore; + } + + public override async Task OnConnectedAsync() + { + var userId = GetUserId(); + if (userId == Guid.Empty) + { + _logger.LogWarning("User connected with invalid UserID claim."); + Context.Abort(); + return; + } + + _onlineUserStore.SetOnlineUser(userId); + await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); + + var friends = await _notificationScopeService.GetNotifiedUsers(userId); + + foreach (var recipient in friends) + { + await Clients.Group(recipient.ToString()) + .SendAsync("UserOnline", userId); + } + + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + var userId = GetUserId(); + if (userId == Guid.Empty) return; + + _onlineUserStore.SetOfflineUser(userId); + + var friends = await _notificationScopeService.GetNotifiedUsers(userId); + + foreach (var recipient in friends) + { + await Clients.Group(recipient.ToString()) + .SendAsync("UserOffline", userId); + } + + await base.OnDisconnectedAsync(exception); + } + + private Guid GetUserId(bool suppressException = false) + { + var userIdClaim = Context.User?.FindFirst("userId")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + if (!suppressException) + { + _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); + throw new UnauthorizedAccessException("userId claim is missing or invalid."); + } + + return Guid.Empty; + } + + return userId; + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs b/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs new file mode 100644 index 0000000..b4cf4ef --- /dev/null +++ b/Govor.Application.Tests/Services/UserOnlineStatus/UserNotificationScopeServiceTests.cs @@ -0,0 +1,15 @@ +using Govor.Application.Services.UserOnlineStatus; + +namespace Govor.Application.Tests.Services.UserOnlineStatus; + +[TestFixture] +[TestOf(typeof(UserNotificationScopeService))] +public class UserNotificationScopeServiceTests +{ + + [Test] + public void METHOD() + { + + } +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/IUserPresenceService.cs b/Govor.Application/Interfaces/IUserPresenceService.cs deleted file mode 100644 index 38eea19..0000000 --- a/Govor.Application/Interfaces/IUserPresenceService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Govor.Application.Interfaces; - -public interface IUserPresenceService -{ - DateTime WhenUserWasOnline(Guid userId); -} \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs b/Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs new file mode 100644 index 0000000..65c4030 --- /dev/null +++ b/Govor.Application/Interfaces/UserOnlineStatus/IOnlineUserStore.cs @@ -0,0 +1,9 @@ +namespace Govor.Application.Interfaces.UserOnlineStatus; + +public interface IOnlineUserStore +{ + void SetOnlineUser(Guid userId); + void SetOfflineUser(Guid userId); + bool IsOnline(Guid userId); + IReadOnlyCollection GetAllOnlineUsers(); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserOnlineStatus/IUserNotificationScopeService.cs b/Govor.Application/Interfaces/UserOnlineStatus/IUserNotificationScopeService.cs new file mode 100644 index 0000000..0ac93a0 --- /dev/null +++ b/Govor.Application/Interfaces/UserOnlineStatus/IUserNotificationScopeService.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces.UserOnlineStatus; + +public interface IUserNotificationScopeService +{ + Task> GetNotifiedUsers(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/UserOnlineStatus/IUserPresenceReader.cs b/Govor.Application/Interfaces/UserOnlineStatus/IUserPresenceReader.cs new file mode 100644 index 0000000..5291ccb --- /dev/null +++ b/Govor.Application/Interfaces/UserOnlineStatus/IUserPresenceReader.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces.UserOnlineStatus; + +public interface IUserPresenceReader +{ + Task GetLastSeenAsync(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs new file mode 100644 index 0000000..a3813b9 --- /dev/null +++ b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs @@ -0,0 +1,26 @@ +using Govor.Application.Interfaces.UserOnlineStatus; + +namespace Govor.Application.Services.UserOnlineStatus; + +public class OnlineUserStore : IOnlineUserStore +{ + public void SetOnlineUser(Guid userId) + { + throw new NotImplementedException(); + } + + public void SetOfflineUser(Guid userId) + { + throw new NotImplementedException(); + } + + public bool IsOnline(Guid userId) + { + throw new NotImplementedException(); + } + + public IReadOnlyCollection GetAllOnlineUsers() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs b/Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs new file mode 100644 index 0000000..5435f49 --- /dev/null +++ b/Govor.Application/Services/UserOnlineStatus/UserNotificationScopeService.cs @@ -0,0 +1,54 @@ +using Govor.Application.Interfaces.Friends; +using Govor.Application.Interfaces.UserOnlineStatus; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services.UserOnlineStatus; + +public class UserNotificationScopeService : IUserNotificationScopeService +{ + private readonly ILogger _logger; + private readonly IFriendshipService _friendships; + + public UserNotificationScopeService(ILogger logger, IFriendshipService friendships) + { + _logger = logger; + _friendships = friendships; + } + + public async Task> GetNotifiedUsers(Guid userId) + { + try + { + _logger.LogInformation($"Getting notified users of online/offline action user {userId}"); + var users = await _friendships.GetFriendsAsync(userId); + return users.Select(u => u.Id).ToList(); + } + catch (NotFoundByKeyException ex) + { + _logger.LogError(ex, ex.Message); + throw new InvalidOperationException("User not found"); + } + } + + /*public async Task SetWasOnlineAsync(Guid userId, DateTime when) + { + try + { + _logger.LogInformation("Set was-online for user {UserId}", userId); + var user = await _users.FindByIdAsync(userId); + user.WasOnline = when; + await _users.UpdateAsync(user); + } + catch (NotFoundByKeyException ex) + { + _logger.LogError(ex, ex.Message); + throw new InvalidOperationException("User not found"); + } + catch (UpdateException ex) + { + _logger.LogError(ex, "Failed to set WasOnline for user {UserId} at {Time}", userId, when); + throw new InvalidOperationException("Something went wrong when trying to set was-online for user"); + } + }*/ +} \ No newline at end of file diff --git a/Govor.Application/Services/UserOnlineStatus/UserPresenceReader.cs b/Govor.Application/Services/UserOnlineStatus/UserPresenceReader.cs new file mode 100644 index 0000000..aeab7ab --- /dev/null +++ b/Govor.Application/Services/UserOnlineStatus/UserPresenceReader.cs @@ -0,0 +1,11 @@ +using Govor.Application.Interfaces.UserOnlineStatus; + +namespace Govor.Application.Services.UserOnlineStatus; + +public class UserPresenceReader : IUserPresenceReader +{ + public Task GetLastSeenAsync(Guid userId) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Govor.Contracts/DTOs/UserDto.cs b/Govor.Contracts/DTOs/UserDto.cs index 06b00cf..c89db9d 100644 --- a/Govor.Contracts/DTOs/UserDto.cs +++ b/Govor.Contracts/DTOs/UserDto.cs @@ -7,4 +7,5 @@ public class UserDto public string Description { get; set; } public DateTime WasOnline { get; set; } public Guid IconId {get; set;} + public bool IsOnline { get; set; } } \ No newline at end of file diff --git a/Govor.Core/Models/Users/PrivacyUserSettings.cs b/Govor.Core/Models/Users/PrivacyUserSettings.cs index 9997c9f..001c38d 100644 --- a/Govor.Core/Models/Users/PrivacyUserSettings.cs +++ b/Govor.Core/Models/Users/PrivacyUserSettings.cs @@ -34,7 +34,8 @@ public enum PrivacyTargetArea { CanSend = 0, CanSeeTimeWas = 1, - CanSeeImage = 2 + CanSeeImage = 2, + CanSendImage = 3, } public enum PrivacyRuleType