// src/rules-engine/rules-storage.service.ts import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { dirname, join } from 'path'; import { randomUUID } from 'crypto'; import type { Rule, PriorityConfig } from './types/rule.types'; import { DEFAULT_PRIORITY_CONFIG } from './types/rule.types'; const RULES_KEY = 'rules:config'; const PRIORITY_CONFIG_KEY = 'rules:priority-config'; const VERSION_KEY = 'rules:scores:version'; @Injectable() export class RulesStorageService implements OnModuleInit { private readonly logger = new Logger(RulesStorageService.name); private readonly redis: Redis; private readonly backupDir: string; constructor(private config: ConfigService) { this.redis = new Redis(config.get('REDIS_URL') ?? 'redis://localhost:6379'); this.backupDir = config.get('RULES_BACKUP_DIR') ?? join(process.cwd(), 'data'); } async onModuleInit() { // Restore rules from backup if Redis is empty const existing = await this.redis.get(RULES_KEY); if (!existing) { const rulesBackup = join(this.backupDir, 'rules-config.json'); if (existsSync(rulesBackup)) { const data = readFileSync(rulesBackup, 'utf8'); await this.redis.set(RULES_KEY, data); this.logger.log(`Restored ${JSON.parse(data).length} rules from backup`); } else { await this.redis.set(RULES_KEY, '[]'); this.logger.log('Initialized empty rules config'); } } // Restore priority config from backup if Redis is empty const existingConfig = await this.redis.get(PRIORITY_CONFIG_KEY); if (!existingConfig) { const configBackup = join(this.backupDir, 'priority-config.json'); if (existsSync(configBackup)) { const data = readFileSync(configBackup, 'utf8'); await this.redis.set(PRIORITY_CONFIG_KEY, data); this.logger.log('Restored priority config from backup'); } else { await this.redis.set(PRIORITY_CONFIG_KEY, JSON.stringify(DEFAULT_PRIORITY_CONFIG)); this.logger.log('Initialized default priority config'); } } } // --- Priority Config --- async getPriorityConfig(): Promise { const data = await this.redis.get(PRIORITY_CONFIG_KEY); return data ? JSON.parse(data) : DEFAULT_PRIORITY_CONFIG; } async updatePriorityConfig(config: PriorityConfig): Promise { await this.redis.set(PRIORITY_CONFIG_KEY, JSON.stringify(config)); await this.redis.incr(VERSION_KEY); this.backupFile('priority-config.json', config); return config; } // --- Rules CRUD --- async getAll(): Promise { const data = await this.redis.get(RULES_KEY); return data ? JSON.parse(data) : []; } async getById(id: string): Promise { const rules = await this.getAll(); return rules.find(r => r.id === id) ?? null; } async getByTrigger(triggerType: string, triggerValue?: string): Promise { const rules = await this.getAll(); return rules.filter(r => { if (!r.enabled) return false; if (r.trigger.type !== triggerType) return false; if (triggerValue && 'request' in r.trigger && r.trigger.request !== triggerValue) return false; if (triggerValue && 'event' in r.trigger && r.trigger.event !== triggerValue) return false; return true; }).sort((a, b) => a.priority - b.priority); } async create(rule: Omit & { createdBy?: string }): Promise { const rules = await this.getAll(); const newRule: Rule = { ...rule, id: randomUUID(), metadata: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), createdBy: rule.createdBy ?? 'system', category: this.inferCategory(rule.action.type), tags: [], }, }; rules.push(newRule); await this.saveRules(rules); return newRule; } async update(id: string, updates: Partial): Promise { const rules = await this.getAll(); const index = rules.findIndex(r => r.id === id); if (index === -1) return null; rules[index] = { ...rules[index], ...updates, id, metadata: { ...rules[index].metadata, updatedAt: new Date().toISOString(), ...(updates.metadata ?? {}) }, }; await this.saveRules(rules); return rules[index]; } async delete(id: string): Promise { const rules = await this.getAll(); const filtered = rules.filter(r => r.id !== id); if (filtered.length === rules.length) return false; await this.saveRules(filtered); return true; } async toggle(id: string): Promise { const rule = await this.getById(id); if (!rule) return null; return this.update(id, { enabled: !rule.enabled }); } async reorder(ids: string[]): Promise { const rules = await this.getAll(); const reorderedIds = new Set(ids); const reordered = ids.map((id, i) => { const rule = rules.find(r => r.id === id); if (rule) rule.priority = i; return rule; }).filter(Boolean) as Rule[]; const remaining = rules.filter(r => !reorderedIds.has(r.id)); const final = [...reordered, ...remaining]; await this.saveRules(final); return final; } async getVersion(): Promise { const v = await this.redis.get(VERSION_KEY); return v ? parseInt(v, 10) : 0; } // --- Internal --- private async saveRules(rules: Rule[]) { const json = JSON.stringify(rules, null, 2); await this.redis.set(RULES_KEY, json); await this.redis.incr(VERSION_KEY); this.backupFile('rules-config.json', rules); } private backupFile(filename: string, data: any) { try { if (!existsSync(this.backupDir)) mkdirSync(this.backupDir, { recursive: true }); writeFileSync(join(this.backupDir, filename), JSON.stringify(data, null, 2), 'utf8'); } catch (err) { this.logger.warn(`Failed to write backup ${filename}: ${err}`); } } private inferCategory(actionType: string): Rule['metadata']['category'] { switch (actionType) { case 'score': return 'priority'; case 'assign': return 'assignment'; case 'escalate': return 'escalation'; case 'update': return 'lifecycle'; default: return 'priority'; } } }