Introduces comprehensive audit trail system with: - AuditService to track entity changes - SyncManager for background sync of audit entries - New CoreEvents for entity and audit tracking - Simplified sync architecture with event-driven approach Prepares system for enhanced compliance and change tracking
259 lines
7.2 KiB
TypeScript
259 lines
7.2 KiB
TypeScript
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<IAuditEntry>;
|
|
|
|
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<string, number> = new Map();
|
|
|
|
constructor(
|
|
eventBus: IEventBus,
|
|
auditService: AuditService,
|
|
auditApiRepository: IApiRepository<IAuditEntry>
|
|
) {
|
|
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<void> {
|
|
// 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<void> {
|
|
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<void> {
|
|
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
|
|
}
|
|
}
|