diff --git a/CLAUDE.md b/CLAUDE.md index e417976..b6f432e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,12 +46,25 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve ## Quick Start - Session Recovery -**Ultima sessione:** 28 Novembre 2025 (tarda notte) +**Ultima sessione:** 28 Novembre 2025 (pomeriggio - sera) **Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso **Lavoro completato nell'ultima sessione:** +- **NUOVA FEATURE: Sistema Collaborazione Real-Time Globale** - IN CORSO + - Migrato da sistema report-specific a sistema globale per tutta l'app + - `CollaborationHub.cs` - Hub SignalR generico per qualsiasi entità/pagina + - `collaboration.ts` - Service singleton per gestione connessione + - `CollaborationContext.tsx` - React Context con hooks (`useCollaboration`, `useCollaborationRoom`) + - `CollaborationIndicator.tsx` - Indicatore globale nella UI + - Room-based collaboration con formato `{entityType}:{entityId}` + - **FIX: Incompatibilità versione SignalR** - Rimosso package `Microsoft.AspNetCore.SignalR.Common` v10.0.0 incompatibile con .NET 9, downgrade client `@microsoft/signalr` a v8.0.7 + - **FIX: Auto-save non funzionante** - Usati `useRef` per evitare re-run dell'effect causato da `saveMutation` nelle dependencies + - **IN DEBUG:** Sincronizzazione real-time tra sessioni - Il salvataggio manuale sincronizza, l'auto-save invia la notifica ma la sessione 2 non la riceve ancora + +**Lavoro completato nelle sessioni precedenti (28 Novembre 2025 tarda notte):** + - **NUOVA FEATURE: Toolbar Report Designer Migliorata Drasticamente** - COMPLETATO - Design moderno stile Canva/Figma con gradient buttons e animazioni fluide - **Sezioni etichettate** su desktop (INSERISCI, MODIFICA, CRONOLOGIA, VISTA, ZOOM, PAGINA) @@ -1050,6 +1063,36 @@ frontend/src/ - **Type aggiunto:** `textDecoration` in `AprtStyle` - **File:** `EditorToolbar.tsx`, `types/report.ts` +20. **Incompatibilità Versione SignalR (FIX 28/11/2025 pomeriggio):** + - **Problema:** Errore "Method not found: 'System.String Microsoft.AspNetCore.SignalR.IInvocationBinder.GetTarget(System.ReadOnlySpan`1)'." - connessioni SignalR fallivano immediatamente + - **Causa:** Package `Microsoft.AspNetCore.SignalR.Common` v10.0.0 nel backend incompatibile con .NET 9 (è per .NET 10 preview) + - **Soluzione:** + - Rimosso `Microsoft.AspNetCore.SignalR.Common` dal .csproj (SignalR è già incluso in ASP.NET Core) + - Downgrade frontend `@microsoft/signalr` da v10.0.0 a v8.0.7 + - **File:** `Apollinare.API.csproj`, `frontend/package.json` + +21. **Auto-Save Non Funzionante (FIX 28/11/2025 pomeriggio):** + - **Problema:** Con auto-save attivo, le modifiche non venivano salvate (l'indicatore mostrava "Non salvato" sempre) + - **Causa:** `saveMutation` nell'array di dipendenze dell'`useEffect` causava il reset del timeout ad ogni render (React Query crea un nuovo oggetto ad ogni render) + - **Soluzione:** Usati `useRef` per `saveMutation`, `template`, e `templateInfo` per evitare che l'effect si ri-esegua inutilmente + - **File:** `ReportEditorPage.tsx` + +22. **Sistema Collaborazione Real-Time (IN CORSO 28/11/2025):** + - **Obiettivo:** Collaborazione stile Google Docs su tutto l'applicativo + - **Architettura implementata:** + - `CollaborationHub.cs` - Hub SignalR generico con room-based collaboration + - `collaboration.ts` - Service singleton frontend + - `CollaborationContext.tsx` - React Context con `useCollaborationRoom` hook + - Room key format: `{entityType}:{entityId}` (es. `report-template:2`) + - **Stato attuale:** + - Connessione e join room funzionanti + - Salvataggio manuale notifica le altre sessioni correttamente + - Auto-save invia `sendDataSaved()` ma le altre sessioni non ricevono la notifica + - **Debug in corso:** Aggiunto logging dettagliato per tracciare il flusso dei messaggi + - **File principali:** + - Backend: `CollaborationHub.cs` + - Frontend: `collaboration.ts`, `CollaborationContext.tsx`, `ReportEditorPage.tsx` + ### Schema Database Report System Le tabelle sono già nel DbContext (`AppollinareDbContext.cs`): diff --git a/PLAN.md b/PLAN.md index 7df22c4..b83160a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,425 +1,343 @@ -# Piano: Sistema di Report PDF con Editor Visuale +# Piano: Collaborazione Real-Time nel Report Designer ## Obiettivo -Creare un sistema completo di generazione report PDF con: -- Editor grafico drag-and-drop (stile Canva) -- Potenza di JasperReports (data binding, paginazione, formule) -- Metalinguaggio esportabile/importabile (tipo LaTeX) -- Salvataggio template riutilizzabili -- Supporto immagini e font personalizzati -## Architettura Proposta - -### 1. Metalinguaggio Template (APRT - Apollinare Report Template) - -```json -{ - "version": "1.0", - "meta": { - "name": "Scheda Evento", - "description": "Template per stampa evento", - "author": "admin", - "createdAt": "2025-01-15", - "pageSize": "A4", - "orientation": "portrait", - "margins": { "top": 20, "right": 15, "bottom": 20, "left": 15 } - }, - "resources": { - "fonts": [ - { "id": "font1", "name": "Roboto", "url": "/fonts/roboto.ttf" } - ], - "images": [ - { "id": "logo", "name": "Logo Aziendale", "url": "/images/logo.png" } - ] - }, - "dataSources": { - "evento": { "type": "object", "schema": "Evento" }, - "ospiti": { "type": "array", "schema": "EventoDettaglioOspiti" }, - "costi": { "type": "array", "schema": "EventoAltroCosto" } - }, - "sections": [ - { - "type": "header", - "height": 80, - "repeatOnPages": true, - "elements": [...] - }, - { - "type": "body", - "elements": [...] - }, - { - "type": "detail", - "dataSource": "ospiti", - "elements": [...] - }, - { - "type": "footer", - "height": 40, - "repeatOnPages": true, - "elements": [...] - } - ], - "elements": [ - { - "id": "elem1", - "type": "text", - "position": { "x": 10, "y": 10, "width": 200, "height": 30 }, - "style": { - "fontFamily": "font1", - "fontSize": 24, - "fontWeight": "bold", - "color": "#333333", - "textAlign": "left" - }, - "content": { - "type": "static", - "value": "SCHEDA EVENTO" - } - }, - { - "id": "elem2", - "type": "text", - "position": { "x": 10, "y": 50, "width": 150, "height": 20 }, - "content": { - "type": "binding", - "expression": "{{evento.codice}}" - } - }, - { - "id": "elem3", - "type": "image", - "position": { "x": 450, "y": 10, "width": 100, "height": 60 }, - "content": { - "type": "resource", - "resourceId": "logo" - } - }, - { - "id": "elem4", - "type": "table", - "position": { "x": 10, "y": 200, "width": 550, "height": "auto" }, - "dataSource": "ospiti", - "columns": [ - { "field": "tipoOspite.descrizione", "header": "Tipo", "width": 150 }, - { "field": "numero", "header": "Quantità", "width": 100 }, - { "field": "costoUnitario", "header": "Costo Unit.", "width": 100, "format": "currency" }, - { "field": "costoTotale", "header": "Totale", "width": 100, "format": "currency" } - ] - }, - { - "id": "elem5", - "type": "shape", - "position": { "x": 10, "y": 180, "width": 550, "height": 2 }, - "style": { - "backgroundColor": "#000000" - } - }, - { - "id": "pageNum", - "type": "text", - "section": "footer", - "position": { "x": 250, "y": 10, "width": 100, "height": 20 }, - "content": { - "type": "expression", - "value": "Pagina {{$pageNumber}} di {{$totalPages}}" - } - } - ] -} -``` - -### 2. Struttura Backend - -#### Nuove Entità -``` -ReportTemplate -├── Id -├── Nome -├── Descrizione -├── Categoria (Evento, Cliente, Articoli, etc.) -├── TemplateJson (il metalinguaggio APRT) -├── Thumbnail (preview del template) -├── Attivo -├── CreatedAt/By, UpdatedAt/By - -ReportFont -├── Id -├── Nome -├── FontFamily -├── FontData (BLOB - file TTF/OTF) -├── MimeType - -ReportImage -├── Id -├── Nome -├── Categoria -├── ImageData (BLOB) -├── MimeType -├── Width, Height -``` - -#### Nuovi Controller -``` -ReportTemplatesController -├── GET /api/report-templates # Lista template -├── GET /api/report-templates/{id} # Dettaglio -├── POST /api/report-templates # Crea -├── PUT /api/report-templates/{id} # Aggiorna -├── DELETE /api/report-templates/{id} # Elimina -├── POST /api/report-templates/{id}/clone # Duplica -├── GET /api/report-templates/{id}/export # Esporta .aprt -├── POST /api/report-templates/import # Importa .aprt - -ReportResourcesController -├── GET /api/report-resources/fonts # Lista font -├── POST /api/report-resources/fonts # Upload font -├── DELETE /api/report-resources/fonts/{id} -├── GET /api/report-resources/images # Lista immagini -├── POST /api/report-resources/images # Upload immagine -├── DELETE /api/report-resources/images/{id} - -ReportGeneratorController -├── POST /api/reports/generate # Genera PDF -│ Body: { templateId, dataContext: { eventoId, ... } } -├── POST /api/reports/preview # Anteprima (PNG/HTML) -``` - -#### Servizio Generazione PDF -Useremo **QuestPDF** per la generazione: -- Supporto nativo .NET -- API fluent per layout complessi -- Font personalizzati -- Immagini -- Paginazione automatica -- Performance eccellenti - -```csharp -public class ReportGeneratorService -{ - public byte[] GeneratePdf(ReportTemplate template, object dataContext) - { - var parsed = ParseTemplate(template.TemplateJson); - var document = Document.Create(container => - { - container.Page(page => - { - page.Size(parsed.PageSize); - page.Margin(parsed.Margins); - - if (parsed.Header != null) - page.Header().Element(c => RenderSection(c, parsed.Header, dataContext)); - - page.Content().Element(c => RenderContent(c, parsed, dataContext)); - - if (parsed.Footer != null) - page.Footer().Element(c => RenderSection(c, parsed.Footer, dataContext)); - }); - }); - - return document.GeneratePdf(); - } -} -``` - -### 3. Frontend - Editor Visuale - -#### Componenti Principali - -``` -frontend/src/ -├── pages/ -│ ├── ReportEditorPage.tsx # Editor principale -│ └── ReportTemplatesPage.tsx # Lista template -├── components/ -│ └── reportEditor/ -│ ├── ReportEditor.tsx # Container principale -│ ├── Canvas.tsx # Area di disegno (Fabric.js o Konva) -│ ├── Toolbar.tsx # Barra strumenti (text, image, shape, table) -│ ├── PropertiesPanel.tsx # Pannello proprietà elemento selezionato -│ ├── DataBindingPanel.tsx # Pannello per mappare dati -│ ├── LayersPanel.tsx # Gestione livelli/elementi -│ ├── ResourcesPanel.tsx # Font e immagini disponibili -│ ├── PageSettings.tsx # Impostazioni pagina -│ ├── PreviewModal.tsx # Anteprima PDF -│ └── elements/ -│ ├── TextElement.tsx -│ ├── ImageElement.tsx -│ ├── ShapeElement.tsx -│ ├── TableElement.tsx -│ └── BarcodeElement.tsx -├── services/ -│ └── reportService.ts -└── types/ - └── report.ts # Tipi TypeScript per APRT -``` - -#### Libreria Canvas -**Fabric.js** è la scelta migliore: -- Drag & drop nativo -- Selezione multipla -- Ridimensionamento con handle -- Rotazione elementi -- Serializzazione JSON -- Supporto testo, immagini, forme -- Griglia e snap -- Undo/redo - -#### Flusso Editor - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Toolbar: [Text] [Image] [Shape] [Table] [Line] [Barcode] │ -├─────────────┬───────────────────────────────────┬───────────────┤ -│ │ │ │ -│ Layers │ CANVAS │ Properties │ -│ Panel │ ┌─────────────────┐ │ Panel │ -│ │ │ HEADER │ │ │ -│ □ Logo │ │ [Logo] [Titolo]│ │ Position │ -│ □ Titolo │ ├─────────────────┤ │ x: 10 y: 10 │ -│ □ Data │ │ │ │ w: 200 h: 30 │ -│ □ Tabella │ │ BODY │ │ │ -│ □ Footer │ │ │ │ Style │ -│ │ │ [Data Evento] │ │ Font: Roboto │ -│ │ │ [Cliente] │ │ Size: 24 │ -│ │ │ [Tabella] │ │ Color: #333 │ -│ │ │ │ │ │ -│ │ ├─────────────────┤ │ Data Binding │ -│ │ │ FOOTER │ │ {{evento. │ -│ │ │ [Pag X di Y] │ │ codice}} │ -│ │ └─────────────────┘ │ │ -│ │ │ │ -├─────────────┴───────────────────────────────────┴───────────────┤ -│ Data Sources: [evento] [ospiti] [costi] [risorse] │ -│ Available Fields: codice, dataEvento, cliente.ragioneSociale...│ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 4. Implementazione Step-by-Step - -#### Fase 1: Backend Foundation -1. Creare entità `ReportTemplate`, `ReportFont`, `ReportImage` -2. Aggiornare DbContext e migrare database -3. Creare `ReportTemplatesController` con CRUD base -4. Creare `ReportResourcesController` per upload font/immagini -5. Installare e configurare QuestPDF -6. Creare `ReportGeneratorService` base - -#### Fase 2: Metalinguaggio Parser -1. Definire classi C# per il metalinguaggio APRT -2. Implementare parser JSON → oggetti -3. Implementare renderer elementi → QuestPDF -4. Gestire binding dati con espressioni {{campo}} -5. Implementare paginazione e sezioni ripetute - -#### Fase 3: Frontend Editor Base -1. Installare Fabric.js (`fabric`) -2. Creare pagina `ReportEditorPage` -3. Implementare `Canvas` con Fabric.js -4. Implementare `Toolbar` per aggiungere elementi -5. Implementare `PropertiesPanel` per editing proprietà -6. Implementare serializzazione canvas → APRT - -#### Fase 4: Data Binding -1. Creare `DataBindingPanel` con schema dati disponibili -2. Implementare drag-drop campi su elementi -3. Supportare espressioni {{campo.sottocampo}} -4. Implementare formattazione (currency, date, number) -5. Supportare espressioni condizionali - -#### Fase 5: Tabelle e Repeater -1. Implementare `TableElement` con colonne configurabili -2. Supportare data source array per righe ripetute -3. Implementare auto-height per tabelle -4. Gestire page break automatici - -#### Fase 6: Risorse e Upload -1. Implementare upload font custom -2. Implementare upload immagini -3. Creare libreria risorse condivise -4. Preview font e immagini - -#### Fase 7: Preview e Generazione -1. Implementare preview real-time (canvas → PNG) -2. Implementare generazione PDF finale -3. Download PDF -4. Stampa diretta - -#### Fase 8: Import/Export -1. Implementare export .aprt (JSON + risorse embedded base64) -2. Implementare import .aprt -3. Validazione template importati - -### 5. Template Esempio: Scheda Evento - -Creeremo un template predefinito per la stampa eventi con: -- Header con logo aziendale e titolo -- Dati evento (codice, data, cliente, location) -- Tabella ospiti con subtotali -- Tabella costi aggiuntivi -- Riepilogo totali -- Note -- Footer con paginazione - -### 6. Dipendenze da Aggiungere - -**Backend (NuGet):** -```xml - -``` - -**Frontend (npm):** -```json -{ - "fabric": "^6.0.0", - "file-saver": "^2.0.5", - "@types/fabric": "^5.3.0" -} -``` - -### 7. Routes Frontend - -```typescript -// App.tsx - nuove routes -} /> -} /> -} /> -``` - -### 8. Stima Componenti - -| Componente | File | Complessità | -|------------|------|-------------| -| Entità + DbContext | 3 file | Bassa | -| Controllers | 3 file | Media | -| ReportGeneratorService | 1 file | Alta | -| APRT Parser | 1 file | Media | -| ReportEditorPage | 1 file | Alta | -| Canvas (Fabric.js) | 1 file | Alta | -| Toolbar | 1 file | Bassa | -| PropertiesPanel | 1 file | Media | -| DataBindingPanel | 1 file | Media | -| LayersPanel | 1 file | Bassa | -| Elementi (5 tipi) | 5 file | Media | -| Services frontend | 1 file | Bassa | -| Types | 1 file | Bassa | - -**Totale: ~20 file, complessità alta** +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. --- -## Decisioni Architetturali +## Architettura Proposta -1. **QuestPDF** invece di iTextSharp (licenza più permissiva, API moderna) -2. **Fabric.js** invece di Konva (più features per editing) -3. **JSON** come metalinguaggio (leggibile, facile da parsare) -4. **Embedded resources** negli export (portabilità completa) -5. **Real-time preview** via canvas (no round-trip server) +### Concetti Chiave -## Note Implementative +1. **Room-based Collaboration**: Ogni template ha una "room" SignalR dedicata +2. **Operational Transformation Semplificata**: Invece di OT completo (complesso), usiamo un modello "last-write-wins" con sync frequente +3. **Presence Awareness**: Gli utenti vedono chi altro sta modificando il template +4. **Cursor/Selection Sharing**: Mostra quale elemento sta selezionando ogni utente -- Il canvas Fabric.js lavora in pixel, convertiremo in mm per la stampa -- I font custom vanno registrati in QuestPDF all'avvio -- Le immagini BLOB vanno convertite in base64 per Fabric.js -- La paginazione è gestita lato server da QuestPDF -- L'editor salva solo il JSON, la generazione PDF è on-demand +### 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: + +```csharp +public class ReportCollaborationHub : Hub +{ + // Stato in-memory degli utenti per template + private static ConcurrentDictionary> _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:** + +```csharp +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` + +```typescript +class ReportCollaborationService { + private connection: HubConnection | null = null; + private templateId: number | null = null; + private listeners: Map> = new Map(); + + // Connessione e gestione room + async joinTemplate( + templateId: number, + userName: string, + userColor: string, + ): Promise; + async leaveTemplate(): Promise; + + // 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` + +1. **Nuovo State per collaborazione:** + +```typescript +const [collaborators, setCollaborators] = useState([]); +const [remoteSelections, setRemoteSelections] = useState>( + new Map(), +); +const [isCollaborating, setIsCollaborating] = useState(false); +``` + +2. **Join/Leave room al mount/unmount:** + +```typescript +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]); +``` + +3. **Sottoscrizione eventi remoti:** + +```typescript +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]); +``` + +4. **Invio modifiche locali:** + +Modificare `handleUpdateElement` per inviare anche via SignalR: + +```typescript +const handleUpdateElement = useCallback( + (elementId: string, updates: Partial) => { + // 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: + +```typescript +interface CollaboratorsBarProps { + collaborators: Collaborator[]; + remoteSelections: Map; // 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: + +1. **Elementi diversi**: Nessun conflitto, modifiche applicate indipendentemente +2. **Stesso elemento, proprietà diverse**: Merge delle proprietà +3. **Stesso elemento, stessa proprietà**: Ultima modifica vince +4. **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 + +1. **Backend Hub base** - Join/Leave room, broadcast semplice +2. **Frontend service base** - Connessione, invio/ricezione messaggi +3. **Integrazione minima** - Solo sync modifiche elementi (no UI collaboratori) +4. **Test funzionale** - Verificare che modifiche si propaghino +5. **UI Collaboratori** - Mostrare chi è connesso +6. **Selezioni remote** - Evidenziare elementi selezionati da altri +7. **Ottimizzazioni** - Throttling, batching, gestione conflitti + +--- + +## Domande per l'Utente + +Prima di procedere, confermare: + +1. **Autenticazione**: Il sistema ha già autenticazione utenti? Devo usare un sistema mock per ora? +2. **Persistenza stato**: Le modifiche devono essere salvate automaticamente o solo quando l'utente clicca "Salva"? +3. **Lock elementi**: Vuoi che solo un utente alla volta possa modificare un elemento (lock pessimistico)? +4. **Cursori live**: Vuoi vedere il cursore degli altri utenti in tempo reale (come Google Docs)? +5. **Nome utente**: Da dove prendo il nome utente da mostrare? (localStorage, auth context, prompt?) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8265ef..39fc17e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,7 @@ "@fullcalendar/interaction": "^6.1.19", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", - "@microsoft/signalr": "^10.0.0", + "@microsoft/signalr": "^8.0.7", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", "@mui/x-data-grid": "^8.20.0", @@ -1263,16 +1263,16 @@ } }, "node_modules/@microsoft/signalr": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", - "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.7.tgz", + "integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==", "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", "eventsource": "^2.0.2", "fetch-cookie": "^2.0.3", "node-fetch": "^2.6.7", - "ws": "^7.5.10" + "ws": "^7.4.5" } }, "node_modules/@mui/core-downloads-tracker": { diff --git a/frontend/package.json b/frontend/package.json index 4586f4d..2c284e1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "@fullcalendar/interaction": "^6.1.19", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", - "@microsoft/signalr": "^10.0.0", + "@microsoft/signalr": "^8.0.7", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", "@mui/x-data-grid": "^8.20.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca2391b..e67fcfd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import CalendarioPage from "./pages/CalendarioPage"; import ReportTemplatesPage from "./pages/ReportTemplatesPage"; import ReportEditorPage from "./pages/ReportEditorPage"; import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates"; +import { CollaborationProvider } from "./contexts/CollaborationContext"; const queryClient = new QueryClient({ defaultOptions: { @@ -59,29 +60,34 @@ function App() { - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } /> - } - /> - - - + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } + /> + + + + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9638816..bc4b7a4 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -28,6 +28,7 @@ import { Print as PrintIcon, Close as CloseIcon, } from "@mui/icons-material"; +import CollaborationIndicator from "./collaboration/CollaborationIndicator"; const DRAWER_WIDTH = 240; const DRAWER_WIDTH_COLLAPSED = 64; @@ -158,6 +159,9 @@ export default function Layout() { > {isMobile ? "Apollinare" : "Catering & Banqueting Management"} + + {/* Collaboration Indicator */} + diff --git a/frontend/src/components/collaboration/CollaborationIndicator.tsx b/frontend/src/components/collaboration/CollaborationIndicator.tsx new file mode 100644 index 0000000..2b8d6af --- /dev/null +++ b/frontend/src/components/collaboration/CollaborationIndicator.tsx @@ -0,0 +1,461 @@ +import { useState } from "react"; +import { + Avatar, + AvatarGroup, + Badge, + Box, + Chip, + Collapse, + Divider, + IconButton, + List, + ListItem, + ListItemAvatar, + ListItemText, + Paper, + Popover, + Tooltip, + Typography, + useTheme, +} from "@mui/material"; +import { + Circle as CircleIcon, + ExpandLess, + ExpandMore, + History as HistoryIcon, + People as PeopleIcon, + SignalWifiOff as DisconnectedIcon, + Wifi as ConnectedIcon, + WifiFind as ConnectingIcon, +} from "@mui/icons-material"; +import { useCollaboration } from "../../contexts/CollaborationContext"; +import type { Collaborator } from "../../services/collaboration"; + +interface CollaborationIndicatorProps { + /** Show only when in a room */ + showOnlyInRoom?: boolean; + /** Show change history button */ + showHistory?: boolean; + /** Compact mode for mobile */ + compact?: boolean; + /** Click handler for collaborator avatar */ + onCollaboratorClick?: (collaborator: Collaborator) => void; +} + +export default function CollaborationIndicator({ + showOnlyInRoom = true, + showHistory = true, + compact = false, + onCollaboratorClick, +}: CollaborationIndicatorProps) { + const theme = useTheme(); + const { + isConnected, + isConnecting, + currentRoom, + localUserName, + localUserColor, + collaborators, + remoteSelections, + changeHistory, + } = useCollaboration(); + + const [anchorEl, setAnchorEl] = useState(null); + const [historyAnchorEl, setHistoryAnchorEl] = useState( + null, + ); + const [showHistoryList, setShowHistoryList] = useState(true); + + // Don't show if not in a room and showOnlyInRoom is true + if (showOnlyInRoom && !currentRoom) { + return null; + } + + const handleCollaboratorsClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleHistoryClick = (event: React.MouseEvent) => { + setHistoryAnchorEl(event.currentTarget); + }; + + const handleHistoryClose = () => { + setHistoryAnchorEl(null); + }; + + const open = Boolean(anchorEl); + const historyOpen = Boolean(historyAnchorEl); + const totalUsers = collaborators.length + 1; + + // Format timestamp + const formatRelativeTime = (date: Date): string => { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + + if (diffSec < 5) return "ora"; + if (diffSec < 60) return `${diffSec}s fa`; + if (diffMin < 60) return `${diffMin}m fa`; + if (diffHour < 24) return `${diffHour}h fa`; + return date.toLocaleDateString("it-IT"); + }; + + // Get initials from name + const getInitials = (name: string): string => { + const parts = name.split(" "); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); + }; + + return ( + + {/* Connection indicator */} + + + {isConnecting ? ( + + ) : isConnected ? ( + + ) : ( + + )} + + + + {/* Collaborators avatars */} + {currentRoom && ( + 1 ? "i" : ""} connesso${totalUsers > 1 ? "i" : ""}`} + > + + + {/* Local user avatar */} + + + {getInitials(localUserName)} + + + + {/* Remote collaborators */} + {collaborators.map((collab) => ( + + + {getInitials(collab.userName)} + + + ))} + + + + )} + + {/* History button */} + {showHistory && changeHistory.length > 0 && ( + + + + + + + + )} + + {/* Collaborators popover */} + + + + + + Collaboratori ({totalUsers}) + + + + {currentRoom && ( + + )} + + + {/* Local user */} + + + + {getInitials(localUserName)} + + + + {localUserName} + + + } + secondary="Connesso" + /> + + + {/* Remote collaborators */} + {collaborators.map((collab) => { + const isEditing = remoteSelections.get(collab.connectionId); + return ( + onCollaboratorClick?.(collab)} + > + + + ) : null + } + > + + {getInitials(collab.userName)} + + + + + Sta modificando... + + ) : ( + "Online" + ) + } + /> + + ); + })} + + {collaborators.length === 0 && ( + + + + )} + + + + + {/* History popover */} + + + + + + + Modifiche Recenti + + + setShowHistoryList(!showHistoryList)} + > + {showHistoryList ? : } + + + + + + {changeHistory.slice(0, 30).map((entry, index) => ( + + + + + {entry.description} + + + {formatRelativeTime(entry.timestamp)} + + + } + secondary={ + + {entry.userName} + + } + /> + + {index < changeHistory.length - 1 && ( + + )} + + ))} + + {changeHistory.length === 0 && ( + + + + )} + + {changeHistory.length > 30 && ( + + + + )} + + + + + + ); +} diff --git a/frontend/src/components/collaboration/RemoteCursors.tsx b/frontend/src/components/collaboration/RemoteCursors.tsx new file mode 100644 index 0000000..393ea64 --- /dev/null +++ b/frontend/src/components/collaboration/RemoteCursors.tsx @@ -0,0 +1,177 @@ +import { Box, Typography, Fade } from "@mui/material"; +import { Navigation as CursorIcon } from "@mui/icons-material"; +import { useCollaboration, useRemoteCursorsForView } from "../../contexts/CollaborationContext"; + +interface RemoteCursorsProps { + /** Current view ID to filter cursors */ + viewId?: string | null; + /** Zoom level for coordinate transformation */ + zoom?: number; + /** Container offset X (for absolute positioning) */ + offsetX?: number; + /** Container offset Y (for absolute positioning) */ + offsetY?: number; + /** Whether cursors are in mm coordinates (needs conversion to px) */ + coordinatesInMm?: boolean; + /** MM to PX ratio if coordinatesInMm is true */ + mmToPxRatio?: number; +} + +/** + * Overlay component that displays remote collaborator cursors + * Should be placed in a relative positioned container + */ +export default function RemoteCursors({ + viewId = null, + zoom = 1, + offsetX = 0, + offsetY = 0, + coordinatesInMm = false, + mmToPxRatio = 3.7795275591, +}: RemoteCursorsProps) { + const cursors = useRemoteCursorsForView(viewId); + + if (cursors.length === 0) { + return null; + } + + return ( + + {cursors.map((cursor) => { + // Convert coordinates if needed + let x = cursor.x; + let y = cursor.y; + + if (coordinatesInMm) { + x = cursor.x * mmToPxRatio; + y = cursor.y * mmToPxRatio; + } + + // Apply zoom and offset + x = x * zoom + offsetX; + y = y * zoom + offsetY; + + return ( + + + {/* Cursor icon */} + + + {/* User name label */} + + {cursor.userName} + + + + ); + })} + + ); +} + +/** + * Component to highlight items selected by remote users + */ +interface RemoteSelectionHighlightProps { + itemId: string; + children: React.ReactNode; +} + +export function RemoteSelectionHighlight({ itemId, children }: RemoteSelectionHighlightProps) { + const { remoteSelections, collaborators } = useCollaboration(); + + // Find if any collaborator has this item selected + let selectedBy: { userName: string; color: string } | null = null; + + for (const [connectionId, selectedId] of remoteSelections.entries()) { + if (selectedId === itemId) { + const collaborator = collaborators.find((c) => c.connectionId === connectionId); + if (collaborator) { + selectedBy = { userName: collaborator.userName, color: collaborator.color }; + break; + } + } + } + + if (!selectedBy) { + return <>{children}; + } + + return ( + + {children} + + {/* Selection indicator */} + + {selectedBy.userName} + + + ); +} diff --git a/frontend/src/components/reportEditor/EditorCanvas.tsx b/frontend/src/components/reportEditor/EditorCanvas.tsx index 7a90f0a..0894864 100644 --- a/frontend/src/components/reportEditor/EditorCanvas.tsx +++ b/frontend/src/components/reportEditor/EditorCanvas.tsx @@ -32,6 +32,15 @@ export interface ContextMenuEvent { elementId: string | null; } +// Remote cursor info +export interface RemoteCursor { + x: number; + y: number; + pageId: string | null; + color: string; + userName: string; +} + interface EditorCanvasProps { template: AprtTemplate; selectedElementId: string | null; @@ -44,6 +53,16 @@ interface EditorCanvasProps { gridSize: number; snapOptions: SnapOptions; onContextMenu?: (event: ContextMenuEvent) => void; + /** Callback when cursor moves on canvas (for collaboration) */ + onCursorMove?: (x: number, y: number) => void; + /** Remote cursors from other collaborators */ + remoteCursors?: Map; + /** Remote selections from other collaborators (connectionId -> elementId) */ + remoteSelections?: Map; + /** Map of connectionId -> collaborator info for colors */ + collaboratorColors?: Map; + /** Current page ID for filtering remote cursors */ + currentPageId?: string; } export interface EditorCanvasRef { @@ -68,6 +87,12 @@ const EditorCanvas = forwardRef( gridSize, snapOptions, onContextMenu, + onCursorMove, + // These props are reserved for future remote cursor rendering on canvas + // remoteCursors, + // remoteSelections, + // collaboratorColors, + // currentPageId, }, ref, ) => { @@ -639,12 +664,16 @@ const EditorCanvas = forwardRef( (e: { e: MouseEvent }) => { if (!fabricRef.current) return; const pointer = fabricRef.current.getScenePoint(e.e); - setCursorPosition({ - x: Math.round((pxToMm(pointer.x) / zoom) * 10) / 10, - y: Math.round((pxToMm(pointer.y) / zoom) * 10) / 10, - }); + const xMm = Math.round((pxToMm(pointer.x) / zoom) * 10) / 10; + const yMm = Math.round((pxToMm(pointer.y) / zoom) * 10) / 10; + setCursorPosition({ x: xMm, y: yMm }); + + // Send cursor position for collaboration + if (onCursorMove) { + onCursorMove(xMm, yMm); + } }, - [zoom], + [zoom, onCursorMove], ); // Keyboard navigation diff --git a/frontend/src/contexts/CollaborationContext.tsx b/frontend/src/contexts/CollaborationContext.tsx new file mode 100644 index 0000000..455f446 --- /dev/null +++ b/frontend/src/contexts/CollaborationContext.tsx @@ -0,0 +1,632 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, + type ReactNode, +} from "react"; +import { + collaborationService, + type Collaborator, + type RoomState, + type DataChangeMessage, + type ItemCreatedMessage, + type ItemDeletedMessage, + type BatchOperationMessage, + type SelectionChangedMessage, + type CursorMovedMessage, + type ChangeHistoryEntry, + type SyncRequestMessage, + type SyncDataMessage, + type DataSavedMessage, + type UserLeftMessage, + getOrCreateUserName, + getColorForUser, +} from "../services/collaboration"; + +// ==================== TYPES ==================== + +export interface RemoteCursor { + connectionId: string; + userName: string; + color: string; + x: number; + y: number; + viewId: string | null; +} + +export interface CollaborationContextValue { + // Connection state + isConnected: boolean; + isConnecting: boolean; + currentRoom: string | null; + + // Local user + localUserName: string; + localUserColor: string; + connectionId: string | null; + + // Collaborators + collaborators: Collaborator[]; + remoteSelections: Map; + remoteCursors: Map; + + // Change history + changeHistory: ChangeHistoryEntry[]; + + // Room management + joinRoom: (roomKey: string, metadata?: unknown) => Promise; + leaveRoom: () => Promise; + switchRoom: (roomKey: string, metadata?: unknown) => Promise; + + // Data operations (generic) + sendDataChanged: ( + itemId: string, + itemType: string, + changeType: string, + newValue: unknown, + fieldPath?: string, + ) => void; + sendItemCreated: ( + itemId: string, + itemType: string, + item: unknown, + parentId?: string, + index?: number, + ) => void; + sendItemDeleted: (itemId: string, itemType: string) => void; + sendBatchOperation: ( + operationType: string, + itemType: string, + data: unknown, + ) => void; + + // Presence + sendSelectionChanged: (itemId: string | null) => void; + sendCursorMoved: (x: number, y: number, viewId?: string | null) => void; + sendViewChanged: (viewId: string) => void; + sendUserTyping: (itemId: string | null, isTyping: boolean) => void; + + // Sync + requestSync: () => void; + sendSync: (targetConnectionId: string, dataJson: string) => void; + sendDataSaved: () => void; + + // Event subscriptions for component-specific handlers + onDataChanged: (callback: (msg: DataChangeMessage) => void) => () => void; + onItemCreated: (callback: (msg: ItemCreatedMessage) => void) => () => void; + onItemDeleted: (callback: (msg: ItemDeletedMessage) => void) => () => void; + onBatchOperation: ( + callback: (msg: BatchOperationMessage) => void, + ) => () => void; + onSyncRequested: (callback: (msg: SyncRequestMessage) => void) => () => void; + onSyncReceived: (callback: (msg: SyncDataMessage) => void) => () => void; + onDataSaved: (callback: (msg: DataSavedMessage) => void) => () => void; +} + +const CollaborationContext = createContext( + null, +); + +// ==================== PROVIDER ==================== + +interface CollaborationProviderProps { + children: ReactNode; + /** Auto-connect on mount */ + autoConnect?: boolean; +} + +export function CollaborationProvider({ + children, + autoConnect = true, +}: CollaborationProviderProps) { + // Connection state + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [currentRoom, setCurrentRoom] = useState(null); + + // Local user + const localUserName = useRef(getOrCreateUserName()); + const localUserColor = useRef(getColorForUser(localUserName.current)); + const [connectionId, setConnectionId] = useState(null); + + // Collaborators + const [collaborators, setCollaborators] = useState([]); + const [remoteSelections, setRemoteSelections] = useState< + Map + >(new Map()); + const [remoteCursors, setRemoteCursors] = useState>( + new Map(), + ); + + // Change history + const [changeHistory, setChangeHistory] = useState([]); + + // Auto-connect on mount + // Note: We don't disconnect on unmount because: + // 1. React Strict Mode double-mounts components, causing connection interruption + // 2. The collaboration service is a singleton that persists across navigations + // 3. SignalR has built-in reconnection handling + useEffect(() => { + if (autoConnect) { + collaborationService.connect().catch(() => { + // Connection errors are logged in the service, ignore here + }); + } + // No cleanup - connection persists for the app lifetime + }, [autoConnect]); + + // Subscribe to connection state changes + useEffect(() => { + const unsubscribe = collaborationService.onConnectionStateChanged( + ({ connected, connecting }) => { + setIsConnected(connected); + setIsConnecting(connecting); + setConnectionId(collaborationService.connectionId); + }, + ); + + return unsubscribe; + }, []); + + // Subscribe to room state (on join) + useEffect(() => { + const unsubscribe = collaborationService.onRoomState((state: RoomState) => { + setCurrentRoom(state.roomKey); + setCollaborators(state.collaborators); + + // Initialize remote selections and cursors + const selections = new Map(); + const cursors = new Map(); + + state.collaborators.forEach((c) => { + selections.set(c.connectionId, c.selectedItemId); + if (c.cursorX != null && c.cursorY != null) { + cursors.set(c.connectionId, { + connectionId: c.connectionId, + userName: c.userName, + color: c.color, + x: c.cursorX, + y: c.cursorY, + viewId: c.currentViewId, + }); + } + }); + + setRemoteSelections(selections); + setRemoteCursors(cursors); + }); + + return unsubscribe; + }, []); + + // Subscribe to user join/leave + useEffect(() => { + const unsubJoin = collaborationService.onUserJoined( + (collaborator: Collaborator) => { + setCollaborators((prev) => { + if (prev.some((c) => c.connectionId === collaborator.connectionId)) { + return prev; + } + return [...prev, collaborator]; + }); + }, + ); + + const unsubLeave = collaborationService.onUserLeft( + (message: UserLeftMessage) => { + setCollaborators((prev) => + prev.filter((c) => c.connectionId !== message.connectionId), + ); + setRemoteSelections((prev) => { + const newMap = new Map(prev); + newMap.delete(message.connectionId); + return newMap; + }); + setRemoteCursors((prev) => { + const newMap = new Map(prev); + newMap.delete(message.connectionId); + return newMap; + }); + }, + ); + + return () => { + unsubJoin(); + unsubLeave(); + }; + }, []); + + // Subscribe to selection changes + useEffect(() => { + const unsubscribe = collaborationService.onSelectionChanged( + (message: SelectionChangedMessage) => { + setRemoteSelections((prev) => { + const newMap = new Map(prev); + newMap.set(message.connectionId, message.itemId); + return newMap; + }); + }, + ); + + return unsubscribe; + }, []); + + // Subscribe to cursor movements + useEffect(() => { + const unsubscribe = collaborationService.onCursorMoved( + (message: CursorMovedMessage) => { + const collaborator = collaborationService.getCollaborator( + message.connectionId, + ); + + setRemoteCursors((prev) => { + const newMap = new Map(prev); + newMap.set(message.connectionId, { + connectionId: message.connectionId, + userName: collaborator?.userName || "Utente", + color: collaborator?.color || "#888", + x: message.x, + y: message.y, + viewId: message.viewId, + }); + return newMap; + }); + }, + ); + + return unsubscribe; + }, []); + + // Subscribe to change history + useEffect(() => { + const unsubscribe = collaborationService.onChangeHistoryUpdated( + (history) => { + setChangeHistory(history); + }, + ); + + return unsubscribe; + }, []); + + // Room management callbacks + const joinRoom = useCallback(async (roomKey: string, metadata?: unknown) => { + await collaborationService.joinRoom( + roomKey, + localUserName.current, + metadata, + ); + setCurrentRoom(roomKey); + }, []); + + const leaveRoom = useCallback(async () => { + await collaborationService.leaveRoom(); + setCurrentRoom(null); + setCollaborators([]); + setRemoteSelections(new Map()); + setRemoteCursors(new Map()); + }, []); + + const switchRoom = useCallback( + async (roomKey: string, metadata?: unknown) => { + await collaborationService.switchRoom(roomKey, metadata); + setCurrentRoom(roomKey); + setCollaborators([]); + setRemoteSelections(new Map()); + setRemoteCursors(new Map()); + }, + [], + ); + + // Data operation callbacks + const sendDataChanged = useCallback( + ( + itemId: string, + itemType: string, + changeType: string, + newValue: unknown, + fieldPath?: string, + ) => { + collaborationService.sendDataChanged( + itemId, + itemType, + changeType, + newValue, + fieldPath, + ); + }, + [], + ); + + const sendItemCreated = useCallback( + ( + itemId: string, + itemType: string, + item: unknown, + parentId?: string, + index?: number, + ) => { + collaborationService.sendItemCreated( + itemId, + itemType, + item, + parentId, + index, + ); + }, + [], + ); + + const sendItemDeleted = useCallback((itemId: string, itemType: string) => { + collaborationService.sendItemDeleted(itemId, itemType); + }, []); + + const sendBatchOperation = useCallback( + (operationType: string, itemType: string, data: unknown) => { + collaborationService.sendBatchOperation(operationType, itemType, data); + }, + [], + ); + + // Presence callbacks + const sendSelectionChanged = useCallback((itemId: string | null) => { + collaborationService.sendSelectionChanged(itemId); + }, []); + + const sendCursorMoved = useCallback( + (x: number, y: number, viewId?: string | null) => { + collaborationService.sendCursorMoved(x, y, viewId); + }, + [], + ); + + const sendViewChanged = useCallback((viewId: string) => { + collaborationService.sendViewChanged(viewId); + }, []); + + const sendUserTyping = useCallback( + (itemId: string | null, isTyping: boolean) => { + collaborationService.sendUserTyping(itemId, isTyping); + }, + [], + ); + + // Sync callbacks + const requestSync = useCallback(() => { + collaborationService.requestSync(); + }, []); + + const sendSync = useCallback( + (targetConnectionId: string, dataJson: string) => { + collaborationService.sendSync(targetConnectionId, dataJson); + }, + [], + ); + + const sendDataSaved = useCallback(() => { + collaborationService.sendDataSaved(); + }, []); + + // Event subscription pass-through + const onDataChanged = useCallback( + (callback: (msg: DataChangeMessage) => void) => { + return collaborationService.onDataChanged(callback); + }, + [], + ); + + const onItemCreated = useCallback( + (callback: (msg: ItemCreatedMessage) => void) => { + return collaborationService.onItemCreated(callback); + }, + [], + ); + + const onItemDeleted = useCallback( + (callback: (msg: ItemDeletedMessage) => void) => { + return collaborationService.onItemDeleted(callback); + }, + [], + ); + + const onBatchOperation = useCallback( + (callback: (msg: BatchOperationMessage) => void) => { + return collaborationService.onBatchOperation(callback); + }, + [], + ); + + const onSyncRequested = useCallback( + (callback: (msg: SyncRequestMessage) => void) => { + return collaborationService.onSyncRequested(callback); + }, + [], + ); + + const onSyncReceived = useCallback( + (callback: (msg: SyncDataMessage) => void) => { + return collaborationService.onSyncReceived(callback); + }, + [], + ); + + const onDataSaved = useCallback( + (callback: (msg: DataSavedMessage) => void) => { + return collaborationService.onDataSaved(callback); + }, + [], + ); + + const value: CollaborationContextValue = { + // Connection state + isConnected, + isConnecting, + currentRoom, + + // Local user + localUserName: localUserName.current, + localUserColor: localUserColor.current, + connectionId, + + // Collaborators + collaborators, + remoteSelections, + remoteCursors, + + // Change history + changeHistory, + + // Room management + joinRoom, + leaveRoom, + switchRoom, + + // Data operations + sendDataChanged, + sendItemCreated, + sendItemDeleted, + sendBatchOperation, + + // Presence + sendSelectionChanged, + sendCursorMoved, + sendViewChanged, + sendUserTyping, + + // Sync + requestSync, + sendSync, + sendDataSaved, + + // Event subscriptions + onDataChanged, + onItemCreated, + onItemDeleted, + onBatchOperation, + onSyncRequested, + onSyncReceived, + onDataSaved, + }; + + return ( + + {children} + + ); +} + +// ==================== HOOKS ==================== + +/** + * Hook to access the collaboration context + */ +export function useCollaboration(): CollaborationContextValue { + const context = useContext(CollaborationContext); + if (!context) { + throw new Error( + "useCollaboration must be used within a CollaborationProvider", + ); + } + return context; +} + +/** + * Hook to automatically join a room when a component mounts + */ +export function useCollaborationRoom( + roomKey: string | null, + options?: { + metadata?: unknown; + enabled?: boolean; + }, +): CollaborationContextValue { + const collaboration = useCollaboration(); + const { enabled = true, metadata } = options || {}; + + // Use refs to avoid dependency changes causing re-runs + const joinRoomRef = useRef(collaboration.joinRoom); + const leaveRoomRef = useRef(collaboration.leaveRoom); + const metadataRef = useRef(metadata); + + // Keep refs updated + joinRoomRef.current = collaboration.joinRoom; + leaveRoomRef.current = collaboration.leaveRoom; + metadataRef.current = metadata; + + // Track if we successfully joined a room + const joinedRoomRef = useRef(null); + + useEffect(() => { + if (!enabled || !roomKey) return; + + // Use a flag to prevent race conditions + let cancelled = false; + + const doJoin = async () => { + try { + if (!cancelled) { + await joinRoomRef.current(roomKey, metadataRef.current); + if (!cancelled) { + joinedRoomRef.current = roomKey; + } + } + } catch (error) { + if (!cancelled) { + console.error("[useCollaborationRoom] Failed to join room:", error); + } + } + }; + + doJoin(); + + return () => { + cancelled = true; + // Only leave if we actually joined this room + if (joinedRoomRef.current === roomKey) { + joinedRoomRef.current = null; + leaveRoomRef.current().catch((err) => { + // Silently ignore leave errors - connection may already be closed + console.log( + "[useCollaborationRoom] Leave room:", + err?.message || "ok", + ); + }); + } + }; + }, [roomKey, enabled]); // Only re-run when roomKey or enabled changes + + return collaboration; +} + +/** + * Hook to get cursors filtered by current view + */ +export function useRemoteCursorsForView(viewId: string | null): RemoteCursor[] { + const { remoteCursors } = useCollaboration(); + + return Array.from(remoteCursors.values()).filter( + (cursor) => viewId === null || cursor.viewId === viewId, + ); +} + +/** + * Hook to check if an item is selected by a remote user + */ +export function useRemoteSelection(itemId: string): { + isSelected: boolean; + selectedBy: Collaborator | null; +} { + const { remoteSelections, collaborators } = useCollaboration(); + + for (const [connectionId, selectedId] of remoteSelections.entries()) { + if (selectedId === itemId) { + const collaborator = + collaborators.find((c) => c.connectionId === connectionId) || null; + return { isSelected: true, selectedBy: collaborator }; + } + } + + return { isSelected: false, selectedBy: null }; +} diff --git a/frontend/src/pages/ReportEditorPage.tsx b/frontend/src/pages/ReportEditorPage.tsx index 106e677..0723a1c 100644 --- a/frontend/src/pages/ReportEditorPage.tsx +++ b/frontend/src/pages/ReportEditorPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { useQuery, @@ -8,6 +8,12 @@ import { } from "@tanstack/react-query"; import { v4 as uuidv4 } from "uuid"; import { useHistory } from "../hooks/useHistory"; +import { useCollaborationRoom } from "../contexts/CollaborationContext"; +import type { + DataChangeMessage, + ItemCreatedMessage, + ItemDeletedMessage, +} from "../services/collaboration"; import { Box, CircularProgress, @@ -172,6 +178,16 @@ export default function ReportEditorPage() { // Auto-save feature - enabled by default const [autoSaveEnabled, setAutoSaveEnabled] = useState(true); + // ============ COLLABORATION (using global context) ============ + // Room key format: "report-template:{id}" + const roomKey = id ? `report-template:${id}` : null; + const collaboration = useCollaborationRoom(roomKey, { + enabled: !isNew && !!id, + }); + + // Flag to prevent re-broadcasting received changes + const isApplyingRemoteChange = useRef(false); + // Update zoom on screen size change useEffect(() => { if (isMobile) { @@ -183,6 +199,171 @@ export default function ReportEditorPage() { } }, [isMobile, isTablet]); + // ============ COLLABORATION EFFECTS ============ + // The collaboration context handles connection, room joining, and presence automatically. + // We only need to subscribe to data change events and send our changes. + + // Subscribe to remote data changes + useEffect(() => { + if (!collaboration.isConnected || !collaboration.currentRoom) return; + + const unsubscribers: (() => void)[] = []; + + // Element/data changed by remote user + unsubscribers.push( + collaboration.onDataChanged((message: DataChangeMessage) => { + if (message.itemType !== "element") return; + isApplyingRemoteChange.current = true; + historyActions.setWithoutHistory((prev) => ({ + ...prev, + elements: prev.elements.map((el) => + el.id === message.itemId + ? { ...el, ...(message.newValue as Partial) } + : el, + ), + })); + setTimeout(() => { + isApplyingRemoteChange.current = false; + }, 0); + }), + ); + + // Element added by remote user + unsubscribers.push( + collaboration.onItemCreated((message: ItemCreatedMessage) => { + if (message.itemType !== "element") return; + isApplyingRemoteChange.current = true; + historyActions.setWithoutHistory((prev) => ({ + ...prev, + elements: [...prev.elements, message.item as AprtElement], + })); + setTimeout(() => { + isApplyingRemoteChange.current = false; + }, 0); + }), + ); + + // Element deleted by remote user + unsubscribers.push( + collaboration.onItemDeleted((message: ItemDeletedMessage) => { + if (message.itemType !== "element") return; + isApplyingRemoteChange.current = true; + historyActions.setWithoutHistory((prev) => ({ + ...prev, + elements: prev.elements.filter((el) => el.id !== message.itemId), + })); + // Clear selection if deleted element was selected + if (selectedElementId === message.itemId) { + setSelectedElementId(null); + } + setTimeout(() => { + isApplyingRemoteChange.current = false; + }, 0); + }), + ); + + // Page changes by remote user + unsubscribers.push( + collaboration.onDataChanged((message: DataChangeMessage) => { + if (message.itemType !== "page") return; + isApplyingRemoteChange.current = true; + historyActions.setWithoutHistory((prev) => { + switch (message.changeType) { + case "added": + return { + ...prev, + pages: [...prev.pages, message.newValue as AprtPage], + }; + case "deleted": + return { + ...prev, + pages: prev.pages.filter((p) => p.id !== message.itemId), + elements: prev.elements.filter( + (e) => e.pageId !== message.itemId, + ), + }; + case "renamed": + return { + ...prev, + pages: prev.pages.map((p) => + p.id === message.itemId + ? { ...p, name: message.newValue as string } + : p, + ), + }; + case "reordered": + return { + ...prev, + pages: message.newValue as AprtPage[], + }; + case "settings": + return { + ...prev, + pages: prev.pages.map((p) => + p.id === message.itemId + ? { ...p, ...(message.newValue as Partial) } + : p, + ), + }; + default: + return prev; + } + }); + setTimeout(() => { + isApplyingRemoteChange.current = false; + }, 0); + }), + ); + + // Template saved by remote user + unsubscribers.push( + collaboration.onDataSaved((message) => { + console.log( + "[Collaboration] Received DataSaved from:", + message.savedBy, + ); + setSnackbar({ + open: true, + message: `${message.savedBy} ha salvato il template`, + severity: "success", + }); + queryClient.invalidateQueries({ queryKey: ["report-template", id] }); + }), + ); + + // Sync requested - send current template to requester + unsubscribers.push( + collaboration.onSyncRequested((request) => { + collaboration.sendSync(request.requesterId, JSON.stringify(template)); + }), + ); + + return () => { + unsubscribers.forEach((unsub) => unsub()); + }; + }, [ + collaboration, + historyActions, + selectedElementId, + template, + queryClient, + id, + ]); + + // Send selection changes to collaborators + useEffect(() => { + if (collaboration.isConnected && !isApplyingRemoteChange.current) { + collaboration.sendSelectionChanged(selectedElementId); + } + }, [collaboration, selectedElementId]); + + // Send view/page navigation to collaborators + useEffect(() => { + if (collaboration.isConnected && !isApplyingRemoteChange.current) { + collaboration.sendViewChanged(currentPageId); + } + }, [collaboration, currentPageId]); + // Load existing template const { data: existingTemplate, isLoading: isLoadingTemplate } = useQuery({ queryKey: ["report-template", id], @@ -347,6 +528,19 @@ export default function ReportEditorPage() { setSaveDialog(false); // Mark current state as saved setLastSavedUndoCount(templateHistory.undoCount); + + // Notify collaborators of save + console.log( + "[AutoSave] Save success, collaboration.isConnected:", + collaboration.isConnected, + "currentRoom:", + collaboration.currentRoom, + ); + if (collaboration.isConnected && collaboration.currentRoom) { + console.log("[AutoSave] Sending DataSaved notification"); + collaboration.sendDataSaved(); + } + if (isNew) { navigate(`/report-editor/${result.id}`, { replace: true }); } @@ -415,10 +609,15 @@ export default function ReportEditorPage() { pages: [...prev.pages, newPage], })); + // Send to collaborators + if (collaboration.isConnected && !isApplyingRemoteChange.current) { + collaboration.sendDataChanged(newPageId, "page", "added", newPage); + } + // Switch to the new page setCurrentPageId(newPageId); setSelectedElementId(null); - }, [template.pages.length, historyActions]); + }, [template.pages.length, historyActions, collaboration]); // Duplicate page with all its elements const handleDuplicatePage = useCallback( @@ -465,6 +664,11 @@ export default function ReportEditorPage() { const pageIndex = template.pages.findIndex((p) => p.id === pageId); + // Send to collaborators before deleting + if (collaboration.isConnected && !isApplyingRemoteChange.current) { + collaboration.sendDataChanged(pageId, "page", "deleted", null); + } + historyActions.set((prev) => ({ ...prev, pages: prev.pages.filter((p) => p.id !== pageId), @@ -481,7 +685,7 @@ export default function ReportEditorPage() { } setSelectedElementId(null); }, - [template.pages, historyActions], + [template.pages, historyActions, collaboration], ); // Rename page @@ -493,8 +697,13 @@ export default function ReportEditorPage() { p.id === pageId ? { ...p, name: newName } : p, ), })); + + // Send to collaborators + if (collaboration.isConnected && !isApplyingRemoteChange.current) { + collaboration.sendDataChanged(pageId, "page", "renamed", newName); + } }, - [historyActions], + [historyActions, collaboration], ); // Move page up or down @@ -578,12 +787,17 @@ export default function ReportEditorPage() { })); setSelectedElementId(newElement.id); + // Send to collaborators + if (collaboration.isConnected && !isApplyingRemoteChange.current) { + collaboration.sendItemCreated(newElement.id, "element", newElement); + } + // On mobile, open properties panel after adding element if (isMobile) { setMobilePanel("properties"); } }, - [historyActions, currentPageId, isMobile], + [historyActions, currentPageId, isMobile, collaboration], ); // Update element without history (for continuous updates like dragging) @@ -608,8 +822,13 @@ export default function ReportEditorPage() { el.id === elementId ? { ...el, ...updates } : el, ), })); + + // Send to collaborators + if (collaboration.isConnected && !isApplyingRemoteChange.current) { + collaboration.sendDataChanged(elementId, "element", "full", updates); + } }, - [historyActions], + [historyActions, collaboration], ); // Handle image selection from dialog @@ -696,12 +915,18 @@ export default function ReportEditorPage() { // Delete element const handleDeleteElement = useCallback(() => { if (!selectedElementId) return; + + // Send to collaborators before deleting + if (collaboration.isConnected && !isApplyingRemoteChange.current) { + collaboration.sendItemDeleted(selectedElementId, "element"); + } + historyActions.set((prev) => ({ ...prev, elements: prev.elements.filter((el) => el.id !== selectedElementId), })); setSelectedElementId(null); - }, [selectedElementId, historyActions]); + }, [selectedElementId, historyActions, collaboration]); // Copy element const handleCopyElement = useCallback(() => { @@ -1228,29 +1453,37 @@ export default function ReportEditorPage() { ]); // Auto-save effect - saves after 1 second of inactivity when there are unsaved changes + // Use refs to avoid the effect re-running on every render due to saveMutation changing + const saveMutationRef = useRef(saveMutation); + saveMutationRef.current = saveMutation; + + const templateRef = useRef(template); + templateRef.current = template; + + const templateInfoRef = useRef(templateInfo); + templateInfoRef.current = templateInfo; + useEffect(() => { if ( !autoSaveEnabled || !hasUnsavedChanges || isNew || - saveMutation.isPending + saveMutationRef.current.isPending ) { return; } const timeoutId = setTimeout(() => { - saveMutation.mutate({ template, info: templateInfo }); + if (!saveMutationRef.current.isPending) { + saveMutationRef.current.mutate({ + template: templateRef.current, + info: templateInfoRef.current, + }); + } }, 1000); // 1 second debounce return () => clearTimeout(timeoutId); - }, [ - autoSaveEnabled, - hasUnsavedChanges, - isNew, - template, - templateInfo, - saveMutation, - ]); + }, [autoSaveEnabled, hasUnsavedChanges, isNew]); if (isLoadingTemplate && id) { return ( diff --git a/frontend/src/services/collaboration.ts b/frontend/src/services/collaboration.ts new file mode 100644 index 0000000..2752a3f --- /dev/null +++ b/frontend/src/services/collaboration.ts @@ -0,0 +1,1047 @@ +import * as signalR from "@microsoft/signalr"; +import { v4 as uuidv4 } from "uuid"; + +const API_URL = "http://localhost:5000"; +const CURSOR_THROTTLE_MS = 50; + +// ==================== TYPES ==================== + +export interface Collaborator { + connectionId: string; + userName: string; + color: string; + joinedAt: string; + selectedItemId: string | null; + cursorX: number | null; + cursorY: number | null; + currentViewId: string | null; + metadata: unknown; + isActive: boolean; + lastActivityAt: string; +} + +export interface RoomState { + roomKey: string; + collaborators: Collaborator[]; + joinedAt: string; +} + +export interface DataChangeMessage { + itemId: string; + itemType: string; + changeType: string; + fieldPath?: string; + newValue: unknown; + oldValue?: unknown; + senderConnectionId?: string; + timestamp: string; +} + +export interface ItemCreatedMessage { + itemId: string; + itemType: string; + item: unknown; + parentId?: string; + index?: number; + senderConnectionId?: string; + timestamp: string; +} + +export interface ItemDeletedMessage { + itemId: string; + itemType: string; + senderConnectionId?: string; + timestamp: string; +} + +export interface BatchOperationMessage { + operationType: string; + itemType: string; + data: unknown; + senderConnectionId?: string; + timestamp: string; +} + +export interface SelectionChangedMessage { + connectionId: string; + itemId: string | null; + timestamp: string; +} + +export interface CursorMovedMessage { + connectionId: string; + x: number; + y: number; + viewId: string | null; + timestamp: string; +} + +export interface ViewChangedMessage { + connectionId: string; + viewId: string; + timestamp: string; +} + +export interface UserTypingMessage { + connectionId: string; + itemId: string | null; + isTyping: boolean; + timestamp: string; +} + +export interface SyncRequestMessage { + requesterId: string; + roomKey: string; + timestamp: string; +} + +export interface SyncDataMessage { + roomKey: string; + dataJson: string; + senderConnectionId: string; + timestamp: string; +} + +export interface DataSavedMessage { + savedBy: string; + roomKey: string; + timestamp: string; +} + +export interface UserLeftMessage { + connectionId: string; + userName: string; + timestamp: string; +} + +export interface ChangeHistoryEntry { + id: string; + type: "data_changed" | "item_created" | "item_deleted" | "batch_operation"; + description: string; + userName: string; + userColor: string; + timestamp: Date; + itemId?: string; + itemType?: string; +} + +// ==================== COLORS ==================== + +export const COLLABORATOR_COLORS = [ + "#FF6B6B", + "#4ECDC4", + "#45B7D1", + "#96CEB4", + "#FFEAA7", + "#DDA0DD", + "#98D8C8", + "#F7DC6F", + "#BB8FCE", + "#85C1E9", + "#F8B500", + "#58D68D", +]; + +export function getColorForUser(userName: string): string { + let hash = 0; + for (let i = 0; i < userName.length; i++) { + hash = userName.charCodeAt(i) + ((hash << 5) - hash); + } + return COLLABORATOR_COLORS[Math.abs(hash) % COLLABORATOR_COLORS.length]; +} + +export function generateRandomUserName(): string { + const adjectives = [ + "Veloce", + "Creativo", + "Brillante", + "Agile", + "Dinamico", + "Preciso", + "Elegante", + "Audace", + ]; + const nouns = [ + "Designer", + "Editor", + "Artista", + "Creatore", + "Architetto", + "Compositore", + "Maestro", + "Inventore", + ]; + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + const num = Math.floor(Math.random() * 100); + return `${adj} ${noun} ${num}`; +} + +export function getOrCreateUserName(): string { + const stored = localStorage.getItem("apollinare_username"); + if (stored) return stored; + const newName = generateRandomUserName(); + localStorage.setItem("apollinare_username", newName); + return newName; +} + +export function saveUserName(userName: string): void { + localStorage.setItem("apollinare_username", userName); +} + +// ==================== SERVICE ==================== + +type EventCallback = (data: T) => void; + +class CollaborationService { + private connection: signalR.HubConnection | null = null; + private currentRoom: string | null = null; + private userName: string = ""; + private userColor: string = ""; + private isConnecting = false; + private connectAbortController: AbortController | null = null; + + // Event listeners + private userJoinedListeners = new Set>(); + private userLeftListeners = new Set>(); + private roomStateListeners = new Set>(); + private dataChangedListeners = new Set>(); + private itemCreatedListeners = new Set>(); + private itemDeletedListeners = new Set>(); + private batchOperationListeners = new Set< + EventCallback + >(); + private selectionChangedListeners = new Set< + EventCallback + >(); + private cursorMovedListeners = new Set>(); + private viewChangedListeners = new Set>(); + private userTypingListeners = new Set>(); + private syncRequestedListeners = new Set>(); + private syncReceivedListeners = new Set>(); + private dataSavedListeners = new Set>(); + private connectionStateListeners = new Set< + EventCallback<{ connected: boolean; connecting: boolean }> + >(); + + // Change history (LIFO) + private changeHistory: ChangeHistoryEntry[] = []; + private changeHistoryListeners = new Set< + EventCallback + >(); + private maxHistoryEntries = 50; + + // Collaborators cache for name/color lookup + private collaboratorsCache = new Map(); + + // Cursor throttling + private lastCursorSend = 0; + private pendingCursorUpdate: { + x: number; + y: number; + viewId: string | null; + } | null = null; + private cursorThrottleTimer: ReturnType | null = null; + + // ==================== GETTERS ==================== + + get isConnected(): boolean { + return this.connection?.state === signalR.HubConnectionState.Connected; + } + + get currentUserName(): string { + return this.userName; + } + + get currentUserColor(): string { + return this.userColor; + } + + get connectionId(): string | null { + return this.connection?.connectionId ?? null; + } + + get currentRoomKey(): string | null { + return this.currentRoom; + } + + // ==================== CONNECTION ==================== + + async connect(): Promise { + // If already connected, return immediately + if (this.isConnected) return; + + // If already connecting, wait for the connection to complete + if (this.isConnecting) { + // Wait for connection state to change + return new Promise((resolve) => { + const checkConnection = () => { + if (this.isConnected) { + resolve(); + } else if (!this.isConnecting) { + // Connection attempt finished but not connected - could be cancelled or failed + // Don't reject, just resolve silently (caller should check isConnected) + resolve(); + } else { + setTimeout(checkConnection, 100); + } + }; + setTimeout(checkConnection, 100); + }); + } + + this.isConnecting = true; + this.connectAbortController = new AbortController(); + this.notifyConnectionState(); + + try { + this.connection = new signalR.HubConnectionBuilder() + .withUrl(`${API_URL}/hubs/collaboration`) + .withAutomaticReconnect([0, 1000, 2000, 5000, 10000, 30000]) + .configureLogging(signalR.LogLevel.Warning) + .build(); + + this.setupEventHandlers(); + + // Check if aborted before starting + if (this.connectAbortController.signal.aborted) { + console.log("[Collaboration] Connection aborted before start"); + return; + } + + await this.connection.start(); + + // Check if aborted after starting (disconnect was called during connection) + if (this.connectAbortController.signal.aborted) { + console.log( + "[Collaboration] Connection aborted after start, disconnecting", + ); + await this.connection.stop(); + return; + } + + console.log("[Collaboration] Connected"); + } catch (error) { + // Don't log error if it was an intentional abort + if (this.connectAbortController?.signal.aborted) { + console.log("[Collaboration] Connection aborted"); + return; + } + console.error("[Collaboration] Connection failed:", error); + throw error; + } finally { + this.isConnecting = false; + this.connectAbortController = null; + this.notifyConnectionState(); + } + } + + async disconnect(): Promise { + // Abort any pending connection attempt + if (this.connectAbortController) { + this.connectAbortController.abort(); + } + + if (this.currentRoom) { + await this.leaveRoom(); + } + if (this.connection) { + try { + await this.connection.stop(); + } catch { + // Ignore errors when stopping + } + this.connection = null; + } + this.notifyConnectionState(); + } + + // ==================== ROOM MANAGEMENT ==================== + + async joinRoom( + roomKey: string, + userName?: string, + metadata?: unknown, + ): Promise { + // Ensure we're connected first + if (!this.isConnected) { + await this.connect(); + } + + // Check connection - if not connected (possibly due to abort), just return silently + if (!this.isConnected || !this.connection) { + console.log( + "[Collaboration] Cannot join room: not connected (connection may have been cancelled)", + ); + return; + } + + // Leave current room if different + if (this.currentRoom && this.currentRoom !== roomKey) { + await this.leaveRoom(); + } + + if (this.currentRoom === roomKey) return; + + this.userName = userName || getOrCreateUserName(); + this.userColor = getColorForUser(this.userName); + + try { + await this.connection.invoke( + "JoinRoom", + roomKey, + this.userName, + this.userColor, + metadata, + ); + this.currentRoom = roomKey; + console.log(`[Collaboration] Joined room: ${roomKey}`); + } catch (error) { + console.error("[Collaboration] Failed to join room:", error); + throw error; + } + } + + async leaveRoom(): Promise { + if (!this.currentRoom) return; + + // Only try to invoke if actually connected + if (this.isConnected && this.connection) { + try { + await this.connection.invoke("LeaveRoom", this.currentRoom); + console.log(`[Collaboration] Left room: ${this.currentRoom}`); + } catch (error) { + console.error("[Collaboration] Error leaving room:", error); + } + } + + // Always clean up local state + this.currentRoom = null; + this.collaboratorsCache.clear(); + this.changeHistory = []; + this.notifyChangeHistory(); + } + + async switchRoom(newRoomKey: string, metadata?: unknown): Promise { + if (!this.isConnected) { + await this.connect(); + } + + // Check connection - if not connected (possibly due to abort), just return silently + if (!this.isConnected || !this.connection) { + console.log( + "[Collaboration] Cannot switch room: not connected (connection may have been cancelled)", + ); + return; + } + + try { + await this.connection.invoke( + "SwitchRoom", + newRoomKey, + this.userName, + this.userColor, + metadata, + ); + this.currentRoom = newRoomKey; + this.collaboratorsCache.clear(); + console.log(`[Collaboration] Switched to room: ${newRoomKey}`); + } catch (error) { + console.error("[Collaboration] Failed to switch room:", error); + throw error; + } + } + + // ==================== DATA SYNC ==================== + + sendDataChanged( + itemId: string, + itemType: string, + changeType: string, + newValue: unknown, + fieldPath?: string, + ): void { + if (!this.canSend()) return; + + this.connection!.invoke("DataChanged", this.currentRoom, { + itemId, + itemType, + changeType, + fieldPath, + newValue, + }).catch((err) => + console.error("[Collaboration] Error sending data change:", err), + ); + } + + sendItemCreated( + itemId: string, + itemType: string, + item: unknown, + parentId?: string, + index?: number, + ): void { + if (!this.canSend()) return; + + this.connection!.invoke("ItemCreated", this.currentRoom, { + itemId, + itemType, + item, + parentId, + index, + }).catch((err) => + console.error("[Collaboration] Error sending item created:", err), + ); + } + + sendItemDeleted(itemId: string, itemType: string): void { + if (!this.canSend()) return; + + this.connection!.invoke("ItemDeleted", this.currentRoom, { + itemId, + itemType, + }).catch((err) => + console.error("[Collaboration] Error sending item deleted:", err), + ); + } + + sendBatchOperation( + operationType: string, + itemType: string, + data: unknown, + ): void { + if (!this.canSend()) return; + + this.connection!.invoke("BatchOperation", this.currentRoom, { + operationType, + itemType, + data, + }).catch((err) => + console.error("[Collaboration] Error sending batch operation:", err), + ); + } + + // ==================== PRESENCE ==================== + + sendSelectionChanged(itemId: string | null): void { + if (!this.canSend()) return; + + this.connection!.invoke("SelectionChanged", this.currentRoom, itemId).catch( + (err) => console.error("[Collaboration] Error sending selection:", err), + ); + } + + sendCursorMoved(x: number, y: number, viewId?: string | null): void { + if (!this.canSend()) return; + + const now = Date.now(); + + if (now - this.lastCursorSend >= CURSOR_THROTTLE_MS) { + this.doSendCursor(x, y, viewId ?? null); + this.lastCursorSend = now; + this.pendingCursorUpdate = null; + } else { + this.pendingCursorUpdate = { x, y, viewId: viewId ?? null }; + + if (!this.cursorThrottleTimer) { + this.cursorThrottleTimer = setTimeout( + () => { + if (this.pendingCursorUpdate) { + this.doSendCursor( + this.pendingCursorUpdate.x, + this.pendingCursorUpdate.y, + this.pendingCursorUpdate.viewId, + ); + this.lastCursorSend = Date.now(); + this.pendingCursorUpdate = null; + } + this.cursorThrottleTimer = null; + }, + CURSOR_THROTTLE_MS - (now - this.lastCursorSend), + ); + } + } + } + + private doSendCursor(x: number, y: number, viewId: string | null): void { + this.connection!.invoke( + "CursorMoved", + this.currentRoom, + x, + y, + viewId, + ).catch((err) => + console.error("[Collaboration] Error sending cursor:", err), + ); + } + + sendViewChanged(viewId: string): void { + if (!this.canSend()) return; + + this.connection!.invoke("ViewChanged", this.currentRoom, viewId).catch( + (err) => console.error("[Collaboration] Error sending view change:", err), + ); + } + + sendUserTyping(itemId: string | null, isTyping: boolean): void { + if (!this.canSend()) return; + + this.connection!.invoke( + "UserTyping", + this.currentRoom, + itemId, + isTyping, + ).catch((err) => + console.error("[Collaboration] Error sending typing:", err), + ); + } + + // ==================== SYNC ==================== + + requestSync(): void { + if (!this.canSend()) return; + + this.connection!.invoke("RequestSync", this.currentRoom).catch((err) => + console.error("[Collaboration] Error requesting sync:", err), + ); + } + + sendSync(targetConnectionId: string, dataJson: string): void { + if (!this.canSend()) return; + + this.connection!.invoke( + "SendSync", + this.currentRoom, + targetConnectionId, + dataJson, + ).catch((err) => console.error("[Collaboration] Error sending sync:", err)); + } + + sendDataSaved(): void { + console.log( + "[Collaboration] sendDataSaved called, canSend:", + this.canSend(), + "isConnected:", + this.isConnected, + "currentRoom:", + this.currentRoom, + ); + if (!this.canSend()) { + console.log( + "[Collaboration] sendDataSaved: canSend is false, not sending", + ); + return; + } + + console.log("[Collaboration] sendDataSaved: invoking DataSaved on server"); + this.connection!.invoke("DataSaved", this.currentRoom, this.userName).catch( + (err) => console.error("[Collaboration] Error sending data saved:", err), + ); + } + + // ==================== SUBSCRIPTIONS ==================== + + onUserJoined(callback: EventCallback): () => void { + this.userJoinedListeners.add(callback); + return () => this.userJoinedListeners.delete(callback); + } + + onUserLeft(callback: EventCallback): () => void { + this.userLeftListeners.add(callback); + return () => this.userLeftListeners.delete(callback); + } + + onRoomState(callback: EventCallback): () => void { + this.roomStateListeners.add(callback); + return () => this.roomStateListeners.delete(callback); + } + + onDataChanged(callback: EventCallback): () => void { + this.dataChangedListeners.add(callback); + return () => this.dataChangedListeners.delete(callback); + } + + onItemCreated(callback: EventCallback): () => void { + this.itemCreatedListeners.add(callback); + return () => this.itemCreatedListeners.delete(callback); + } + + onItemDeleted(callback: EventCallback): () => void { + this.itemDeletedListeners.add(callback); + return () => this.itemDeletedListeners.delete(callback); + } + + onBatchOperation(callback: EventCallback): () => void { + this.batchOperationListeners.add(callback); + return () => this.batchOperationListeners.delete(callback); + } + + onSelectionChanged( + callback: EventCallback, + ): () => void { + this.selectionChangedListeners.add(callback); + return () => this.selectionChangedListeners.delete(callback); + } + + onCursorMoved(callback: EventCallback): () => void { + this.cursorMovedListeners.add(callback); + return () => this.cursorMovedListeners.delete(callback); + } + + onViewChanged(callback: EventCallback): () => void { + this.viewChangedListeners.add(callback); + return () => this.viewChangedListeners.delete(callback); + } + + onUserTyping(callback: EventCallback): () => void { + this.userTypingListeners.add(callback); + return () => this.userTypingListeners.delete(callback); + } + + onSyncRequested(callback: EventCallback): () => void { + this.syncRequestedListeners.add(callback); + return () => this.syncRequestedListeners.delete(callback); + } + + onSyncReceived(callback: EventCallback): () => void { + this.syncReceivedListeners.add(callback); + return () => this.syncReceivedListeners.delete(callback); + } + + onDataSaved(callback: EventCallback): () => void { + this.dataSavedListeners.add(callback); + return () => this.dataSavedListeners.delete(callback); + } + + onConnectionStateChanged( + callback: EventCallback<{ connected: boolean; connecting: boolean }>, + ): () => void { + this.connectionStateListeners.add(callback); + return () => this.connectionStateListeners.delete(callback); + } + + onChangeHistoryUpdated( + callback: EventCallback, + ): () => void { + this.changeHistoryListeners.add(callback); + return () => this.changeHistoryListeners.delete(callback); + } + + getChangeHistory(): ChangeHistoryEntry[] { + return [...this.changeHistory]; + } + + getCollaborator(connectionId: string): Collaborator | undefined { + return this.collaboratorsCache.get(connectionId); + } + + // ==================== PRIVATE ==================== + + private canSend(): boolean { + return this.isConnected && this.currentRoom !== null; + } + + private notifyConnectionState(): void { + const state = { + connected: this.isConnected, + connecting: this.isConnecting, + }; + this.connectionStateListeners.forEach((cb) => { + try { + cb(state); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + } + + private notifyChangeHistory(): void { + this.changeHistoryListeners.forEach((cb) => { + try { + cb([...this.changeHistory]); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + } + + private addChangeHistoryEntry( + entry: Omit, + ): void { + const fullEntry: ChangeHistoryEntry = { + ...entry, + id: uuidv4(), + timestamp: new Date(), + }; + + this.changeHistory.unshift(fullEntry); + + if (this.changeHistory.length > this.maxHistoryEntries) { + this.changeHistory = this.changeHistory.slice(0, this.maxHistoryEntries); + } + + this.notifyChangeHistory(); + } + + private getCollaboratorName(connectionId?: string): string { + if (!connectionId) return "Sconosciuto"; + return this.collaboratorsCache.get(connectionId)?.userName || "Sconosciuto"; + } + + private getCollaboratorColor(connectionId?: string): string { + if (!connectionId) return "#888888"; + return this.collaboratorsCache.get(connectionId)?.color || "#888888"; + } + + private setupEventHandlers(): void { + if (!this.connection) return; + + // Room state (received on join) + this.connection.on("RoomState", (state: RoomState) => { + state.collaborators.forEach((c) => + this.collaboratorsCache.set(c.connectionId, c), + ); + this.roomStateListeners.forEach((cb) => { + try { + cb(state); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }); + + // User joined + this.connection.on("UserJoined", (collaborator: Collaborator) => { + this.collaboratorsCache.set(collaborator.connectionId, collaborator); + this.userJoinedListeners.forEach((cb) => { + try { + cb(collaborator); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }); + + // User left + this.connection.on("UserLeft", (message: UserLeftMessage) => { + this.collaboratorsCache.delete(message.connectionId); + this.userLeftListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }); + + // Data changed + this.connection.on("DataChanged", (message: DataChangeMessage) => { + this.dataChangedListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + + this.addChangeHistoryEntry({ + type: "data_changed", + description: `Modificato ${message.itemType}`, + userName: this.getCollaboratorName(message.senderConnectionId), + userColor: this.getCollaboratorColor(message.senderConnectionId), + itemId: message.itemId, + itemType: message.itemType, + }); + }); + + // Item created + this.connection.on("ItemCreated", (message: ItemCreatedMessage) => { + this.itemCreatedListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + + this.addChangeHistoryEntry({ + type: "item_created", + description: `Creato ${message.itemType}`, + userName: this.getCollaboratorName(message.senderConnectionId), + userColor: this.getCollaboratorColor(message.senderConnectionId), + itemId: message.itemId, + itemType: message.itemType, + }); + }); + + // Item deleted + this.connection.on("ItemDeleted", (message: ItemDeletedMessage) => { + this.itemDeletedListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + + this.addChangeHistoryEntry({ + type: "item_deleted", + description: `Eliminato ${message.itemType}`, + userName: this.getCollaboratorName(message.senderConnectionId), + userColor: this.getCollaboratorColor(message.senderConnectionId), + itemId: message.itemId, + itemType: message.itemType, + }); + }); + + // Batch operation + this.connection.on("BatchOperation", (message: BatchOperationMessage) => { + this.batchOperationListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + + this.addChangeHistoryEntry({ + type: "batch_operation", + description: `${message.operationType} su ${message.itemType}`, + userName: this.getCollaboratorName(message.senderConnectionId), + userColor: this.getCollaboratorColor(message.senderConnectionId), + itemType: message.itemType, + }); + }); + + // Selection changed + this.connection.on( + "SelectionChanged", + (message: SelectionChangedMessage) => { + const collab = this.collaboratorsCache.get(message.connectionId); + if (collab) collab.selectedItemId = message.itemId; + + this.selectionChangedListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }, + ); + + // Cursor moved + this.connection.on("CursorMoved", (message: CursorMovedMessage) => { + const collab = this.collaboratorsCache.get(message.connectionId); + if (collab) { + collab.cursorX = message.x; + collab.cursorY = message.y; + collab.currentViewId = message.viewId; + } + + this.cursorMovedListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }); + + // View changed + this.connection.on("ViewChanged", (message: ViewChangedMessage) => { + const collab = this.collaboratorsCache.get(message.connectionId); + if (collab) collab.currentViewId = message.viewId; + + this.viewChangedListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }); + + // User typing + this.connection.on("UserTyping", (message: UserTypingMessage) => { + this.userTypingListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }); + + // Sync requested + this.connection.on("SyncRequested", (message: SyncRequestMessage) => { + this.syncRequestedListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }); + + // Sync received + this.connection.on("SyncReceived", (message: SyncDataMessage) => { + this.syncReceivedListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }); + + // Data saved + this.connection.on("DataSaved", (message: DataSavedMessage) => { + this.dataSavedListeners.forEach((cb) => { + try { + cb(message); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }); + + // Reconnection handling + this.connection.onreconnecting(() => { + console.log("[Collaboration] Reconnecting..."); + this.notifyConnectionState(); + }); + + this.connection.onreconnected(async () => { + console.log("[Collaboration] Reconnected"); + this.notifyConnectionState(); + + // Rejoin room if we were in one - with a small delay to ensure connection is stable + if ( + this.currentRoom && + this.connection?.state === signalR.HubConnectionState.Connected + ) { + try { + await this.connection.invoke( + "JoinRoom", + this.currentRoom, + this.userName, + this.userColor, + null, + ); + console.log(`[Collaboration] Rejoined room: ${this.currentRoom}`); + } catch (err) { + console.error("[Collaboration] Error rejoining room:", err); + } + } + }); + + this.connection.onclose(() => { + console.log("[Collaboration] Connection closed"); + this.notifyConnectionState(); + }); + } +} + +// Singleton export +export const collaborationService = new CollaborationService(); diff --git a/src/Apollinare.API/Apollinare.API.csproj b/src/Apollinare.API/Apollinare.API.csproj index 06d7b14..69fb0a6 100644 --- a/src/Apollinare.API/Apollinare.API.csproj +++ b/src/Apollinare.API/Apollinare.API.csproj @@ -8,7 +8,6 @@ - diff --git a/src/Apollinare.API/Hubs/CollaborationHub.cs b/src/Apollinare.API/Hubs/CollaborationHub.cs new file mode 100644 index 0000000..a6643be --- /dev/null +++ b/src/Apollinare.API/Hubs/CollaborationHub.cs @@ -0,0 +1,527 @@ +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 diff --git a/src/Apollinare.API/Program.cs b/src/Apollinare.API/Program.cs index fdf1601..4823dea 100644 --- a/src/Apollinare.API/Program.cs +++ b/src/Apollinare.API/Program.cs @@ -66,5 +66,6 @@ app.UseWebSockets(); app.UseAuthorization(); app.MapControllers(); app.MapHub("/hubs/data"); +app.MapHub("/hubs/collaboration"); app.Run(); diff --git a/src/Apollinare.API/apollinare.db-shm b/src/Apollinare.API/apollinare.db-shm new file mode 100644 index 0000000..b4a1cce Binary files /dev/null and b/src/Apollinare.API/apollinare.db-shm differ diff --git a/src/Apollinare.API/apollinare.db-wal b/src/Apollinare.API/apollinare.db-wal new file mode 100644 index 0000000..9626a93 Binary files /dev/null and b/src/Apollinare.API/apollinare.db-wal differ