import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { OpsConfigEventDoc, type IOpsConfigEventFilter, } from '../db/documents/classes.ops-config-event.doc.js'; import type { IOpsConfigEvent, IOpsConfigEventCursor, IOpsConfigEventContext, TOpsConfigEventSeverity, } from '../../ts_interfaces/data/config-events.js'; export interface IRecordOpsConfigEventOptions { severity: TOpsConfigEventSeverity; category: string; title: string; detail: string; context?: IOpsConfigEventContext; } /** Retention for acknowledged events (90 days). */ const ACKNOWLEDGED_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000; /** * OpsEventManager — durable, acknowledgeable platform configuration events. * * These are operator-facing config conflicts (e.g. gateway DNS automation * overwriting a manual record), not application runtime errors. Producers call * `recordEvent()` fire-and-forget; the ops UI lists and acknowledges them. * * Repeated occurrences of the same unacknowledged conflict coalesce into one * event (refreshed timestamp/detail) so reconcile loops cannot flood the inbox. */ export class OpsEventManager { private pruneInterval: ReturnType | null = null; public async start(): Promise { this.pruneInterval = setInterval(() => { OpsConfigEventDoc.pruneAcknowledged(ACKNOWLEDGED_RETENTION_MS).catch((error: unknown) => { logger.log('warn', `OpsEventManager prune failed: ${(error as Error).message}`); }); }, PRUNE_INTERVAL_MS); // Prune once at startup so long-stopped instances converge immediately. await OpsConfigEventDoc.pruneAcknowledged(ACKNOWLEDGED_RETENTION_MS).catch(() => 0); } public async stop(): Promise { if (this.pruneInterval) { clearInterval(this.pruneInterval); this.pruneInterval = null; } } public async recordEvent(options: IRecordOpsConfigEventOptions): Promise { const context = options.context || {}; const sourceKey = [ options.title, context.recordName || '', context.domain || '', context.routeRef || '', ].join('|'); const openDedupeKey = plugins.crypto .createHash('sha256') .update(sourceKey) .digest('hex'); const now = Date.now(); const { event, created } = await OpsConfigEventDoc.upsertOpenEvent({ id: `oce_${now.toString(36)}_${Math.random().toString(36).slice(2, 10)}`, openDedupeKey, severity: options.severity, category: options.category, title: options.title, detail: options.detail, context, createdAt: now, }); if (created) { logger.log( options.severity === 'error' ? 'error' : 'warn', `Platform config event [${options.category}] ${options.title}`, ); } return event.toApiObject(); } public async listEvents( filter: IOpsConfigEventFilter = {}, ): Promise<{ events: IOpsConfigEvent[]; total: number; unacknowledgedCount: number; nextCursor?: IOpsConfigEventCursor }> { const { events, total, nextCursor } = await OpsConfigEventDoc.findFiltered(filter); const unacknowledgedCount = await OpsConfigEventDoc.countUnacknowledged(); return { events: events.map((doc) => doc.toApiObject()), total, unacknowledgedCount, nextCursor }; } public async acknowledgeEvents(ids: string[], userId: string): Promise { return OpsConfigEventDoc.acknowledgeByIds(ids, userId); } }