Refactor entity services with hybrid sync pattern

Introduces BaseEntityService and SyncPlugin to eliminate code duplication across entity services

Improves:
- Code reusability through inheritance and composition
- Sync infrastructure for all entity types
- Polymorphic sync status management
- Reduced boilerplate code by ~75%

Supports generic sync for Event, Booking, Customer, and Resource entities
This commit is contained in:
Janus C. H. Knudsen 2025-11-18 16:37:33 +01:00
parent 2aa9d06fab
commit 8e52d670d6
30 changed files with 1960 additions and 526 deletions

View file

@ -1,14 +1,28 @@
import { IEventBus } from '../types/CalendarTypes';
import { IEventBus, EntityType, ISync } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { OperationQueue } from '../storage/OperationQueue';
import { IQueueOperation } from '../storage/IndexedDBService';
import { IndexedDBService } from '../storage/IndexedDBService';
import { ApiEventRepository } from '../repositories/ApiEventRepository';
import { IApiRepository } from '../repositories/IApiRepository';
import { IEntityService } from '../storage/IEntityService';
/**
* SyncManager - Background sync worker
* Processes operation queue and syncs with 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
*
* 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
*
* Features:
* - Monitors online/offline status
* - Processes queue with FIFO order
@ -20,7 +34,8 @@ export class SyncManager {
private eventBus: IEventBus;
private queue: OperationQueue;
private indexedDB: IndexedDBService;
private apiRepository: ApiEventRepository;
private repositories: Map<EntityType, IApiRepository<any>>;
private entityServices: IEntityService<any>[];
private isOnline: boolean = navigator.onLine;
private isSyncing: boolean = false;
@ -32,16 +47,22 @@ export class SyncManager {
eventBus: IEventBus,
queue: OperationQueue,
indexedDB: IndexedDBService,
apiRepository: ApiEventRepository
apiRepositories: IApiRepository<any>[],
entityServices: IEntityService<any>[]
) {
this.eventBus = eventBus;
this.queue = queue;
this.indexedDB = indexedDB;
this.apiRepository = apiRepository;
this.entityServices = entityServices;
// Build map: EntityType → IApiRepository
this.repositories = new Map(
apiRepositories.map(repo => [repo.entityType, repo])
);
this.setupNetworkListeners();
this.startSync();
console.log('SyncManager initialized and started');
console.log(`SyncManager initialized with ${apiRepositories.length} entity repositories and ${entityServices.length} entity services`);
}
/**
@ -147,13 +168,22 @@ export class SyncManager {
/**
* Process a single operation
* Generic - routes to correct API repository based on entity type
*/
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.markEventAsError(operation.eventId);
await this.markEntityAsError(operation.dataEntity.typename, operation.entityId);
return;
}
// 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);
return;
}
@ -161,15 +191,15 @@ export class SyncManager {
// Send to API based on operation type
switch (operation.type) {
case 'create':
await this.apiRepository.sendCreate(operation.data as any);
await repository.sendCreate(operation.dataEntity.data);
break;
case 'update':
await this.apiRepository.sendUpdate(operation.eventId, operation.data);
await repository.sendUpdate(operation.entityId, operation.dataEntity.data);
break;
case 'delete':
await this.apiRepository.sendDelete(operation.eventId);
await repository.sendDelete(operation.entityId);
break;
default:
@ -180,9 +210,9 @@ export class SyncManager {
// Success - remove from queue and mark as synced
await this.queue.remove(operation.id);
await this.markEventAsSynced(operation.eventId);
await this.markEntityAsSynced(operation.dataEntity.typename, operation.entityId);
console.log(`SyncManager: Successfully synced operation ${operation.id}`);
console.log(`SyncManager: Successfully synced ${operation.dataEntity.typename} operation ${operation.id}`);
} catch (error) {
console.error(`SyncManager: Failed to sync operation ${operation.id}:`, error);
@ -202,32 +232,38 @@ export class SyncManager {
}
/**
* Mark event as synced in IndexedDB
* Mark entity as synced in IndexedDB
* Uses polymorphism - delegates to IEntityService.markAsSynced()
*/
private async markEventAsSynced(eventId: string): Promise<void> {
private async markEntityAsSynced(entityType: EntityType, entityId: string): Promise<void> {
try {
const event = await this.indexedDB.getEvent(eventId);
if (event) {
event.syncStatus = 'synced';
await this.indexedDB.saveEvent(event);
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 event ${eventId} as synced:`, error);
console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as synced:`, error);
}
}
/**
* Mark event as error in IndexedDB
* Mark entity as error in IndexedDB
* Uses polymorphism - delegates to IEntityService.markAsError()
*/
private async markEventAsError(eventId: string): Promise<void> {
private async markEntityAsError(entityType: EntityType, entityId: string): Promise<void> {
try {
const event = await this.indexedDB.getEvent(eventId);
if (event) {
event.syncStatus = 'error';
await this.indexedDB.saveEvent(event);
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 event ${eventId} as error:`, error);
console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as error:`, error);
}
}