Calendar/src/storage/audit/AuditService.ts
Janus C. H. Knudsen 185330402e Refactor event payload types and event handling
Extracts common event payload interfaces for entity saved, deleted, and audit logged events

Improves type safety and reduces code duplication by centralizing event payload definitions
2025-11-21 23:33:48 +01:00

168 lines
5.6 KiB
TypeScript

import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
import { IAuditEntry } from '../../types/AuditTypes';
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import { CoreEvents } from '../../constants/CoreEvents';
import { IEntitySavedPayload, IEntityDeletedPayload, IAuditLoggedPayload } from '../../types/EventTypes';
/**
* 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: IEntitySavedPayload): 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: IEntityDeletedPayload): 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
const payload: IAuditLoggedPayload = {
auditId: entity.id,
entityType: entity.entityType,
entityId: entity.entityId,
operation: entity.operation,
timestamp: entity.timestamp
};
this.eventBus.emit(CoreEvents.AUDIT_LOGGED, payload);
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}`));
};
});
}
}