using System.Text.Json; using GB5Shared.DTO.Framework.Login; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace ECPSL.Communication.Hubs; /// /// CommunicationHub — real-time push for comment threads/replies/reactions/mentions/status /// changes (plan's "SignalR hub" section). Hub is transient; no mutable state stored in /// instance fields. LoginDTO is always reconstructed from the HTTP context — never accepted /// from client payload. All business logic goes through BLL — no direct DAL access from here. /// public class CommunicationHub(ILogger _logger) : Hub { // ── Group naming ────────────────────────────────────────────────── private static string ObjectGroup(int objectTypeId, int objectId) => $"communication:{objectTypeId}:{objectId}"; private static string UserGroup(int userId) => $"communication:user:{userId}"; // ── Connection lifecycle ────────────────────────────────────────── public override async Task OnConnectedAsync() { var login = GetLoginDTO(); await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(login.UserId)); _logger.LogInformation("CommunicationHub connected: {ConnectionId} user {UserId}", Context.ConnectionId, login.UserId); await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { try { var login = GetLoginDTO(); await Groups.RemoveFromGroupAsync(Context.ConnectionId, UserGroup(login.UserId)); } catch (Exception ex) { _logger.LogWarning(ex, "CommunicationHub OnDisconnectedAsync cleanup failed for {ConnectionId}", Context.ConnectionId); } if (exception is not null) _logger.LogWarning(exception, "CommunicationHub disconnected with error: {ConnectionId}", Context.ConnectionId); await base.OnDisconnectedAsync(exception); } // ── Hub methods (all async Task, never async void) ─────────────── /// Client calls this when opening a comment panel for a given business object. public async Task JoinObjectContext(int objectTypeId, int objectId) { try { await Groups.AddToGroupAsync(Context.ConnectionId, ObjectGroup(objectTypeId, objectId)); } catch (Exception ex) { _logger.LogError(ex, "JoinObjectContext failed for ObjectType {ObjectTypeId} Object {ObjectId}", objectTypeId, objectId); await Clients.Caller.ReceiveError("JOIN_FAILED", "Failed to join comment context."); } } /// Client calls this when closing a comment panel — hygiene only; SignalR also /// drops all group membership automatically on disconnect. public async Task LeaveObjectContext(int objectTypeId, int objectId) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, ObjectGroup(objectTypeId, objectId)); } // ── Helper ──────────────────────────────────────────────────────── /// /// Reconstructs LoginDTO from the HTTP context "Login" header. Never accepts LoginDTO from /// client payload — that would allow impersonation. /// private LoginDTO GetLoginDTO() { var http = Context.GetHttpContext() ?? throw new InvalidOperationException("Hub invoked outside HTTP context."); var raw = http.Request.Headers["Login"].FirstOrDefault() ?? throw new InvalidOperationException("Login header missing."); return JsonSerializer.Deserialize(raw) ?? throw new InvalidOperationException("Login header is invalid JSON."); } }