12 KiB
Piano: Collaborazione Real-Time nel Report Designer
Obiettivo
Implementare la collaborazione in tempo reale nel Report Designer, simile a Google Docs o Excel Online. Quando un utente modifica un template, tutti gli altri utenti collegati allo stesso template vedono le modifiche istantaneamente.
Architettura Proposta
Concetti Chiave
- Room-based Collaboration: Ogni template ha una "room" SignalR dedicata
- Operational Transformation Semplificata: Invece di OT completo (complesso), usiamo un modello "last-write-wins" con sync frequente
- Presence Awareness: Gli utenti vedono chi altro sta modificando il template
- Cursor/Selection Sharing: Mostra quale elemento sta selezionando ogni utente
Flusso Dati
User A modifica elemento
↓
Template locale aggiornato (come ora)
↓
SignalR invia delta alla room
↓
Backend riceve e ritrasmette a tutti nella room (eccetto mittente)
↓
User B/C/... ricevono delta
↓
Applicano delta al loro template locale
↓
Canvas si aggiorna automaticamente (già funziona così)
Implementazione Dettagliata
FASE 1: Backend - ReportCollaborationHub
File: /src/Apollinare.API/Hubs/ReportCollaborationHub.cs
Nuovo Hub dedicato per la collaborazione sui report:
public class ReportCollaborationHub : Hub
{
// Stato in-memory degli utenti per template
private static ConcurrentDictionary<string, HashSet<CollaboratorInfo>> _templateUsers = new();
// Join a template room
public async Task JoinTemplate(int templateId, string userName, string userColor)
// Leave template room
public async Task LeaveTemplate(int templateId)
// Broadcast element change to room
public async Task ElementChanged(int templateId, ElementChangeDto change)
// Broadcast element added
public async Task ElementAdded(int templateId, AprtElement element)
// Broadcast element deleted
public async Task ElementDeleted(int templateId, string elementId)
// Broadcast page changes
public async Task PageChanged(int templateId, PageChangeDto change)
// Broadcast selection change (which element user is editing)
public async Task SelectionChanged(int templateId, string? elementId)
// Broadcast cursor position (optional, for live cursors)
public async Task CursorMoved(int templateId, float x, float y)
// Request full template sync (when joining late or after reconnect)
public async Task RequestSync(int templateId)
// Send full template state (host responds to sync requests)
public async Task SendSync(int templateId, string connectionId, AprtTemplate template)
}
DTOs:
public record CollaboratorInfo(string ConnectionId, string UserName, string Color, string? SelectedElementId);
public record ElementChangeDto(
string ElementId,
string ChangeType, // "position", "style", "content", "visibility", etc.
object NewValue
);
public record PageChangeDto(
string PageId,
string ChangeType, // "added", "deleted", "reordered", "renamed", "settings"
object? Data
);
FASE 2: Frontend - Servizio Collaborazione
File: /frontend/src/services/reportCollaboration.ts
class ReportCollaborationService {
private connection: HubConnection | null = null;
private templateId: number | null = null;
private listeners: Map<string, Set<Function>> = new Map();
// Connessione e gestione room
async joinTemplate(
templateId: number,
userName: string,
userColor: string,
): Promise<void>;
async leaveTemplate(): Promise<void>;
// Invio modifiche (chiamati dal ReportEditorPage)
sendElementChange(elementId: string, changeType: string, newValue: any): void;
sendElementAdded(element: AprtElement): void;
sendElementDeleted(elementId: string): void;
sendPageChange(pageId: string, changeType: string, data?: any): void;
sendSelectionChange(elementId: string | null): void;
// Sottoscrizione eventi (per ricevere modifiche da altri)
onElementChanged(callback: (change: ElementChange) => void): () => void;
onElementAdded(callback: (element: AprtElement) => void): () => void;
onElementDeleted(callback: (elementId: string) => void): () => void;
onPageChanged(callback: (change: PageChange) => void): () => void;
onCollaboratorsChanged(
callback: (collaborators: Collaborator[]) => void,
): () => void;
onSelectionChanged(
callback: (userId: string, elementId: string | null) => void,
): () => void;
onSyncRequested(callback: (requesterId: string) => void): () => void;
// Sync
requestSync(): void;
sendSync(connectionId: string, template: AprtTemplate): void;
}
FASE 3: Frontend - Integrazione in ReportEditorPage
Modifiche a: /frontend/src/pages/ReportEditorPage.tsx
- Nuovo State per collaborazione:
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [remoteSelections, setRemoteSelections] = useState<Map<string, string>>(
new Map(),
);
const [isCollaborating, setIsCollaborating] = useState(false);
- Join/Leave room al mount/unmount:
useEffect(() => {
if (!isNew && id) {
const userName = getCurrentUserName(); // Da auth context
const userColor = generateUserColor(userName);
reportCollaborationService
.joinTemplate(Number(id), userName, userColor)
.then(() => setIsCollaborating(true));
return () => {
reportCollaborationService.leaveTemplate();
};
}
}, [id, isNew]);
- Sottoscrizione eventi remoti:
useEffect(() => {
if (!isCollaborating) return;
const unsubscribers = [
reportCollaborationService.onElementChanged((change) => {
// Applica modifica senza creare history entry
historyActions.setWithoutHistory((prev) => {
// ... applica change.newValue a element con change.elementId
});
}),
reportCollaborationService.onElementAdded((element) => {
historyActions.setWithoutHistory((prev) => ({
...prev,
elements: [...prev.elements, element],
}));
}),
// ... altri handler
reportCollaborationService.onCollaboratorsChanged(setCollaborators),
];
return () => unsubscribers.forEach((unsub) => unsub());
}, [isCollaborating]);
- Invio modifiche locali:
Modificare handleUpdateElement per inviare anche via SignalR:
const handleUpdateElement = useCallback(
(elementId: string, updates: Partial<AprtElement>) => {
// Aggiorna stato locale (come ora)
historyActions.set((prev) => ({...}));
// Invia a collaboratori
if (isCollaborating) {
reportCollaborationService.sendElementChange(elementId, "update", updates);
}
},
[historyActions, isCollaborating],
);
FASE 4: UI Collaborazione
File: /frontend/src/components/reportEditor/CollaboratorsBar.tsx
Barra che mostra gli utenti connessi:
interface CollaboratorsBarProps {
collaborators: Collaborator[];
remoteSelections: Map<string, string>; // userId -> elementId
}
// Mostra:
// - Avatar circolari colorati per ogni collaboratore
// - Tooltip con nome utente
// - Indicatore "sta modificando [elemento]"
// - Badge con conteggio totale collaboratori
Modifiche a EditorCanvas: Evidenziare elementi selezionati da altri utenti con bordo colorato.
FASE 5: Gestione Conflitti
Per semplicità, usiamo strategia "last-write-wins" con alcune ottimizzazioni:
- Elementi diversi: Nessun conflitto, modifiche applicate indipendentemente
- Stesso elemento, proprietà diverse: Merge delle proprietà
- Stesso elemento, stessa proprietà: Ultima modifica vince
- Lock visivo: Quando un utente seleziona un elemento, gli altri vedono un indicatore
Opzionale (Fase futura): Lock pessimistico - solo un utente alla volta può modificare un elemento.
File da Creare/Modificare
Nuovi File
| File | Descrizione |
|---|---|
src/Apollinare.API/Hubs/ReportCollaborationHub.cs |
Hub SignalR per collaborazione |
src/Apollinare.API/Models/CollaborationDtos.cs |
DTOs per messaggi collaborazione |
frontend/src/services/reportCollaboration.ts |
Client SignalR per collaborazione |
frontend/src/components/reportEditor/CollaboratorsBar.tsx |
UI collaboratori connessi |
frontend/src/types/collaboration.ts |
Types TypeScript per collaborazione |
File da Modificare
| File | Modifiche |
|---|---|
src/Apollinare.API/Program.cs |
Registrare nuovo hub /hubs/report-collaboration |
frontend/src/pages/ReportEditorPage.tsx |
Integrare collaborazione, stato collaboratori |
frontend/src/components/reportEditor/EditorCanvas.tsx |
Mostrare selezioni remote |
frontend/src/components/reportEditor/EditorToolbar.tsx |
Mostrare CollaboratorsBar |
Stima Complessità
| Fase | Complessità | Note |
|---|---|---|
| 1. Backend Hub | Media | SignalR groups, gestione stato in-memory |
| 2. Frontend Service | Media | Gestione connessione, eventi |
| 3. Integrazione Editor | Alta | Molti handler da modificare |
| 4. UI Collaboratori | Bassa | Componente semplice |
| 5. Gestione Conflitti | Media | Merge logic |
Considerazioni Aggiuntive
Performance
- Throttling degli aggiornamenti durante drag (ogni 50-100ms invece di ogni frame)
- Debounce per modifiche testo (300ms)
- Batch di modifiche multiple in singolo messaggio
Scalabilità
- Per deployment multi-server: usare Redis backplane per SignalR
- Considerare Azure SignalR Service per produzione
Autenticazione
- Aggiungere autenticazione al hub (verificare che utente abbia accesso al template)
- Usare JWT token per identificare utente
Edge Cases
- Reconnessione dopo disconnessione: richiedere sync completo
- Template eliminato mentre utenti connessi: notificare e chiudere
- Conflitto salvataggio: merge o "force save" con conferma
Ordine di Implementazione Consigliato
- Backend Hub base - Join/Leave room, broadcast semplice
- Frontend service base - Connessione, invio/ricezione messaggi
- Integrazione minima - Solo sync modifiche elementi (no UI collaboratori)
- Test funzionale - Verificare che modifiche si propaghino
- UI Collaboratori - Mostrare chi è connesso
- Selezioni remote - Evidenziare elementi selezionati da altri
- Ottimizzazioni - Throttling, batching, gestione conflitti
Domande per l'Utente
Prima di procedere, confermare:
- Autenticazione: Il sistema ha già autenticazione utenti? Devo usare un sistema mock per ora?
- Persistenza stato: Le modifiche devono essere salvate automaticamente o solo quando l'utente clicca "Salva"?
- Lock elementi: Vuoi che solo un utente alla volta possa modificare un elemento (lock pessimistico)?
- Cursori live: Vuoi vedere il cursore degli altri utenti in tempo reale (come Google Docs)?
- Nome utente: Da dove prendo il nome utente da mostrare? (localStorage, auth context, prompt?)