using Collaboration.Application.Interfaces; using Collaboration.Domain.Enums; using Collaboration.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; using System.Security.Claims; namespace Collaboration.Hubs; [Authorize] public class CollabHub : Hub { private readonly ICollabSessionRepository _sessions; private readonly ICollabParticipantRepository _participants; private readonly ICollabConfigRepository _configs; private readonly ILogger _logger; private Guid CurrentUserId => Guid.Parse( Context.User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new UnauthorizedAccessException("User not authenticated.")); private Guid CurrentOrgId => Guid.Parse( Context.User?.FindFirstValue("orgId") ?? throw new UnauthorizedAccessException("OrgId claim missing.")); private string SessionGroup(Guid sessionId) => $"session:{sessionId}"; public CollabHub( ICollabSessionRepository sessions, ICollabParticipantRepository participants, ICollabConfigRepository configs, ILogger logger) { _sessions = sessions; _participants = participants; _configs = configs; _logger = logger; } // ── Connection lifecycle ───────────────────────────────────────────────── public override async Task OnDisconnectedAsync(Exception? exception) { var participant = await _participants.GetByConnectionIdAsync(Context.ConnectionId); if (participant is not null) { var sessionId = participant.SessionId; var userId = participant.UserId; var displayName = participant.DisplayName; var wasControl = participant.HasControl; participant.Leave(); await _participants.SaveChangesAsync(); await Groups.RemoveFromGroupAsync(Context.ConnectionId, SessionGroup(sessionId)); await Clients.Group(SessionGroup(sessionId)) .ParticipantLeft(userId, displayName); // If the person who left had control, log it — host can reassign if (wasControl) { var session = await _sessions.GetByIdAsync(sessionId); session?.AddEvent(SessionEventType.ControlRevoked, userId, "{\"reason\":\"disconnect\"}"); await _sessions.SaveChangesAsync(); await Clients.Group(SessionGroup(sessionId)).ControlRevoked(userId); } _logger.LogInformation("User {UserId} disconnected from session {SessionId}", userId, sessionId); } await base.OnDisconnectedAsync(exception); } // ── Hub Methods (Client → Server) ──────────────────────────────────────── /// /// Called immediately after a client joins a session via HTTP API. /// Registers the SignalR connection with the participant record. /// public async Task JoinSession(Guid sessionId) { var userId = CurrentUserId; var participant = await _participants.GetActiveByUserAndSessionAsync(userId, sessionId); if (participant is null) { await Clients.Caller.ErrorOccurred("NOT_IN_SESSION", "You have not joined this session via the API."); return; } participant.UpdateConnectionId(Context.ConnectionId); await _participants.SaveChangesAsync(); await Groups.AddToGroupAsync(Context.ConnectionId, SessionGroup(sessionId)); _logger.LogDebug("User {UserId} connected to session {SessionId} hub", userId, sessionId); } public async Task LeaveSession(Guid sessionId) { var userId = CurrentUserId; var participant = await _participants.GetActiveByUserAndSessionAsync(userId, sessionId); if (participant is null) return; var wasControl = participant.HasControl; participant.Leave(); var session = await _sessions.GetByIdAsync(sessionId); session?.AddEvent(SessionEventType.ParticipantLeft, userId); await _participants.SaveChangesAsync(); await Groups.RemoveFromGroupAsync(Context.ConnectionId, SessionGroup(sessionId)); await Clients.Group(SessionGroup(sessionId)).ParticipantLeft(userId, participant.DisplayName); if (wasControl) await Clients.Group(SessionGroup(sessionId)).ControlRevoked(userId); } /// /// Broadcast current route to all following participants. /// Only sent by the participant who has control OR the host. /// public async Task SyncRoute(RouteSyncPayload payload) { if (!await HasControlOrIsHost(payload.SessionId)) return; // Update session context in DB (debounce on client side already) var session = await _sessions.GetByIdAsync(payload.SessionId); if (session is not null) { session.UpdateContext(payload.ModuleCode, payload.RecordId, payload.RecordLabel, payload.Route); session.AddEvent(SessionEventType.RouteSync, CurrentUserId, $"{{\"route\":\"{payload.Route}\"}}"); await _sessions.SaveChangesAsync(); } // Broadcast to all OTHER participants (not sender) await Clients.OthersInGroup(SessionGroup(payload.SessionId)).RouteChanged(payload); } /// /// Cursor position broadcast — high frequency, not persisted. /// public async Task SyncCursor(CursorPayload payload) { var config = await _configs.GetOrCreateDefaultAsync(CurrentOrgId); if (!config.EnableCursorBroadcast) return; // Forward to others — no DB write, pure real-time relay await Clients.OthersInGroup(SessionGroup(payload.SessionId)).CursorMoved(payload); } /// /// Form field sync — only control holder can broadcast. /// public async Task SyncFormState(FormSyncPayload payload) { var config = await _configs.GetOrCreateDefaultAsync(CurrentOrgId); if (!config.EnableFormSync) return; if (!await HasControl(payload.SessionId)) return; var session = await _sessions.GetByIdAsync(payload.SessionId); session?.AddEvent(SessionEventType.FormSync, CurrentUserId, $"{{\"form\":\"{payload.FormId}\",\"field\":\"{payload.FieldPath}\"}}"); await _sessions.SaveChangesAsync(); await Clients.OthersInGroup(SessionGroup(payload.SessionId)).FormStateChanged(payload); } /// Request control from the host. Host will receive ControlRequested event. public async Task RequestControl(Guid sessionId) { var userId = CurrentUserId; var participant = await _participants.GetActiveByUserAndSessionAsync(userId, sessionId); if (participant is null) return; var session = await _sessions.GetWithParticipantsAsync(sessionId); if (session is null) return; session.AddEvent(SessionEventType.ControlRequested, userId); await _sessions.SaveChangesAsync(); // Notify host only var host = session.Participants.FirstOrDefault(p => p.Role == ParticipantRole.Host && p.IsActive); if (host?.SignalRConnectionId is not null) { await Clients.Client(host.SignalRConnectionId) .ControlRequested(userId, participant.DisplayName); } } /// /// Send a chat message to all session participants. /// public async Task SendChatMessage(Guid sessionId, string text) { var config = await _configs.GetOrCreateDefaultAsync(CurrentOrgId); if (!config.EnableInSessionChat) return; var userId = CurrentUserId; var participant = await _participants.GetActiveByUserAndSessionAsync(userId, sessionId); if (participant is null) return; if (string.IsNullOrWhiteSpace(text) || text.Length > 1000) return; var message = new ChatMessage( sessionId, userId, participant.DisplayName, participant.CursorColor, text.Trim(), DateTime.UtcNow); await Clients.Group(SessionGroup(sessionId)).ChatMessageReceived(message); } /// /// Broadcast an annotation overlay (draw, highlight, arrow, clear). /// public async Task BroadcastAnnotation(AnnotationPayload annotation) { var config = await _configs.GetOrCreateDefaultAsync(CurrentOrgId); if (!config.EnableAnnotations) return; if (!await HasControlOrIsHost(annotation.SessionId)) return; await Clients.OthersInGroup(SessionGroup(annotation.SessionId)) .AnnotationReceived(annotation); } /// Keep-alive heartbeat — updates last activity timestamp. public async Task Heartbeat(HeartbeatPayload payload) { var userId = CurrentUserId; var participant = await _participants.GetActiveByUserAndSessionAsync(userId, payload.SessionId); participant?.Heartbeat(); var session = await _sessions.GetByIdAsync(payload.SessionId); session?.RecordActivity(); await _participants.SaveChangesAsync(); } // ── Private helpers ────────────────────────────────────────────────────── private async Task HasControl(Guid sessionId) { var participant = await _participants .GetActiveByUserAndSessionAsync(CurrentUserId, sessionId); return participant?.HasControl ?? false; } private async Task HasControlOrIsHost(Guid sessionId) { var participant = await _participants .GetActiveByUserAndSessionAsync(CurrentUserId, sessionId); return participant is not null && (participant.HasControl || participant.Role == ParticipantRole.Host); } }