2025-11-21 23:23:04 +01:00
|
|
|
import { BaseEntityService } from '../BaseEntityService';
|
|
|
|
|
import { IndexedDBContext } from '../IndexedDBContext';
|
|
|
|
|
import { IAuditEntry } from '../../types/AuditTypes';
|
|
|
|
|
import { EntityType, IEventBus } from '../../types/CalendarTypes';
|
|
|
|
|
import { CoreEvents } from '../../constants/CoreEvents';
|
2025-11-21 23:33:48 +01:00
|
|
|
import { IEntitySavedPayload, IEntityDeletedPayload, IAuditLoggedPayload } from '../../types/EventTypes';
|
2025-11-21 23:23:04 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2025-11-21 23:33:48 +01:00
|
|
|
private async handleEntitySaved(payload: IEntitySavedPayload): Promise<void> {
|
2025-11-21 23:23:04 +01:00
|
|
|
// 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
|
|
|
|
|
*/
|
2025-11-21 23:33:48 +01:00
|
|
|
private async handleEntityDeleted(payload: IEntityDeletedPayload): Promise<void> {
|
2025-11-21 23:23:04 +01:00
|
|
|
// 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
|
2025-11-21 23:33:48 +01:00
|
|
|
const payload: IAuditLoggedPayload = {
|
2025-11-21 23:23:04 +01:00
|
|
|
auditId: entity.id,
|
|
|
|
|
entityType: entity.entityType,
|
|
|
|
|
entityId: entity.entityId,
|
|
|
|
|
operation: entity.operation,
|
|
|
|
|
timestamp: entity.timestamp
|
2025-11-21 23:33:48 +01:00
|
|
|
};
|
|
|
|
|
this.eventBus.emit(CoreEvents.AUDIT_LOGGED, payload);
|
2025-11-21 23:23:04 +01:00
|
|
|
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}`));
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|