mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: rules engine — json-rules-engine integration with worklist scoring
- Self-contained NestJS module: types, storage (Redis+JSON), fact providers, action handlers - PriorityConfig CRUD (slider values for task weights, campaign weights, source weights) - Score action handler with SLA multiplier + campaign multiplier formula - Worklist consumer: scores and ranks items before returning - Hospital starter template (7 rules) - REST API: /api/rules/* (CRUD, priority-config, evaluate, templates) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
186
src/rules-engine/rules-storage.service.ts
Normal file
186
src/rules-engine/rules-storage.service.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
// 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<string>('REDIS_URL') ?? 'redis://localhost:6379');
|
||||
this.backupDir = config.get<string>('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<PriorityConfig> {
|
||||
const data = await this.redis.get(PRIORITY_CONFIG_KEY);
|
||||
return data ? JSON.parse(data) : DEFAULT_PRIORITY_CONFIG;
|
||||
}
|
||||
|
||||
async updatePriorityConfig(config: PriorityConfig): Promise<PriorityConfig> {
|
||||
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<Rule[]> {
|
||||
const data = await this.redis.get(RULES_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Rule | null> {
|
||||
const rules = await this.getAll();
|
||||
return rules.find(r => r.id === id) ?? null;
|
||||
}
|
||||
|
||||
async getByTrigger(triggerType: string, triggerValue?: string): Promise<Rule[]> {
|
||||
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<Rule, 'id' | 'metadata'> & { createdBy?: string }): Promise<Rule> {
|
||||
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<Rule>): Promise<Rule | null> {
|
||||
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<boolean> {
|
||||
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<Rule | null> {
|
||||
const rule = await this.getById(id);
|
||||
if (!rule) return null;
|
||||
return this.update(id, { enabled: !rule.enabled });
|
||||
}
|
||||
|
||||
async reorder(ids: string[]): Promise<Rule[]> {
|
||||
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<number> {
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user