import * as plugins from '../../plugins.js'; import { DcRouterDb } from '../classes.dcrouter-db.js'; import type { IOpsConfigEvent, IOpsConfigEventCursor, IOpsConfigEventContext, TOpsConfigEventSeverity, } from '../../../ts_interfaces/data/config-events.js'; const getDb = () => DcRouterDb.getInstance().getDb(); export interface IOpsConfigEventFilter { acknowledged?: boolean; severity?: TOpsConfigEventSeverity; category?: string; limit?: number; cursor?: IOpsConfigEventCursor; } /** * Durable operator-facing platform configuration event (see * ts_interfaces/data/config-events.ts). Unacknowledged events persist * indefinitely; acknowledged events are pruned after a retention window. */ @plugins.smartdata.Collection(() => getDb()) export class OpsConfigEventDoc extends plugins.smartdata.SmartDataDbDoc implements IOpsConfigEvent { @plugins.smartdata.unI() @plugins.smartdata.svDb() public id!: string; @plugins.smartdata.index() @plugins.smartdata.svDb() public severity!: TOpsConfigEventSeverity; @plugins.smartdata.index() @plugins.smartdata.svDb() public category!: string; @plugins.smartdata.svDb() public title!: string; @plugins.smartdata.svDb() public detail!: string; @plugins.smartdata.svDb() public context: IOpsConfigEventContext = {}; @plugins.smartdata.index() @plugins.smartdata.svDb() public createdAt: number = Date.now(); @plugins.smartdata.svDb() public acknowledgedBy?: string; @plugins.smartdata.index() @plugins.smartdata.svDb() public acknowledgedAt?: number; /** Sparse uniqueness authority for the current open occurrence. */ @plugins.smartdata.index({ unique: true, sparse: true }) @plugins.smartdata.svDb() public openDedupeKey?: string; constructor() { super(); } public toApiObject(): IOpsConfigEvent { return { id: this.id, severity: this.severity, category: this.category, title: this.title, detail: this.detail, context: this.context || {}, createdAt: this.createdAt, acknowledgedBy: this.acknowledgedBy ?? undefined, acknowledgedAt: this.acknowledgedAt ?? undefined, }; } public override async createSavableObject(): Promise { const savable = await super.createSavableObject() as OpsConfigEventDoc; // Presence is the uniqueness authority for an open event. Acknowledged // events must not leave a null-valued sparse-index key behind. if (!this.openDedupeKey) delete (savable as any).openDedupeKey; return savable; } private static async getNativeCollection() { const smartdataCollection = (OpsConfigEventDoc as typeof OpsConfigEventDoc & { collection: plugins.smartdata.SmartdataCollection; }).collection; await smartdataCollection.init(); const probe = new OpsConfigEventDoc(); await smartdataCollection.markUniqueIndexes(probe.uniqueIndexes || []); await smartdataCollection.createRegularIndexes(probe.regularIndexes || []); return smartdataCollection.mongoDbCollection; } private static hydrate(rawArg: Record): OpsConfigEventDoc { return OpsConfigEventDoc.createInstanceFromMongoDbNativeDoc(rawArg as any); } public static async upsertOpenEvent(optionsArg: { id: string; openDedupeKey: string; severity: TOpsConfigEventSeverity; category: string; title: string; detail: string; context: IOpsConfigEventContext; createdAt: number; }): Promise<{ event: OpsConfigEventDoc; created: boolean }> { const collection = await OpsConfigEventDoc.getNativeCollection(); const timestamp = new Date(optionsArg.createdAt).toISOString(); const result = await collection.updateOne( { openDedupeKey: optionsArg.openDedupeKey }, { $set: { severity: optionsArg.severity, detail: optionsArg.detail, context: optionsArg.context, createdAt: optionsArg.createdAt, _updatedAt: timestamp, }, $setOnInsert: { id: optionsArg.id, category: optionsArg.category, title: optionsArg.title, openDedupeKey: optionsArg.openDedupeKey, _createdAt: timestamp, }, }, { upsert: true }, ); const raw = await collection.findOne({ openDedupeKey: optionsArg.openDedupeKey }); if (!raw) throw new Error('Failed to read the recorded platform configuration event'); return { event: OpsConfigEventDoc.hydrate(raw as Record), created: result.upsertedCount > 0, }; } public static async findFiltered( filter: IOpsConfigEventFilter = {}, ): Promise<{ events: OpsConfigEventDoc[]; total: number; nextCursor?: IOpsConfigEventCursor }> { const selector: Record = {}; if (filter.acknowledged === true) selector.acknowledgedAt = { $gt: 0 }; if (filter.acknowledged === false) selector.acknowledgedAt = null; if (filter.severity) selector.severity = filter.severity; if (filter.category) selector.category = filter.category; const requestedLimit = Number.isSafeInteger(filter.limit) && filter.limit! > 0 ? filter.limit! : 100; const limit = Math.min(requestedLimit, 200); const countSelector = structuredClone(selector); if (filter.cursor) { if (!Number.isSafeInteger(filter.cursor.createdAt) || filter.cursor.createdAt < 0 || typeof filter.cursor.id !== 'string' || !filter.cursor.id.trim()) { throw new Error('Invalid platform configuration event cursor'); } selector.$or = [ { createdAt: { $lt: filter.cursor.createdAt } }, { createdAt: filter.cursor.createdAt, id: { $lt: filter.cursor.id } }, ]; } const collection = await OpsConfigEventDoc.getNativeCollection(); const cursor = collection .find(selector) .sort({ createdAt: -1, id: -1 }) .limit(limit + 1); try { const [rawEvents, total] = await Promise.all([ cursor.toArray(), collection.countDocuments(countSelector), ]); const hasMore = rawEvents.length > limit; const pageRows = rawEvents.slice(0, limit); const lastRow = pageRows.at(-1) as Record | undefined; return { events: pageRows.map((rawArg) => OpsConfigEventDoc.hydrate(rawArg as Record)), total, ...(hasMore && lastRow ? { nextCursor: { createdAt: Number(lastRow.createdAt), id: String(lastRow.id), }, } : {}), }; } finally { await cursor.close(); } } public static async countUnacknowledged(): Promise { const collection = await OpsConfigEventDoc.getNativeCollection(); return await collection.countDocuments({ acknowledgedAt: null }); } /** Acknowledge the given event ids; returns how many were newly acknowledged. */ public static async acknowledgeByIds(ids: string[], userId: string): Promise { if (typeof userId !== 'string' || !userId.trim()) { throw new Error('acknowledgeByIds requires a non-empty user id'); } if (ids.length > 500 || ids.some((idArg) => typeof idArg !== 'string' || !idArg.trim())) { throw new Error('acknowledgeByIds requires 0 to 500 non-empty event ids'); } const uniqueIds = Array.from(new Set(ids)); if (uniqueIds.length === 0) return 0; const collection = await OpsConfigEventDoc.getNativeCollection(); const now = Date.now(); const result = await collection.updateMany( { id: { $in: uniqueIds }, acknowledgedAt: null }, { $set: { acknowledgedBy: userId, acknowledgedAt: now, _updatedAt: new Date(now).toISOString(), }, $unset: { openDedupeKey: '' }, }, ); return result.modifiedCount; } /** Delete acknowledged events older than the retention window. */ public static async pruneAcknowledged(maxAgeMs: number): Promise { const cutoff = Date.now() - maxAgeMs; let pruned = 0; const collection = await OpsConfigEventDoc.getNativeCollection(); while (true) { const cursor = collection .find( { acknowledgedAt: { $lt: cutoff } }, { projection: { _id: 1 } }, ) .sort({ acknowledgedAt: 1, _id: 1 }) .limit(500); let rows: Array<{ _id: unknown }>; try { rows = await cursor.toArray() as Array<{ _id: unknown }>; } finally { await cursor.close(); } if (rows.length === 0) break; const deleteFilter: Record = { _id: { $in: rows.map((rowArg) => rowArg._id) }, acknowledgedAt: { $lt: cutoff }, }; const result = await collection.deleteMany(deleteFilter); if (result.deletedCount === 0) break; pruned += result.deletedCount; } return pruned; } }