Adds audit logging and sync management infrastructure
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
This commit is contained in:
parent
dcd76836bd
commit
9ea98e3a04
18 changed files with 469 additions and 414 deletions
|
|
@ -1,38 +1,33 @@
|
|||
import { IEventBus, EntityType, ISync } from '../types/CalendarTypes';
|
||||
import { IEventBus } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { OperationQueue, IQueueOperation } from '../storage/OperationQueue';
|
||||
import { IAuditEntry } from '../types/AuditTypes';
|
||||
import { AuditService } from '../storage/audit/AuditService';
|
||||
import { IApiRepository } from '../repositories/IApiRepository';
|
||||
import { IEntityService } from '../storage/IEntityService';
|
||||
|
||||
/**
|
||||
* SyncManager - Background sync worker
|
||||
* Processes operation queue and syncs with API when online
|
||||
* Syncs audit entries with backend API when online
|
||||
*
|
||||
* GENERIC ARCHITECTURE:
|
||||
* - Handles all entity types (Event, Booking, Customer, Resource)
|
||||
* - Routes operations based on IQueueOperation.dataEntity.typename
|
||||
* - Uses IApiRepository<T> pattern for type-safe API calls
|
||||
* - Uses IEntityService<T> polymorphism for sync status management
|
||||
* 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
|
||||
*
|
||||
* POLYMORFI DESIGN:
|
||||
* - Services implement IEntityService<T extends ISync> interface
|
||||
* - SyncManager uses Array.find() for service lookup (simple, only 4 entities)
|
||||
* - Services encapsulate sync status manipulation (markAsSynced, markAsError)
|
||||
* - SyncManager does NOT manipulate entity.syncStatus directly
|
||||
* - Open/Closed Principle: Adding new entity requires only DI registration
|
||||
* EVENT CHAIN:
|
||||
* Entity change → ENTITY_SAVED/DELETED → AuditService → AUDIT_LOGGED → SyncManager
|
||||
*
|
||||
* Features:
|
||||
* - Monitors online/offline status
|
||||
* - Processes queue with FIFO order
|
||||
* - 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 queue: OperationQueue;
|
||||
private repositories: Map<EntityType, IApiRepository<any>>;
|
||||
private entityServices: IEntityService<any>[];
|
||||
private auditService: AuditService;
|
||||
private auditApiRepository: IApiRepository<IAuditEntry>;
|
||||
|
||||
private isOnline: boolean = navigator.onLine;
|
||||
private isSyncing: boolean = false;
|
||||
|
|
@ -40,24 +35,35 @@ export class SyncManager {
|
|||
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,
|
||||
queue: OperationQueue,
|
||||
apiRepositories: IApiRepository<any>[],
|
||||
entityServices: IEntityService<any>[]
|
||||
auditService: AuditService,
|
||||
auditApiRepository: IApiRepository<IAuditEntry>
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.queue = queue;
|
||||
this.entityServices = entityServices;
|
||||
|
||||
// Build map: EntityType → IApiRepository
|
||||
this.repositories = new Map(
|
||||
apiRepositories.map(repo => [repo.entityType, repo])
|
||||
);
|
||||
this.auditService = auditService;
|
||||
this.auditApiRepository = auditApiRepository;
|
||||
|
||||
this.setupNetworkListeners();
|
||||
this.setupAuditListener();
|
||||
this.startSync();
|
||||
console.log(`SyncManager initialized with ${apiRepositories.length} entity repositories and ${entityServices.length} entity services`);
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,11 +100,11 @@ export class SyncManager {
|
|||
console.log('SyncManager: Starting background sync');
|
||||
|
||||
// Process immediately
|
||||
this.processQueue();
|
||||
this.processPendingAudits();
|
||||
|
||||
// Then poll every syncInterval
|
||||
this.intervalId = window.setInterval(() => {
|
||||
this.processQueue();
|
||||
this.processPendingAudits();
|
||||
}, this.syncInterval);
|
||||
}
|
||||
|
||||
|
|
@ -114,10 +120,10 @@ export class SyncManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Process operation queue
|
||||
* Sends pending operations to API
|
||||
* Process pending audit entries
|
||||
* Fetches from AuditService and syncs to backend
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
private async processPendingAudits(): Promise<void> {
|
||||
// Don't sync if offline
|
||||
if (!this.isOnline) {
|
||||
return;
|
||||
|
|
@ -128,31 +134,33 @@ export class SyncManager {
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if queue is empty
|
||||
if (await this.queue.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
const operations = await this.queue.getAll();
|
||||
const pendingAudits = await this.auditService.getPendingAudits();
|
||||
|
||||
if (pendingAudits.length === 0) {
|
||||
this.isSyncing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.eventBus.emit(CoreEvents.SYNC_STARTED, {
|
||||
operationCount: operations.length
|
||||
operationCount: pendingAudits.length
|
||||
});
|
||||
|
||||
// Process operations one by one (FIFO)
|
||||
for (const operation of operations) {
|
||||
await this.processOperation(operation);
|
||||
// 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: operations.length
|
||||
operationCount: pendingAudits.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('SyncManager: Queue processing error:', error);
|
||||
console.error('SyncManager: Audit processing error:', error);
|
||||
this.eventBus.emit(CoreEvents.SYNC_FAILED, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
|
|
@ -162,106 +170,47 @@ export class SyncManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Process a single operation
|
||||
* Generic - routes to correct API repository based on entity type
|
||||
* Process a single audit entry
|
||||
* Sends to backend API and marks as synced
|
||||
*/
|
||||
private async processOperation(operation: IQueueOperation): Promise<void> {
|
||||
// Check if max retries exceeded
|
||||
if (operation.retryCount >= this.maxRetries) {
|
||||
console.error(`SyncManager: Max retries exceeded for operation ${operation.id}`, operation);
|
||||
await this.queue.remove(operation.id);
|
||||
await this.markEntityAsError(operation.dataEntity.typename, operation.entityId);
|
||||
return;
|
||||
}
|
||||
private async processAuditEntry(audit: IAuditEntry): Promise<void> {
|
||||
const retryCount = this.retryCounts.get(audit.id) || 0;
|
||||
|
||||
// Get the appropriate API repository for this entity type
|
||||
const repository = this.repositories.get(operation.dataEntity.typename);
|
||||
if (!repository) {
|
||||
console.error(`SyncManager: No repository found for entity type ${operation.dataEntity.typename}`);
|
||||
await this.queue.remove(operation.id);
|
||||
// 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 to API based on operation type
|
||||
switch (operation.type) {
|
||||
case 'create':
|
||||
await repository.sendCreate(operation.dataEntity.data);
|
||||
break;
|
||||
// Send audit entry to backend
|
||||
await this.auditApiRepository.sendCreate(audit);
|
||||
|
||||
case 'update':
|
||||
await repository.sendUpdate(operation.entityId, operation.dataEntity.data);
|
||||
break;
|
||||
// Success - mark as synced and clear retry count
|
||||
await this.auditService.markAsSynced(audit.id);
|
||||
this.retryCounts.delete(audit.id);
|
||||
|
||||
case 'delete':
|
||||
await repository.sendDelete(operation.entityId);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`SyncManager: Unknown operation type ${operation.type}`);
|
||||
await this.queue.remove(operation.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - remove from queue and mark as synced
|
||||
await this.queue.remove(operation.id);
|
||||
await this.markEntityAsSynced(operation.dataEntity.typename, operation.entityId);
|
||||
|
||||
console.log(`SyncManager: Successfully synced ${operation.dataEntity.typename} operation ${operation.id}`);
|
||||
console.log(`SyncManager: Successfully synced audit ${audit.id} (${audit.entityType}:${audit.operation})`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`SyncManager: Failed to sync operation ${operation.id}:`, error);
|
||||
console.error(`SyncManager: Failed to sync audit ${audit.id}:`, error);
|
||||
|
||||
// Increment retry count
|
||||
await this.queue.incrementRetryCount(operation.id);
|
||||
this.retryCounts.set(audit.id, retryCount + 1);
|
||||
|
||||
// Calculate backoff delay
|
||||
const backoffDelay = this.calculateBackoff(operation.retryCount + 1);
|
||||
const backoffDelay = this.calculateBackoff(retryCount + 1);
|
||||
|
||||
this.eventBus.emit(CoreEvents.SYNC_RETRY, {
|
||||
operationId: operation.id,
|
||||
retryCount: operation.retryCount + 1,
|
||||
auditId: audit.id,
|
||||
retryCount: retryCount + 1,
|
||||
nextRetryIn: backoffDelay
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark entity as synced in IndexedDB
|
||||
* Uses polymorphism - delegates to IEntityService.markAsSynced()
|
||||
*/
|
||||
private async markEntityAsSynced(entityType: EntityType, entityId: string): Promise<void> {
|
||||
try {
|
||||
const service = this.entityServices.find(s => s.entityType === entityType);
|
||||
if (!service) {
|
||||
console.error(`SyncManager: No service found for entity type ${entityType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await service.markAsSynced(entityId);
|
||||
} catch (error) {
|
||||
console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as synced:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark entity as error in IndexedDB
|
||||
* Uses polymorphism - delegates to IEntityService.markAsError()
|
||||
*/
|
||||
private async markEntityAsError(entityType: EntityType, entityId: string): Promise<void> {
|
||||
try {
|
||||
const service = this.entityServices.find(s => s.entityType === entityType);
|
||||
if (!service) {
|
||||
console.error(`SyncManager: No service found for entity type ${entityType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await service.markAsError(entityId);
|
||||
} catch (error) {
|
||||
console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as error:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay
|
||||
* @param retryCount Current retry count
|
||||
|
|
@ -281,7 +230,7 @@ export class SyncManager {
|
|||
*/
|
||||
public async triggerManualSync(): Promise<void> {
|
||||
console.log('SyncManager: Manual sync triggered');
|
||||
await this.processQueue();
|
||||
await this.processPendingAudits();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -304,6 +253,7 @@ export class SyncManager {
|
|||
*/
|
||||
public destroy(): void {
|
||||
this.stopSync();
|
||||
this.retryCounts.clear();
|
||||
// Note: We don't remove window event listeners as they're global
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue