mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 10:07:22 +00:00
- 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>
187 lines
7.0 KiB
TypeScript
187 lines
7.0 KiB
TypeScript
// 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';
|
|
}
|
|
}
|
|
}
|