using System.Text.Json;
using CollabBLL.CollabSession;
using CollabDAL.DTO.CollabParticipant;
using GB5Shared.DTO.Framework.Login;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace CollabSL.EndPoints.Hubs;
///
/// CollabHub — real-time bidirectional communication for CollabSpace sessions.
/// Hub is transient; no mutable state stored in instance fields.
/// LoginDTO is always reconstructed from the HTTP context — never accepted from client payload.
/// All operations go through BLL — no direct DAL access.
///
public class CollabHub(
ICollabSessionBLL _sessionBll,
ILogger _logger
) : Hub
{
// ── Group naming ──────────────────────────────────────────────────
private static string SessionGroup(Guid sessionId) => $"collab:{sessionId}";
// ── Connection lifecycle ──────────────────────────────────────────
public override async Task OnConnectedAsync()
{
_logger.LogInformation("CollabHub connected: {ConnectionId}", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
try
{
var login = GetLoginDTO();
var participant = await _sessionBll
.HandleHubDisconnectAsync(Context.ConnectionId, login, CancellationToken.None)
.ConfigureAwait(false);
if (participant is not null)
{
var group = SessionGroup(participant.SessionId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, group);
await Clients.Group(group).ParticipantLeft(login.UserId.ToString());
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "CollabHub OnDisconnectedAsync error for {ConnectionId}",
Context.ConnectionId);
}
if (exception is not null)
_logger.LogWarning(exception, "CollabHub disconnected with error: {ConnectionId}",
Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
// ── Hub methods ───────────────────────────────────────────────────
///
/// Called by client after REST JoinCollabSession succeeds.
/// Registers the SignalR connection ID on the participant row and broadcasts participant list.
///
public async Task JoinSession(Guid sessionId)
{
var login = GetLoginDTO();
try
{
var group = SessionGroup(sessionId);
await Groups.AddToGroupAsync(Context.ConnectionId, group);
await _sessionBll.RecordHubConnectionAsync(
sessionId, login.UserId.ToString(), Context.ConnectionId, login,
CancellationToken.None).ConfigureAwait(false);
var sessionJson = await _sessionBll
.GetSessionAsync(sessionId, login, CancellationToken.None)
.ConfigureAwait(false);
var session = JsonConvert.DeserializeAnonymousType(
sessionJson,
new { Participants = new List() });
if (session?.Participants is { Count: > 0 })
await Clients.Group(group).ParticipantListUpdated(session.Participants);
}
catch (Exception ex)
{
_logger.LogError(ex, "JoinSession failed for {SessionId} user {UserId}",
sessionId, login.UserId);
await Clients.Caller.ErrorOccurred("JOIN_FAILED", "Failed to join session.");
}
}
/// Broadcasts the current route/navigation state to all participants except the sender.
public async Task SyncRoute(Guid sessionId, RouteSyncPayload payload)
{
try
{
await Clients.GroupExcept(SessionGroup(sessionId), Context.ConnectionId)
.ReceiveRouteSync(payload);
}
catch (Exception ex)
{
_logger.LogError(ex, "SyncRoute failed for {SessionId}", sessionId);
await Clients.Caller.ErrorOccurred("SYNC_ROUTE_FAILED", "Route sync failed.");
}
}
///
/// High-frequency cursor broadcast — no try/catch for performance.
/// Sends cursor position to all participants except the sender.
///
public async Task SyncCursor(Guid sessionId, CursorPayload payload)
{
await Clients.GroupExcept(SessionGroup(sessionId), Context.ConnectionId)
.ReceiveCursorMove(payload);
}
/// Broadcasts a single form field change to all participants except the sender.
public async Task SyncForm(Guid sessionId, FormSyncPayload payload)
{
try
{
await Clients.GroupExcept(SessionGroup(sessionId), Context.ConnectionId)
.ReceiveFormSync(payload);
}
catch (Exception ex)
{
_logger.LogError(ex, "SyncForm failed for {SessionId}", sessionId);
await Clients.Caller.ErrorOccurred("SYNC_FORM_FAILED", "Form sync failed.");
}
}
/// Sends a chat message to all participants in the session (including sender).
public async Task SendChatMessage(Guid sessionId, string message)
{
var login = GetLoginDTO();
try
{
var payload = new ChatMessagePayload(
login.UserId.ToString(),
login.UserName,
message,
DateTime.UtcNow);
await Clients.Group(SessionGroup(sessionId)).ReceiveChatMessage(payload);
}
catch (Exception ex)
{
_logger.LogError(ex, "SendChatMessage failed for {SessionId}", sessionId);
await Clients.Caller.ErrorOccurred("CHAT_FAILED", "Failed to send message.");
}
}
/// Broadcasts an annotation/drawing event to all participants except the sender.
public async Task SendAnnotation(Guid sessionId, AnnotationPayload payload)
{
try
{
await Clients.GroupExcept(SessionGroup(sessionId), Context.ConnectionId)
.ReceiveAnnotation(payload);
}
catch (Exception ex)
{
_logger.LogError(ex, "SendAnnotation failed for {SessionId}", sessionId);
await Clients.Caller.ErrorOccurred("ANNOTATION_FAILED", "Annotation sync failed.");
}
}
/// Notifies the current control holder that this user is requesting control.
public async Task RequestControl(Guid sessionId)
{
var login = GetLoginDTO();
try
{
var holderConnectionId = await _sessionBll
.GetControlHolderConnectionAsync(sessionId, login, CancellationToken.None)
.ConfigureAwait(false);
if (holderConnectionId is not null)
await Clients.Client(holderConnectionId)
.ControlRequested(login.UserId.ToString(), login.UserName);
}
catch (Exception ex)
{
_logger.LogError(ex, "RequestControl failed for {SessionId}", sessionId);
await Clients.Caller.ErrorOccurred("REQUEST_CONTROL_FAILED", "Control request failed.");
}
}
/// Updates participant heartbeat — keeps session alive. Failures are silent.
public async Task Heartbeat(Guid sessionId)
{
try
{
var login = GetLoginDTO();
await _sessionBll.RecordHeartbeatAsync(
sessionId, login.UserId.ToString(), login, CancellationToken.None)
.ConfigureAwait(false);
}
catch
{
// Heartbeat failures are silent — client will retry
}
}
// ── 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 System.Text.Json.JsonSerializer.Deserialize(raw)
?? throw new InvalidOperationException("Login header is invalid JSON.");
}
}