586 Zeilen
14 KiB
JavaScript
586 Zeilen
14 KiB
JavaScript
/**
|
|
* TASKMATE - Real-time Sync Module
|
|
* =================================
|
|
* WebSocket synchronization with Socket.io
|
|
*/
|
|
|
|
import store from './store.js';
|
|
import api from './api.js';
|
|
|
|
class SyncManager {
|
|
constructor() {
|
|
this.socket = null;
|
|
this.isConnected = false;
|
|
this.reconnectAttempts = 0;
|
|
this.maxReconnectAttempts = 10;
|
|
this.reconnectDelay = 1000;
|
|
this.pendingOperations = [];
|
|
this.operationQueue = [];
|
|
this.isProcessingQueue = false;
|
|
}
|
|
|
|
// Initialize Socket.io connection
|
|
async connect() {
|
|
// Verhindere doppelte Verbindungen (auch während des Verbindungsaufbaus)
|
|
if (this.socket) {
|
|
console.log('[Sync] Socket already exists, skipping connect');
|
|
return;
|
|
}
|
|
|
|
const token = api.getToken();
|
|
if (!token) {
|
|
console.log('[Sync] No auth token, skipping connection');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Socket.io is loaded globally via script tag
|
|
if (typeof io === 'undefined') {
|
|
throw new Error('Socket.io not loaded');
|
|
}
|
|
|
|
this.socket = io({
|
|
auth: { token },
|
|
reconnection: true,
|
|
reconnectionAttempts: this.maxReconnectAttempts,
|
|
reconnectionDelay: this.reconnectDelay,
|
|
reconnectionDelayMax: 10000,
|
|
timeout: 20000
|
|
});
|
|
|
|
this.setupEventListeners();
|
|
} catch (error) {
|
|
console.error('[Sync] Failed to connect:', error);
|
|
store.setSyncStatus('error');
|
|
}
|
|
}
|
|
|
|
// Setup socket event listeners
|
|
setupEventListeners() {
|
|
if (!this.socket) return;
|
|
|
|
// Connection events
|
|
this.socket.on('connect', () => {
|
|
console.log('[Sync] Connected');
|
|
this.isConnected = true;
|
|
this.reconnectAttempts = 0;
|
|
store.setSyncStatus('synced');
|
|
|
|
// Join current project room
|
|
const projectId = store.get('currentProjectId');
|
|
if (projectId) {
|
|
this.joinProject(projectId);
|
|
}
|
|
|
|
// Process pending operations
|
|
this.processPendingOperations();
|
|
});
|
|
|
|
this.socket.on('disconnect', (reason) => {
|
|
console.log('[Sync] Disconnected:', reason);
|
|
this.isConnected = false;
|
|
|
|
if (reason === 'io server disconnect') {
|
|
// Server initiated disconnect, try reconnecting
|
|
this.socket.connect();
|
|
}
|
|
|
|
store.setSyncStatus('offline');
|
|
});
|
|
|
|
this.socket.on('connect_error', (error) => {
|
|
console.error('[Sync] Connection error:', error);
|
|
this.reconnectAttempts++;
|
|
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
store.setSyncStatus('error');
|
|
} else {
|
|
store.setSyncStatus('offline');
|
|
}
|
|
});
|
|
|
|
// Auth error
|
|
this.socket.on('error', (error) => {
|
|
console.error('[Sync] Socket error:', error);
|
|
|
|
if (error.type === 'auth') {
|
|
// Nur ausloggen wenn wir wirklich nicht eingeloggt sind
|
|
// (verhindert Logout durch alte Socket-Verbindungen nach neuem Login)
|
|
const currentToken = localStorage.getItem('auth_token');
|
|
if (!currentToken) {
|
|
console.log('[Sync] Auth error and no token, triggering logout');
|
|
window.dispatchEvent(new CustomEvent('auth:logout'));
|
|
} else {
|
|
console.log('[Sync] Auth error ignored - new login occurred');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Data sync events
|
|
this.socket.on('project:updated', (data) => {
|
|
this.handleProjectUpdated(data);
|
|
});
|
|
|
|
this.socket.on('column:created', (data) => {
|
|
this.handleColumnCreated(data);
|
|
});
|
|
|
|
this.socket.on('column:updated', (data) => {
|
|
this.handleColumnUpdated(data);
|
|
});
|
|
|
|
this.socket.on('column:deleted', (data) => {
|
|
this.handleColumnDeleted(data);
|
|
});
|
|
|
|
this.socket.on('column:reordered', (data) => {
|
|
this.handleColumnsReordered(data);
|
|
});
|
|
|
|
this.socket.on('task:created', (data) => {
|
|
this.handleTaskCreated(data);
|
|
});
|
|
|
|
this.socket.on('task:updated', (data) => {
|
|
this.handleTaskUpdated(data);
|
|
});
|
|
|
|
this.socket.on('task:deleted', (data) => {
|
|
this.handleTaskDeleted(data);
|
|
});
|
|
|
|
this.socket.on('task:moved', (data) => {
|
|
this.handleTaskMoved(data);
|
|
});
|
|
|
|
this.socket.on('label:created', (data) => {
|
|
this.handleLabelCreated(data);
|
|
});
|
|
|
|
this.socket.on('label:updated', (data) => {
|
|
this.handleLabelUpdated(data);
|
|
});
|
|
|
|
this.socket.on('label:deleted', (data) => {
|
|
this.handleLabelDeleted(data);
|
|
});
|
|
|
|
this.socket.on('comment:created', (data) => {
|
|
this.handleCommentCreated(data);
|
|
});
|
|
|
|
this.socket.on('subtask:updated', (data) => {
|
|
this.handleSubtaskUpdated(data);
|
|
});
|
|
|
|
// User presence events
|
|
this.socket.on('user:joined', (data) => {
|
|
this.handleUserJoined(data);
|
|
});
|
|
|
|
this.socket.on('user:left', (data) => {
|
|
this.handleUserLeft(data);
|
|
});
|
|
|
|
this.socket.on('user:typing', (data) => {
|
|
this.handleUserTyping(data);
|
|
});
|
|
|
|
// Notification events
|
|
this.socket.on('notification:new', (data) => {
|
|
this.handleNotificationNew(data);
|
|
});
|
|
|
|
this.socket.on('notification:count', (data) => {
|
|
this.handleNotificationCount(data);
|
|
});
|
|
|
|
this.socket.on('notification:deleted', (data) => {
|
|
this.handleNotificationDeleted(data);
|
|
});
|
|
}
|
|
|
|
// Disconnect socket
|
|
disconnect() {
|
|
if (this.socket) {
|
|
this.socket.disconnect();
|
|
this.socket = null;
|
|
this.isConnected = false;
|
|
}
|
|
}
|
|
|
|
// Join project room
|
|
joinProject(projectId) {
|
|
if (!this.socket?.connected) return;
|
|
|
|
this.socket.emit('project:join', { projectId });
|
|
console.log('[Sync] Joined project:', projectId);
|
|
}
|
|
|
|
// Leave project room
|
|
leaveProject(projectId) {
|
|
if (!this.socket?.connected) return;
|
|
|
|
this.socket.emit('project:leave', { projectId });
|
|
console.log('[Sync] Left project:', projectId);
|
|
}
|
|
|
|
// Emit event with queueing for offline support
|
|
emit(event, data) {
|
|
if (this.socket?.connected) {
|
|
this.socket.emit(event, data);
|
|
return true;
|
|
}
|
|
|
|
// Queue for later
|
|
this.pendingOperations.push({ event, data, timestamp: Date.now() });
|
|
store.setSyncStatus('offline');
|
|
return false;
|
|
}
|
|
|
|
// Process pending operations when reconnected
|
|
async processPendingOperations() {
|
|
if (!this.socket?.connected || this.pendingOperations.length === 0) {
|
|
return;
|
|
}
|
|
|
|
console.log(`[Sync] Processing ${this.pendingOperations.length} pending operations`);
|
|
store.setSyncStatus('syncing');
|
|
|
|
const operations = [...this.pendingOperations];
|
|
this.pendingOperations = [];
|
|
|
|
for (const op of operations) {
|
|
try {
|
|
this.socket.emit(op.event, op.data);
|
|
await this.delay(100); // Small delay between operations
|
|
} catch (error) {
|
|
console.error('[Sync] Failed to process operation:', error);
|
|
// Re-queue failed operation
|
|
this.pendingOperations.push(op);
|
|
}
|
|
}
|
|
|
|
store.setSyncStatus(this.pendingOperations.length > 0 ? 'offline' : 'synced');
|
|
}
|
|
|
|
// Helper delay function
|
|
delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// =====================
|
|
// EVENT HANDLERS
|
|
// =====================
|
|
|
|
handleProjectUpdated(data) {
|
|
const { project, userId } = data;
|
|
|
|
// Ignore own updates
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.updateProject(project.id, project);
|
|
|
|
this.showNotification('Projekt aktualisiert', `${data.username} hat das Projekt aktualisiert`);
|
|
}
|
|
|
|
handleColumnCreated(data) {
|
|
const { column, userId } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.addColumn(column);
|
|
|
|
this.showNotification('Neue Spalte', `${data.username} hat eine Spalte erstellt`);
|
|
}
|
|
|
|
handleColumnUpdated(data) {
|
|
const { column, userId } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.updateColumn(column.id, column);
|
|
}
|
|
|
|
handleColumnDeleted(data) {
|
|
const { columnId, userId } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.removeColumn(columnId);
|
|
}
|
|
|
|
handleColumnsReordered(data) {
|
|
const { columnIds, userId } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.reorderColumns(columnIds);
|
|
}
|
|
|
|
handleTaskCreated(data) {
|
|
const { task, userId, username } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.addTask(task);
|
|
|
|
this.showNotification('Neue Aufgabe', `${username} hat "${task.title}" erstellt`);
|
|
}
|
|
|
|
handleTaskUpdated(data) {
|
|
const { task, userId, changes } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.updateTask(task.id, task);
|
|
|
|
// Show notification for significant changes
|
|
if (changes.includes('status') || changes.includes('assignee')) {
|
|
this.showNotification('Aufgabe aktualisiert', `"${task.title}" wurde aktualisiert`);
|
|
}
|
|
}
|
|
|
|
handleTaskDeleted(data) {
|
|
const { taskId, userId, taskTitle } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.removeTask(taskId);
|
|
|
|
this.showNotification('Aufgabe gelöscht', `"${taskTitle}" wurde gelöscht`);
|
|
}
|
|
|
|
handleTaskMoved(data) {
|
|
const { taskId, columnId, position, userId } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.moveTask(taskId, columnId, position);
|
|
}
|
|
|
|
handleLabelCreated(data) {
|
|
const { label, userId } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.addLabel(label);
|
|
}
|
|
|
|
handleLabelUpdated(data) {
|
|
const { label, userId } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.updateLabel(label.id, label);
|
|
}
|
|
|
|
handleLabelDeleted(data) {
|
|
const { labelId, userId } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
store.removeLabel(labelId);
|
|
}
|
|
|
|
handleCommentCreated(data) {
|
|
const { taskId, comment, userId, username } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
// Update task comment count
|
|
const task = store.getTaskById(taskId);
|
|
if (task) {
|
|
store.updateTask(taskId, {
|
|
commentCount: (task.commentCount || 0) + 1
|
|
});
|
|
}
|
|
|
|
// Check for mention
|
|
const currentUser = store.get('currentUser');
|
|
if (comment.content.includes(`@${currentUser?.username}`)) {
|
|
this.showNotification(
|
|
'Du wurdest erwähnt',
|
|
`${username} hat dich in "${task?.title}" erwähnt`,
|
|
'mention'
|
|
);
|
|
}
|
|
}
|
|
|
|
handleSubtaskUpdated(data) {
|
|
const { taskId, subtask, userId } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
// Trigger task refresh if viewing this task
|
|
const editingTask = store.get('editingTask');
|
|
if (editingTask?.id === taskId) {
|
|
window.dispatchEvent(new CustomEvent('task:refresh', {
|
|
detail: { taskId }
|
|
}));
|
|
}
|
|
}
|
|
|
|
handleUserJoined(data) {
|
|
const { userId, username } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
this.showNotification('Benutzer online', `${username} ist jetzt online`, 'info');
|
|
}
|
|
|
|
handleUserLeft(data) {
|
|
const { userId, username } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
// Optional: show notification
|
|
}
|
|
|
|
handleUserTyping(data) {
|
|
const { userId, taskId, isTyping } = data;
|
|
|
|
if (userId === store.get('currentUser')?.id) return;
|
|
|
|
window.dispatchEvent(new CustomEvent('user:typing', {
|
|
detail: { userId, taskId, isTyping }
|
|
}));
|
|
}
|
|
|
|
handleNotificationNew(data) {
|
|
const { notification } = data;
|
|
window.dispatchEvent(new CustomEvent('notification:new', {
|
|
detail: { notification }
|
|
}));
|
|
}
|
|
|
|
handleNotificationCount(data) {
|
|
const { count } = data;
|
|
window.dispatchEvent(new CustomEvent('notification:count', {
|
|
detail: { count }
|
|
}));
|
|
}
|
|
|
|
handleNotificationDeleted(data) {
|
|
const { notificationId } = data;
|
|
window.dispatchEvent(new CustomEvent('notification:deleted', {
|
|
detail: { notificationId }
|
|
}));
|
|
}
|
|
|
|
// =====================
|
|
// OUTGOING EVENTS
|
|
// =====================
|
|
|
|
notifyTaskCreated(task) {
|
|
this.emit('task:create', {
|
|
task,
|
|
projectId: store.get('currentProjectId')
|
|
});
|
|
}
|
|
|
|
notifyTaskUpdated(task, changes) {
|
|
this.emit('task:update', {
|
|
task,
|
|
changes,
|
|
projectId: store.get('currentProjectId')
|
|
});
|
|
}
|
|
|
|
notifyTaskDeleted(taskId, taskTitle) {
|
|
this.emit('task:delete', {
|
|
taskId,
|
|
taskTitle,
|
|
projectId: store.get('currentProjectId')
|
|
});
|
|
}
|
|
|
|
notifyTaskMoved(taskId, columnId, position) {
|
|
this.emit('task:move', {
|
|
taskId,
|
|
columnId,
|
|
position,
|
|
projectId: store.get('currentProjectId')
|
|
});
|
|
}
|
|
|
|
notifyColumnCreated(column) {
|
|
this.emit('column:create', {
|
|
column,
|
|
projectId: store.get('currentProjectId')
|
|
});
|
|
}
|
|
|
|
notifyColumnUpdated(column) {
|
|
this.emit('column:update', {
|
|
column,
|
|
projectId: store.get('currentProjectId')
|
|
});
|
|
}
|
|
|
|
notifyColumnDeleted(columnId) {
|
|
this.emit('column:delete', {
|
|
columnId,
|
|
projectId: store.get('currentProjectId')
|
|
});
|
|
}
|
|
|
|
notifyColumnsReordered(columnIds) {
|
|
this.emit('column:reorder', {
|
|
columnIds,
|
|
projectId: store.get('currentProjectId')
|
|
});
|
|
}
|
|
|
|
notifyTyping(taskId, isTyping) {
|
|
this.emit('user:typing', {
|
|
taskId,
|
|
isTyping,
|
|
projectId: store.get('currentProjectId')
|
|
});
|
|
}
|
|
|
|
// =====================
|
|
// NOTIFICATIONS
|
|
// =====================
|
|
|
|
showNotification(title, message, type = 'info') {
|
|
window.dispatchEvent(new CustomEvent('toast:show', {
|
|
detail: { title, message, type }
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
const syncManager = new SyncManager();
|
|
|
|
// Listen for auth events
|
|
// Hinweis: syncManager.connect() wird NICHT hier aufgerufen,
|
|
// sondern in app.js initializeApp() um doppelte Verbindungen zu vermeiden
|
|
|
|
window.addEventListener('auth:logout', () => {
|
|
syncManager.disconnect();
|
|
});
|
|
|
|
// Listen for project changes
|
|
store.subscribe('currentProjectId', (newProjectId, oldProjectId) => {
|
|
if (oldProjectId) {
|
|
syncManager.leaveProject(oldProjectId);
|
|
}
|
|
if (newProjectId) {
|
|
syncManager.joinProject(newProjectId);
|
|
}
|
|
});
|
|
|
|
// Listen for online/offline events
|
|
window.addEventListener('online', () => {
|
|
store.setOnline(true);
|
|
syncManager.connect();
|
|
});
|
|
|
|
window.addEventListener('offline', () => {
|
|
store.setOnline(false);
|
|
});
|
|
|
|
export default syncManager;
|