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