import { BaseModel } from '#database/base_model' import { getMongoDb } from '#providers/mongodb_provider' export interface CwmpExchange { deviceId: string direction: 'CPE→ACS' | 'ACS→CPE' timestamp: string sessionId: string cwmpMethod: string rawXml: string sequenceNumber: number createdAt?: Date } export interface ExchangeMetadata { recordedFrom: string | null recordedUntil: string | null count: number } export default class ExchangeModel extends BaseModel { static collectionName = 'cwmp_exchanges' /** * Find exchanges for a device, ordered by timestamp ascending. * Supports cursor-based pagination with `after` (newer) and `before` (older) parameters. * When `before` is used, returns the most recent `limit` messages before that timestamp (ascending order). * When `latest` is true, returns the most recent `limit` messages (ascending order). */ static async findByDevice( deviceId: string, options?: { limit?: number; after?: string; before?: string; latest?: boolean } ): Promise { const collection = this.getCollection() const filter: any = { deviceId } if (options?.after) { filter.timestamp = { $gt: options.after } } else if (options?.before) { filter.timestamp = { $lt: options.before } } const limit = options?.limit ?? 200 // When using `latest` or `before`, we want the most recent messages. // Query in descending order then reverse to return in ascending order. if (options?.latest || options?.before) { const results = await collection .find(filter) .sort({ timestamp: -1 }) .limit(limit) .toArray() as unknown as CwmpExchange[] return results.reverse() } return collection .find(filter) .sort({ timestamp: 1 }) .limit(limit) .toArray() as unknown as Promise } /** * Find exchanges with sequence number greater than given value. * Used for SSE reconnection via Last-Event-ID. */ static async findAfterSequence(deviceId: string, seqNumber: number): Promise { const collection = this.getCollection() return collection .find({ deviceId, sequenceNumber: { $gt: seqNumber } }) .sort({ sequenceNumber: 1 }) .toArray() as unknown as Promise } /** * Delete all exchanges for a device. * Returns the number of deleted documents. */ static async deleteByDevice(deviceId: string): Promise { const collection = this.getCollection() const result = await collection.deleteMany({ deviceId }) return result.deletedCount } /** * Get the maximum sequence number for a device. * Returns 0 if no exchanges exist. */ static async getMaxSequenceNumber(deviceId: string): Promise { const collection = this.getCollection() const result = await collection .find({ deviceId }) .sort({ sequenceNumber: -1 }) .limit(1) .toArray() return result.length > 0 ? result[0].sequenceNumber : 0 } /** * Count exchanges for a device. */ static async countByDevice(deviceId: string): Promise { const collection = this.getCollection() return collection.countDocuments({ deviceId }) } /** * Delete oldest exchanges exceeding the cap for a device. * Keeps the most recent `maxCount` exchanges (by sequenceNumber). */ static async deleteOldestBeyondCap(deviceId: string, maxCount: number): Promise { const collection = this.getCollection() const count = await collection.countDocuments({ deviceId }) if (count <= maxCount) return const toDelete = count - maxCount const oldest = await collection .find({ deviceId }) .sort({ sequenceNumber: 1 }) .limit(toDelete) .project({ sequenceNumber: 1 }) .toArray() if (oldest.length === 0) return const maxSeqToDelete = oldest[oldest.length - 1].sequenceNumber await collection.deleteMany({ deviceId, sequenceNumber: { $lte: maxSeqToDelete }, }) } /** * Check if any exchange exists for a device (ever traced). */ static async hasExchanges(deviceId: string): Promise { const collection = this.getCollection() const doc = await collection.findOne({ deviceId }, { projection: { _id: 1 } }) return doc !== null } /** * Get metadata (oldest and newest timestamps, count). */ static async getMetadata(deviceId: string): Promise { const collection = this.getCollection() const count = await collection.countDocuments({ deviceId }) if (count === 0) { return { recordedFrom: null, recordedUntil: null, count: 0 } } const [oldest] = await collection .find({ deviceId }) .sort({ timestamp: 1 }) .limit(1) .project({ timestamp: 1 }) .toArray() const [newest] = await collection .find({ deviceId }) .sort({ timestamp: -1 }) .limit(1) .project({ timestamp: 1 }) .toArray() return { recordedFrom: oldest?.timestamp ?? null, recordedUntil: newest?.timestamp ?? null, count, } } /** * Ensure required indexes exist on the cwmp_exchanges collection. * Creates indexes for: * - (deviceId, timestamp): efficient retrieval by device ordered by time * - (deviceId, sequenceNumber) unique: atomic sequence numbering, Last-Event-ID queries * - (createdAt) TTL 24h: auto-remove old exchanges */ static async ensureIndexes(): Promise { const db = getMongoDb() const collection = db.collection('cwmp_exchanges') await collection.createIndex( { deviceId: 1, timestamp: 1 }, { name: 'idx_deviceId_timestamp', background: true } ) await collection.createIndex( { deviceId: 1, sequenceNumber: 1 }, { unique: true, name: 'idx_deviceId_sequenceNumber', background: true } ) await collection.createIndex( { createdAt: 1 }, { expireAfterSeconds: 86400, name: 'idx_createdAt_ttl', background: true } ) } }