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,7 +1,12 @@
|
||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"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": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -11,7 +11,8 @@
|
||||||
"@novadi/core": "^0.6.0",
|
"@novadi/core": "^0.6.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
|
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"fuse.js": "^7.1.0"
|
"fuse.js": "^7.1.0",
|
||||||
|
"json-diff-ts": "^4.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fullhuman/postcss-purgecss": "^7.0.2",
|
"@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": {
|
"node_modules/jsonfile": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
"@novadi/core": "^0.6.0",
|
"@novadi/core": "^0.6.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
|
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"fuse.js": "^7.1.0"
|
"fuse.js": "^7.1.0",
|
||||||
|
"json-diff-ts": "^4.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ export const CoreEvents = {
|
||||||
SYNC_COMPLETED: 'sync:completed',
|
SYNC_COMPLETED: 'sync:completed',
|
||||||
SYNC_FAILED: 'sync:failed',
|
SYNC_FAILED: 'sync:failed',
|
||||||
SYNC_RETRY: 'sync:retry',
|
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 events (1)
|
||||||
FILTER_CHANGED: 'filter:changed',
|
FILTER_CHANGED: 'filter:changed',
|
||||||
|
|
|
||||||
27
src/index.ts
27
src/index.ts
|
|
@ -27,14 +27,17 @@ import { MockEventRepository } from './repositories/MockEventRepository';
|
||||||
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
||||||
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||||
import { MockResourceRepository } from './repositories/MockResourceRepository';
|
import { MockResourceRepository } from './repositories/MockResourceRepository';
|
||||||
|
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
||||||
import { IApiRepository } from './repositories/IApiRepository';
|
import { IApiRepository } from './repositories/IApiRepository';
|
||||||
|
import { IAuditEntry } from './types/AuditTypes';
|
||||||
import { ApiEventRepository } from './repositories/ApiEventRepository';
|
import { ApiEventRepository } from './repositories/ApiEventRepository';
|
||||||
import { ApiBookingRepository } from './repositories/ApiBookingRepository';
|
import { ApiBookingRepository } from './repositories/ApiBookingRepository';
|
||||||
import { ApiCustomerRepository } from './repositories/ApiCustomerRepository';
|
import { ApiCustomerRepository } from './repositories/ApiCustomerRepository';
|
||||||
import { ApiResourceRepository } from './repositories/ApiResourceRepository';
|
import { ApiResourceRepository } from './repositories/ApiResourceRepository';
|
||||||
import { IndexedDBContext } from './storage/IndexedDBContext';
|
import { IndexedDBContext } from './storage/IndexedDBContext';
|
||||||
import { OperationQueue } from './storage/OperationQueue';
|
|
||||||
import { IStore } from './storage/IStore';
|
import { IStore } from './storage/IStore';
|
||||||
|
import { AuditStore } from './storage/audit/AuditStore';
|
||||||
|
import { AuditService } from './storage/audit/AuditService';
|
||||||
import { BookingStore } from './storage/bookings/BookingStore';
|
import { BookingStore } from './storage/bookings/BookingStore';
|
||||||
import { CustomerStore } from './storage/customers/CustomerStore';
|
import { CustomerStore } from './storage/customers/CustomerStore';
|
||||||
import { ResourceStore } from './storage/resources/ResourceStore';
|
import { ResourceStore } from './storage/resources/ResourceStore';
|
||||||
|
|
@ -121,11 +124,10 @@ async function initializeCalendar(): Promise<void> {
|
||||||
builder.registerType(CustomerStore).as<IStore>();
|
builder.registerType(CustomerStore).as<IStore>();
|
||||||
builder.registerType(ResourceStore).as<IStore>();
|
builder.registerType(ResourceStore).as<IStore>();
|
||||||
builder.registerType(EventStore).as<IStore>();
|
builder.registerType(EventStore).as<IStore>();
|
||||||
|
builder.registerType(AuditStore).as<IStore>();
|
||||||
|
|
||||||
// Register storage and repository services
|
// Register storage and repository services
|
||||||
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
|
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
|
||||||
builder.registerType(OperationQueue).as<OperationQueue>();
|
|
||||||
|
|
||||||
// Register Mock repositories (development/testing - load from JSON files)
|
// Register Mock repositories (development/testing - load from JSON files)
|
||||||
// Each entity type has its own Mock repository implementing IApiRepository<T>
|
// Each entity type has its own Mock repository implementing IApiRepository<T>
|
||||||
|
|
@ -133,6 +135,7 @@ async function initializeCalendar(): Promise<void> {
|
||||||
builder.registerType(MockBookingRepository).as<IApiRepository<IBooking>>();
|
builder.registerType(MockBookingRepository).as<IApiRepository<IBooking>>();
|
||||||
builder.registerType(MockCustomerRepository).as<IApiRepository<ICustomer>>();
|
builder.registerType(MockCustomerRepository).as<IApiRepository<ICustomer>>();
|
||||||
builder.registerType(MockResourceRepository).as<IApiRepository<IResource>>();
|
builder.registerType(MockResourceRepository).as<IApiRepository<IResource>>();
|
||||||
|
builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
|
||||||
|
|
||||||
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
|
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
|
||||||
// Register entity services (sync status management)
|
// Register entity services (sync status management)
|
||||||
|
|
@ -141,6 +144,7 @@ async function initializeCalendar(): Promise<void> {
|
||||||
builder.registerType(BookingService).as<IEntityService<IBooking>>();
|
builder.registerType(BookingService).as<IEntityService<IBooking>>();
|
||||||
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
|
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
|
||||||
builder.registerType(ResourceService).as<IEntityService<IResource>>();
|
builder.registerType(ResourceService).as<IEntityService<IResource>>();
|
||||||
|
builder.registerType(AuditService).as<AuditService>();
|
||||||
|
|
||||||
// Register workers
|
// Register workers
|
||||||
builder.registerType(SyncManager).as<SyncManager>();
|
builder.registerType(SyncManager).as<SyncManager>();
|
||||||
|
|
@ -211,12 +215,11 @@ async function initializeCalendar(): Promise<void> {
|
||||||
await calendarManager.initialize?.();
|
await calendarManager.initialize?.();
|
||||||
await resizeHandleManager.initialize?.();
|
await resizeHandleManager.initialize?.();
|
||||||
|
|
||||||
// Resolve SyncManager (starts automatically in constructor)
|
// Resolve AuditService (starts listening for entity events)
|
||||||
// Resolve SyncManager (starts automatically in constructor)
|
const auditService = app.resolveType<AuditService>();
|
||||||
// Resolve SyncManager (starts automatically in constructor)
|
|
||||||
// Resolve SyncManager (starts automatically in constructor)
|
// Resolve SyncManager (starts background sync automatically)
|
||||||
// Resolve SyncManager (starts automatically in constructor)
|
const syncManager = app.resolveType<SyncManager>();
|
||||||
//const syncManager = app.resolveType<SyncManager>();
|
|
||||||
|
|
||||||
// Handle deep linking after managers are initialized
|
// Handle deep linking after managers are initialized
|
||||||
await handleDeepLinking(eventManager, urlManager);
|
await handleDeepLinking(eventManager, urlManager);
|
||||||
|
|
@ -229,7 +232,8 @@ async function initializeCalendar(): Promise<void> {
|
||||||
calendarManager: typeof calendarManager;
|
calendarManager: typeof calendarManager;
|
||||||
eventManager: typeof eventManager;
|
eventManager: typeof eventManager;
|
||||||
workweekPresetsManager: typeof workweekPresetsManager;
|
workweekPresetsManager: typeof workweekPresetsManager;
|
||||||
//syncManager: typeof syncManager;
|
auditService: typeof auditService;
|
||||||
|
syncManager: typeof syncManager;
|
||||||
};
|
};
|
||||||
}).calendarDebug = {
|
}).calendarDebug = {
|
||||||
eventBus,
|
eventBus,
|
||||||
|
|
@ -237,7 +241,8 @@ async function initializeCalendar(): Promise<void> {
|
||||||
calendarManager,
|
calendarManager,
|
||||||
eventManager,
|
eventManager,
|
||||||
workweekPresetsManager,
|
workweekPresetsManager,
|
||||||
//syncManager,
|
auditService,
|
||||||
|
syncManager,
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
47
src/repositories/MockAuditRepository.ts
Normal file
47
src/repositories/MockAuditRepository.ts
Normal file
|
|
@ -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<IAuditEntry> {
|
||||||
|
readonly entityType: EntityType = 'Audit';
|
||||||
|
|
||||||
|
async sendCreate(entity: IAuditEntry): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
// Audit entries are immutable - updates should not happen
|
||||||
|
throw new Error('Audit entries cannot be updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendDelete(_id: string): Promise<void> {
|
||||||
|
// Audit entries should never be deleted
|
||||||
|
throw new Error('Audit entries cannot be deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAll(): Promise<IAuditEntry[]> {
|
||||||
|
// 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<IAuditEntry | null> {
|
||||||
|
// For now, return null - audit entries are local-first
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@ import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
|
||||||
import { IEntityService } from './IEntityService';
|
import { IEntityService } from './IEntityService';
|
||||||
import { SyncPlugin } from './SyncPlugin';
|
import { SyncPlugin } from './SyncPlugin';
|
||||||
import { IndexedDBContext } from './IndexedDBContext';
|
import { IndexedDBContext } from './IndexedDBContext';
|
||||||
|
import { IEventBus } from '../types/CalendarTypes';
|
||||||
|
import { CoreEvents } from '../constants/CoreEvents';
|
||||||
|
import { diff } from 'json-diff-ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
|
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
|
||||||
|
|
@ -42,11 +45,16 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
|
||||||
// IndexedDB context - provides database connection
|
// IndexedDB context - provides database connection
|
||||||
private context: IndexedDBContext;
|
private context: IndexedDBContext;
|
||||||
|
|
||||||
|
// EventBus for emitting entity events
|
||||||
|
protected eventBus: IEventBus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param context - IndexedDBContext instance (injected dependency)
|
* @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.context = context;
|
||||||
|
this.eventBus = eventBus;
|
||||||
this.syncPlugin = new SyncPlugin<T>(this);
|
this.syncPlugin = new SyncPlugin<T>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,10 +140,28 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save an entity (create or update)
|
* Save an entity (create or update)
|
||||||
|
* Emits ENTITY_SAVED event with operation type and changes
|
||||||
*
|
*
|
||||||
* @param entity - Entity to save
|
* @param entity - Entity to save
|
||||||
*/
|
*/
|
||||||
async save(entity: T): Promise<void> {
|
async save(entity: T): Promise<void> {
|
||||||
|
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);
|
const serialized = this.serialize(entity);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -144,17 +170,26 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
|
||||||
const request = store.put(serialized);
|
const request = store.put(serialized);
|
||||||
|
|
||||||
request.onsuccess = () => {
|
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();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
request.onerror = () => {
|
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
|
* Delete an entity
|
||||||
|
* Emits ENTITY_DELETED event
|
||||||
*
|
*
|
||||||
* @param id - Entity ID to delete
|
* @param id - Entity ID to delete
|
||||||
*/
|
*/
|
||||||
|
|
@ -165,6 +200,13 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
|
||||||
const request = store.delete(id);
|
const request = store.delete(id);
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
// Emit ENTITY_DELETED event
|
||||||
|
this.eventBus.emit(CoreEvents.ENTITY_DELETED, {
|
||||||
|
entityType: this.entityType,
|
||||||
|
entityId: id,
|
||||||
|
operation: 'delete',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { IStore } from './IStore';
|
||||||
*/
|
*/
|
||||||
export class IndexedDBContext {
|
export class IndexedDBContext {
|
||||||
private static readonly DB_NAME = 'CalendarDB';
|
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 QUEUE_STORE = 'operationQueue';
|
||||||
static readonly SYNC_STATE_STORE = 'syncState';
|
static readonly SYNC_STATE_STORE = 'syncState';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<IQueueOperation, 'id'>): Promise<void> {
|
|
||||||
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<IQueueOperation[]> {
|
|
||||||
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<IQueueOperation | null> {
|
|
||||||
const queue = await this.getAll();
|
|
||||||
return queue.length > 0 ? queue[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a specific operation from the queue
|
|
||||||
*/
|
|
||||||
async remove(operationId: string): Promise<void> {
|
|
||||||
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<IQueueOperation | null> {
|
|
||||||
const operation = await this.peek();
|
|
||||||
if (operation) {
|
|
||||||
await this.remove(operation.id);
|
|
||||||
}
|
|
||||||
return operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all operations from the queue
|
|
||||||
*/
|
|
||||||
async clear(): Promise<void> {
|
|
||||||
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<number> {
|
|
||||||
const queue = await this.getAll();
|
|
||||||
return queue.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if queue is empty
|
|
||||||
*/
|
|
||||||
async isEmpty(): Promise<boolean> {
|
|
||||||
const size = await this.size();
|
|
||||||
return size === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get operations for a specific entity ID
|
|
||||||
*/
|
|
||||||
async getOperationsForEntity(entityId: string): Promise<IQueueOperation[]> {
|
|
||||||
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<void> {
|
|
||||||
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<IQueueOperation[]> {
|
|
||||||
return this.getOperationsForEntity(eventId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use removeOperationsForEntity instead
|
|
||||||
*/
|
|
||||||
async removeOperationsForEvent(eventId: string): Promise<void> {
|
|
||||||
return this.removeOperationsForEntity(eventId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update retry count for an operation
|
|
||||||
*/
|
|
||||||
async incrementRetryCount(operationId: string): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<any | null> {
|
|
||||||
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}`));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
177
src/storage/audit/AuditService.ts
Normal file
177
src/storage/audit/AuditService.ts
Normal file
|
|
@ -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<IAuditEntry> {
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
throw new Error('Audit entries cannot be deleted (compliance requirement)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending audit entries (for sync)
|
||||||
|
*/
|
||||||
|
async getPendingAudits(): Promise<IAuditEntry[]> {
|
||||||
|
return this.getBySyncStatus('pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit entries for a specific entity
|
||||||
|
*/
|
||||||
|
async getByEntityId(entityId: string): Promise<IAuditEntry[]> {
|
||||||
|
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}`));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/storage/audit/AuditStore.ts
Normal file
25
src/storage/audit/AuditStore.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { IBooking } from '../../types/BookingTypes';
|
import { IBooking } from '../../types/BookingTypes';
|
||||||
import { EntityType } from '../../types/CalendarTypes';
|
import { EntityType, IEventBus } from '../../types/CalendarTypes';
|
||||||
import { BookingStore } from './BookingStore';
|
import { BookingStore } from './BookingStore';
|
||||||
import { BookingSerialization } from './BookingSerialization';
|
import { BookingSerialization } from './BookingSerialization';
|
||||||
import { BaseEntityService } from '../BaseEntityService';
|
import { BaseEntityService } from '../BaseEntityService';
|
||||||
|
import { IndexedDBContext } from '../IndexedDBContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BookingService - CRUD operations for bookings in IndexedDB
|
* BookingService - CRUD operations for bookings in IndexedDB
|
||||||
|
|
@ -24,6 +25,10 @@ export class BookingService extends BaseEntityService<IBooking> {
|
||||||
readonly storeName = BookingStore.STORE_NAME;
|
readonly storeName = BookingStore.STORE_NAME;
|
||||||
readonly entityType: EntityType = 'Booking';
|
readonly entityType: EntityType = 'Booking';
|
||||||
|
|
||||||
|
constructor(context: IndexedDBContext, eventBus: IEventBus) {
|
||||||
|
super(context, eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize booking for IndexedDB storage
|
* Serialize booking for IndexedDB storage
|
||||||
* Converts Date objects to ISO strings
|
* Converts Date objects to ISO strings
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { ICustomer } from '../../types/CustomerTypes';
|
import { ICustomer } from '../../types/CustomerTypes';
|
||||||
import { EntityType } from '../../types/CalendarTypes';
|
import { EntityType, IEventBus } from '../../types/CalendarTypes';
|
||||||
import { CustomerStore } from './CustomerStore';
|
import { CustomerStore } from './CustomerStore';
|
||||||
import { BaseEntityService } from '../BaseEntityService';
|
import { BaseEntityService } from '../BaseEntityService';
|
||||||
|
import { IndexedDBContext } from '../IndexedDBContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CustomerService - CRUD operations for customers in IndexedDB
|
* CustomerService - CRUD operations for customers in IndexedDB
|
||||||
|
|
@ -23,7 +24,9 @@ export class CustomerService extends BaseEntityService<ICustomer> {
|
||||||
readonly storeName = CustomerStore.STORE_NAME;
|
readonly storeName = CustomerStore.STORE_NAME;
|
||||||
readonly entityType: EntityType = 'Customer';
|
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
|
* Get customers by phone number
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { ICalendarEvent, EntityType } from '../../types/CalendarTypes';
|
import { ICalendarEvent, EntityType, IEventBus } from '../../types/CalendarTypes';
|
||||||
import { EventStore } from './EventStore';
|
import { EventStore } from './EventStore';
|
||||||
import { EventSerialization } from './EventSerialization';
|
import { EventSerialization } from './EventSerialization';
|
||||||
import { BaseEntityService } from '../BaseEntityService';
|
import { BaseEntityService } from '../BaseEntityService';
|
||||||
|
import { IndexedDBContext } from '../IndexedDBContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EventService - CRUD operations for calendar events in IndexedDB
|
* EventService - CRUD operations for calendar events in IndexedDB
|
||||||
|
|
@ -26,6 +27,10 @@ export class EventService extends BaseEntityService<ICalendarEvent> {
|
||||||
readonly storeName = EventStore.STORE_NAME;
|
readonly storeName = EventStore.STORE_NAME;
|
||||||
readonly entityType: EntityType = 'Event';
|
readonly entityType: EntityType = 'Event';
|
||||||
|
|
||||||
|
constructor(context: IndexedDBContext, eventBus: IEventBus) {
|
||||||
|
super(context, eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize event for IndexedDB storage
|
* Serialize event for IndexedDB storage
|
||||||
* Converts Date objects to ISO strings
|
* Converts Date objects to ISO strings
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { IResource } from '../../types/ResourceTypes';
|
import { IResource } from '../../types/ResourceTypes';
|
||||||
import { EntityType } from '../../types/CalendarTypes';
|
import { EntityType, IEventBus } from '../../types/CalendarTypes';
|
||||||
import { ResourceStore } from './ResourceStore';
|
import { ResourceStore } from './ResourceStore';
|
||||||
import { BaseEntityService } from '../BaseEntityService';
|
import { BaseEntityService } from '../BaseEntityService';
|
||||||
|
import { IndexedDBContext } from '../IndexedDBContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResourceService - CRUD operations for resources in IndexedDB
|
* ResourceService - CRUD operations for resources in IndexedDB
|
||||||
|
|
@ -24,7 +25,9 @@ export class ResourceService extends BaseEntityService<IResource> {
|
||||||
readonly storeName = ResourceStore.STORE_NAME;
|
readonly storeName = ResourceStore.STORE_NAME;
|
||||||
readonly entityType: EntityType = 'Resource';
|
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
|
* Get resources by type
|
||||||
|
|
|
||||||
38
src/types/AuditTypes.ts
Normal file
38
src/types/AuditTypes.ts
Normal file
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ export type SyncStatus = 'synced' | 'pending' | 'error';
|
||||||
/**
|
/**
|
||||||
* EntityType - Discriminator for all syncable entities
|
* 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
|
* ISync - Interface composition for sync status tracking
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,33 @@
|
||||||
import { IEventBus, EntityType, ISync } from '../types/CalendarTypes';
|
import { IEventBus } from '../types/CalendarTypes';
|
||||||
import { CoreEvents } from '../constants/CoreEvents';
|
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 { IApiRepository } from '../repositories/IApiRepository';
|
||||||
import { IEntityService } from '../storage/IEntityService';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SyncManager - Background sync worker
|
* SyncManager - Background sync worker
|
||||||
* Processes operation queue and syncs with API when online
|
* Syncs audit entries with backend API when online
|
||||||
*
|
*
|
||||||
* GENERIC ARCHITECTURE:
|
* NEW ARCHITECTURE:
|
||||||
* - Handles all entity types (Event, Booking, Customer, Resource)
|
* - Listens to AUDIT_LOGGED events (triggered after AuditService saves)
|
||||||
* - Routes operations based on IQueueOperation.dataEntity.typename
|
* - Polls AuditService for pending audit entries
|
||||||
* - Uses IApiRepository<T> pattern for type-safe API calls
|
* - Syncs audit entries to backend API
|
||||||
* - Uses IEntityService<T> polymorphism for sync status management
|
* - Marks audit entries as synced when successful
|
||||||
*
|
*
|
||||||
* POLYMORFI DESIGN:
|
* EVENT CHAIN:
|
||||||
* - Services implement IEntityService<T extends ISync> interface
|
* Entity change → ENTITY_SAVED/DELETED → AuditService → AUDIT_LOGGED → SyncManager
|
||||||
* - 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:
|
* Features:
|
||||||
* - Monitors online/offline status
|
* - Monitors online/offline status
|
||||||
* - Processes queue with FIFO order
|
* - Processes pending audits with FIFO order
|
||||||
* - Exponential backoff retry logic
|
* - Exponential backoff retry logic
|
||||||
* - Updates syncStatus in IndexedDB after successful sync
|
* - Updates syncStatus in IndexedDB after successful sync
|
||||||
* - Emits sync events for UI feedback
|
* - Emits sync events for UI feedback
|
||||||
*/
|
*/
|
||||||
export class SyncManager {
|
export class SyncManager {
|
||||||
private eventBus: IEventBus;
|
private eventBus: IEventBus;
|
||||||
private queue: OperationQueue;
|
private auditService: AuditService;
|
||||||
private repositories: Map<EntityType, IApiRepository<any>>;
|
private auditApiRepository: IApiRepository<IAuditEntry>;
|
||||||
private entityServices: IEntityService<any>[];
|
|
||||||
|
|
||||||
private isOnline: boolean = navigator.onLine;
|
private isOnline: boolean = navigator.onLine;
|
||||||
private isSyncing: boolean = false;
|
private isSyncing: boolean = false;
|
||||||
|
|
@ -40,24 +35,35 @@ export class SyncManager {
|
||||||
private maxRetries: number = 5;
|
private maxRetries: number = 5;
|
||||||
private intervalId: number | null = null;
|
private intervalId: number | null = null;
|
||||||
|
|
||||||
|
// Track retry counts per audit entry (in memory)
|
||||||
|
private retryCounts: Map<string, number> = new Map();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
eventBus: IEventBus,
|
eventBus: IEventBus,
|
||||||
queue: OperationQueue,
|
auditService: AuditService,
|
||||||
apiRepositories: IApiRepository<any>[],
|
auditApiRepository: IApiRepository<IAuditEntry>
|
||||||
entityServices: IEntityService<any>[]
|
|
||||||
) {
|
) {
|
||||||
this.eventBus = eventBus;
|
this.eventBus = eventBus;
|
||||||
this.queue = queue;
|
this.auditService = auditService;
|
||||||
this.entityServices = entityServices;
|
this.auditApiRepository = auditApiRepository;
|
||||||
|
|
||||||
// Build map: EntityType → IApiRepository
|
|
||||||
this.repositories = new Map(
|
|
||||||
apiRepositories.map(repo => [repo.entityType, repo])
|
|
||||||
);
|
|
||||||
|
|
||||||
this.setupNetworkListeners();
|
this.setupNetworkListeners();
|
||||||
|
this.setupAuditListener();
|
||||||
this.startSync();
|
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');
|
console.log('SyncManager: Starting background sync');
|
||||||
|
|
||||||
// Process immediately
|
// Process immediately
|
||||||
this.processQueue();
|
this.processPendingAudits();
|
||||||
|
|
||||||
// Then poll every syncInterval
|
// Then poll every syncInterval
|
||||||
this.intervalId = window.setInterval(() => {
|
this.intervalId = window.setInterval(() => {
|
||||||
this.processQueue();
|
this.processPendingAudits();
|
||||||
}, this.syncInterval);
|
}, this.syncInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,10 +120,10 @@ export class SyncManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process operation queue
|
* Process pending audit entries
|
||||||
* Sends pending operations to API
|
* Fetches from AuditService and syncs to backend
|
||||||
*/
|
*/
|
||||||
private async processQueue(): Promise<void> {
|
private async processPendingAudits(): Promise<void> {
|
||||||
// Don't sync if offline
|
// Don't sync if offline
|
||||||
if (!this.isOnline) {
|
if (!this.isOnline) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -128,31 +134,33 @@ export class SyncManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if queue is empty
|
|
||||||
if (await this.queue.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isSyncing = true;
|
this.isSyncing = true;
|
||||||
|
|
||||||
try {
|
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, {
|
this.eventBus.emit(CoreEvents.SYNC_STARTED, {
|
||||||
operationCount: operations.length
|
operationCount: pendingAudits.length
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process operations one by one (FIFO)
|
// Process audits one by one (FIFO - oldest first by timestamp)
|
||||||
for (const operation of operations) {
|
const sortedAudits = pendingAudits.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
await this.processOperation(operation);
|
|
||||||
|
for (const audit of sortedAudits) {
|
||||||
|
await this.processAuditEntry(audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.eventBus.emit(CoreEvents.SYNC_COMPLETED, {
|
this.eventBus.emit(CoreEvents.SYNC_COMPLETED, {
|
||||||
operationCount: operations.length
|
operationCount: pendingAudits.length
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('SyncManager: Queue processing error:', error);
|
console.error('SyncManager: Audit processing error:', error);
|
||||||
this.eventBus.emit(CoreEvents.SYNC_FAILED, {
|
this.eventBus.emit(CoreEvents.SYNC_FAILED, {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
});
|
});
|
||||||
|
|
@ -162,106 +170,47 @@ export class SyncManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a single operation
|
* Process a single audit entry
|
||||||
* Generic - routes to correct API repository based on entity type
|
* Sends to backend API and marks as synced
|
||||||
*/
|
*/
|
||||||
private async processOperation(operation: IQueueOperation): Promise<void> {
|
private async processAuditEntry(audit: IAuditEntry): Promise<void> {
|
||||||
// Check if max retries exceeded
|
const retryCount = this.retryCounts.get(audit.id) || 0;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the appropriate API repository for this entity type
|
// Check if max retries exceeded
|
||||||
const repository = this.repositories.get(operation.dataEntity.typename);
|
if (retryCount >= this.maxRetries) {
|
||||||
if (!repository) {
|
console.error(`SyncManager: Max retries exceeded for audit ${audit.id}`);
|
||||||
console.error(`SyncManager: No repository found for entity type ${operation.dataEntity.typename}`);
|
await this.auditService.markAsError(audit.id);
|
||||||
await this.queue.remove(operation.id);
|
this.retryCounts.delete(audit.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send to API based on operation type
|
// Send audit entry to backend
|
||||||
switch (operation.type) {
|
await this.auditApiRepository.sendCreate(audit);
|
||||||
case 'create':
|
|
||||||
await repository.sendCreate(operation.dataEntity.data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'update':
|
// Success - mark as synced and clear retry count
|
||||||
await repository.sendUpdate(operation.entityId, operation.dataEntity.data);
|
await this.auditService.markAsSynced(audit.id);
|
||||||
break;
|
this.retryCounts.delete(audit.id);
|
||||||
|
|
||||||
case 'delete':
|
console.log(`SyncManager: Successfully synced audit ${audit.id} (${audit.entityType}:${audit.operation})`);
|
||||||
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}`);
|
|
||||||
|
|
||||||
} catch (error) {
|
} 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
|
// Increment retry count
|
||||||
await this.queue.incrementRetryCount(operation.id);
|
this.retryCounts.set(audit.id, retryCount + 1);
|
||||||
|
|
||||||
// Calculate backoff delay
|
// Calculate backoff delay
|
||||||
const backoffDelay = this.calculateBackoff(operation.retryCount + 1);
|
const backoffDelay = this.calculateBackoff(retryCount + 1);
|
||||||
|
|
||||||
this.eventBus.emit(CoreEvents.SYNC_RETRY, {
|
this.eventBus.emit(CoreEvents.SYNC_RETRY, {
|
||||||
operationId: operation.id,
|
auditId: audit.id,
|
||||||
retryCount: operation.retryCount + 1,
|
retryCount: retryCount + 1,
|
||||||
nextRetryIn: backoffDelay
|
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
|
* Calculate exponential backoff delay
|
||||||
* @param retryCount Current retry count
|
* @param retryCount Current retry count
|
||||||
|
|
@ -281,7 +230,7 @@ export class SyncManager {
|
||||||
*/
|
*/
|
||||||
public async triggerManualSync(): Promise<void> {
|
public async triggerManualSync(): Promise<void> {
|
||||||
console.log('SyncManager: Manual sync triggered');
|
console.log('SyncManager: Manual sync triggered');
|
||||||
await this.processQueue();
|
await this.processPendingAudits();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -304,6 +253,7 @@ export class SyncManager {
|
||||||
*/
|
*/
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.stopSync();
|
this.stopSync();
|
||||||
|
this.retryCounts.clear();
|
||||||
// Note: We don't remove window event listeners as they're global
|
// Note: We don't remove window event listeners as they're global
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue