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