using System.Collections.Concurrent; using Microsoft.AspNetCore.SignalR; namespace Apollinare.API.Hubs; /// /// Hub SignalR generico per la collaborazione in tempo reale su qualsiasi entità/pagina /// Supporta: presenza utenti, cursori, selezioni, modifiche dati, chat /// public class CollaborationHub : Hub { // Stato in-memory: roomKey -> lista collaboratori // roomKey format: "{entityType}:{entityId}" es. "report-template:123", "evento:456", "page:dashboard" private static readonly ConcurrentDictionary> _rooms = new(); // Mapping connectionId -> roomKey per cleanup su disconnect private static readonly ConcurrentDictionary _connectionRooms = new(); #region Room Management /// /// Un utente entra in una room (pagina/entità) /// public async Task JoinRoom(string roomKey, string userName, string userColor, object? metadata = null) { try { var connectionId = Context.ConnectionId; // Validate inputs if (string.IsNullOrEmpty(roomKey)) { Console.WriteLine($"[Collaboration] JoinRoom failed: roomKey is null or empty"); return; } // Aggiungi alla room SignalR await Groups.AddToGroupAsync(connectionId, roomKey); // Crea info collaboratore var collaborator = new CollaboratorInfo { ConnectionId = connectionId, UserName = userName ?? "Anonymous", Color = userColor ?? "#888888", JoinedAt = DateTime.UtcNow, SelectedItemId = null, CursorX = null, CursorY = null, CurrentViewId = null, Metadata = metadata, IsActive = true, LastActivityAt = DateTime.UtcNow }; // Aggiungi al dizionario collaboratori della room var roomCollabs = _rooms.GetOrAdd(roomKey, _ => new ConcurrentDictionary()); roomCollabs[connectionId] = collaborator; // Traccia mapping connection -> room _connectionRooms[connectionId] = roomKey; // Notifica tutti gli altri nella room await Clients.OthersInGroup(roomKey).SendAsync("UserJoined", collaborator); // Invia al nuovo utente la lista dei collaboratori già presenti var existingCollaborators = roomCollabs.Values .Where(c => c.ConnectionId != connectionId) .ToList(); await Clients.Caller.SendAsync("RoomState", new RoomStateMessage { RoomKey = roomKey, Collaborators = existingCollaborators, JoinedAt = DateTime.UtcNow }); Console.WriteLine($"[Collaboration] {userName} joined room {roomKey}"); } catch (Exception ex) { Console.WriteLine($"[Collaboration] ERROR in JoinRoom: {ex.Message}"); Console.WriteLine($"[Collaboration] Stack: {ex.StackTrace}"); throw; // Re-throw to let SignalR handle it } } /// /// Un utente esce da una room /// public async Task LeaveRoom(string roomKey) { var connectionId = Context.ConnectionId; await RemoveFromRoom(connectionId, roomKey); } /// /// Cambia room (es. navigazione tra pagine) /// public async Task SwitchRoom(string newRoomKey, string userName, string userColor, object? metadata = null) { var connectionId = Context.ConnectionId; // Esci dalla room corrente se presente if (_connectionRooms.TryGetValue(connectionId, out var currentRoom) && currentRoom != newRoomKey) { await RemoveFromRoom(connectionId, currentRoom); } // Entra nella nuova room await JoinRoom(newRoomKey, userName, userColor, metadata); } #endregion #region Data Sync /// /// Notifica modifica di un oggetto/campo /// public async Task DataChanged(string roomKey, DataChangeMessage change) { change.SenderConnectionId = Context.ConnectionId; change.Timestamp = DateTime.UtcNow; UpdateUserActivity(Context.ConnectionId); await Clients.OthersInGroup(roomKey).SendAsync("DataChanged", change); } /// /// Notifica creazione di un nuovo oggetto /// public async Task ItemCreated(string roomKey, ItemCreatedMessage message) { message.SenderConnectionId = Context.ConnectionId; message.Timestamp = DateTime.UtcNow; UpdateUserActivity(Context.ConnectionId); await Clients.OthersInGroup(roomKey).SendAsync("ItemCreated", message); } /// /// Notifica eliminazione di un oggetto /// public async Task ItemDeleted(string roomKey, ItemDeletedMessage message) { message.SenderConnectionId = Context.ConnectionId; message.Timestamp = DateTime.UtcNow; UpdateUserActivity(Context.ConnectionId); await Clients.OthersInGroup(roomKey).SendAsync("ItemDeleted", message); } /// /// Notifica operazione batch (es. riordino, bulk update) /// public async Task BatchOperation(string roomKey, BatchOperationMessage message) { message.SenderConnectionId = Context.ConnectionId; message.Timestamp = DateTime.UtcNow; UpdateUserActivity(Context.ConnectionId); await Clients.OthersInGroup(roomKey).SendAsync("BatchOperation", message); } #endregion #region Presence & Awareness /// /// Notifica cambio selezione (quale elemento sta modificando l'utente) /// public async Task SelectionChanged(string roomKey, string? itemId) { var connectionId = Context.ConnectionId; // Aggiorna stato locale UpdateCollaboratorState(roomKey, connectionId, c => c.SelectedItemId = itemId); UpdateUserActivity(connectionId); await Clients.OthersInGroup(roomKey).SendAsync("SelectionChanged", new SelectionChangedMessage { ConnectionId = connectionId, ItemId = itemId, Timestamp = DateTime.UtcNow }); } /// /// Notifica movimento cursore /// public async Task CursorMoved(string roomKey, float x, float y, string? viewId = null) { var connectionId = Context.ConnectionId; // Aggiorna stato locale UpdateCollaboratorState(roomKey, connectionId, c => { c.CursorX = x; c.CursorY = y; c.CurrentViewId = viewId; }); // Non aggiorniamo LastActivityAt per ogni movimento cursore (troppo frequente) await Clients.OthersInGroup(roomKey).SendAsync("CursorMoved", new CursorMovedMessage { ConnectionId = connectionId, X = x, Y = y, ViewId = viewId, Timestamp = DateTime.UtcNow }); } /// /// Notifica cambio vista/sezione (es. cambio tab, pagina, scroll) /// public async Task ViewChanged(string roomKey, string viewId) { var connectionId = Context.ConnectionId; UpdateCollaboratorState(roomKey, connectionId, c => c.CurrentViewId = viewId); UpdateUserActivity(connectionId); await Clients.OthersInGroup(roomKey).SendAsync("ViewChanged", new ViewChangedMessage { ConnectionId = connectionId, ViewId = viewId, Timestamp = DateTime.UtcNow }); } /// /// Indica che l'utente sta digitando/modificando /// public async Task UserTyping(string roomKey, string? itemId, bool isTyping) { var connectionId = Context.ConnectionId; UpdateUserActivity(connectionId); await Clients.OthersInGroup(roomKey).SendAsync("UserTyping", new UserTypingMessage { ConnectionId = connectionId, ItemId = itemId, IsTyping = isTyping, Timestamp = DateTime.UtcNow }); } #endregion #region Sync & Recovery /// /// Richiesta sync completo (per nuovo utente o dopo reconnect) /// public async Task RequestSync(string roomKey) { // Chiedi al primo collaboratore (host) di inviare lo stato completo if (_rooms.TryGetValue(roomKey, out var collabs)) { var host = collabs.Values .Where(c => c.ConnectionId != Context.ConnectionId && c.IsActive) .OrderBy(c => c.JoinedAt) .FirstOrDefault(); if (host != null) { await Clients.Client(host.ConnectionId).SendAsync("SyncRequested", new SyncRequestMessage { RequesterId = Context.ConnectionId, RoomKey = roomKey, Timestamp = DateTime.UtcNow }); } } } /// /// Invio sync completo a un utente specifico /// public async Task SendSync(string roomKey, string targetConnectionId, string dataJson) { await Clients.Client(targetConnectionId).SendAsync("SyncReceived", new SyncDataMessage { RoomKey = roomKey, DataJson = dataJson, SenderConnectionId = Context.ConnectionId, Timestamp = DateTime.UtcNow }); } /// /// Notifica salvataggio dati /// public async Task DataSaved(string roomKey, string savedBy) { Console.WriteLine($"[Collaboration] DataSaved received from {savedBy} for room {roomKey}"); await Clients.OthersInGroup(roomKey).SendAsync("DataSaved", new DataSavedMessage { SavedBy = savedBy, RoomKey = roomKey, Timestamp = DateTime.UtcNow }); Console.WriteLine($"[Collaboration] DataSaved sent to others in room {roomKey}"); } #endregion #region Lifecycle public override async Task OnConnectedAsync() { Console.WriteLine($"[Collaboration] Client connected: {Context.ConnectionId}"); await base.OnConnectedAsync(); Console.WriteLine($"[Collaboration] Client connected (done): {Context.ConnectionId}"); } public override async Task OnDisconnectedAsync(Exception? exception) { var connectionId = Context.ConnectionId; if (exception != null) { Console.WriteLine($"[Collaboration] Client disconnected with ERROR: {connectionId} - {exception.Message}"); } else { Console.WriteLine($"[Collaboration] Client disconnected: {connectionId}"); } // Rimuovi da eventuali room if (_connectionRooms.TryRemove(connectionId, out var roomKey)) { await RemoveFromRoom(connectionId, roomKey); } await base.OnDisconnectedAsync(exception); } #endregion #region Helpers private async Task RemoveFromRoom(string connectionId, string roomKey) { // Rimuovi dalla room SignalR await Groups.RemoveFromGroupAsync(connectionId, roomKey); // Rimuovi dal dizionario if (_rooms.TryGetValue(roomKey, out var collabs)) { if (collabs.TryRemove(connectionId, out var removedCollab)) { // Notifica gli altri await Clients.OthersInGroup(roomKey).SendAsync("UserLeft", new UserLeftMessage { ConnectionId = connectionId, UserName = removedCollab.UserName, Timestamp = DateTime.UtcNow }); Console.WriteLine($"[Collaboration] {removedCollab.UserName} left room {roomKey}"); } // Pulisci dizionario se vuoto if (collabs.IsEmpty) { _rooms.TryRemove(roomKey, out _); } } _connectionRooms.TryRemove(connectionId, out _); } private void UpdateCollaboratorState(string roomKey, string connectionId, Action update) { if (_rooms.TryGetValue(roomKey, out var collabs) && collabs.TryGetValue(connectionId, out var collaborator)) { update(collaborator); } } private void UpdateUserActivity(string connectionId) { if (_connectionRooms.TryGetValue(connectionId, out var roomKey) && _rooms.TryGetValue(roomKey, out var collabs) && collabs.TryGetValue(connectionId, out var collaborator)) { collaborator.LastActivityAt = DateTime.UtcNow; collaborator.IsActive = true; } } #endregion } #region DTOs / Models public class CollaboratorInfo { public string ConnectionId { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty; public string Color { get; set; } = string.Empty; public DateTime JoinedAt { get; set; } public string? SelectedItemId { get; set; } public float? CursorX { get; set; } public float? CursorY { get; set; } public string? CurrentViewId { get; set; } public object? Metadata { get; set; } public bool IsActive { get; set; } public DateTime LastActivityAt { get; set; } } public class RoomStateMessage { public string RoomKey { get; set; } = string.Empty; public List Collaborators { get; set; } = new(); public DateTime JoinedAt { get; set; } } public class DataChangeMessage { public string ItemId { get; set; } = string.Empty; public string ItemType { get; set; } = string.Empty; public string ChangeType { get; set; } = string.Empty; // "update", "partial", "field" public string? FieldPath { get; set; } // es. "position.x", "style.color" public object? NewValue { get; set; } public object? OldValue { get; set; } public string? SenderConnectionId { get; set; } public DateTime Timestamp { get; set; } } public class ItemCreatedMessage { public string ItemId { get; set; } = string.Empty; public string ItemType { get; set; } = string.Empty; public object Item { get; set; } = null!; public string? ParentId { get; set; } public int? Index { get; set; } public string? SenderConnectionId { get; set; } public DateTime Timestamp { get; set; } } public class ItemDeletedMessage { public string ItemId { get; set; } = string.Empty; public string ItemType { get; set; } = string.Empty; public string? SenderConnectionId { get; set; } public DateTime Timestamp { get; set; } } public class BatchOperationMessage { public string OperationType { get; set; } = string.Empty; // "reorder", "bulk-update", "bulk-delete" public string ItemType { get; set; } = string.Empty; public object Data { get; set; } = null!; public string? SenderConnectionId { get; set; } public DateTime Timestamp { get; set; } } public class SelectionChangedMessage { public string ConnectionId { get; set; } = string.Empty; public string? ItemId { get; set; } public DateTime Timestamp { get; set; } } public class CursorMovedMessage { public string ConnectionId { get; set; } = string.Empty; public float X { get; set; } public float Y { get; set; } public string? ViewId { get; set; } public DateTime Timestamp { get; set; } } public class ViewChangedMessage { public string ConnectionId { get; set; } = string.Empty; public string ViewId { get; set; } = string.Empty; public DateTime Timestamp { get; set; } } public class UserTypingMessage { public string ConnectionId { get; set; } = string.Empty; public string? ItemId { get; set; } public bool IsTyping { get; set; } public DateTime Timestamp { get; set; } } public class SyncRequestMessage { public string RequesterId { get; set; } = string.Empty; public string RoomKey { get; set; } = string.Empty; public DateTime Timestamp { get; set; } } public class SyncDataMessage { public string RoomKey { get; set; } = string.Empty; public string DataJson { get; set; } = string.Empty; public string SenderConnectionId { get; set; } = string.Empty; public DateTime Timestamp { get; set; } } public class DataSavedMessage { public string SavedBy { get; set; } = string.Empty; public string RoomKey { get; set; } = string.Empty; public DateTime Timestamp { get; set; } } public class UserLeftMessage { public string ConnectionId { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty; public DateTime Timestamp { get; set; } } #endregion