diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2206350..b8def76 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,12 @@ { "permissions": { "allow": [ - "Bash(npm run build:*)" + "Bash(npm run build:*)", + "WebSearch", + "WebFetch(domain:web.dev)", + "WebFetch(domain:caniuse.com)", + "WebFetch(domain:blog.rasc.ch)", + "WebFetch(domain:developer.chrome.com)" ], "deny": [], "ask": [] diff --git a/package-lock.json b/package-lock.json index 11fc31c..1389069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "@novadi/core": "^0.6.0", "@rollup/rollup-win32-x64-msvc": "^4.52.2", "dayjs": "^1.11.19", - "fuse.js": "^7.1.0" + "fuse.js": "^7.1.0", + "json-diff-ts": "^4.8.2" }, "devDependencies": { "@fullhuman/postcss-purgecss": "^7.0.2", @@ -3097,6 +3098,12 @@ } } }, + "node_modules/json-diff-ts": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/json-diff-ts/-/json-diff-ts-4.8.2.tgz", + "integrity": "sha512-7LgOTnfK5XnBs0o0AtHTkry5QGZT7cSlAgu5GtiomUeoHqOavAUDcONNm/bCe4Lapt0AHnaidD5iSE+ItvxKkA==", + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", diff --git a/package.json b/package.json index f42899e..d2aadc1 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@novadi/core": "^0.6.0", "@rollup/rollup-win32-x64-msvc": "^4.52.2", "dayjs": "^1.11.19", - "fuse.js": "^7.1.0" + "fuse.js": "^7.1.0", + "json-diff-ts": "^4.8.2" } } diff --git a/src/constants/CoreEvents.ts b/src/constants/CoreEvents.ts index 52b285d..983e121 100644 --- a/src/constants/CoreEvents.ts +++ b/src/constants/CoreEvents.ts @@ -47,6 +47,11 @@ export const CoreEvents = { SYNC_COMPLETED: 'sync:completed', SYNC_FAILED: 'sync:failed', SYNC_RETRY: 'sync:retry', + + // Entity events (3) - for audit and sync + ENTITY_SAVED: 'entity:saved', + ENTITY_DELETED: 'entity:deleted', + AUDIT_LOGGED: 'audit:logged', // Filter events (1) FILTER_CHANGED: 'filter:changed', diff --git a/src/index.ts b/src/index.ts index 75dfe13..71d0181 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,14 +27,17 @@ import { MockEventRepository } from './repositories/MockEventRepository'; import { MockBookingRepository } from './repositories/MockBookingRepository'; import { MockCustomerRepository } from './repositories/MockCustomerRepository'; import { MockResourceRepository } from './repositories/MockResourceRepository'; +import { MockAuditRepository } from './repositories/MockAuditRepository'; import { IApiRepository } from './repositories/IApiRepository'; +import { IAuditEntry } from './types/AuditTypes'; import { ApiEventRepository } from './repositories/ApiEventRepository'; import { ApiBookingRepository } from './repositories/ApiBookingRepository'; import { ApiCustomerRepository } from './repositories/ApiCustomerRepository'; import { ApiResourceRepository } from './repositories/ApiResourceRepository'; import { IndexedDBContext } from './storage/IndexedDBContext'; -import { OperationQueue } from './storage/OperationQueue'; import { IStore } from './storage/IStore'; +import { AuditStore } from './storage/audit/AuditStore'; +import { AuditService } from './storage/audit/AuditService'; import { BookingStore } from './storage/bookings/BookingStore'; import { CustomerStore } from './storage/customers/CustomerStore'; import { ResourceStore } from './storage/resources/ResourceStore'; @@ -121,11 +124,10 @@ async function initializeCalendar(): Promise { builder.registerType(CustomerStore).as(); builder.registerType(ResourceStore).as(); builder.registerType(EventStore).as(); - + builder.registerType(AuditStore).as(); // Register storage and repository services builder.registerType(IndexedDBContext).as(); - builder.registerType(OperationQueue).as(); // Register Mock repositories (development/testing - load from JSON files) // Each entity type has its own Mock repository implementing IApiRepository @@ -133,6 +135,7 @@ async function initializeCalendar(): Promise { builder.registerType(MockBookingRepository).as>(); builder.registerType(MockCustomerRepository).as>(); builder.registerType(MockResourceRepository).as>(); + builder.registerType(MockAuditRepository).as>(); builder.registerType(DateColumnDataSource).as(); // Register entity services (sync status management) @@ -141,6 +144,7 @@ async function initializeCalendar(): Promise { builder.registerType(BookingService).as>(); builder.registerType(CustomerService).as>(); builder.registerType(ResourceService).as>(); + builder.registerType(AuditService).as(); // Register workers builder.registerType(SyncManager).as(); @@ -211,12 +215,11 @@ async function initializeCalendar(): Promise { await calendarManager.initialize?.(); await resizeHandleManager.initialize?.(); - // Resolve SyncManager (starts automatically in constructor) - // Resolve SyncManager (starts automatically in constructor) - // Resolve SyncManager (starts automatically in constructor) - // Resolve SyncManager (starts automatically in constructor) - // Resolve SyncManager (starts automatically in constructor) - //const syncManager = app.resolveType(); + // Resolve AuditService (starts listening for entity events) + const auditService = app.resolveType(); + + // Resolve SyncManager (starts background sync automatically) + const syncManager = app.resolveType(); // Handle deep linking after managers are initialized await handleDeepLinking(eventManager, urlManager); @@ -229,7 +232,8 @@ async function initializeCalendar(): Promise { calendarManager: typeof calendarManager; eventManager: typeof eventManager; workweekPresetsManager: typeof workweekPresetsManager; - //syncManager: typeof syncManager; + auditService: typeof auditService; + syncManager: typeof syncManager; }; }).calendarDebug = { eventBus, @@ -237,7 +241,8 @@ async function initializeCalendar(): Promise { calendarManager, eventManager, workweekPresetsManager, - //syncManager, + auditService, + syncManager, }; } catch (error) { diff --git a/src/repositories/MockAuditRepository.ts b/src/repositories/MockAuditRepository.ts new file mode 100644 index 0000000..33448f7 --- /dev/null +++ b/src/repositories/MockAuditRepository.ts @@ -0,0 +1,47 @@ +import { IApiRepository } from './IApiRepository'; +import { IAuditEntry } from '../types/AuditTypes'; +import { EntityType } from '../types/CalendarTypes'; + +/** + * MockAuditRepository - Mock API repository for audit entries + * + * In production, this would send audit entries to the backend. + * For development/testing, it just logs the operations. + */ +export class MockAuditRepository implements IApiRepository { + readonly entityType: EntityType = 'Audit'; + + async sendCreate(entity: IAuditEntry): Promise { + // Simulate API call delay + await new Promise(resolve => setTimeout(resolve, 100)); + + console.log('MockAuditRepository: Audit entry synced to backend:', { + id: entity.id, + entityType: entity.entityType, + entityId: entity.entityId, + operation: entity.operation, + timestamp: new Date(entity.timestamp).toISOString() + }); + } + + async sendUpdate(_id: string, _entity: IAuditEntry): Promise { + // Audit entries are immutable - updates should not happen + throw new Error('Audit entries cannot be updated'); + } + + async sendDelete(_id: string): Promise { + // Audit entries should never be deleted + throw new Error('Audit entries cannot be deleted'); + } + + async fetchAll(): Promise { + // For now, return empty array - audit entries are local-first + // In production, this could fetch audit history from backend + return []; + } + + async fetchById(_id: string): Promise { + // For now, return null - audit entries are local-first + return null; + } +} diff --git a/src/storage/BaseEntityService.ts b/src/storage/BaseEntityService.ts index 079a90d..3d83070 100644 --- a/src/storage/BaseEntityService.ts +++ b/src/storage/BaseEntityService.ts @@ -2,6 +2,9 @@ import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes'; import { IEntityService } from './IEntityService'; import { SyncPlugin } from './SyncPlugin'; import { IndexedDBContext } from './IndexedDBContext'; +import { IEventBus } from '../types/CalendarTypes'; +import { CoreEvents } from '../constants/CoreEvents'; +import { diff } from 'json-diff-ts'; /** * BaseEntityService - Abstract base class for all entity services @@ -42,11 +45,16 @@ export abstract class BaseEntityService implements IEntityServi // IndexedDB context - provides database connection private context: IndexedDBContext; + // EventBus for emitting entity events + protected eventBus: IEventBus; + /** * @param context - IndexedDBContext instance (injected dependency) + * @param eventBus - EventBus for emitting entity events */ - constructor(context: IndexedDBContext) { + constructor(context: IndexedDBContext, eventBus: IEventBus) { this.context = context; + this.eventBus = eventBus; this.syncPlugin = new SyncPlugin(this); } @@ -132,10 +140,28 @@ export abstract class BaseEntityService implements IEntityServi /** * Save an entity (create or update) + * Emits ENTITY_SAVED event with operation type and changes * * @param entity - Entity to save */ async save(entity: T): Promise { + const entityId = (entity as any).id; + + // Check if entity exists to determine create vs update + const existingEntity = await this.get(entityId); + const isCreate = existingEntity === null; + + // Calculate changes: full entity for create, diff for update + let changes: any; + if (isCreate) { + changes = entity; + } else { + // Calculate diff between existing and new entity + const existingSerialized = this.serialize(existingEntity); + const newSerialized = this.serialize(entity); + changes = diff(existingSerialized, newSerialized); + } + const serialized = this.serialize(entity); return new Promise((resolve, reject) => { @@ -144,17 +170,26 @@ export abstract class BaseEntityService implements IEntityServi const request = store.put(serialized); request.onsuccess = () => { + // Emit ENTITY_SAVED event + this.eventBus.emit(CoreEvents.ENTITY_SAVED, { + entityType: this.entityType, + entityId, + operation: isCreate ? 'create' : 'update', + changes, + timestamp: Date.now() + }); resolve(); }; request.onerror = () => { - reject(new Error(`Failed to save ${this.entityType} ${(entity as any).id}: ${request.error}`)); + reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`)); }; }); } /** * Delete an entity + * Emits ENTITY_DELETED event * * @param id - Entity ID to delete */ @@ -165,6 +200,13 @@ export abstract class BaseEntityService implements IEntityServi const request = store.delete(id); request.onsuccess = () => { + // Emit ENTITY_DELETED event + this.eventBus.emit(CoreEvents.ENTITY_DELETED, { + entityType: this.entityType, + entityId: id, + operation: 'delete', + timestamp: Date.now() + }); resolve(); }; diff --git a/src/storage/IndexedDBContext.ts b/src/storage/IndexedDBContext.ts index b50d0f8..be51585 100644 --- a/src/storage/IndexedDBContext.ts +++ b/src/storage/IndexedDBContext.ts @@ -19,7 +19,7 @@ import { IStore } from './IStore'; */ export class IndexedDBContext { private static readonly DB_NAME = 'CalendarDB'; - private static readonly DB_VERSION = 2; + private static readonly DB_VERSION = 3; // Bumped for audit store static readonly QUEUE_STORE = 'operationQueue'; static readonly SYNC_STATE_STORE = 'syncState'; diff --git a/src/storage/OperationQueue.ts b/src/storage/OperationQueue.ts deleted file mode 100644 index c302d84..0000000 --- a/src/storage/OperationQueue.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { IndexedDBContext } from './IndexedDBContext'; -import { IDataEntity } from '../types/CalendarTypes'; - -/** - * Operation for the sync queue - * Generic structure supporting all entity types (Event, Booking, Customer, Resource) - */ -export interface IQueueOperation { - id: string; - type: 'create' | 'update' | 'delete'; - entityId: string; - dataEntity: IDataEntity; - timestamp: number; - retryCount: number; -} - -/** - * Operation Queue Manager - * Handles FIFO queue of pending sync operations and sync state metadata - * - * RESPONSIBILITY: - * - Queue operations (enqueue, dequeue, peek, clear) - * - Sync state management (setSyncState, getSyncState) - * - Direct IndexedDB operations on queue and syncState stores - * - * ARCHITECTURE: - * - Moved from IndexedDBService to achieve better separation of concerns - * - IndexedDBContext provides database connection - * - OperationQueue owns queue business logic - */ -export class OperationQueue { - private context: IndexedDBContext; - - constructor(context: IndexedDBContext) { - this.context = context; - } - - // ======================================== - // Queue Operations - // ======================================== - - /** - * Add operation to the end of the queue - */ - async enqueue(operation: Omit): Promise { - const db = this.context.getDatabase(); - const queueItem: IQueueOperation = { - ...operation, - id: `${operation.type}-${operation.entityId}-${Date.now()}` - }; - - return new Promise((resolve, reject) => { - const transaction = db.transaction([IndexedDBContext.QUEUE_STORE], 'readwrite'); - const store = transaction.objectStore(IndexedDBContext.QUEUE_STORE); - const request = store.put(queueItem); - - request.onsuccess = () => { - resolve(); - }; - - request.onerror = () => { - reject(new Error(`Failed to add to queue: ${request.error}`)); - }; - }); - } - - /** - * Get all operations in the queue (sorted by timestamp FIFO) - */ - async getAll(): Promise { - const db = this.context.getDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction([IndexedDBContext.QUEUE_STORE], 'readonly'); - const store = transaction.objectStore(IndexedDBContext.QUEUE_STORE); - const index = store.index('timestamp'); - const request = index.getAll(); - - request.onsuccess = () => { - resolve(request.result as IQueueOperation[]); - }; - - request.onerror = () => { - reject(new Error(`Failed to get queue: ${request.error}`)); - }; - }); - } - - /** - * Get the first operation from the queue (without removing it) - * Returns null if queue is empty - */ - async peek(): Promise { - const queue = await this.getAll(); - return queue.length > 0 ? queue[0] : null; - } - - /** - * Remove a specific operation from the queue - */ - async remove(operationId: string): Promise { - const db = this.context.getDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction([IndexedDBContext.QUEUE_STORE], 'readwrite'); - const store = transaction.objectStore(IndexedDBContext.QUEUE_STORE); - const request = store.delete(operationId); - - request.onsuccess = () => { - resolve(); - }; - - request.onerror = () => { - reject(new Error(`Failed to remove from queue: ${request.error}`)); - }; - }); - } - - /** - * Remove the first operation from the queue and return it - * Returns null if queue is empty - */ - async dequeue(): Promise { - const operation = await this.peek(); - if (operation) { - await this.remove(operation.id); - } - return operation; - } - - /** - * Clear all operations from the queue - */ - async clear(): Promise { - const db = this.context.getDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction([IndexedDBContext.QUEUE_STORE], 'readwrite'); - const store = transaction.objectStore(IndexedDBContext.QUEUE_STORE); - const request = store.clear(); - - request.onsuccess = () => { - resolve(); - }; - - request.onerror = () => { - reject(new Error(`Failed to clear queue: ${request.error}`)); - }; - }); - } - - /** - * Get the number of operations in the queue - */ - async size(): Promise { - const queue = await this.getAll(); - return queue.length; - } - - /** - * Check if queue is empty - */ - async isEmpty(): Promise { - const size = await this.size(); - return size === 0; - } - - /** - * Get operations for a specific entity ID - */ - async getOperationsForEntity(entityId: string): Promise { - const queue = await this.getAll(); - return queue.filter(op => op.entityId === entityId); - } - - /** - * Remove all operations for a specific entity ID - */ - async removeOperationsForEntity(entityId: string): Promise { - const operations = await this.getOperationsForEntity(entityId); - for (const op of operations) { - await this.remove(op.id); - } - } - - /** - * @deprecated Use getOperationsForEntity instead - */ - async getOperationsForEvent(eventId: string): Promise { - return this.getOperationsForEntity(eventId); - } - - /** - * @deprecated Use removeOperationsForEntity instead - */ - async removeOperationsForEvent(eventId: string): Promise { - return this.removeOperationsForEntity(eventId); - } - - /** - * Update retry count for an operation - */ - async incrementRetryCount(operationId: string): Promise { - const queue = await this.getAll(); - const operation = queue.find(op => op.id === operationId); - - if (operation) { - operation.retryCount++; - // Re-add to queue with updated retry count - await this.remove(operationId); - await this.enqueue(operation); - } - } - - // ======================================== - // Sync State Operations - // ======================================== - - /** - * Save sync state value - * Used to store sync metadata like lastSyncTime, etc. - * - * @param key - State key - * @param value - State value (any serializable data) - */ - async setSyncState(key: string, value: any): Promise { - const db = this.context.getDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction([IndexedDBContext.SYNC_STATE_STORE], 'readwrite'); - const store = transaction.objectStore(IndexedDBContext.SYNC_STATE_STORE); - const request = store.put({ key, value }); - - request.onsuccess = () => { - resolve(); - }; - - request.onerror = () => { - reject(new Error(`Failed to set sync state ${key}: ${request.error}`)); - }; - }); - } - - /** - * Get sync state value - * - * @param key - State key - * @returns State value or null if not found - */ - async getSyncState(key: string): Promise { - const db = this.context.getDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction([IndexedDBContext.SYNC_STATE_STORE], 'readonly'); - const store = transaction.objectStore(IndexedDBContext.SYNC_STATE_STORE); - const request = store.get(key); - - request.onsuccess = () => { - const result = request.result; - resolve(result ? result.value : null); - }; - - request.onerror = () => { - reject(new Error(`Failed to get sync state ${key}: ${request.error}`)); - }; - }); - } -} diff --git a/src/storage/audit/AuditService.ts b/src/storage/audit/AuditService.ts new file mode 100644 index 0000000..9ddbdd7 --- /dev/null +++ b/src/storage/audit/AuditService.ts @@ -0,0 +1,177 @@ +import { BaseEntityService } from '../BaseEntityService'; +import { IndexedDBContext } from '../IndexedDBContext'; +import { IAuditEntry } from '../../types/AuditTypes'; +import { EntityType, IEventBus } from '../../types/CalendarTypes'; +import { CoreEvents } from '../../constants/CoreEvents'; + +/** + * AuditService - Entity service for audit entries + * + * RESPONSIBILITIES: + * - Store audit entries in IndexedDB + * - Listen for ENTITY_SAVED/ENTITY_DELETED events + * - Create audit entries for all entity changes + * - Emit AUDIT_LOGGED after saving (for SyncManager to listen) + * + * OVERRIDE PATTERN: + * - Overrides save() to NOT emit events (prevents infinite loops) + * - AuditService saves audit entries without triggering more audits + * + * EVENT CHAIN: + * Entity change → ENTITY_SAVED/DELETED → AuditService → AUDIT_LOGGED → SyncManager + */ +export class AuditService extends BaseEntityService { + readonly storeName = 'audit'; + readonly entityType: EntityType = 'Audit'; + + // Hardcoded userId for now - will come from session later + private static readonly DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000001'; + + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + this.setupEventListeners(); + } + + /** + * Setup listeners for ENTITY_SAVED and ENTITY_DELETED events + */ + private setupEventListeners(): void { + // Listen for entity saves (create/update) + this.eventBus.on(CoreEvents.ENTITY_SAVED, (event: Event) => { + const detail = (event as CustomEvent).detail; + this.handleEntitySaved(detail); + }); + + // Listen for entity deletes + this.eventBus.on(CoreEvents.ENTITY_DELETED, (event: Event) => { + const detail = (event as CustomEvent).detail; + this.handleEntityDeleted(detail); + }); + } + + /** + * Handle ENTITY_SAVED event - create audit entry + */ + private async handleEntitySaved(payload: { + entityType: EntityType; + entityId: string; + operation: 'create' | 'update'; + changes: any; + timestamp: number; + }): Promise { + // Don't audit audit entries (prevent infinite loops) + if (payload.entityType === 'Audit') return; + + const auditEntry: IAuditEntry = { + id: crypto.randomUUID(), + entityType: payload.entityType, + entityId: payload.entityId, + operation: payload.operation, + userId: AuditService.DEFAULT_USER_ID, + timestamp: payload.timestamp, + changes: payload.changes, + synced: false, + syncStatus: 'pending' + }; + + await this.save(auditEntry); + } + + /** + * Handle ENTITY_DELETED event - create audit entry + */ + private async handleEntityDeleted(payload: { + entityType: EntityType; + entityId: string; + operation: 'delete'; + timestamp: number; + }): Promise { + // Don't audit audit entries (prevent infinite loops) + if (payload.entityType === 'Audit') return; + + const auditEntry: IAuditEntry = { + id: crypto.randomUUID(), + entityType: payload.entityType, + entityId: payload.entityId, + operation: 'delete', + userId: AuditService.DEFAULT_USER_ID, + timestamp: payload.timestamp, + changes: { id: payload.entityId }, // For delete, just store the ID + synced: false, + syncStatus: 'pending' + }; + + await this.save(auditEntry); + } + + /** + * Override save to NOT trigger ENTITY_SAVED event + * Instead, emits AUDIT_LOGGED for SyncManager to listen + * + * This prevents infinite loops: + * - BaseEntityService.save() emits ENTITY_SAVED + * - AuditService listens to ENTITY_SAVED and creates audit + * - If AuditService.save() also emitted ENTITY_SAVED, it would loop + */ + async save(entity: IAuditEntry): Promise { + const serialized = this.serialize(entity); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.storeName], 'readwrite'); + const store = transaction.objectStore(this.storeName); + const request = store.put(serialized); + + request.onsuccess = () => { + // Emit AUDIT_LOGGED instead of ENTITY_SAVED + this.eventBus.emit(CoreEvents.AUDIT_LOGGED, { + auditId: entity.id, + entityType: entity.entityType, + entityId: entity.entityId, + operation: entity.operation, + timestamp: entity.timestamp + }); + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to save audit entry ${entity.id}: ${request.error}`)); + }; + }); + } + + /** + * Override delete to NOT trigger ENTITY_DELETED event + * Audit entries should never be deleted (compliance requirement) + */ + async delete(_id: string): Promise { + throw new Error('Audit entries cannot be deleted (compliance requirement)'); + } + + /** + * Get pending audit entries (for sync) + */ + async getPendingAudits(): Promise { + return this.getBySyncStatus('pending'); + } + + /** + * Get audit entries for a specific entity + */ + async getByEntityId(entityId: string): Promise { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const index = store.index('entityId'); + const request = index.getAll(entityId); + + request.onsuccess = () => { + const entries = request.result as IAuditEntry[]; + resolve(entries); + }; + + request.onerror = () => { + reject(new Error(`Failed to get audit entries for entity ${entityId}: ${request.error}`)); + }; + }); + } +} diff --git a/src/storage/audit/AuditStore.ts b/src/storage/audit/AuditStore.ts new file mode 100644 index 0000000..bdef64e --- /dev/null +++ b/src/storage/audit/AuditStore.ts @@ -0,0 +1,25 @@ +import { IStore } from '../IStore'; + +/** + * AuditStore - IndexedDB store configuration for audit entries + * + * Stores all entity changes for: + * - Compliance and audit trail + * - Sync tracking with backend + * - Change history + * + * Indexes: + * - syncStatus: For finding pending entries to sync + * - synced: Boolean flag for quick sync queries + */ +export class AuditStore implements IStore { + readonly storeName = 'audit'; + + create(db: IDBDatabase): void { + const store = db.createObjectStore(this.storeName, { keyPath: 'id' }); + store.createIndex('syncStatus', 'syncStatus', { unique: false }); + store.createIndex('synced', 'synced', { unique: false }); + store.createIndex('entityId', 'entityId', { unique: false }); + store.createIndex('timestamp', 'timestamp', { unique: false }); + } +} diff --git a/src/storage/bookings/BookingService.ts b/src/storage/bookings/BookingService.ts index 3719666..3550627 100644 --- a/src/storage/bookings/BookingService.ts +++ b/src/storage/bookings/BookingService.ts @@ -1,8 +1,9 @@ import { IBooking } from '../../types/BookingTypes'; -import { EntityType } from '../../types/CalendarTypes'; +import { EntityType, IEventBus } from '../../types/CalendarTypes'; import { BookingStore } from './BookingStore'; import { BookingSerialization } from './BookingSerialization'; import { BaseEntityService } from '../BaseEntityService'; +import { IndexedDBContext } from '../IndexedDBContext'; /** * BookingService - CRUD operations for bookings in IndexedDB @@ -24,6 +25,10 @@ export class BookingService extends BaseEntityService { readonly storeName = BookingStore.STORE_NAME; readonly entityType: EntityType = 'Booking'; + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + } + /** * Serialize booking for IndexedDB storage * Converts Date objects to ISO strings diff --git a/src/storage/customers/CustomerService.ts b/src/storage/customers/CustomerService.ts index 8de8f90..8b076f0 100644 --- a/src/storage/customers/CustomerService.ts +++ b/src/storage/customers/CustomerService.ts @@ -1,7 +1,8 @@ import { ICustomer } from '../../types/CustomerTypes'; -import { EntityType } from '../../types/CalendarTypes'; +import { EntityType, IEventBus } from '../../types/CalendarTypes'; import { CustomerStore } from './CustomerStore'; import { BaseEntityService } from '../BaseEntityService'; +import { IndexedDBContext } from '../IndexedDBContext'; /** * CustomerService - CRUD operations for customers in IndexedDB @@ -23,7 +24,9 @@ export class CustomerService extends BaseEntityService { readonly storeName = CustomerStore.STORE_NAME; readonly entityType: EntityType = 'Customer'; - // No serialization override needed - ICustomer has no Date fields + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + } /** * Get customers by phone number diff --git a/src/storage/events/EventService.ts b/src/storage/events/EventService.ts index ad1c847..7207898 100644 --- a/src/storage/events/EventService.ts +++ b/src/storage/events/EventService.ts @@ -1,7 +1,8 @@ -import { ICalendarEvent, EntityType } from '../../types/CalendarTypes'; +import { ICalendarEvent, EntityType, IEventBus } from '../../types/CalendarTypes'; import { EventStore } from './EventStore'; import { EventSerialization } from './EventSerialization'; import { BaseEntityService } from '../BaseEntityService'; +import { IndexedDBContext } from '../IndexedDBContext'; /** * EventService - CRUD operations for calendar events in IndexedDB @@ -26,6 +27,10 @@ export class EventService extends BaseEntityService { readonly storeName = EventStore.STORE_NAME; readonly entityType: EntityType = 'Event'; + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + } + /** * Serialize event for IndexedDB storage * Converts Date objects to ISO strings diff --git a/src/storage/resources/ResourceService.ts b/src/storage/resources/ResourceService.ts index 45b9bbe..e59cef9 100644 --- a/src/storage/resources/ResourceService.ts +++ b/src/storage/resources/ResourceService.ts @@ -1,7 +1,8 @@ import { IResource } from '../../types/ResourceTypes'; -import { EntityType } from '../../types/CalendarTypes'; +import { EntityType, IEventBus } from '../../types/CalendarTypes'; import { ResourceStore } from './ResourceStore'; import { BaseEntityService } from '../BaseEntityService'; +import { IndexedDBContext } from '../IndexedDBContext'; /** * ResourceService - CRUD operations for resources in IndexedDB @@ -24,7 +25,9 @@ export class ResourceService extends BaseEntityService { readonly storeName = ResourceStore.STORE_NAME; readonly entityType: EntityType = 'Resource'; - // No serialization override needed - IResource has no Date fields + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + } /** * Get resources by type diff --git a/src/types/AuditTypes.ts b/src/types/AuditTypes.ts new file mode 100644 index 0000000..9710bb1 --- /dev/null +++ b/src/types/AuditTypes.ts @@ -0,0 +1,38 @@ +import { ISync, EntityType } from './CalendarTypes'; + +/** + * IAuditEntry - Audit log entry for tracking all entity changes + * + * Used for: + * - Compliance and audit trail + * - Sync tracking with backend + * - Change history + */ +export interface IAuditEntry extends ISync { + /** Unique audit entry ID */ + id: string; + + /** Type of entity that was changed */ + entityType: EntityType; + + /** ID of the entity that was changed */ + entityId: string; + + /** Type of operation performed */ + operation: 'create' | 'update' | 'delete'; + + /** User who made the change */ + userId: string; + + /** Timestamp when change was made */ + timestamp: number; + + /** Changes made (full entity for create, diff for update, { id } for delete) */ + changes: any; + + /** Whether this audit entry has been synced to backend */ + synced: boolean; + + /** Sync status inherited from ISync */ + syncStatus: 'synced' | 'pending' | 'error'; +} diff --git a/src/types/CalendarTypes.ts b/src/types/CalendarTypes.ts index 734a61d..0b8a785 100644 --- a/src/types/CalendarTypes.ts +++ b/src/types/CalendarTypes.ts @@ -12,7 +12,7 @@ export type SyncStatus = 'synced' | 'pending' | 'error'; /** * EntityType - Discriminator for all syncable entities */ -export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource'; +export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Audit'; /** * ISync - Interface composition for sync status tracking diff --git a/src/workers/SyncManager.ts b/src/workers/SyncManager.ts index 89860f6..2ec2b5f 100644 --- a/src/workers/SyncManager.ts +++ b/src/workers/SyncManager.ts @@ -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 pattern for type-safe API calls - * - Uses IEntityService 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 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>; - private entityServices: IEntityService[]; + private auditService: AuditService; + private auditApiRepository: IApiRepository; 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 = new Map(); + constructor( eventBus: IEventBus, - queue: OperationQueue, - apiRepositories: IApiRepository[], - entityServices: IEntityService[] + auditService: AuditService, + auditApiRepository: IApiRepository ) { 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 { + private async processPendingAudits(): Promise { // 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 { - // 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 { + 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 { - 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 { - 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 { 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 } }