528 lines
17 KiB
C#
528 lines
17 KiB
C#
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
|