-
This commit is contained in:
527
src/Apollinare.API/Hubs/CollaborationHub.cs
Normal file
527
src/Apollinare.API/Hubs/CollaborationHub.cs
Normal file
@@ -0,0 +1,527 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Apollinare.API.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Hub SignalR generico per la collaborazione in tempo reale su qualsiasi entità/pagina
|
||||
/// Supporta: presenza utenti, cursori, selezioni, modifiche dati, chat
|
||||
/// </summary>
|
||||
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<string, ConcurrentDictionary<string, CollaboratorInfo>> _rooms = new();
|
||||
|
||||
// Mapping connectionId -> roomKey per cleanup su disconnect
|
||||
private static readonly ConcurrentDictionary<string, string> _connectionRooms = new();
|
||||
|
||||
#region Room Management
|
||||
|
||||
/// <summary>
|
||||
/// Un utente entra in una room (pagina/entità)
|
||||
/// </summary>
|
||||
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<string, CollaboratorInfo>());
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Un utente esce da una room
|
||||
/// </summary>
|
||||
public async Task LeaveRoom(string roomKey)
|
||||
{
|
||||
var connectionId = Context.ConnectionId;
|
||||
await RemoveFromRoom(connectionId, roomKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cambia room (es. navigazione tra pagine)
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Notifica modifica di un oggetto/campo
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifica creazione di un nuovo oggetto
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifica eliminazione di un oggetto
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifica operazione batch (es. riordino, bulk update)
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Notifica cambio selezione (quale elemento sta modificando l'utente)
|
||||
/// </summary>
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifica movimento cursore
|
||||
/// </summary>
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifica cambio vista/sezione (es. cambio tab, pagina, scroll)
|
||||
/// </summary>
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indica che l'utente sta digitando/modificando
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Richiesta sync completo (per nuovo utente o dopo reconnect)
|
||||
/// </summary>
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invio sync completo a un utente specifico
|
||||
/// </summary>
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifica salvataggio dati
|
||||
/// </summary>
|
||||
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<CollaboratorInfo> 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<CollaboratorInfo> 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
|
||||
Reference in New Issue
Block a user