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.");
}
}