import { IEventBus } from '../types/CalendarTypes'; import { CoreEvents } from '../constants/CoreEvents'; import { IAuditEntry } from '../types/AuditTypes'; import { AuditService } from '../storage/audit/AuditService'; import { IApiRepository } from '../repositories/IApiRepository'; /** * SyncManager - Background sync worker * Syncs audit entries with backend API when online * * NEW ARCHITECTURE: * - Listens to AUDIT_LOGGED events (triggered after AuditService saves) * - Polls AuditService for pending audit entries * - Syncs audit entries to backend API * - Marks audit entries as synced when successful * * EVENT CHAIN: * Entity change → ENTITY_SAVED/DELETED → AuditService → AUDIT_LOGGED → SyncManager * * Features: * - Monitors online/offline status * - Processes pending audits with FIFO order * - Exponential backoff retry logic * - Updates syncStatus in IndexedDB after successful sync * - Emits sync events for UI feedback */ export class SyncManager { private eventBus: IEventBus; private auditService: AuditService; private auditApiRepository: IApiRepository; private isOnline: boolean = navigator.onLine; private isSyncing: boolean = false; private syncInterval: number = 5000; // 5 seconds private maxRetries: number = 5; private intervalId: number | null = null; // Track retry counts per audit entry (in memory) private retryCounts: Map = new Map(); constructor( eventBus: IEventBus, auditService: AuditService, auditApiRepository: IApiRepository ) { this.eventBus = eventBus; this.auditService = auditService; this.auditApiRepository = auditApiRepository; this.setupNetworkListeners(); this.setupAuditListener(); this.startSync(); console.log('SyncManager initialized - listening for AUDIT_LOGGED events'); } /** * Setup listener for AUDIT_LOGGED events * Triggers immediate sync attempt when new audit entry is saved */ private setupAuditListener(): void { this.eventBus.on(CoreEvents.AUDIT_LOGGED, () => { // New audit entry saved - try to sync if online if (this.isOnline && !this.isSyncing) { this.processPendingAudits(); } }); } /** * Setup online/offline event listeners */ private setupNetworkListeners(): void { window.addEventListener('online', () => { this.isOnline = true; this.eventBus.emit(CoreEvents.OFFLINE_MODE_CHANGED, { isOnline: true }); console.log('SyncManager: Network online - starting sync'); this.startSync(); }); window.addEventListener('offline', () => { this.isOnline = false; this.eventBus.emit(CoreEvents.OFFLINE_MODE_CHANGED, { isOnline: false }); console.log('SyncManager: Network offline - pausing sync'); this.stopSync(); }); } /** * Start background sync worker */ public startSync(): void { if (this.intervalId) { return; // Already running } console.log('SyncManager: Starting background sync'); // Process immediately this.processPendingAudits(); // Then poll every syncInterval this.intervalId = window.setInterval(() => { this.processPendingAudits(); }, this.syncInterval); } /** * Stop background sync worker */ public stopSync(): void { if (this.intervalId) { window.clearInterval(this.intervalId); this.intervalId = null; console.log('SyncManager: Stopped background sync'); } } /** * Process pending audit entries * Fetches from AuditService and syncs to backend */ private async processPendingAudits(): Promise { // Don't sync if offline if (!this.isOnline) { return; } // Don't start new sync if already syncing if (this.isSyncing) { return; } this.isSyncing = true; try { const pendingAudits = await this.auditService.getPendingAudits(); if (pendingAudits.length === 0) { this.isSyncing = false; return; } this.eventBus.emit(CoreEvents.SYNC_STARTED, { operationCount: pendingAudits.length }); // Process audits one by one (FIFO - oldest first by timestamp) const sortedAudits = pendingAudits.sort((a, b) => a.timestamp - b.timestamp); for (const audit of sortedAudits) { await this.processAuditEntry(audit); } this.eventBus.emit(CoreEvents.SYNC_COMPLETED, { operationCount: pendingAudits.length }); } catch (error) { console.error('SyncManager: Audit processing error:', error); this.eventBus.emit(CoreEvents.SYNC_FAILED, { error: error instanceof Error ? error.message : 'Unknown error' }); } finally { this.isSyncing = false; } } /** * Process a single audit entry * Sends to backend API and marks as synced */ private async processAuditEntry(audit: IAuditEntry): Promise { const retryCount = this.retryCounts.get(audit.id) || 0; // Check if max retries exceeded if (retryCount >= this.maxRetries) { console.error(`SyncManager: Max retries exceeded for audit ${audit.id}`); await this.auditService.markAsError(audit.id); this.retryCounts.delete(audit.id); return; } try { // Send audit entry to backend await this.auditApiRepository.sendCreate(audit); // Success - mark as synced and clear retry count await this.auditService.markAsSynced(audit.id); this.retryCounts.delete(audit.id); console.log(`SyncManager: Successfully synced audit ${audit.id} (${audit.entityType}:${audit.operation})`); } catch (error) { console.error(`SyncManager: Failed to sync audit ${audit.id}:`, error); // Increment retry count this.retryCounts.set(audit.id, retryCount + 1); // Calculate backoff delay const backoffDelay = this.calculateBackoff(retryCount + 1); this.eventBus.emit(CoreEvents.SYNC_RETRY, { auditId: audit.id, retryCount: retryCount + 1, nextRetryIn: backoffDelay }); } } /** * Calculate exponential backoff delay * @param retryCount Current retry count * @returns Delay in milliseconds */ private calculateBackoff(retryCount: number): number { // Exponential backoff: 2^retryCount * 1000ms // Retry 1: 2s, Retry 2: 4s, Retry 3: 8s, Retry 4: 16s, Retry 5: 32s const baseDelay = 1000; const exponentialDelay = Math.pow(2, retryCount) * baseDelay; const maxDelay = 60000; // Max 1 minute return Math.min(exponentialDelay, maxDelay); } /** * Manually trigger sync (for testing or manual sync button) */ public async triggerManualSync(): Promise { console.log('SyncManager: Manual sync triggered'); await this.processPendingAudits(); } /** * Get current sync status */ public getSyncStatus(): { isOnline: boolean; isSyncing: boolean; isRunning: boolean; } { return { isOnline: this.isOnline, isSyncing: this.isSyncing, isRunning: this.intervalId !== null }; } /** * Cleanup - stop sync and remove listeners */ public destroy(): void { this.stopSync(); this.retryCounts.clear(); // Note: We don't remove window event listeners as they're global } }